Shared Filters (MeterFilterBar)
Use one page-level filter state to drive MeterUsage, MeterChart, and MeterTable. The core package owns the state; the React/Vue packages render the controls and subscribe the page to changes.
Loading interactive preview…
Loading interactive preview…
View code
Actual React source for this preview, including demo controls, shared styles and isolated mocks. The docs host supplies the client and theme; application installation and React / Vue usage follow below.
import { useMemo } from "react"import { MeterFilterBar, useMeterFilters,} from "@meterry/component-react"import { createMeterFilterController, type MeterThemeInput,} from "@meterry/component"import { MODELS, type PreviewEnvironment } from "./preview-data"import { styleProps } from "./preview-shared"import { UsagePreview } from "./usage-preview"import { ChartPreview } from "./chart-preview"import { TablePreview } from "./table-preview"
export function SharedFiltersPreview({ environment, theme,}: { environment: PreviewEnvironment theme: MeterThemeInput}) { const controller = useMemo( () => createMeterFilterController([ { key: "model", label: "Model", type: "select", options: MODELS.map((value) => ({ label: value, value })), }, { key: "provider", label: "Provider (exact)", type: "text", placeholder: "openai, anthropic, or r9s", }, ]), [] ) const filters = useMeterFilters(controller) const activeFilters = [ filters.model && `Model: ${filters.model}`, filters.provider && `Provider: ${filters.provider}`, ] .filter(Boolean) .join(" · ") return ( <div className="docs-preview-stack"> <MeterFilterBar {...styleProps} controller={controller} debounceMs={300} messages={{ filterBarLabel: "Shared dashboard filters" }} /> <output className="docs-preview-filter-summary" data-testid="committed-filters" > {activeFilters ? `Active filters: ${activeFilters}` : "All models and providers"} </output> <details className="docs-preview-options"> <summary>Connected Usage, Chart & Table</summary> <div className="docs-preview-options-body"> <UsagePreview environment={environment} theme={theme} filters={filters} /> <ChartPreview environment={environment} theme={theme} filters={filters} /> <TablePreview environment={environment} sharedController={controller} /> </div> </details> </div> )}import { MeterUsage, MeterChart,} from "@meterry/component-react"import type { MeterThemeInput, MeterFilterValues,} from "@meterry/component"import { METRICS, type PreviewEnvironment } from "./preview-data"import { EMPTY_FILTERS, styleProps } from "./preview-shared"
export function UsagePreview({ environment, theme, filters = EMPTY_FILTERS,}: { environment: PreviewEnvironment theme: MeterThemeInput filters?: MeterFilterValues}) { return ( <div data-testid="usage-preview"> <MeterUsage {...styleProps} title="Token usage · last 7 days" query={{ startTime: environment.startTime, endTime: environment.endTime, metrics: [...METRICS], measures: ["quantity"], filters, }} slots={{ value: ({ total, formattedTotal }) => ( <div className="docs-preview-value" data-testid="usage-total" data-value={total} > {formattedTotal} <span className="docs-preview-unit">tokens</span> </div> ), chart: ({ model }) => ( <MeterChart unstyled type="bar" theme={theme} data={model.series[0]?.points ?? []} x="label" series={[{ key: "usage", label: "Tokens", value: "value" }]} height={180} showLegend={false} slots={{ header: () => null }} /> ), }} /> </div> )}import { useEffect, useState } from "react"import { MeterChart } from "@meterry/component-react"import type { MeterThemeInput, MeterFilterValues,} from "@meterry/component"import { fetchPreviewTrend, type PreviewEnvironment, type TrendRow,} from "./preview-data"import { EMPTY_FILTERS, styleProps, trendSeries } from "./preview-shared"
export function ChartPreview({ environment, theme, filters = EMPTY_FILTERS,}: { environment: PreviewEnvironment theme: MeterThemeInput filters?: MeterFilterValues}) { const [type, setType] = useState("line") const [visibleSeries, setVisibleSeries] = useState("both") const [showLegend, setShowLegend] = useState(true) const [showGrid, setShowGrid] = useState(true) const [state, setState] = useState<{ rows: TrendRow[] loading: boolean error?: Error }>({ rows: [], loading: true }) const filtersKey = JSON.stringify(filters) useEffect(() => { const abort = new AbortController() setState({ rows: [], loading: true }) void fetchPreviewTrend(environment, JSON.parse(filtersKey), abort.signal) .then((rows) => { if (!abort.signal.aborted) setState({ rows, loading: false }) }) .catch((error: unknown) => { if (!abort.signal.aborted) setState({ rows: [], loading: false, error: error instanceof Error ? error : new Error(String(error)), }) }) return () => abort.abort() }, [environment, filtersKey]) return ( <div data-testid="chart-preview"> <label className="docs-preview-chart-select"> Chart type <select value={type} onChange={(event) => setType(event.target.value)}> <option value="line">Line</option> <option value="bar">Bar</option> <option value="area">Area</option> </select> </label> <div className="docs-preview-actions docs-preview-chart-controls"> <label className="docs-preview-chart-select"> Chart series <select value={visibleSeries} onChange={(event) => setVisibleSeries(event.target.value)} > <option value="both">Input and output</option> <option value="input">Input only</option> <option value="output">Output only</option> </select> </label> <label className="docs-preview-chart-select"> <input type="checkbox" checked={showLegend} onChange={(event) => setShowLegend(event.target.checked)} /> Show legend </label> <label className="docs-preview-chart-select"> <input type="checkbox" checked={showGrid} onChange={(event) => setShowGrid(event.target.checked)} /> Show grid </label> </div> {state.loading ? ( <p className="docs-preview-status" role="status"> Loading chart… </p> ) : state.error ? ( <p className="docs-preview-status" role="alert"> {state.error.message} </p> ) : ( <MeterChart {...styleProps} theme={theme} type={type} title="Token usage · last 7 days" data={state.rows} x="bucket" height={260} showLegend={showLegend} showGrid={showGrid} series={trendSeries.filter( (series) => visibleSeries === "both" || series.key === visibleSeries )} /> )} </div> )}import { useEffect, useMemo, useRef, useState } from "react"import { MeterTable } from "@meterry/component-react"import { createTableController, type MeterFilterController,} from "@meterry/component"import { loadPreviewRows, type PreviewEnvironment } from "./preview-data"import { styleProps, columns, sharedColumns } from "./preview-shared"
export function TablePreview({ environment, sharedController,}: { environment: PreviewEnvironment sharedController?: MeterFilterController}) { const selectedColumns = sharedController ? sharedColumns : columns const failNextLoad = useRef(false) const [loadStatus, setLoadStatus] = useState("idle") const controller = useMemo( () => createTableController({ columns: selectedColumns, pageSize: 6, load: async (query) => { if (failNextLoad.current) { failNextLoad.current = false throw new Error( "Simulated reload failure. Previously loaded rows are retained." ) } return loadPreviewRows( environment, query, sharedController?.getValues() ) }, }), [environment, selectedColumns, sharedController] ) useEffect( () => sharedController?.subscribe(() => { void controller.setPage(1) }), [controller, sharedController] ) useEffect(() => { setLoadStatus(controller.getState().status) return controller.subscribe((state) => setLoadStatus(state.status)) }, [controller]) return ( <div data-testid="table-preview"> <MeterTable {...styleProps} title="Usage events" caption="Synthetic preview data" columns={selectedColumns} controller={controller} pageSizeOptions={[6, 12, 24]} /> <div className="docs-preview-actions docs-preview-reload-actions"> <button type="button" disabled={ loadStatus !== "success" || !controller.getState().rows.length } onClick={() => { failNextLoad.current = true void controller.refresh() }} > Simulate reload failure </button> <button type="button" disabled={loadStatus !== "error"} onClick={() => void controller.refresh()} > Retry load </button> </div> </div> )}import { useSyncExternalStore } from "react"import { createEChartsSeriesRegistry, type ChartSeries, type MeterClassNames, type MeterFilterValues, type TableColumn, type UsageTableRow,} from "@meterry/component"import { METRICS, MODELS, type PreviewEnvironment, type PreviewRow, type TrendRow,} from "./preview-data"
export const EMPTY_FILTERS: MeterFilterValues = {}const classNames: MeterClassNames = { root: "docs-preview-root", card: "docs-preview-card", header: "docs-preview-header", caption: "docs-preview-caption", title: "docs-preview-title", body: "docs-preview-body", toolbar: "docs-preview-filter-bar", filterBar: "docs-preview-filter-bar", filter: "docs-preview-filter", table: "docs-preview-table", list: "docs-preview-list", listFooter: "docs-preview-pagination", pagination: "docs-preview-pagination", actions: "docs-preview-actions", button: "docs-preview-button", chart: "docs-preview-chart", value: "docs-preview-value", unit: "docs-preview-unit", empty: "docs-preview-status", loading: "docs-preview-status", error: "docs-preview-status",}export const styleProps = { unstyled: true, classNames }// Adapt only preview label colors to Starlight, without mutating the global registry.export const previewSeriesRegistry = createEChartsSeriesRegistry()for (const type of ["pie", "doughnut"]) { const builtin = previewSeriesRegistry.builder(type) previewSeriesRegistry.register( type, (series, index, model) => ({ ...builtin(series, index, model), label: { color: model.theme.colors.text, textBorderWidth: 0, }, }), { polar: true } )}
export const columns: TableColumn<PreviewRow>[] = [ { key: "bucket", header: "Date", sortable: true }, { key: "model", header: "Model", sortable: true, filter: { type: "select", options: MODELS.map((value) => ({ label: value, value })), }, }, { key: "provider", header: "Provider" }, { key: "metric", header: "Metric", filter: { type: "select", options: METRICS.map((value) => ({ label: value, value })), }, }, { key: "quantity", header: "Quantity", sortable: true, align: "right", filter: { type: "number", placeholder: "Minimum quantity" }, format: (value) => Number(value).toLocaleString("en-US"), }, { key: "amount", header: "Amount (USD)", sortable: true, align: "right", format: (value) => `$${Number(value).toFixed(4)}`, },]// Shared Model/Provider controls and table-local Metric/Quantity controls are independent.export const sharedColumns = columns.map((column) => column.key === "model" ? { ...column, filter: undefined } : column)
export const trendSeries: ChartSeries<TrendRow>[] = [ { key: "input", label: "Input tokens", value: "input_tokens" }, { key: "output", label: "Output tokens", value: "output_tokens" },]
// API identity is raw_event_id; override the snapshot's legacy default event column.export const eventColumns: TableColumn<UsageTableRow>[] = [ { key: "raw_event_id", header: "Event ID" }, { key: "occurred_at", header: "Occurred at", format: (value) => new Date(Number(value) * 1000).toISOString().slice(0, 10), }, { key: "source", header: "Source" }, { key: "model", header: "Model", accessor: (row) => (row.labels as Record<string, string> | undefined)?.model, },]
export function RequestSummary({ environment,}: { environment: PreviewEnvironment}) { useSyncExternalStore( environment.subscribeRequests, () => environment.requests.length, () => 0 ) const details = environment.requests.filter((request) => request.path.endsWith("/details") ).length const trend = environment.requests.filter((request) => request.path.endsWith("/analysis/query") ).length const aggregate = environment.requests.filter((request) => request.path.endsWith("/usage/query") ).length return ( <output className="docs-preview-filter-summary" data-testid="request-count"> Mock API requests: {environment.requests.length} · Events: {details} · Trend: {trend} · Aggregate: {aggregate} </output> )}import { SubjectClient, createSubjectTokenProvider, mergeMeterFilters, type AnalysisQueryRequest, type AnalysisQueryResponse, type FetchLike, type MeterFilterValues, type TableQuery, type TableResult, type UsageQueryRequest, type UsageQueryResponse,} from "@meterry/component"
export const DAY = 86_400export const MODELS = ["gpt-4o-mini", "claude-sonnet", "glm-5.1"] as constexport const PROVIDERS = ["openai", "anthropic", "r9s"] as constexport const METRICS = ["input_tokens", "output_tokens"] as constexport type PreviewScenario = "sample" | "empty" | "error"export type PreviewRow = { bucket: string model: string provider: string metric: string quantity: number amount: number}export type TrendRow = { bucket: string input_tokens: number output_tokens: number}export type PreviewRequest = { path: string; afterId?: string }export type PreviewEnvironment = { requests: PreviewRequest[] subscribeRequests: (listener: () => void) => () => void failNextRequest: () => void client: SubjectClient startTime: number endTime: number}
/** Synthetic browser-only fixtures, not production usage or pricing. */export function createFixture(now = Date.now()) { const today = Math.floor(now / 1000 / DAY) * DAY const startTime = today - 6 * DAY const rows: PreviewRow[] = [] for (let day = 0; day < 7; day++) { for (let model = 0; model < MODELS.length; model++) { for (let metric = 0; metric < METRICS.length; metric++) { const quantity = 1000 + day * 140 + model * 600 + metric * 400 rows.push({ bucket: new Date((startTime + day * DAY) * 1000) .toISOString() .slice(0, 10), model: MODELS[model]!, provider: PROVIDERS[model]!, metric: METRICS[metric]!, quantity, amount: Number((quantity * 0.000002).toFixed(6)), }) } } } return { rows, startTime, endTime: Math.floor(now / 1000) + 1 }}
function selectRows( rows: PreviewRow[], request: UsageQueryRequest | AnalysisQueryRequest) { const metrics = request.metrics?.length ? request.metrics : METRICS return rows.filter((row) => { const timestamp = Date.parse(row.bucket) / 1000 if ( timestamp < (request.start_time ?? -Infinity) || timestamp >= (request.end_time ?? Infinity) ) return false if (!metrics.includes(row.metric as (typeof METRICS)[number])) return false return Object.entries(request.filters ?? {}).every(([key, value]) => { if (value === "") return true if (key === "quantity" || key === "amount") { return Number.isFinite(Number(value)) && row[key] >= Number(value) } return key in row && String(row[key as keyof PreviewRow]) === value }) })}
function analysisResponse( rows: PreviewRow[], request: AnalysisQueryRequest): AnalysisQueryResponse { const groups = new Map< string, { metric: string measure: string model?: string points: Map<string, number> } >() for (const row of selectRows(rows, request)) { for (const measure of request.measures) { const model = request.group_by?.includes("model") ? row.model : undefined const key = [row.metric, measure, model].filter(Boolean).join(":") const group = groups.get(key) ?? { metric: row.metric, measure, model, points: new Map(), } const value = measure === "amount" ? row.amount : measure === "item_count" ? 1 : row.quantity group.points.set(row.bucket, (group.points.get(row.bucket) ?? 0) + value) groups.set(key, group) } } let remaining = request.limit ?? Infinity const series: AnalysisQueryResponse["series"] = [] for (const [key, group] of groups) { const points = [...group.points] .sort(([a], [b]) => a.localeCompare(b)) .slice(0, remaining) .map(([bucket, value]) => ({ bucket, value })) remaining -= points.length if (points.length) series.push({ key, label: group.metric, measure: group.measure, dimensions: { metric: group.metric, ...(group.model ? { model: group.model } : {}), }, points, }) } return { group_by: request.group_by ?? ["bucket"], measures: request.measures, series, meta: { timezone: "UTC", bucket_size: request.bucket_size ?? "1d", start_time: request.start_time, end_time: request.end_time, }, }}
function usageResponse( rows: PreviewRow[], request: UsageQueryRequest): UsageQueryResponse { const dimensions = request.group_by ?? ["bucket", "model", "metric"] const groups = new Map< string, { dimensions: Record<string, string>; measures: Record<string, number> } >() for (const row of selectRows(rows, request)) { const values = Object.fromEntries( dimensions.map((key) => [key, String(row[key as keyof PreviewRow] ?? "")]) ) const key = JSON.stringify(values) const group = groups.get(key) ?? { dimensions: values, measures: {} } for (const measure of request.measures ?? ["quantity"]) { const value = measure === "amount" ? row.amount : measure === "item_count" ? 1 : row.quantity group.measures[measure] = (group.measures[measure] ?? 0) + value } groups.set(key, group) } return { rows: [...groups.values()].slice(0, request.limit ?? groups.size) }}
function delay( milliseconds: number, signal?: AbortSignal | null): Promise<void> { return new Promise((resolve, reject) => { const abort = () => { clearTimeout(timer) signal?.removeEventListener("abort", abort) reject(new DOMException("Preview request aborted", "AbortError")) } const timer = setTimeout(() => { signal?.removeEventListener("abort", abort) resolve() }, milliseconds) if (signal?.aborted) abort() else signal?.addEventListener("abort", abort, { once: true }) })}
/** Every request stays in this fetch adapter; no network or stored credentials. */export function createPreviewEnvironment( options: { scenario?: PreviewScenario now?: number delayMs?: number } = {}): PreviewEnvironment { const fixture = createFixture(options.now) const rows = options.scenario === "empty" ? [] : fixture.rows const requests: PreviewRequest[] = [] const listeners = new Set<() => void>() let failNext = false const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }) const fetch: FetchLike = async (input, init) => { const url = new URL( typeof input === "string" ? input : input instanceof URL ? input.href : input.url, "https://docs-preview.invalid" ) requests.push({ path: url.pathname, afterId: url.searchParams.get("after_id") ?? undefined, }) for (const listener of listeners) listener() await delay(options.delayMs ?? 180, init?.signal) if ( url.origin !== "https://docs-preview.invalid" || ![ "/v1/subject/analysis/query", "/v1/subject/usage/query", "/v1/subject/usage/events/details", ].includes(url.pathname) ) return json({ message: "Unknown preview endpoint" }, 404) if (options.scenario === "error" || failNext) { failNext = false return json({ message: "Simulated preview request failure" }, 503) } if (url.pathname.endsWith("/details")) { if (init?.method !== "GET") return json({ message: "Preview details require GET" }, 405) const request: UsageQueryRequest = { start_time: Number(url.searchParams.get("start_time")) || undefined, end_time: Number(url.searchParams.get("end_time")) || undefined, metrics: url.searchParams .getAll("metrics") .flatMap((value) => value.split(",")), metric: url.searchParams.get("metric") ?? undefined, } if (!request.metrics?.length && request.metric) request.metrics = [request.metric] const events = selectRows(rows, request).map((row) => ({ raw_event_id: `raw_preview_${String(fixture.rows.indexOf(row) + 1).padStart(4, "0")}`, occurred_at: Date.parse(row.bucket) / 1000, source: "documentation", subject_type: "user", subject_id: "documentation-preview", labels: { model: row.model, provider: row.provider }, metrics: { [row.metric]: { value: String(row.quantity), amount: String(row.amount), currency: "USD", }, }, })) const after = url.searchParams.get("after_id") const cursorIndex = after ? events.findIndex((event) => event.raw_event_id === after) : -1 if (after && cursorIndex < 0) return json({ message: "Invalid preview cursor" }, 400) const start = cursorIndex + 1 const limit = Math.min( 1000, Math.max(1, Number(url.searchParams.get("limit")) || 100) ) const page = events.slice(start, start + limit) return json({ usage_events: page, total: events.length, ...(start + page.length < events.length ? { next_after_id: page.at(-1)?.raw_event_id } : {}), }) } if (init?.method !== "POST") return json({ message: "Preview query requires POST" }, 405) let body: UsageQueryRequest | AnalysisQueryRequest try { body = JSON.parse(String(init?.body ?? "{}")) } catch { return json({ message: "Invalid preview query" }, 400) } if (!Array.isArray(body.measures)) return json({ message: "Missing preview measures" }, 400) return json( url.pathname.endsWith("/analysis/query") ? analysisResponse(rows, body as AnalysisQueryRequest) : usageResponse(rows, body) ) } const provider = createSubjectTokenProvider( async () => ({ token_type: "Bearer", access_token: "documentation-preview-only", expires_in: 900, expires_at: Math.floor(Date.now() / 1000) + 900, subject: { type: "user", id: "documentation-preview" }, rules: ["analysis"], }), { tokenStorage: null } ) return { requests, subscribeRequests: (listener) => { listeners.add(listener) return () => { listeners.delete(listener) } }, failNextRequest: () => { failNext = true }, client: new SubjectClient(provider, { baseUrl: "https://docs-preview.invalid", credentials: "omit", fetch, }), startTime: fixture.startTime, endTime: fixture.endTime, }}
export async function fetchPreviewTrend( environment: PreviewEnvironment, filters: MeterFilterValues = {}, signal?: AbortSignal): Promise<TrendRow[]> { const response = await environment.client.analysisQuery( { bucket_size: "1d", start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket"], measures: ["quantity"], filters, }, { signal } ) const byBucket = new Map<string, TrendRow>() for (const series of response.series) { const metric = series.dimensions?.metric if (metric !== "input_tokens" && metric !== "output_tokens") continue for (const point of series.points) { const row = byBucket.get(point.bucket) ?? { bucket: point.bucket, input_tokens: 0, output_tokens: 0, } row[metric] += Number(point.value) byBucket.set(point.bucket, row) } } return [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket))}
export async function loadPreviewRows( environment: PreviewEnvironment, query: TableQuery, shared: MeterFilterValues = {}): Promise<TableResult<PreviewRow>> { const response = await environment.client.usageQuery({ bucket_size: "1d", start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket", "model", "provider", "metric"], measures: ["quantity", "amount"], filters: mergeMeterFilters(query.filters, shared), limit: 500, }) const rows: PreviewRow[] = response.rows.map((row) => ({ bucket: String(row.dimensions.bucket), model: String(row.dimensions.model), provider: String(row.dimensions.provider), metric: String(row.dimensions.metric), quantity: Number(row.measures.quantity), amount: Number(row.measures.amount), })) if (query.sort) { const { key, direction } = query.sort rows.sort((a, b) => { const left = a[key as keyof PreviewRow], right = b[key as keyof PreviewRow] const order = typeof left === "number" && typeof right === "number" ? left - right : String(left).localeCompare(String(right)) return direction === "desc" ? -order : order }) } return { total: rows.length, rows: rows.slice( (query.page - 1) * query.pageSize, query.page * query.pageSize ), }}Installation
Section titled “Installation”npm install @meterry/component @meterry/component-react echarts echarts-for-reactnpm install @meterry/component @meterry/component-vue echarts vue-echartsFor default styles, optionally install @meterry/component-tailwindcss. For authentication, shared clients and theming, see Getting Started.
Automatic query components
Section titled “Automatic query components”Connect filter values directly to both wrappers instead of writing request/pivot/reload effects:
function UsageViews({ controller }: { controller: MeterFilterController }) { const filters = useMeterFilters(controller) return <> <MeterUsageTable source="usage" query={{ metrics: ['input_tokens'], group_by: ['bucket', 'model', 'metric'], filters }} /> <MeterUsageChart type="line" query={{ metrics: ['input_tokens', 'output_tokens'], group_by: ['bucket'], filters }} /> </>}Import MeterUsageTable, MeterUsageChart and useMeterFilters from the framework package, and MeterFilterController from core. Render under a shared client provider. The wrappers own query changes and cancellation. source="events" only accepts time/metric selectors in the current wrapper, not arbitrary shared dimension filters. The rest of this page explains the still-supported lower-level MeterTable / MeterChart integration.
Change Model or enter an exact Provider to update Usage, Chart, and the remote Table together. Text commits after 300ms; selects commit immediately. Table-local Metric and Quantity filters remain independent when you clear the shared bar.
MeterFilterBar → MeterFilterController → page subscriptions ├── MeterUsage query.filters ├── filtered MeterChart data └── filtered MeterTable data or a remote loadReact: one controller for a dashboard
Section titled “React: one controller for a dashboard”The page below receives an authenticated SubjectClient and already-fetched rows. Keep the controller and column definitions stable; changing filters should not recreate them.
import { useMemo } from 'react'import { MeterChart, MeterClientProvider, MeterFilterBar, MeterTable, MeterUsage, useMeterFilters,} from '@meterry/component-react'import { createMeterFilterController, type MeterFilterDefinition, type SubjectClient, type TableColumn,} from '@meterry/component'
type Row = { model: string; provider: string; quantity: number }
const definitions: MeterFilterDefinition[] = [ { key: 'model', label: 'Model', type: 'select', options: [ { label: 'GLM', value: 'glm-5.1' }, { label: 'GPT', value: 'gpt-4o' }, ], }, { key: 'provider', label: 'Provider', type: 'text', placeholder: 'Exact provider name' },]const columns: TableColumn<Row>[] = [ { key: 'model', header: 'Model' }, { key: 'provider', header: 'Provider' }, { key: 'quantity', header: 'Quantity', sortable: true },]
export function Dashboard({ client, rows }: { client: SubjectClient; rows: Row[] }) { const controller = useMemo(() => createMeterFilterController(definitions), []) const filters = useMeterFilters(controller) // This page deliberately uses exact dimension matching for local data. const filteredRows = useMemo(() => rows.filter((row) => (!filters.model || row.model === filters.model) && (!filters.provider || row.provider === filters.provider), ), [rows, filters])
return ( <MeterClientProvider client={client}> <MeterFilterBar controller={controller} debounceMs={300} /> <MeterUsage query={{ metrics: ['tokens'], filters }} /> <MeterChart type="bar" data={filteredRows} x="model" series={[{ key: 'quantity', value: 'quantity' }]} /> <MeterTable columns={columns} data={filteredRows} pageSize={10} /> </MeterClientProvider> )}Local filtering only sees rows already loaded into the browser. For complete dashboard comparisons, fetch those rows for the same time window and measures as the Usage query. Use remote filtering instead when the browser only holds a page or subset of the data.
Vue 3: the same shared state
Section titled “Vue 3: the same shared state”Call useMeterFilterValues inside setup / <script setup> (or an active effect scope). It returns a Ref<MeterFilterValues> and unsubscribes when that scope is disposed. Use .value in script; Vue unwraps the ref in templates.
<script setup lang="ts">import { computed } from 'vue'import { MeterChart, MeterFilterBar, MeterTable, MeterUsage, provideMeterClient, useMeterFilterValues,} from '@meterry/component-vue'import { createMeterFilterController, type SubjectClient, type TableColumn,} from '@meterry/component'
type Row = { model: string; provider: string; quantity: number }const props = defineProps<{ client: SubjectClient; rows: Row[] }>()provideMeterClient({ client: props.client })const controller = createMeterFilterController([ { key: 'model', label: 'Model', type: 'select', options: [ { label: 'GLM', value: 'glm-5.1' }, { label: 'GPT', value: 'gpt-4o' }, ], }, { key: 'provider', label: 'Provider', type: 'text', placeholder: 'Exact provider name' },])const filters = useMeterFilterValues(controller)const filteredRows = computed(() => props.rows.filter((row) => (!filters.value.model || row.model === filters.value.model) && (!filters.value.provider || row.provider === filters.value.provider),))const columns: TableColumn<Row>[] = [ { key: 'model', header: 'Model' }, { key: 'provider', header: 'Provider' }, { key: 'quantity', header: 'Quantity', sortable: true },]</script>
<template> <MeterFilterBar :controller="controller" :debounce-ms="300" /> <MeterUsage :query="{ metrics: ['tokens'], filters }" /> <MeterChart type="bar" :data="filteredRows" x="model" :series="[{ key: 'quantity', value: 'quantity' }]" /> <MeterTable :columns="columns" :data="filteredRows" :page-size="10" /></template>Controlled and standalone modes
Section titled “Controlled and standalone modes”When the page already owns its values, omit controller and pass definitions, values, and a change handler. The handler receives the next complete value record, not a single changed field.
React (using the definitions from above):
import { useState } from 'react'import { MeterFilterBar, MeterUsage } from '@meterry/component-react'import { normalizeMeterFilters, type MeterFilterValues } from '@meterry/component'
function ControlledFilters() { const [values, setValues] = useState<MeterFilterValues>({}) return ( <> <MeterFilterBar definitions={definitions} values={values} onChange={setValues} /> {/* Render under a MeterClientProvider, or pass an explicit client. */} <MeterUsage query={{ filters: normalizeMeterFilters(values) }} /> </> )}Vue (in page setup, using the same definitions):
<script setup lang="ts">import { ref } from 'vue'import { MeterFilterBar } from '@meterry/component-vue'import type { MeterFilterValues } from '@meterry/component'// Define or import the definitions shown above.const values = ref<MeterFilterValues>({})const onChange = (next: MeterFilterValues) => { values.value = next }</script>
<template> <MeterFilterBar :definitions="definitions" :values="values" @change="onChange" /></template>This is values + @change, not v-model: the component does not emit update:values. With neither controller nor values, the bar maintains internal values and still reports changes through onChange / @change.
Choose one state owner
When controller is present it takes precedence over values, and changes go to the controller rather than onChange / @change. Subscribe to the controller to observe them. definitions can override the rendered controls, but does not replace the controller’s definitions; Clear clears all controller values, including keys not rendered by that bar.
Applying and clearing filters
Section titled “Applying and clearing filters”- Text and number filters apply after you pause typing for 300ms. Select filters apply immediately.
- Changing a select does not submit unfinished text or number input in another field. Each field waits for its own input to finish.
- Set
debounceMs={0}(React) or:debounce-ms="0"(Vue) to apply text/number changes immediately, or choose a longer delay for your page. - Clear removes all shared filters and cancels unfinished input. It does not clear the table’s separate column filters.
- To change filters from another page control, call
controller.setValue(key, value)orcontroller.setValues(values).
When filters change, the connected Usage, Chart, and Table views show the new selection. See MeterUsage and MeterTable for loading, empty, and error messages.
Initial values and empty filters
Section titled “Initial values and empty filters”Filter values are strings, including values from number inputs. An empty string removes that condition; '0' is a valid value. Use a definition’s defaultValue or supply initial values when creating the controller:
import { createMeterFilterController, mergeMeterFilters, normalizeMeterFilters,} from '@meterry/component'
const controller = createMeterFilterController( [{ key: 'model', type: 'text', defaultValue: 'gpt-4o' }], { model: '' },)controller.getValues() // {}: the explicit blank clears the defaultnormalizeMeterFilters({ model: '', quantity: '0' }) // { quantity: '0' }mergeMeterFilters({ model: 'gpt-4o', provider: 'openai' }, { model: '' })// { provider: 'openai' }: a later explicit blank removes an earlier key- Defaults apply when the controller is created. Clear removes the conditions rather than restoring defaults. For a bar without a controller, initialize the page’s
valuesexplicitly. - Use
setValuesto replace all shared filters, orsetValueto change one condition. - Use
normalizeMeterFiltersto remove empty values before making your own query, andmergeMeterFiltersto combine shared and table-local filters. MeterUsageremoves empty dimension filters automatically. Metric and date selectors should updatequery.metricsor the time range instead of being sent as dimensions.
Matching rules and server-side dimensions
Section titled “Matching rules and server-side dimensions”definition.type chooses the input control; it is not a query operator. The shared controller does not implement contains, numeric comparison, date ranges, multi-select, or a match callback. Those require application-side matching and/or conversion to the service’s supported request format.
For instance, the dashboard examples above deliberately use equality for both local dimensions, even though Provider uses a text input. Check that this matches your service’s rules. Do not use substring matching locally while expecting exact matching remotely and assume both components will return the same result. A metric selector or date range may need to map to query.metrics or startTime / endTime, rather than blindly becoming a dimension filter.
Table-local column filters have their own rules: text defaults to case-insensitive contains, select to equality, and number to numeric equality; column.filter.match can override matching in local-data mode. See TableColumn.
Remote tables and local filter scope
Section titled “Remote tables and local filter scope”For a local table, pass the filtered rows through data. The table keeps its sort order and column filters and returns to page 1. Public/shared filters and table-local filters remain independent; Clear on the shared bar does not clear the table’s column filters.
For remote tables, merge shared values inside load and explicitly trigger a reload on shared changes. Simply reading controller.getValues() in a stable load does not subscribe the table to the controller.
The following wiring assumes the page already owns controller, stable columns, and an application loadRows(query) function. loadRows must query the service, map rows, and implement the requested sorting/pagination before returning { rows, total }.
import { createTableController, mergeMeterFilters } from '@meterry/component'
// Create once in page setup (useMemo in React).const tableController = createTableController({ columns, load: (query) => loadRows({ ...query, filters: mergeMeterFilters(query.filters, controller.getValues()), }),})// Register in useEffect (React), or setup with onScopeDispose (Vue).const unsubscribe = controller.subscribe(() => { void tableController.setPage(1) // resets pagination and loads once})// Invoke unsubscribe on cleanup. Render MeterTable with this tableController.Here shared values win when keys overlap. Prefer distinct keys for shared and local concerns; merging cannot represent two conflicting predicates on one key. A missing shared key does not delete a table-local key, so clearing shared filters can reveal a still-active local filter.
Create the table controller once and reload it when shared filters change, rather than creating a new table for every selection. For an example, see MeterTable: shared filters.
Props, styling, and localization
Section titled “Props, styling, and localization”| Prop | Type | Default | Description |
|---|---|---|---|
definitions |
MeterFilterDefinition[] |
Controller definitions, or [] |
Controls to render |
controller |
MeterFilterController |
— | Shared state owner; takes precedence over values |
values |
MeterFilterValues |
Internal {} |
Page-owned committed values when no controller is supplied |
onChange / @change |
(values: MeterFilterValues) => void |
— | Next full value set in controlled/standalone modes |
debounceMs / :debounce-ms |
number |
300 |
Text/number input delay in milliseconds; select changes apply immediately |
messages |
Partial<MeterMessages> |
English defaults | Labels and placeholders |
className / class |
string |
— | Class on the bar’s root node |
classNames / :class-names |
MeterClassNames |
— | Override the filterBar, filter, and button slots |
unstyled |
boolean |
false |
Disable default semantic classes |
Use messages.filterBarLabel to name the filter group and definition.label to label each control. Customize its appearance with the Tailwind plugin or classNames.filterBar, classNames.filter, and classNames.button.
Copy shared with table controls: clearFilters, allFilterOption, and filterPlaceholder(label). See Internationalization for an override example.
API details: core filter controller, React, and Vue.