Şablonlar
Liste ve filtreler
Filtreler, sıralanabilen ve seçilebilen tablo, sayfalama, dışa aktarma, satır menüsü, ayrıntı paneli, silme onayı ve geri alınabilen toplu işlemlerle kayıt listesi.
Liste bloğu, kayıtların arandığı, filtrelendiği ve toplu işlendiği sayfayı AppShell şablonu içinde kurar. Filtreler adres sorgusuna yansır; sıralama sayfalamadan önce bütün sonuca uygulanır.
Önizleme
Bölüm başlığı “Önizleme”Arayın, durum ve kategori seçin, sütun başlığıyla sıralayın, sayfa değiştirin ve satır seçin. Satır sonundaki menüden ayrıntıları açın ya da bir kaydı silin; arşivlemeyi kısa bildirimdeki “Geri al” ile geri alın. Sayfa başlığındaki marka ve mod seçimi önizlemeyi de değiştirir.
pnpm add @bigbang/ui| Veri | Tür | Not |
|---|---|---|
| Kayıtlar | Row[] |
Sunucu sayfalamasında yalnız görünen sayfa gelir; totalCount ile toplam verilir. |
| Filtreler | { query, status, category, mine } |
filtersToSearchParams ile adres sorgusuna, searchParamsToFilters ile geri. |
onFiltersChange |
(filters, query) => void |
Uygulama burada adres çubuğunu günceller. |
state |
'ready' | 'loading' | 'empty' | 'error' |
Tablo durumları ve ilk kullanım mesajı. |
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) } } : {})} /> </> );}- PageHeader:
h1“Kayıtlar”, SplitButton dışa aktarma ve birincil “Kayıt ekle”. - FilterBar: arama, durum, kategori ve “Yalnızca benim”; uygulanan filtre çipleri ve sonuç sayısı.
- Seçim varken ActionBar: Arşivle, Dışa aktar, Sil.
- DataTable: sıralama, çoklu seçim, filtre sonucu boş ve hata durumları. Her satırda bir DropdownMenu: Ayrıntıları göster, Düzenle, Arşivle, ayraç ve en sonda
dangertonlu Sil. - Tablo altında Pagination, 10 satırlık sayfalar.
- “Ayrıntıları göster”, Ayrıntı paneli bloğundaki
RecordDetailDrawer’ı açar; panelde filtrelenmiş sonuçta önceki ve sonraki kayda geçilir. - Sil (satırdan ya da ActionBar’dan) bir AlertDialog açar; onaylanınca kayıtlar kalkar.
- Sonuçlar Toast olarak gelir; arşivleme bildiriminde “Geri al” eylemi vardır.
- Hiç kayıt yokken EmptyState.
Erişilebilirlik
Bölüm başlığı “Erişilebilirlik”- Sonuç sayısı yazma bittikten 500 ms sonra bir kez okunur; her tuşta okunmaz.
- Seçim sayısını yalnız ActionBar duyurur; tablonun kendi sayı metni boş verilir, iki kez okunmaz.
- Sıralanan sütun
aria-sorttaşır; satır seçim kutuları satır adıyla adlandırılır. - Sayfa ve filtre değişiminde satırlar giriş hareketi yapmaz; odak düğmede kalır.
- Hiç kayıt yokken filtre çubuğu ve boş tablo yerine
h2başlıklı EmptyState gelir. - Satır menüsü düğmesi “Diğer işlemler: Kayıt 1” gibi satırın adıyla adlandırılır. Menüden açılan panel ya da onay kapanınca odak bu düğmeye döner.
- Silme onayında odak önce Vazgeç’tedir; onay sürerken kutu kapanmaz.
- Arşivleme bildirimi kibar okunur; “Geri al” düğmesine F6 ile bildirim bölgesinden ulaşılır ve süre odaklanınca durur.
Kullanılan bileşenler
Bölüm başlığı “Kullanılan bileşenler”FilterBar, DataTable, Pagination, ActionBar, SplitButton, StatusBadge, IconButton, DropdownMenu, Drawer, AlertDialog, Toast, EmptyState. Kalıplar: Filtreleme, Toplu işlemler, Veri dışa aktarma, Boş durumlar.