@dloizides/ui-tables
v1.18.0
Published
Themable, brand-agnostic React Native (RN-web) table/stat components for the dloizides.com portfolio. Starts with StatCard; DataTable/StatGrid join when a 2nd consumer appears. Shares the @dloizides/ui-feedback UI context.
Maintainers
Readme
@dloizides/ui-tables
Themable, brand-agnostic React Native (RN-web) table/stat components for the dloizides.com
portfolio. Reads theme + translations from the shared @dloizides/ui-feedback UI context (useUi).
Components
| Component | Status |
|-----------|--------|
| StatCard | ✅ Available — labelled metric tile (label + locale-formatted value). |
| DataTable | ✅ Available — the shared, tokenized RN-web grid (the GRID.md contract): columns + rows API, sticky header, zebra striping, per-row tint, pressable rows, per-row testID + a11y, optional expandable rows, and a responsive label:value card-stack below stackBreakpoint. |
| FilterBar | ✅ Available — the .ui-filters shell: wrapping field row + live results count + actions slot. |
| Filters | ✅ Available — the declarative filter bar built ON FilterBar: pass a fields schema (select / text / number / dateRange / typeahead / boolean) + a value map + onChange. Two value models (LIVE, or DRAFT/APPLY via useFilterDraft + an Apply button). The superset of every portal's hand-rolled filters; only the theme changes. See Filters. |
| Pager | ✅ Available — the .ui-pager control: from–to of N page-info + rows-per-page control + Prev/Next (+ optional First/Last via showFirstLast). Pass an optional, already-translated unitLabel (e.g. "leadership terms") — plus unitLabelSingular for 1 result singularisation — to render 1–50 of 3,023 leadership terms. responsive collapses to a compact Prev/Next-only nav below stackBreakpoint (mobile fallback). The rows-per-page control defaults to size pills (rowsVariant="pills"); pass rowsVariant="dropdown" for the compact <select>-style anchored dropdown. |
| StatGrid | ⏳ Deferred — exists only in kefi-web (n=1); moves in when a 2nd consumer appears. |
DataTable / FilterBar / Pager
import { DataTable, FilterBar, Pager, type DataTableColumn } from '@dloizides/ui-tables';
const columns: DataTableColumn<Row>[] = [
{ key: 'name', header: 'Name', weight: 2, render: (r) => r.name },
{ key: 'score', header: 'Score', numeric: true, render: (r) => String(r.score) },
];
<FilterBar resultsCount={rows.length} actions={<ApplyButton />}>{fields}</FilterBar>
<DataTable columns={columns} rows={rows} keyExtractor={(r) => r.id} zebra stickyHeader testID="grid" />
<Pager page={page} pageSize={size} total={total} onPageChange={setPage} onPageSizeChange={setSize} />Filters — declarative filter bar
FilterBar is the shell (layout + results count + actions). Filters is the declarative
bar built on it: describe your filters as data and the bar renders the same structure +
behaviour everywhere — only the theme changes. Field kinds: select, text, number,
dateRange, typeahead (search-as-you-type), boolean.
Every user-facing string is pre-localized and passed in (labels, option labels, placeholders, errors) — the package never calls FM/i18n for content, touches a router, or reads a store.
Since 1.14.0 the controls themselves live in
@dloizides/ui-formsasSelectControl,TypeaheadControlandDateRangeControl. They used to be private to this bar, which is why the fleet grew 6 selects, 5 date fields and 2 typeaheads — apps could see them working and could not import them. If you need a select or a date range OUTSIDE a filter bar, import it fromui-forms— do not rebuild it, and do not reach into this package.Filtersis unchanged and still the right thing for a filter bar; it now composes the shared controls.
import { Filters, useFilterDraft, type FilterField, type FilterValues } from '@dloizides/ui-tables';
const fields: FilterField[] = [
{ key: 'q', kind: 'text', label: FM('cases.q'), grow: true, placeholder: FM('cases.qHint') },
{ key: 'status', kind: 'select', label: FM('cases.status'),
options: [{ label: FM('common.any'), value: '' }, { label: FM('status.open'), value: 'open' }] },
{ key: 'dates', kind: 'dateRange', label: FM('cases.valueDate') },
{ key: 'country', kind: 'typeahead', label: FM('leaders.country'), options: countryOptions, minChars: 1 },
{ key: 'active', kind: 'boolean', label: FM('cases.activeOnly') },
];Live model (agora / zygos) — each edit applies immediately, no Apply button:
const [values, setValues] = useState<FilterValues>(EMPTY);
<Filters fields={fields} values={values} onChange={setValues} resultsCount={total} resultsLabel={FM('cases.results')} />Draft / apply model (aml / kefi audit) — edits accumulate in a draft; Apply (or Enter in a
field) commits it, and page resets to 1 via your onApply. useFilterDraft is the state engine:
const draft = useFilterDraft({ initial: EMPTY, onApply: () => setPage(1) });
<Filters
fields={fields}
values={draft.draft}
onChange={draft.setDraft}
onApply={draft.apply} // presence of onApply → shows the Apply button + Enter-to-commit
applyDisabled={datesInvalid} // aml's validity gate
onClear={draft.reset} // presence of onClear → shows Clear/Reset
actions={<ExportButtons query={draft.committed} />} // custom actions (Export CSV/PDF, bulk bar)
resultsCount={total}
resultsLabel={total === 1 ? FM('cases.resultOne') : FM('cases.resultMany')} // caller singularises
/>Injecting ModalDropdown — the built-in select is a dependency-free in-tree dropdown. To
use @dloizides/ui-layout's responsive ModalDropdown (modal on mobile/native) instead, pass
renderSelect:
<Filters
fields={fields}
values={values}
onChange={setValues}
renderSelect={({ field, value, onChange, testID }) => (
<ModalDropdown testID={testID} accessibilityLabel={field.label} accessibilityHint={field.label}
value={value} options={field.options} onChange={onChange} />
)}
/>Expandable rows (optional)
For surfaces that open a full-width detail panel under a row (e.g. an audit log's
before/after JSON snapshots), pass renderRowDetail + expandedRowKeys. The panel renders
between that row and the next — spanning all columns on desktop, and full-width beneath the
card in the card-stack mode. Expansion is controlled by you: the table keeps no internal
expand state and renders no chevron; toggle from the existing onRowPress.
const [expanded, setExpanded] = useState<readonly string[]>([]);
const toggle = (r: Row) =>
setExpanded((keys) => (keys.includes(r.id) ? keys.filter((k) => k !== r.id) : [...keys, r.id]));
<DataTable
columns={columns}
rows={rows}
keyExtractor={(r) => r.id} // the SAME key is matched against expandedRowKeys
onRowPress={toggle}
expandedRowKeys={expanded}
renderRowDetail={(r) => <AuditDetail entry={r} />}
testID="audit"
/>The panel's test id is `${testID}-row-detail-${key}` (e.g. audit-row-detail-42) — build it
with the exported rowDetailTestID(tableTestID, key) (rows: rowTestID(tableTestID, key)). It is
exposed to assistive tech as a labelled region (provide the uiTables.rowDetail key), and the row
reports aria-expanded. Omit both props and nothing about the table changes.
Bulk-select (optional)
Pass onSelectionChange to add a checkbox gutter (header + every row). Selection is
controlled by you, exactly like expandedRowKeys: the table keeps no internal selection
state. The header checkbox is tri-state and reports aria-checked="mixed" when only part of
the page is ticked.
const [selected, setSelected] = useState<readonly string[]>([]);
<DataTable
columns={columns}
rows={rows}
keyExtractor={(r) => r.id} // the SAME key is matched against selectedRowKeys
selectedRowKeys={selected}
onSelectionChange={setSelected}
testID="grid"
/>The header only ever adds or removes this page's keys — any off-page keys you hold are
preserved, because the table cannot see the rows they belong to. Test ids:
rowSelectTestID(tableTestID, key) and selectAllTestID(tableTestID).
Select all matching the filter — a flag, not ids (optional)
This table is server-paged and deliberately not virtualized (see the ZY-02 grid spike: render cost is linear at ~2–3 ms and ~16 DOM nodes per row, so a huge page is indistinguishable from an outage). "Select all 3,023 matching" therefore cannot and must not be an id list — the other pages were never fetched.
Add matchingCount (the server's total) + onSelectAllMatchingChange and, once the whole
page is ticked and more rows match, a banner offers the flag:
<DataTable
/* …selection props… */
matchingCount={total} // same number your Pager shows
allMatchingSelected={allMatching} // controlled FLAG
onSelectAllMatchingChange={setAllMatching} // emits `true` / `false` — never ids
/>Resolve the flag server-side against the same filter your list endpoint took. That is a
better design anyway: the work survives the operator closing the tab. While the flag is set
every row reads as selected with an empty selectedRowKeys; toggling any single row drops
the flag (the operator is no longer acting on the filter).
Keyboard navigation (optional)
keyboardNavigation turns the table into a real ARIA grid using the roving tabindex
pattern — exactly one row is tabbable, so Tab crosses the grid in one hop instead of 100.
| Key | Action |
|---|---|
| ↑ / ↓ | Move the focused row (clamped — never wraps) |
| Home / End | First / last row on the page |
| Space | Toggle selection (when selectable) |
| Enter | Activate the row, via the existing onRowPress |
<DataTable columns={columns} rows={rows} keyExtractor={(r) => r.id} keyboardNavigation onRowPress={open} />Off by default, because adding tabIndex to rows changes a page's tab order. Web-only in
effect (focus movement and key events are DOM concerns); inert on native. Keys the table does
not handle are never preventDefaulted, so Tab and browser shortcuts keep working. When the
page changes underneath it, the tab stop re-homes onto the new first row rather than
disappearing.
Pixel-perfect overrides (optional)
The defaults are opinionated but never mandatory. When a surface must match an existing
look exactly, pass styleOverrides — a per-slot map that is merged LAST into each slot's
style array, so it beats both the base StyleSheet and the inline theme colours (this kit
keeps colours out of the StyleSheet and applies them from useUi().theme at render time, so
an override that only beat the base would still lose to the theme).
import type { DataTableStyleOverrides } from '@dloizides/ui-tables';
const overrides: DataTableStyleOverrides = {
wrap: { borderRadius: 10 }, // beats the kit's 12 (base StyleSheet)
headRow: { backgroundColor: theme.surfaceMuted }, // beats theme.colors.background (INLINE colour)
};
<DataTable columns={columns} rows={rows} keyExtractor={(r) => r.id}
stackBreakpoint={0} // ← keeps the desktop grid at EVERY width (no card-stack)
styleOverrides={overrides} />| Component | Type | Slots |
|-----------|------|-------|
| DataTable | DataTableStyleOverrides | wrap, headRow, headCell, row, cell, numCell, state, stateText, rowDetail, card, cardLine, cardLabel, cardValue |
| Pager | PagerStyleOverrides | pager, pagerInfo, pagerNav, pagerRowsLabel, sizeGroup, control, controlText, sizePill, sizePillText |
| FilterBar | FilterBarStyleOverrides | filters, filtersSpacer, results, filtersActions |
The responsive card-stack (default breakpoint 768, aligned with @dloizides/ui-layout's
MENU_BREAKPOINT): below stackBreakpoint each row collapses to a card where every column's
header sits above its value (left-aligned label:value pairs), so a narrow phone never crams a
multi-column table into a side-scrolling row. The desktop grid above the breakpoint is unchanged.
- Test hooks survive both layouts. The head test id (
ui-data-table-head), the row test ids (${testID}-row-${key}) and the per-cell test ids (${testID}-row-${key}-${col.key}) all resolve in the card-stack exactly as they do on the desktop grid — so an E2E suite written against the wide layout keeps working when a table goes card-stack on mobile. A column'srendernode (badge, link, action button) renders identically in a card cell. - Disabling the card-stack: it renders when
width < stackBreakpoint, sostackBreakpoint={0}(never true) keeps the desktop grid at every width — the opt-out for a table that never had one.
Omit styleOverrides entirely and every component renders exactly as it always has.
Colours come entirely from useUi().theme (drive it with @dloizides/design-tokens via tokensToUiTheme), so the grid re-themes per tenant. Every component-authored string is routed through the UiProvider t — provide the uiTables.* keys (see TABLE_I18N) in your locale files; a caller may also pass already-translated loadingLabel / emptyLabel / resultsLabel directly.
⚠️ New in 1.12.0 — three translation keys every host app must add
1.12.0 gives three controls a real accessible name instead of a bare number. Those names are user-facing strings, so they come from your locale files. Add these three keys.
| Key (constant) | Key (string) | Params | Suggested English |
|---|---|---|---|
| TABLE_I18N.pagerRowsTriggerLabel | uiTables.pager.rowsTriggerLabel | {{p1}} = current page size | Rows per page, currently {{p1}} |
| TABLE_I18N.pagerRowsOptionLabel | uiTables.pager.rowsOptionLabel | {{p1}} = that option's size | Show {{p1}} rows per page |
| FILTERS_I18N.selectTriggerLabel | uiTables.filters.selectTriggerLabel | {{p1}} = field label, {{p2}} = selected option | {{p1}}: {{p2}} |
Also newly listed in TABLE_I18N (not new keys — StatCard has always called them, they were
just inlined in the component where a key-coverage guard could not see them). If your app renders
StatCard, check you actually define them:
| Key (constant) | Key (string) | Params |
|---|---|---|
| TABLE_I18N.statCardLabel | analytics.statCardLabel | {{p1}} = label, {{p2}} = value |
| TABLE_I18N.statHint | analytics.statHint | {{p1}} = label |
Your app will not visibly break if you skip this. A key you have not defined degrades to the value it replaced (the bare number, the field label alone) rather than to the raw dotted key — so upgrading is safe, but until you add the keys your screen-reader users keep the old, worse experience. Derive your required-key list from the exported maps rather than hand-maintaining it:
import { TABLE_I18N, FILTERS_I18N } from '@dloizides/ui-tables';
// Guard test: every key the kit can ask for must exist in your locale file.
const required = [...Object.values(TABLE_I18N), ...Object.values(FILTERS_I18N)];
required.forEach((key) => expect(en).toHaveProperty(key));Install
npm install @dloizides/ui-tables @dloizides/ui-feedback @dloizides/ui-forms @dloizides/ui-buttonsPeer dependencies: @dloizides/ui-feedback >= 1.1.0, @dloizides/ui-forms >= 1.8.0,
@dloizides/ui-buttons >= 1.4.0 (inherited through ui-forms), react >= 18,
react-native >= 0.74.
Usage
import { StatCard } from '@dloizides/ui-tables';
<StatCard label="Total responses" value={1234} testID="stat-total" />Mount a FeedbackUiProvider / UiProvider (from @dloizides/ui-feedback) at your app root so the
component picks up your theme + translations. The injected t is called with TABLE_I18N.statHint
(analytics.statHint) and TABLE_I18N.statCardLabel (analytics.statCardLabel) — provide these
keys in your locale files.
License
MIT
