Templates
List and filters
A record list with filters, a sortable selectable table, pagination, export, a row menu, a detail drawer, a delete confirmation and bulk actions that can be undone.
The list block builds the page where records are searched, filtered and handled in bulk, inside the AppShell template. Filters are mirrored to the address query; sorting applies to the whole result before paging.
Preview
Section titled “Preview”Search, pick a status and a category, sort by a column header, change the page and select rows. Open the details or delete a record from the menu at the end of a row; undo archiving with “Undo” in the toast. The brand and mode switches in the page header restyle the preview too.
pnpm add @bigbang/ui| Data | Type | Note |
|---|---|---|
| Records | Row[] |
With server paging only the visible page arrives; pass the total with totalCount. |
| Filters | { query, status, category, mine } |
To the address query with filtersToSearchParams, back with searchParamsToFilters. |
onFiltersChange |
(filters, query) => void |
The app updates the address bar here. |
state |
'ready' | 'loading' | 'empty' | 'error' |
Table states and the first-use message. |
import { ActionBar, AlertDialog, Button, createDataTableColumnHelper, DataTable, DropdownMenu, EmptyState, FilterBar, filtersToSearchParams, Icon, IconButton, Pagination, SplitButton, Stack, StatusBadge, Text, ToolbarButton, useToast,} from '@bigbang/ui';import type { DataTableRowSelection, DataTableSorting, FilterField, StatusKey } from '@bigbang/ui';import { useMemo, useState } from 'react';import type { Lang } from '../../patterns/lang';import { AppShellTemplate } from '../../templates/app-shell/AppShellTemplate';import { RecordDetailDrawer } from '../detail-drawer/DetailDrawerBlock';
const copy = { tr: { title: 'Kayıtlar', description: 'Ekibin tüm kayıtları; filtreleyin, sıralayın ve toplu işlem yapın.', add: 'Kayıt ekle', exportCsv: 'CSV olarak indir', exportXlsx: 'Excel olarak indir', exportPdf: 'PDF olarak indir', exportMenu: 'Diğer dışa aktarma biçimleri', search: 'Ara', searchPlaceholder: 'Kayıt adına göre ara', status: 'Durum', category: 'Kategori', mine: 'Yalnızca benim', caption: 'Kayıt listesi', name: 'Ad', created: 'Oluşturulma', count: 'Adet', more: (name: string) => `Diğer işlemler: ${name}`, details: 'Ayrıntıları göster', edit: 'Düzenle', editing: (name: string) => `${name} düzenleme için açıldı.`, undo: 'Geri al', restored: 'Kayıtlar geri alındı', deleteTitle: (count: number) => (count === 1 ? 'Kayıt silinsin mi?' : `${count} kayıt silinsin mi?`), deleteText: 'Silinen kayıtlar ekipten de kalkar. Bu işlem geri alınamaz.', deleted: (count: number) => `${count} kayıt silindi`, record: (index: number) => `Kayıt ${index}`, categories: { a: 'Kategori A', b: 'Kategori B', c: 'Kategori C' }, statuses: { active: 'Etkin', pending: 'Beklemede', completed: 'Tamamlandı', archived: 'Arşivlendi' }, selected: (count: number) => `${count} kayıt seçildi`, archive: 'Arşivle', exportSelected: 'Dışa aktar', remove: 'Sil', archived: (count: number) => `${count} kayıt arşivlendi`, query: 'Adres sorgusu', pagination: 'Sayfalar', emptyTitle: 'Henüz kayıt yok', emptyText: 'İlk kaydı ekleyin; ekibiniz burada görür.', }, en: { title: 'Records', description: "All of the team's records; filter, sort and act on many at once.", add: 'Add record', exportCsv: 'Download as CSV', exportXlsx: 'Download as Excel', exportPdf: 'Download as PDF', exportMenu: 'Other export formats', search: 'Search', searchPlaceholder: 'Search by record name', status: 'Status', category: 'Category', mine: 'Mine only', caption: 'Record list', name: 'Name', created: 'Created', count: 'Count', more: (name: string) => `More actions: ${name}`, details: 'Show details', edit: 'Edit', editing: (name: string) => `${name} opened for editing.`, undo: 'Undo', restored: 'Records restored', deleteTitle: (count: number) => (count === 1 ? 'Delete the record?' : `Delete ${count} records?`), deleteText: 'Deleted records are removed for the team too. This cannot be undone.', deleted: (count: number) => `${count} ${count === 1 ? 'record' : 'records'} deleted`, record: (index: number) => `Record ${index}`, categories: { a: 'Category A', b: 'Category B', c: 'Category C' }, statuses: { active: 'Active', pending: 'Pending', completed: 'Completed', archived: 'Archived' }, selected: (count: number) => `${count} ${count === 1 ? 'record' : 'records'} selected`, archive: 'Archive', exportSelected: 'Export', remove: 'Delete', archived: (count: number) => `${count} ${count === 1 ? 'record' : 'records'} archived`, query: 'Address query', pagination: 'Pages', emptyTitle: 'No records yet', emptyText: 'Add the first record; your team sees it here.', },};
type Category = 'a' | 'b' | 'c';type Row = { id: string; name: string; status: StatusKey; category: Category; mine: boolean; day: number; count: number };type Filters = { query: string; status: string | null; category: string[]; mine: boolean };
const empty: Filters = { query: '', status: null, category: [], mine: false };const statusCycle: StatusKey[] = ['active', 'pending', 'completed', 'archived'];const categoryCycle: Category[] = ['a', 'b', 'c'];const PAGE_SIZE = 10;
function makeRows(lang: Lang): Row[] { return Array.from({ length: 48 }, (_, index) => ({ id: `record-${index + 1}`, name: copy[lang].record(index + 1), status: statusCycle[index % statusCycle.length] ?? 'active', category: categoryCycle[index % categoryCycle.length] ?? 'a', mine: index % 3 === 0, day: (index % 28) + 1, count: ((index * 25) % 500) + 50, }));}
const helper = createDataTableColumnHelper<Row>();
export type ListFiltersBlockProps = { lang?: Lang; /** `empty` shows the first-use state; `loading` and `error` the table states. */ state?: 'ready' | 'loading' | 'empty' | 'error'; /** Called with the filters on every change; mirror them to the address bar in the app. */ onFiltersChange?: (filters: Filters, query: string) => void;};
/** * A record list page: header actions, filters, a sortable selectable table, pagination, bulk actions, * a row menu, a detail drawer, a delete confirmation and toasts with undo. */export function ListFiltersBlock({ lang = 'tr', state = 'ready', onFiltersChange }: ListFiltersBlockProps) { const t = copy[lang]; return ( <AppShellTemplate lang={lang} current="records" title={t.title} description={t.description} actions={ <> <SplitButton variant="secondary" size="sm" menuLabel={t.exportMenu} actions={[{ label: t.exportXlsx, icon: 'download', onSelect: () => undefined }, { label: t.exportPdf, icon: 'file', onSelect: () => undefined }]}> {t.exportCsv} </SplitButton> <Button size="sm" startIcon={<Icon name="plus" />}> {t.add} </Button> </> } > <ListFiltersContent lang={lang} state={state} {...(onFiltersChange ? { onFiltersChange } : {})} /> </AppShellTemplate> );}
// Inside the template, so `useToast` reaches the template's Toaster.function ListFiltersContent({ lang, state, onFiltersChange }: Required<Pick<ListFiltersBlockProps, 'lang' | 'state'>> & Pick<ListFiltersBlockProps, 'onFiltersChange'>) { const t = copy[lang]; const toast = useToast(); const [rows, setRows] = useState(() => (state === 'empty' ? [] : makeRows(lang))); const [filters, setFilters] = useState<Filters>(empty); const [sorting, setSorting] = useState<DataTableSorting>([]); const [selection, setSelection] = useState<DataTableRowSelection>({}); const [page, setPage] = useState(1); const [detailId, setDetailId] = useState<string | null>(null); const [deleting, setDeleting] = useState<string[] | null>(null); const date = useMemo(() => new Intl.DateTimeFormat(lang === 'tr' ? 'tr-TR' : 'en-GB', { day: 'numeric', month: 'short', year: 'numeric' }), [lang]);
const fields = useMemo<ReadonlyArray<FilterField<Filters>>>( () => [ { key: 'query', label: t.search, kind: 'search', placeholder: t.searchPlaceholder }, { key: 'status', label: t.status, kind: 'select', options: (['active', 'pending', 'completed', 'archived'] as const).map((value) => ({ value, label: t.statuses[value] })) }, { key: 'category', label: t.category, kind: 'multi-select', options: categoryCycle.map((value) => ({ value, label: t.categories[value] })) }, { key: 'mine', label: t.mine, kind: 'toggle' }, ], [t], );
const filtered = useMemo(() => { const query = filters.query.trim().toLocaleLowerCase(lang === 'tr' ? 'tr-TR' : 'en-GB'); const matches = rows.filter( (row) => row.name.toLocaleLowerCase(lang === 'tr' ? 'tr-TR' : 'en-GB').includes(query) && (filters.status === null || row.status === filters.status) && (filters.category.length === 0 || filters.category.includes(row.category)) && (!filters.mine || row.mine), ); // Sorting runs on the whole result, before paging. const [sort] = sorting; if (!sort) return matches; const key = sort.id as keyof Row; return [...matches].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0) * (sort.desc ? -1 : 1)); }, [rows, filters, sorting, lang]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const current = Math.min(page, pageCount); const visible = filtered.slice((current - 1) * PAGE_SIZE, current * PAGE_SIZE); const selected = Object.keys(selection).filter((id) => selection[id]);
const changeFilters = (next: Filters) => { setFilters(next); setPage(1); onFiltersChange?.(next, filtersToSearchParams(next, fields).toString()); };
const archive = (ids: string[]) => { const before = rows; setRows((list) => list.map((row) => (ids.includes(row.id) ? { ...row, status: 'archived' } : row))); setSelection({}); toast.show({ title: t.archived(ids.length), tone: 'success', action: { label: t.undo, onClick: () => { setRows(before); toast.show({ title: t.restored }); } } }); };
const remove = () => { const ids = deleting ?? []; // The pause stands in for the request; the dialog stays busy until it settles. return new Promise<void>((resolve) => setTimeout(() => { setRows((list) => list.filter((row) => !ids.includes(row.id))); setSelection({}); if (detailId && ids.includes(detailId)) setDetailId(null); toast.show({ title: t.deleted(ids.length) }); resolve(); }, 600), ); };
const detailIndex = detailId === null ? -1 : filtered.findIndex((row) => row.id === detailId); const detail = detailIndex === -1 ? null : (filtered[detailIndex] ?? null);
const columns = useMemo( () => [ helper.accessor('name', { header: t.name, meta: { rowHeader: true } }), helper.accessor('status', { header: t.status, enableSorting: false, cell: (context) => <StatusBadge status={context.getValue()} size="sm" /> }), helper.accessor('category', { header: t.category, enableSorting: false, cell: (context) => t.categories[context.getValue()] }), helper.accessor('day', { header: t.created, cell: (context) => date.format(new Date(2026, 9, context.getValue())) }), helper.accessor('count', { header: t.count, meta: { numeric: true } }), ], [t, date], );
const tableStatus = state === 'loading' ? 'loading' : state === 'error' ? 'error' : filtered.length === 0 && rows.length > 0 ? 'filtered-empty' : 'ready'; const query = filtersToSearchParams(filters, fields).toString();
return ( <> {state === 'empty' ? ( <EmptyState icon="list" title={t.emptyTitle} description={t.emptyText} className="rounded-lg border border-line-subtle" actions={<Button startIcon={<Icon name="plus" />}>{t.add}</Button>} /> ) : ( <Stack gap="lg"> <FilterBar<Filters> fields={fields} value={filters} onValueChange={changeFilters} resultCount={filtered.length} defaultValue={empty} /> {query ? ( <Text size="sm" tone="subtlest" numeric> {`${t.query}: ?${query}`} </Text> ) : null} {selected.length > 0 ? ( <ActionBar placement="inline" count={selected.length} countLabel={t.selected} onClear={() => setSelection({})}> <ToolbarButton label={t.archive} icon="file" onClick={() => archive(selected)} /> <ToolbarButton label={t.exportSelected} icon="download" /> <ToolbarButton label={t.remove} icon="trash" onClick={() => setDeleting(selected)} /> </ActionBar> ) : null} <DataTable<Row> caption={t.caption} captionHidden columns={columns} data={visible} getRowId={(row) => row.id} getRowLabel={(row) => row.name} sorting={sorting} onSortingChange={setSorting} manualSorting selectionMode="multiple" rowSelection={selection} onRowSelectionChange={setSelection} status={tableStatus} onClearFilters={() => changeFilters(empty)} onRetry={() => undefined} messages={{ 'selected-count': '' }} density="compact" rowActions={(row) => ( <DropdownMenu align="end" trigger={<IconButton icon="ellipsis" label={t.more(row.name)} variant="tertiary" size="sm" />}> <DropdownMenu.Item icon="eye" onSelect={() => setDetailId(row.id)}> {t.details} </DropdownMenu.Item> <DropdownMenu.Item icon="pencil" onSelect={() => toast.show({ title: t.editing(row.name) })}> {t.edit} </DropdownMenu.Item> <DropdownMenu.Item icon="file" onSelect={() => archive([row.id])} disabled={row.status === 'archived'}> {t.archive} </DropdownMenu.Item> <DropdownMenu.Separator /> <DropdownMenu.Item icon="trash" tone="danger" onSelect={() => setDeleting([row.id])}> {t.remove} </DropdownMenu.Item> </DropdownMenu> )} footer={ tableStatus === 'ready' ? ( <Pagination label={t.pagination} page={current} pageCount={pageCount} onPageChange={setPage} totalCount={filtered.length} pageSize={PAGE_SIZE} size="sm" /> ) : null } /> </Stack> )} <AlertDialog tone="danger" open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }} title={t.deleteTitle(deleting?.length ?? 1)} description={t.deleteText} confirmLabel={t.remove} onConfirm={remove} /> <RecordDetailDrawer lang={lang} record={detail} onOpenChange={(open) => { if (!open) setDetailId(null); }} {...(detail ? { position: { index: detailIndex, total: filtered.length, onMove: (step: -1 | 1) => setDetailId(filtered[detailIndex + step]?.id ?? detailId) } } : {})} /> </> );}Structure
Section titled “Structure”- PageHeader:
h1“Records”, a SplitButton export and a primary “Add record”. - FilterBar: search, status, category and “Mine only”; applied filter chips and the result count.
- With a selection, an ActionBar: Archive, Export, Delete.
- DataTable: sorting, multiple selection, filtered-empty and error states. Each row has a DropdownMenu: Show details, Edit, Archive, a separator and a
dangerDelete last. - Pagination under the table, 10 rows per page.
- “Show details” opens the
RecordDetailDrawerof the Detail drawer block; inside it, previous and next move through the filtered result. - Delete (from a row or the ActionBar) opens an AlertDialog; confirming removes the records.
- Results arrive as Toasts; the archive toast has an “Undo” action.
- With no records at all, an EmptyState.
Accessibility
Section titled “Accessibility”- The result count is read once, 500 ms after typing stops; not on every key.
- Only the ActionBar announces the selection count; the table’s own count text is set empty so it is not read twice.
- The sorted column has
aria-sort; row checkboxes are named after their row. - Rows do not animate in on page or filter changes; focus stays on the control.
- With no records at all, an EmptyState with an
h2replaces the filter bar and the empty table. - The row menu button is named after its row, such as “More actions: Record 1”. When a drawer or confirmation opened from the menu closes, focus returns to this button.
- The delete confirmation puts focus on Cancel first; it cannot close while the deletion runs.
- The archive toast is read politely; F6 reaches its “Undo” button in the notification region, and the timer stops while it has focus.
Components used
Section titled “Components used”FilterBar, DataTable, Pagination, ActionBar, SplitButton, StatusBadge, IconButton, DropdownMenu, Drawer, AlertDialog, Toast, EmptyState. Patterns: Filtering, Bulk actions, Data export, Empty states.