MeterList / MeterUsageList
A Table with Load more pagination: one shared column header and aligned
rows. The bottom Load more button appends rows without replacing earlier
pages. It uses the same TableColumn<T> definitions and table styles as
MeterTable, not cards or arbitrary object-key rendering. Headers remain visible
during loading, empty and error states.
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, useState } from "react"import { MeterList, MeterUsageList,} from "@meterry/component-react"import { METRICS, type PreviewEnvironment } from "./preview-data"import { styleProps, eventColumns, RequestSummary } from "./preview-shared"
export function AutomaticListPreview({ environment,}: { environment: PreviewEnvironment}) { const [source, setSource] = useState<"events" | "usage">("events") const [custom, setCustom] = useState(false) const activities = useMemo( () => Array.from({ length: 7 }, (_, index) => ({ title: `Activity ${index + 1}`, detail: "Local data · appended without replacing earlier items", })), [] ) return ( <div className="docs-preview-stack" data-testid="automatic-list-preview"> <MeterUsageList {...styleProps} source={source} pageSize={3} batchSize={6} title="Usage activity" caption="Query and append · no application loader or cursor" query={ source === "events" ? { start_time: environment.startTime, end_time: environment.endTime, } : { start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket", "model", "metric"], measures: ["quantity"], } } columns={source === "events" ? eventColumns : undefined} slots={ custom ? { cell: ({ column, value }) => column.key === "raw_event_id" || column.key === "model" ? ( <strong>{String(value ?? "")}</strong> ) : typeof value === "object" && value !== null ? ( JSON.stringify(value) ) : ( String(value ?? "") ), } : undefined } /> <details className="docs-preview-options"> <summary>Example controls & local data</summary> <div className="docs-preview-options-body"> <div className="docs-preview-actions"> <label className="docs-preview-chart-select"> List source <select value={source} onChange={(event) => setSource(event.target.value as "events" | "usage") } > <option value="events">Events · automatic cursor</option> <option value="usage">Aggregate · bounded result</option> </select> </label> <label className="docs-preview-chart-select"> <input type="checkbox" checked={custom} onChange={(event) => setCustom(event.target.checked)} /> Custom cell rendering </label> </div> <RequestSummary environment={environment} /> <div className="docs-preview-actions"> <button type="button" onClick={() => environment.failNextRequest()}> Fail next API request </button> <span className="docs-preview-filter-summary"> Then fetch another batch; Retry retains the displayed items. </span> </div> <MeterList {...styleProps} data={activities} pageSize={3} title="Local activity" columns={[ { key: "title", header: "Activity" }, { key: "detail", header: "Description" }, ]} /> </div> </details> </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.
Existing table and chart APIs are unchanged.
The automatic demo uses 6-row API batches and appends 3 rows per click. The third displayed page crosses the first batch boundary. Refresh starts over; Expand Example controls & local data to switch sources or try Fail next API request and built-in Retry. The request counter makes cache reuse visible. No production requests or stored tokens are used.
React: no loader or cursor code
Section titled “React: no loader or cursor code”import { MeterClientProvider, MeterUsageList } from '@meterry/component-react'
export function UsagePage() { return ( <MeterClientProvider tokenEndpoint="/api/meter/subject-token" options={{ baseUrl: '', credentials: 'include', tokenStorage: null }}> <MeterUsageList source="events" rangeDays={7} pageSize={20} /> </MeterClientProvider> )}The backend-owned token route returns a short-lived Subject Token grant. Tenant
API keys never belong in browser code. baseUrl: '' assumes same-origin /v1/...;
configure your actual API origin or proxy prefix otherwise. Existing explicit
client and provider getJwt authentication also work.
Explicit columns
Section titled “Explicit columns”MeterUsageList has source-specific default columns. Pass columns to choose
exactly which columns appear, their order, header labels, widths and alignment:
import type { TableColumn, UsageListRow } from '@meterry/component'
const columns: TableColumn<UsageListRow>[] = [ { key: 'raw_event_id', header: 'Event ID', width: '16rem' }, { key: 'source', header: 'Source' }, { key: 'model', header: 'Model', accessor: (row) => (row.labels as Record<string, unknown> | undefined)?.model },]
// Inside the provider above:<MeterUsageList source="events" columns={columns} pageSize={20} batchSize={500} rowKey="raw_event_id" />No load, mapper or cursor loop is needed for Meter queries. Default event
columns use raw_event_id, occurrence time, source, model and metrics.
rowKey is an optional field name or function; the default index preserves append
order. Only defined columns render: extra row fields are never enumerated.
Provide a client in an ancestor’s setup, not the list’s own setup:
<script setup lang="ts">import { provideMeterClient } from '@meterry/component-vue'import UsagePage from './UsagePage.vue'provideMeterClient({ tokenEndpoint: '/api/meter/subject-token', options: { baseUrl: '', tokenStorage: null },})</script><template><UsagePage /></template><script setup lang="ts">import { MeterUsageList } from '@meterry/component-vue'</script><template> <MeterUsageList source="events" :range-days="7" :page-size="20" /> <!-- Optional custom cells; the table header and row structure stay intact: --> <MeterUsageList source="events" :page-size="10" row-key="raw_event_id"> <template #cell="{ value, column }"> <strong v-if="column.key === 'raw_event_id'">{{ value }}</strong> <span v-else>{{ typeof value === 'object' ? JSON.stringify(value) : value }}</span> </template> </MeterUsageList></template>Pass :columns to customize headers. Vue also supports a function-valued
:slots prop with cell({ value, row, index, column }). Component
refs expose loadMore() and refresh().
Loading and completeness
Section titled “Loading and completeness”MeterUsageListdefaults tosource="events",rangeDays=7,pageSize=20,batchSize=500,refreshable=true. One click reveals at mostpageSizemore rows. Cached rows are revealed without an API request; later clicks fetchnext_after_idautomatically asafter_id. With the default sizes, displaying row 501 fetches the second batch if a cursor exists.- A displayed page can span several short API batches. An empty/short batch with a next cursor does not imply completion. Repeated cursors fail rather than loop.
- The time window is fixed for a query session; Refresh recomputes relative dates. This is not transactional snapshot isolation against concurrent ingestion or revocation. If paging detects an inconsistent dataset, refresh rather than silently repeating a page.
- Loading-more disables the button and preserves all displayed rows. Rapid clicks share one pending operation. Errors retain the prefix and show Retry; retry requests the failed page/cursor, not the first page.
- Exact total remains unknown until cursor exhaustion; the backend’s detail total
is not used by the current wrapper. At the end the button is removed and
No more data.is shown. Empty results display the empty state. source="usage"queries the aggregate endpoint once, then reveals that bounded result locally. There is no aggregate cursor. AtbatchSize, show an incomplete-result warning and do not label it as all data.queryfollows the automatic table contract: events accept time/metric selectors only; aggregate requests support dimensions and measures.- Refresh, changed source/query/auth client and changed page/batch sizes clear the previous prefix. Equivalent inline query objects or changes to cell renderers/columns/styles do not refetch. Unmount cancels owned work and ignores stale results, even if a custom fetch/loader disregards cancellation.
An append-only list retains every displayed row, so memory and DOM grow with Load more. The transport keeps only a bounded rolling batch cache, not a second copy of all historical pages. This is not a virtualized infinite list or an all-data export. Narrow long histories or use the paginated table for bounded rendering. No client-side sort is advertised as a global server-side sort.
MeterList: local data or a custom page loader
Section titled “MeterList: local data or a custom page loader”MeterList<T> supports data, load, or a caller-owned controller (highest
precedence). Local data takes precedence over a loader. A generic loader returns
one complete requested page and the exact matching total; it owns backend
pagination if its backend is not the built-in Meter source. Unlike the automatic
wrapper, generic load does not discover arbitrary cursor conventions.
import { MeterList } from '@meterry/component-react'import type { ListLoad } from '@meterry/component'
type Activity = { id: string; title: string }const loadActivities: ListLoad<Activity> = async ({ page, pageSize, signal }) => { const response = await fetch(`/api/activities?page=${page}&limit=${pageSize}`, { signal }) if (!response.ok) throw new Error('Unable to load activities') return response.json() // { rows: Activity[], total: number }}
const columns = [ { key: 'title', header: 'Activity' }, { key: 'id', header: 'ID', width: '10rem' },]export function ActivityList() { return <MeterList load={loadActivities} columns={columns} pageSize={10} rowKey="id" />}// For local data: <MeterList data={activities} columns={columns} pageSize={10} />For Vue, rows have the shape Record<string, unknown>; use :data, :load,
:columns, and #cell. Updated local data replaces the prefix and starts at
the first page. Keep load stable (outside render or memoized).
Props and customization
Section titled “Props and customization”| Prop | MeterList | MeterUsageList |
|---|---|---|
| Data source | data, load, or controller |
client, source, query, rangeDays, batchSize |
columns |
Required TableColumn<T>[] |
Optional; source-specific defaults |
pageSize |
20 |
20 |
rowKey |
Optional field name or (row, index) => string / number |
Same |
refreshable |
false |
true |
title, caption, empty, loading, error |
Rendering customization | Same |
slots |
header, cell, footer |
Same |
messages, className, classNames, unstyled |
Shared styling and copy | Same |
Columns support key, header, accessor, format, width, and align just
like Table. This append-only component does not provide sort/filter controls;
sortable and filter do not enable them. Sorting only a loaded prefix must not
be confused with global server sorting. Change the query to reset the result.
slots.cell uses MeterTableCellSlotContext<T> (value, row, index,
column) and replaces one cell, not the whole row. Without a cell slot,
column.format or displayTableValue formats the value. The header slot
customizes the component title region, not the column header. The table’s
thead always comes from columns.
MeterListFooterSlotContext<T> contains state and controller. A custom footer
replaces default actions; call controller.loadMore(), load() (retry), and
refresh() and preserve disabled/unknown-total semantics.
Styling reuses table for the horizontally scrolling table wrapper, with list
as an additional class hook and listFooter for actions. There are no card-field
or whole-item renderer APIs. Messages: listTitle, listLoading, listEmpty,
loadMore, loadingMore, noMoreItems, listSummary, usageListTruncated;
existing retry / refresh are reused.
Headless controllers
Section titled “Headless controllers”import { createListController, createUsageListController } from '@meterry/component'
const list = createUsageListController({ client, source: 'events', pageSize: 20 })// Or: createListController({ data, load, pageSize })const unsubscribe = list.subscribe((state) => console.log(state))await list.load()await list.loadMore()await list.refresh()unsubscribe()list.dispose()ListState<T> includes status, all displayed rows, last successful page,
pageSize, total: number | null, hasMore, truncated, and error. Controller
methods are getState, subscribe, load, loadMore, refresh, setData,
dispose. load is idempotent after success and retries the failed operation.
setData is for generic local lists; automatic usage lists own their query.
Caller-provided controllers are not disposed by the rendering component. Clear
old auth-scoped controllers/components when switching users or tenants.