@krizaka/ui 3.16.1 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -103,6 +103,13 @@
103
103
  "description": "The app's link component (Next's `Link`).",
104
104
  "required": false
105
105
  },
106
+ {
107
+ "name": "railFrom",
108
+ "type": "\"default\" | \"wide\"",
109
+ "default": "default",
110
+ "description": "From which window width the filters stay in their column beside the results. `default`: 900 px and above. `wide`:\n1280 px and above — a wide table (DataTable) needs the room; under it the filters are a bar above the results.",
111
+ "required": false
112
+ },
106
113
  {
107
114
  "name": "search",
108
115
  "type": "ReactNode",
@@ -138,7 +145,7 @@
138
145
  "files": [
139
146
  {
140
147
  "path": "catalogue-page/catalogue-page.tsx",
141
- "content": "// Server-safe. A catalogue or an index (blocks.css `.kz-tpl-catalogue`): the hero, then the filters in a sticky column\n// beside the results — folded into a disclosure above them on a phone — the count of what matches and the sort, and\n// the results (a CardGrid of LinkCards, a list). The filters live in the address: a link per choice, no client state.\nimport { Page } from \"@krizaka/ui/page\";\nimport type { LinkComponent } from \"@krizaka/ui/page-hero\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\nimport type * as React from \"react\";\n\nexport type CataloguePageProps = Omit<React.HTMLAttributes<HTMLElement>, \"title\"> & {\n /** The way back. */\n back?: { href: string; label: string };\n /** A mono line above the title. */\n eyebrow?: React.ReactNode;\n /** The page's `h1`. */\n title: React.ReactNode;\n /** One sentence under it. */\n lead?: React.ReactNode;\n /** In the hero: a search field. */\n search?: React.ReactNode;\n /** The filters (groups of ChipLinks, a FilterBar): sticky on a desktop. */\n filters: React.ReactNode;\n /** On a phone, the words that open the filters (`Filters`). */\n filtersLabel: string;\n /** What matches, in words (`24 components`). */\n count?: React.ReactNode;\n /** On the right of the count: the sort, a view switch. */\n toolbar?: React.ReactNode;\n /** The results: a CardGrid, a list, an empty state. */\n children: React.ReactNode;\n /** The app's link component (Next's `Link`). */\n linkAs?: LinkComponent;\n};\n\n/** A catalogue: hero, sticky filters beside the results (folded on a phone), the count, the results. */\nexport function CataloguePage({ back, eyebrow, title, lead, search, filters, filtersLabel, count, toolbar, linkAs, children, ...props }: CataloguePageProps) {\n return (\n <div {...props}>\n <PageHero back={back} linkAs={linkAs} eyebrow={eyebrow} title={title} lead={lead} backdrop=\"plain\">\n {search}\n </PageHero>\n <Page className=\"kz-tpl-catalogue-frame\">\n <div className=\"kz-tpl-catalogue\">\n <details className=\"kz-tpl-catalogue-filters\" open>\n <summary>{filtersLabel}</summary>\n <div>{filters}</div>\n </details>\n <div className=\"min-w-0\">\n {(count || toolbar) && (\n <div className=\"kz-tpl-catalogue-head\">\n {count && (\n <p className=\"kz-tpl-count\" aria-live=\"polite\">\n {count}\n </p>\n )}\n {toolbar}\n </div>\n )}\n {children}\n </div>\n </div>\n </Page>\n </div>\n );\n}\n"
148
+ "content": "// Server-safe. A catalogue or an index (blocks.css `.kz-tpl-catalogue`): the hero, then the filters in a sticky column\n// beside the results — folded into a disclosure above them on a phone — the count of what matches and the sort, and\n// the results (a CardGrid of LinkCards, a list). The filters live in the address: a link per choice, no client state.\nimport { Page } from \"@krizaka/ui/page\";\nimport type { LinkComponent } from \"@krizaka/ui/page-hero\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\nimport type * as React from \"react\";\n\nexport type CataloguePageProps = Omit<React.HTMLAttributes<HTMLElement>, \"title\"> & {\n /** The way back. */\n back?: { href: string; label: string };\n /** A mono line above the title. */\n eyebrow?: React.ReactNode;\n /** The page's `h1`. */\n title: React.ReactNode;\n /** One sentence under it. */\n lead?: React.ReactNode;\n /** In the hero: a search field. */\n search?: React.ReactNode;\n /** The filters (groups of ChipLinks, a FilterBar): sticky on a desktop. */\n filters: React.ReactNode;\n /** On a phone, the words that open the filters (`Filters`). */\n filtersLabel: string;\n /** What matches, in words (`24 components`). */\n count?: React.ReactNode;\n /** On the right of the count: the sort, a view switch. */\n toolbar?: React.ReactNode;\n /** The results: a CardGrid, a list, an empty state. */\n children: React.ReactNode;\n /** The app's link component (Next's `Link`). */\n linkAs?: LinkComponent;\n /**\n * From which window width the filters stay in their column beside the results. `default`: 900 px and above. `wide`:\n * 1280 px and above — a wide table (DataTable) needs the room; under it the filters are a bar above the results.\n */\n railFrom?: \"default\" | \"wide\";\n};\n\n/** A catalogue: hero, sticky filters beside the results (folded on a phone), the count, the results. */\nexport function CataloguePage({ back, eyebrow, title, lead, search, filters, filtersLabel, count, toolbar, linkAs, railFrom = \"default\", children, ...props }: CataloguePageProps) {\n return (\n <div {...props}>\n <PageHero back={back} linkAs={linkAs} eyebrow={eyebrow} title={title} lead={lead} backdrop=\"plain\">\n {search}\n </PageHero>\n <Page className=\"kz-tpl-catalogue-frame\">\n <div className=\"kz-tpl-catalogue\" data-rail={railFrom}>\n <details className=\"kz-tpl-catalogue-filters\" open>\n <summary>{filtersLabel}</summary>\n <div>{filters}</div>\n </details>\n <div className=\"min-w-0\">\n {(count || toolbar) && (\n <div className=\"kz-tpl-catalogue-head\">\n {count && (\n <p className=\"kz-tpl-count\" aria-live=\"polite\">\n {count}\n </p>\n )}\n {toolbar}\n </div>\n )}\n {children}\n </div>\n </div>\n </Page>\n </div>\n );\n}\n"
142
149
  },
143
150
  {
144
151
  "path": "catalogue-page/index.ts",
@@ -223,6 +230,13 @@
223
230
  "description": "The app's link component (Next's `Link`).",
224
231
  "required": false
225
232
  },
233
+ {
234
+ "name": "railFrom",
235
+ "type": "\"default\" | \"wide\"",
236
+ "default": "default",
237
+ "description": "From which window width the filters stay in their column beside the results. `default`: 900 px and above. `wide`:\n1280 px and above — a wide table (DataTable) needs the room; under it the filters are a bar above the results.",
238
+ "required": false
239
+ },
226
240
  {
227
241
  "name": "search",
228
242
  "type": "ReactNode",
@@ -34,6 +34,8 @@
34
34
  "Put `DataTableFilters` in `toolbar`; mark the columns it filters with `filter: \"text\" | \"facet\" | \"range\"`, and give a server table's facets their `options`.",
35
35
  "Server mode: keep the `DataTableQuery` from `onStateChange` in state (or the address), put it in the query key, and pass `rowCount` — `placeholderData: keepPreviousData` keeps the page while the next one loads.",
36
36
  "Say what failed in `error` with a way to retry; say what to do next in `empty` (an `EmptyState` with an action).",
37
+ "Type the columns (`type`: title · badge · status · number · date · link · text) and give the secondary ones a `priority` (2 to 7): the table then never scrolls sideways — columns leave the row for its detail, then the rows become cards. Scrolling is an opt-in (`scroll`).",
38
+ "A `title` column takes the slack and truncates (full value on hover); badges, statuses, numbers and dates are as wide as their content. Do not give a title column a `maxWidth`.",
37
39
  "Align amounts and counts to the end (`align: \"end\"`) in tabular numbers; pin the name column of a wide table.",
38
40
  "Need something the props do not cover? `useDataTable()` gives the TanStack instance — never import `@tanstack/*` in a product."
39
41
  ],
@@ -179,7 +181,7 @@
179
181
  "name": "density",
180
182
  "type": "\"comfortable\" | \"compact\"",
181
183
  "default": null,
182
- "description": "comfortable (default) · compact.",
184
+ "description": "comfortable (default) · compact — one row height per density.",
183
185
  "required": false
184
186
  },
185
187
  {
@@ -385,6 +387,13 @@
385
387
  "description": "The name of a row in its labels (checkbox, expand button). Default: its id.",
386
388
  "required": false
387
389
  },
390
+ {
391
+ "name": "scroll",
392
+ "type": "boolean",
393
+ "default": null,
394
+ "description": "The table never scrolls sideways by default once its columns have a `type` or a `priority`: what does not fit goes to\nthe row's detail, then to cards. `true` opts in to a sideways scroll instead, with fades at the edges.",
395
+ "required": false
396
+ },
388
397
  {
389
398
  "name": "selectable",
390
399
  "type": "boolean",
@@ -413,6 +422,27 @@
413
422
  "description": "The sorted column (controlled), `null` for none.",
414
423
  "required": false
415
424
  },
425
+ {
426
+ "name": "stickyFirstColumn",
427
+ "type": "boolean",
428
+ "default": null,
429
+ "description": "Keeps the first column in view while the table scrolls sideways (with `scroll`).",
430
+ "required": false
431
+ },
432
+ {
433
+ "name": "stickyHeader",
434
+ "type": "number | boolean",
435
+ "default": null,
436
+ "description": "The header stays at the top of the window while the page scrolls (`--dt-top` on the table, or a number of px, clears a fixed bar).",
437
+ "required": false
438
+ },
439
+ {
440
+ "name": "striped",
441
+ "type": "boolean",
442
+ "default": null,
443
+ "description": "Zebra rows.",
444
+ "required": false
445
+ },
416
446
  {
417
447
  "name": "toolbar",
418
448
  "type": "ReactNode",
@@ -505,6 +535,30 @@
505
535
  }
506
536
  ],
507
537
  "examples": [
538
+ {
539
+ "name": "catalogue",
540
+ "title": "Models catalogue",
541
+ "description": "Eight typed columns that never overflow: priorities move the maker, licence, context and date into the row's detail as the table narrows, then cards under 36 rem.",
542
+ "path": "examples/data-table/catalogue.tsx",
543
+ "module": "@krizaka/ui/registry/examples/data-table/catalogue",
544
+ "code": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { DataTable, type DataTableColumn, DataTableFilters } from \"@krizaka/ui/data-table\";\n\ntype Model = { id: string; name: string; maker: string; does: string; where: string; status: string; licence: string; context: number; verified: string };\nconst L = \"https://www.apache.org/licenses/LICENSE-2.0\";\nconst models: Model[] = [\n { id: \"black-forest-labs/flux.1-schnell\", name: \"FLUX.1 schnell\", maker: \"Black Forest Labs\", does: \"Image\", where: \"Local\", status: \"Default\", licence: \"Apache-2.0\", context: 0, verified: \"2026-10-02\" },\n { id: \"meta-llama/llama-3.3-70b-instruct\", name: \"Llama 3.3 70B Instruct\", maker: \"Meta\", does: \"Chat\", where: \"Local\", status: \"Default\", licence: \"Llama 3.3 Community Licence\", context: 131072, verified: \"2026-10-09\" },\n { id: \"anthropic/claude-sonnet\", name: \"Claude Sonnet\", maker: \"Anthropic\", does: \"Chat\", where: \"Provider\", status: \"Available\", licence: \"Provider terms\", context: 200000, verified: \"2026-10-12\" },\n { id: \"lightricks/ltx-video\", name: \"LTX-Video\", maker: \"Lightricks\", does: \"Video\", where: \"Local\", status: \"Planned\", licence: \"OpenRAIL-M\", context: 0, verified: \"\" },\n];\nconst tone = { Default: \"success\", Available: \"neutral\", Planned: \"warning\" } as const;\n// The model, its capability, where it runs and its status stay in the row; the rest leaves it as the table narrows.\nconst columns: DataTableColumn<Model>[] = [\n { id: \"name\", header: \"Model\", type: \"title\", sortable: true, filter: \"text\", subtitle: (m) => m.id },\n { id: \"does\", header: \"Capability\", type: \"badge\", sortable: true, filter: \"facet\", cell: (m) => <Badge size=\"sm\">{m.does}</Badge> },\n { id: \"where\", header: \"Runs where\", type: \"badge\", filter: \"facet\", priority: 2, cell: (m) => <Badge size=\"sm\" tone={m.where === \"Local\" ? \"accent\" : \"neutral\"}>{m.where}</Badge> },\n { id: \"status\", header: \"Status\", type: \"status\", sortable: true, filter: \"facet\", cell: (m) => <Badge size=\"sm\" tone={tone[m.status as keyof typeof tone]}>{m.status}</Badge> },\n { id: \"maker\", header: \"Maker\", type: \"text\", clamp: 1, priority: 3 },\n { id: \"licence\", header: \"Licence\", type: \"link\", priority: 4, href: () => L },\n { id: \"context\", header: \"Context\", type: \"number\", unit: \"tokens\", priority: 5, value: (m) => m.context, cell: (m) => (m.context ? m.context.toLocaleString(\"en\") : \"—\") },\n { id: \"verified\", header: \"Verified\", type: \"date\", priority: 5, cell: (m) => m.verified || \"never\" },\n];\n\n// Eight columns, any width, no overflow: the chevron opens what left the row; under 36 rem each row is a card.\nexport default function DataTableCatalogue() {\n return <DataTable caption=\"Models\" rows={models} columns={columns} getRowId={(m) => m.id} rowLabel={(m) => m.name} density=\"compact\" toolbar={<DataTableFilters />} />;\n}\n"
545
+ },
546
+ {
547
+ "name": "invoices",
548
+ "title": "Invoice list",
549
+ "description": "Title, status and amount always in the row; dates and contact in the detail on a narrow screen; sticky header and zebra rows.",
550
+ "path": "examples/data-table/invoices.tsx",
551
+ "module": "@krizaka/ui/registry/examples/data-table/invoices",
552
+ "code": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Invoice = { id: string; customer: string; email: string; issued: string; due: string; amount: number; status: keyof typeof tone };\nconst tone = { Paid: \"success\", Open: \"accent\", Overdue: \"danger\", Draft: \"neutral\" } as const;\nconst invoices: Invoice[] = [\n { id: \"INV-2026-0143\", customer: \"Harbour Labs\", email: \"finance@harbourlabs.example\", issued: \"2026-10-14\", due: \"2026-11-13\", amount: 2200, status: \"Draft\" },\n { id: \"INV-2026-0142\", customer: \"Atelier Montréal Inc.\", email: \"billing@ateliermtl.example\", issued: \"2026-10-01\", due: \"2026-10-31\", amount: 4280, status: \"Open\" },\n { id: \"INV-2026-0138\", customer: \"Nord & Fils\", email: \"ap@nordfils.example\", issued: \"2026-09-12\", due: \"2026-10-12\", amount: 15900, status: \"Overdue\" },\n { id: \"INV-2026-0137\", customer: \"Lumen Collective\", email: \"hello@lumen.example\", issued: \"2026-09-10\", due: \"2026-10-10\", amount: 640, status: \"Paid\" },\n];\nconst columns: DataTableColumn<Invoice>[] = [\n { id: \"id\", header: \"Invoice\", type: \"title\", sortable: true, subtitle: (i) => i.customer },\n { id: \"status\", header: \"Status\", type: \"status\", sortable: true, cell: (i) => <Badge size=\"sm\" tone={tone[i.status]}>{i.status}</Badge> },\n { id: \"amount\", header: \"Amount\", type: \"number\", sortable: true, cell: (i) => i.amount.toLocaleString(\"en\", { style: \"currency\", currency: \"USD\" }) },\n { id: \"due\", header: \"Due\", type: \"date\", sortable: true, priority: 2 },\n { id: \"issued\", header: \"Issued\", type: \"date\", sortable: true, priority: 3 },\n { id: \"email\", header: \"Billing contact\", type: \"link\", priority: 4, href: (i) => `mailto:${i.email}` },\n];\n\n// Number, status and amount always; dates and contact move into the detail as the screen narrows. Sticky header, zebra.\nexport default function DataTableInvoices() {\n return <DataTable caption=\"Invoices\" rows={invoices} columns={columns} getRowId={(i) => i.id} rowLabel={(i) => i.id} stickyHeader striped defaultSort={{ column: \"due\", direction: \"descending\" }} />;\n}\n"
553
+ },
554
+ {
555
+ "name": "runs",
556
+ "title": "Admin runs",
557
+ "description": "An admin console: filters, columns menu and CSV export in the toolbar, selection, a range filter on the duration, the error in the detail.",
558
+ "path": "examples/data-table/runs.tsx",
559
+ "module": "@krizaka/ui/registry/examples/data-table/runs",
560
+ "code": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { DataTable, type DataTableColumn, DataTableColumnsMenu, DataTableExport, DataTableFilters } from \"@krizaka/ui/data-table\";\n\ntype Run = { id: string; pack: string; model: string; worker: string; status: keyof typeof tone; seconds: number; started: string; error?: string };\nconst tone = { succeeded: \"success\", running: \"accent\", failed: \"danger\", queued: \"neutral\" } as const;\nconst runs: Run[] = [\n { id: \"run_9f2c41ab\", pack: \"video.short\", model: \"ltx-video\", worker: \"-\", status: \"queued\", seconds: 0, started: \"2026-10-15 09:18\" },\n { id: \"run_9f2c41a8\", pack: \"audio.transcribe\", model: \"whisper-large-v3\", worker: \"gpu-lyon-01\", status: \"running\", seconds: 318, started: \"2026-10-15 09:16\" },\n { id: \"run_9f2c41a9\", pack: \"voice.clone\", model: \"xtts-v2\", worker: \"gpu-lyon-02\", status: \"failed\", seconds: 7, started: \"2026-10-15 09:02\", error: \"Out of memory (24 GB)\" },\n { id: \"run_9f2c41aa\", pack: \"chat.summarise\", model: \"llama-3.3-70b-instruct\", worker: \"cpu-mtl-04\", status: \"succeeded\", seconds: 96, started: \"2026-10-15 08:51\" },\n];\nconst columns: DataTableColumn<Run>[] = [\n { id: \"pack\", header: \"Run\", type: \"title\", sortable: true, filter: \"text\", subtitle: (r) => r.id },\n { id: \"status\", header: \"Status\", type: \"status\", sortable: true, filter: \"facet\", cell: (r) => <Badge size=\"sm\" tone={tone[r.status]}>{r.status}</Badge> },\n { id: \"seconds\", header: \"Duration\", type: \"number\", unit: \"s\", sortable: true, filter: \"range\" },\n { id: \"model\", header: \"Model\", type: \"text\", clamp: 1, priority: 2 },\n { id: \"worker\", header: \"Worker\", type: \"text\", clamp: 1, priority: 3 },\n { id: \"started\", header: \"Started\", type: \"date\", sortable: true, priority: 4 },\n { id: \"error\", header: \"Error\", type: \"text\", priority: 5, cell: (r) => r.error ?? \"—\" },\n];\nconst bar = <div className=\"flex flex-col gap-3\"><DataTableFilters /><div className=\"flex gap-2\"><DataTableColumnsMenu /><DataTableExport filename=\"runs.csv\" /></div></div>;\n\n// An admin console: filters, columns menu and CSV export in the toolbar; selection; the error in the detail.\nexport default function DataTableRuns() {\n return <DataTable caption=\"Runs\" rows={runs} columns={columns} getRowId={(r) => r.id} rowLabel={(r) => r.id} selectable density=\"compact\" stickyHeader toolbar={bar} />;\n}\n"
561
+ },
508
562
  {
509
563
  "name": "jobs",
510
564
  "title": "Jobs",
@@ -611,7 +665,7 @@
611
665
  },
612
666
  {
613
667
  "path": "data-table/data-table.tsx",
614
- "content": "// Client (the table's state, the windowing of long lists). A real `<table>` with a caption and column headers\n// (`scope=\"col\"`, `aria-sort` on the sorted one) that turns into a stack of cards in a narrow container — one DOM, the\n// roles written out so that the change of `display` never loses the table's semantics. The engine is TanStack Table v9\n// (engine.ts): sorting, filters and facets, grouping, expanding, pagination, selection, visibility, pinning, resizing —\n// in the browser, or on the server (`manual` + `onStateChange`). Long lists render by what is in view (`virtualized`).\nimport { useTable } from \"@tanstack/react-table\";\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { Checkbox } from \"../checkbox\";\nimport { ChevronGlyph, SortGlyph } from \"../glyphs\";\nimport { useControllable } from \"../media\";\nimport { Pagination, type PaginationLabels } from \"../pagination\";\nimport { useLayoutFallbacks, useVirtualizer } from \"../virtual/virtual\";\nimport {\n type CellValue,\n type ColumnDef,\n type ColumnResizingState,\n type DataTableFilterState,\n type DataTableInstance,\n features,\n FILTERS,\n type FilterValue,\n type RowData,\n sortByValue,\n type TableCell,\n type TableColumn,\n type TableRow,\n} from \"./engine\";\n\nexport const dataTable = tv({\n slots: {\n root: \"@container w-full overflow-hidden rounded-xl border border-border-default bg-surface-1\",\n toolbar: \"border-b border-border-default p-3\",\n scroller: \"w-full overflow-x-auto\",\n table: \"w-full border-collapse text-left text-sm text-fg\",\n caption: \"px-4 pt-4 pb-2 text-left text-sm font-semibold text-fg\",\n thead: \"bg-surface-2/60\",\n headRow: \"border-b border-border-default\",\n th: \"relative whitespace-nowrap px-4 py-3 text-xs font-semibold text-fg-secondary\",\n sortButton:\n \"-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring aria-[pressed=true]:text-fg\",\n resizer:\n \"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-transparent hover:bg-border-strong \" +\n \"focus-visible:bg-accent focus-visible:outline-hidden data-[resizing]:bg-accent\",\n tbody: \"\",\n tr: \"border-b border-border-subtle transition-colors last:border-b-0 hover:bg-surface-2/50 data-[state=selected]:bg-accent-soft\",\n groupRow: \"bg-surface-2/40 font-semibold\",\n td: \"px-4 py-3 align-middle\",\n select: \"w-10\",\n expander: \"w-10\",\n toggle:\n \"inline-flex h-7 min-w-7 items-center justify-center gap-1.5 rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring [&[aria-expanded=true]>svg]:rotate-90 [&>svg]:transition-transform motion-reduce:[&>svg]:transition-none\",\n detail: \"bg-surface-0 px-4 py-4\",\n message: \"px-4 py-10 text-center text-sm text-fg-secondary\",\n skeleton: \"h-4 w-full max-w-40 animate-pulse rounded bg-surface-3 motion-reduce:animate-none\",\n footer: \"border-t border-border-default px-4 py-3\",\n },\n variants: {\n layout: {\n table: {\n th: \"data-[pin]:sticky data-[pin]:z-[2] data-[pin]:bg-surface-2 data-[pin=start]:left-(--dt-pin) data-[pin=end]:right-(--dt-pin)\",\n td: \"data-[pin]:sticky data-[pin]:z-[1] data-[pin]:bg-surface-1 data-[pin=start]:left-(--dt-pin) data-[pin=end]:right-(--dt-pin)\",\n },\n // Below 36rem of container: cards. The sortable headers stay, as a row of sort buttons above the cards.\n auto: {\n table: \"@max-xl:block\",\n caption: \"@max-xl:block\",\n thead: \"@max-xl:sr-only @max-xl:bg-transparent @max-xl:has-[[data-keep]]:not-sr-only @max-xl:has-[[data-keep]]:block\",\n headRow: \"@max-xl:flex @max-xl:flex-wrap @max-xl:items-center @max-xl:gap-2 @max-xl:px-3 @max-xl:py-2\",\n th:\n \"@max-xl:sr-only @max-xl:data-[keep]:not-sr-only @max-xl:data-[keep]:block @max-xl:p-0 \" +\n \"@xl:data-[pin]:sticky @xl:data-[pin]:z-[2] @xl:data-[pin]:bg-surface-2 @xl:data-[pin=start]:left-(--dt-pin) @xl:data-[pin=end]:right-(--dt-pin)\",\n tbody: \"@max-xl:grid @max-xl:gap-3 @max-xl:p-3\",\n tr: \"@max-xl:relative @max-xl:grid @max-xl:gap-1.5 @max-xl:rounded-xl @max-xl:border @max-xl:border-border-default @max-xl:bg-surface-0 @max-xl:p-4 @max-xl:last:border-b\",\n td:\n \"@max-xl:flex @max-xl:items-baseline @max-xl:justify-between @max-xl:gap-4 @max-xl:p-0 @max-xl:text-right \" +\n \"@max-xl:before:shrink-0 @max-xl:before:text-xs @max-xl:before:font-semibold @max-xl:before:text-fg-secondary @max-xl:before:content-[attr(data-label)] \" +\n \"@max-xl:data-[card=title]:order-first @max-xl:data-[card=title]:pr-8 @max-xl:data-[card=title]:text-left @max-xl:data-[card=title]:text-base @max-xl:data-[card=title]:font-semibold @max-xl:data-[card=title]:before:content-none \" +\n \"@max-xl:data-[card=hidden]:hidden @max-xl:data-[card=message]:block @max-xl:data-[card=message]:text-center @max-xl:data-[card=message]:before:content-none \" +\n \"@xl:data-[pin]:sticky @xl:data-[pin]:z-[1] @xl:data-[pin]:bg-surface-1 @xl:data-[pin=start]:left-(--dt-pin) @xl:data-[pin=end]:right-(--dt-pin)\",\n select: \"@max-xl:absolute @max-xl:right-3 @max-xl:top-3 @max-xl:w-auto @max-xl:before:content-none\",\n expander: \"@max-xl:before:content-none\",\n },\n cards: {\n table: \"block\",\n caption: \"block\",\n thead: \"sr-only bg-transparent has-[[data-keep]]:not-sr-only has-[[data-keep]]:block\",\n headRow: \"flex flex-wrap items-center gap-2 px-3 py-2\",\n th: \"sr-only p-0 data-[keep]:not-sr-only data-[keep]:block\",\n tbody: \"grid gap-3 p-3\",\n tr: \"relative grid gap-1.5 rounded-xl border border-border-default bg-surface-0 p-4 last:border-b\",\n td:\n \"flex items-baseline justify-between gap-4 p-0 text-right before:shrink-0 before:text-xs before:font-semibold before:text-fg-secondary before:content-[attr(data-label)] \" +\n \"data-[card=title]:order-first data-[card=title]:pr-8 data-[card=title]:text-left data-[card=title]:text-base data-[card=title]:font-semibold data-[card=title]:before:content-none \" +\n \"data-[card=hidden]:hidden data-[card=message]:block data-[card=message]:text-center data-[card=message]:before:content-none\",\n select: \"absolute right-3 top-3 w-auto before:content-none\",\n expander: \"before:content-none\",\n },\n },\n density: {\n comfortable: {},\n compact: { th: \"py-2\", td: \"py-1.5\" },\n },\n },\n defaultVariants: { layout: \"auto\", density: \"comfortable\" },\n});\n\nexport type SortDirection = \"ascending\" | \"descending\";\nexport type DataTableSort = { column: string; direction: SortDirection } | null;\n\n/** What the server needs to fetch a page (`manual` mode): given to `onStateChange` at each change. */\nexport type DataTableQuery = {\n /** The sorted column, or `null`. */\n sort: DataTableSort;\n /** The filters in force, by column id. */\n filters: DataTableFilterState;\n /** The page, from 1 (1 without pagination). */\n page: number;\n /** Rows per page (`Infinity` without pagination). */\n pageSize: number;\n};\n\nexport type DataTableColumn<T> = {\n /** A key of the column (and of the row, for the default value). */\n id: string;\n /** The column's name, passed translated: the header, the label of the value in a card, the filter's name. */\n header: string;\n /** What a cell shows. Default: the value as text. */\n cell?: (row: T) => React.ReactNode;\n /** The value sorted, filtered, grouped and exported. Default `row[id]`. */\n value?: (row: T) => CellValue;\n /** The header becomes a sort button (ascending → descending → none). */\n sortable?: boolean;\n /** How `DataTableFilters` filters it: `text` (a search), `facet` (values to choose), `range` (numbers between bounds). */\n filter?: \"text\" | \"facet\" | \"range\";\n /** The choices of a `facet` filter, with their words (and counts from the server). Default: the values found in the rows. */\n options?: readonly { value: string; label: string; count?: number }[];\n /** The rows can be grouped by this column (`groupBy`). */\n groupable?: boolean;\n /** What a group's row shows in this column, from the group's rows (a sum, a count). */\n aggregate?: (rows: readonly T[]) => React.ReactNode;\n /** Text alignment: `end` for amounts and counts. */\n align?: \"start\" | \"center\" | \"end\";\n /** The width, in px (the starting width when `resizing`). */\n width?: number;\n /** The narrowest a resized column may be, in px. Default 64. */\n minWidth?: number;\n /** In a card: `title` (the heading of the card), `label` (default: \"Header value\"), `hidden`. */\n card?: \"title\" | \"label\" | \"hidden\";\n /** Hides the header visually (an actions column), keeping it for screen readers. */\n headerHidden?: boolean;\n /** Listed in `DataTableColumnsMenu`, so a person can hide it. Default `true`. */\n hideable?: boolean;\n};\n\nexport type DataTableLabels = {\n /** The checkbox of the header (\"Select all rows\"). */\n selectAll: string;\n /** The checkbox of a row: `(name) => \"Select \" + name`, the name from `rowLabel`. */\n selectRow: (name: string) => string;\n /** Read while the rows load (\"Loading\"). */\n loading: string;\n /** The default empty state (\"Nothing to show\"). */\n empty: string;\n /** The header of the column of the expand buttons, for screen readers (\"Details\"). */\n details: string;\n /** The button that opens a row: `(name) => \"Show details of \" + name`. */\n expandRow: (name: string) => string;\n /** The button of a group's row: `(value, count) => value + \", \" + count + \" rows\"`. */\n group: (value: string, count: number) => string;\n /** The handle that resizes a column: `(header) => \"Resize \" + header`. */\n resize: (header: string) => string;\n /** The count of results under the filters: `(count) => count + \" rows\"`. */\n results: (count: number) => string;\n /** The accessible name of the filters' bar (\"Filter the table\"). */\n filters: string;\n /** \"Clear filters\". */\n clearFilters: string;\n /** A filter's chip remove button: `(label) => \"Remove \" + label`. */\n removeFilter: (label: string) => string;\n /** The words of a filter in force: `(header, value) => header + \": \" + value`. */\n activeFilter: (header: string, value: string) => string;\n /** A `text` filter's field: `(header) => \"Search \" + header`. */\n search: (header: string) => string;\n /** The lower bound of a `range` filter: `(header) => header + \" from\"`. */\n rangeFrom: (header: string) => string;\n /** The upper bound of a `range` filter: `(header) => header + \" to\"`. */\n rangeTo: (header: string) => string;\n /** The button of the columns menu (\"Columns\"). */\n columns: string;\n /** The export button (\"Export CSV\"). */\n export: string;\n /** The words of the pagination under the table. */\n pagination?: Partial<PaginationLabels>;\n};\n\nexport const dataTableLabels: DataTableLabels = {\n selectAll: \"Select all rows\",\n selectRow: (name) => `Select ${name}`,\n loading: \"Loading\",\n empty: \"Nothing to show\",\n details: \"Details\",\n expandRow: (name) => `Show details of ${name}`,\n group: (value, count) => `${value}, ${count} ${count === 1 ? \"row\" : \"rows\"}`,\n resize: (header) => `Resize ${header}`,\n results: (count) => `${count.toLocaleString(\"en\")} ${count === 1 ? \"row\" : \"rows\"}`,\n filters: \"Filter the table\",\n clearFilters: \"Clear filters\",\n removeFilter: (label) => `Remove ${label}`,\n activeFilter: (header, value) => `${header}: ${value}`,\n search: (header) => `Search ${header.toLocaleLowerCase()}`,\n rangeFrom: (header) => `${header} from`,\n rangeTo: (header) => `${header} to`,\n columns: \"Columns\",\n export: \"Export CSV\",\n};\n\nexport type DataTableProps<T> = {\n /** The rows (stable: module scope, state or a query's data). Sorted, filtered and paginated here unless `manual`. */\n rows: readonly T[];\n /** The columns, in order (stable: module scope or `useMemo`). */\n columns: readonly DataTableColumn<T>[];\n /** A stable id per row: the React key, the selection's value, the expansion's key. */\n getRowId: (row: T) => string;\n /** What the table lists (\"Jobs\", \"Your videos\"): its `<caption>`, hidden unless `showCaption`. */\n caption: string;\n /** Shows the caption above the table. */\n showCaption?: boolean;\n\n /** The sorted column (controlled), `null` for none. */\n sort?: DataTableSort;\n /** The sort at first, uncontrolled. */\n defaultSort?: DataTableSort;\n /** Called when a header asks for a new sort. */\n onSortChange?: (sort: DataTableSort) => void;\n\n /** The filters in force (controlled), by column id — what `DataTableFilters` edits. */\n filters?: DataTableFilterState;\n /** The filters at first, uncontrolled. */\n defaultFilters?: DataTableFilterState;\n /** Called with the new filters. */\n onFiltersChange?: (filters: DataTableFilterState) => void;\n\n /** Adds a checkbox per row and one in the header. */\n selectable?: boolean;\n /** The ids selected (controlled). */\n selected?: readonly string[];\n /** The ids selected at first, uncontrolled. */\n defaultSelected?: readonly string[];\n /** Called with the new selection. */\n onSelectedChange?: (selected: string[]) => void;\n /** The name of a row in its labels (checkbox, expand button). Default: its id. */\n rowLabel?: (row: T) => string;\n\n /** The columns the rows are grouped by, in order (controlled) — each `groupable`. */\n groupBy?: readonly string[];\n /** The grouping at first, uncontrolled. */\n defaultGroupBy?: readonly string[];\n /** Called with the new grouping. */\n onGroupByChange?: (groupBy: string[]) => void;\n\n /** Visible (`true`) or hidden (`false`) columns, by id (controlled); a column not listed is visible. */\n columnVisibility?: Readonly<Record<string, boolean>>;\n /** The visibility at first, uncontrolled. */\n defaultColumnVisibility?: Readonly<Record<string, boolean>>;\n /** Called with the new visibility (`DataTableColumnsMenu`). */\n onColumnVisibilityChange?: (visibility: Record<string, boolean>) => void;\n\n /** Columns that stay in view while the table scrolls sideways: at the start (a name) or the end (actions). */\n pinning?: { start?: readonly string[]; end?: readonly string[] };\n /** Columns can be resized (pointer drag, or the arrow keys on the focused handle). */\n resizing?: boolean;\n\n /** The children of a row (a tree): each row with children gets a button that shows them. */\n getSubRows?: (row: T) => readonly T[] | undefined;\n /** A panel under an opened row (the details of a job): each row gets a button that opens it. */\n renderDetail?: (row: T) => React.ReactNode;\n /** The opened rows and groups (controlled), by id. */\n expanded?: Readonly<Record<string, boolean>>;\n /** The opened rows at first, uncontrolled. */\n defaultExpanded?: Readonly<Record<string, boolean>>;\n /** Called with the opened rows. */\n onExpandedChange?: (expanded: Record<string, boolean>) => void;\n\n /** Rows per page (controlled): turns on the pagination under the table. */\n pageSize?: number;\n /** Rows per page at first, uncontrolled: turns on the pagination under the table. */\n defaultPageSize?: number;\n /** Called with the size chosen in the pagination's select (`pageSizeOptions`). */\n onPageSizeChange?: (pageSize: number) => void;\n /** The sizes the pagination offers (`[10, 25, 50]`). */\n pageSizeOptions?: readonly number[];\n /** The page, from 1 (controlled). */\n page?: number;\n /** The page at first, uncontrolled. Default 1. */\n defaultPage?: number;\n /** Called with the page asked for. */\n onPageChange?: (page: number) => void;\n\n /** The server does the work: the rows are shown as given (sorted, filtered, a single page) — fetch in `onStateChange`. */\n manual?: { sorting?: boolean; filtering?: boolean; pagination?: boolean };\n /** The number of rows on the server (`manual.pagination`): the page count and the results' count. */\n rowCount?: number;\n /** Called with the sort, the filters and the page after each change: the query key of a server table. */\n onStateChange?: (query: DataTableQuery) => void;\n\n /** Rows are loading: skeleton rows (`loadingRows` of them) and `aria-busy`. */\n loading?: boolean;\n /** How many skeleton rows while loading. Default 5. */\n loadingRows?: number;\n /** A failure, shown in place of the rows (an `Alert`, a retry button); announced. */\n error?: React.ReactNode;\n /** Shown when there is no row (an `EmptyState`). Default: `labels.empty`. */\n empty?: React.ReactNode;\n\n /** `auto` (default): a table, cards below 36rem of container width. `table` or `cards` to force one. */\n layout?: \"auto\" | \"table\" | \"cards\";\n /** comfortable (default) · compact. */\n density?: \"comfortable\" | \"compact\";\n /** Renders only the rows in view, in a scrolling body of `height` px; rows are measured (`estimateRowHeight` is a guess). Forces the table layout. */\n virtualized?: { height: number; estimateRowHeight?: number; overscan?: number };\n\n /** Above the table, inside its frame: `DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`, actions. */\n toolbar?: React.ReactNode;\n /** Under the table (after the pagination): a total, a note. */\n footer?: React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<DataTableLabels>;\n /** Classes merged on the root. */\n className?: string;\n};\n\ntype Context = {\n table: DataTableInstance<object>;\n columns: readonly DataTableColumn<object>[];\n labels: DataTableLabels;\n filters: DataTableFilterState;\n setFilters: (filters: DataTableFilterState) => void;\n /** The rows after the filters (all pages), or the server's count. */\n resultCount: number;\n /** The rows to export: filtered and sorted, every page, without the group rows. */\n exportRows: () => readonly unknown[];\n manualFiltering: boolean;\n};\n\nconst DataTableContext = React.createContext<Context | null>(null);\n\n/**\n * The table a component sits in (`toolbar`, or anything under it): the TanStack Table instance (`table` — the escape\n * hatch for what the props do not cover), the columns, the words, the filters and their setter.\n */\nexport function useDataTable<T extends RowData = object>() {\n const context = React.useContext(DataTableContext);\n if (!context) throw new Error(\"useDataTable: call it inside a DataTable (its `toolbar`, `footer` or a cell)\");\n return context as unknown as Omit<Context, \"table\" | \"columns\" | \"exportRows\"> & {\n table: DataTableInstance<T>;\n columns: readonly DataTableColumn<T>[];\n exportRows: () => readonly T[];\n };\n}\n\nconst ALIGN = { start: \"text-left\", center: \"text-center\", end: \"text-right\" } as const;\nconst NONE: readonly never[] = [];\nconst NO_RECORD: Readonly<Record<string, never>> = {};\nconst apply = <S,>(updater: S | ((old: S) => S), old: S): S => (typeof updater === \"function\" ? (updater as (old: S) => S)(old) : updater);\n\nconst valueOf = <T,>(column: DataTableColumn<T>, row: T): CellValue =>\n column.value ? column.value(row) : ((row as Record<string, unknown>)[column.id] as CellValue);\n\nconst display = (value: CellValue) => (value instanceof Date ? value.toISOString() : String(value ?? \"\"));\n\n/** A table of records — sort, filters and facets, groups, details, pages, selection, visibility, pinning, resizing — on the client or the server, cards on a phone, windowing for long lists. */\nexport function DataTable<T extends RowData>({\n rows,\n columns,\n getRowId,\n caption,\n showCaption,\n sort: sortProp,\n defaultSort = null,\n onSortChange,\n filters: filtersProp,\n defaultFilters = NO_RECORD,\n onFiltersChange,\n selectable,\n selected: selectedProp,\n defaultSelected = NONE,\n onSelectedChange,\n rowLabel,\n groupBy: groupByProp,\n defaultGroupBy = NONE,\n onGroupByChange,\n columnVisibility: visibilityProp,\n defaultColumnVisibility = NO_RECORD,\n onColumnVisibilityChange,\n pinning,\n resizing,\n getSubRows,\n renderDetail,\n expanded: expandedProp,\n defaultExpanded = NO_RECORD,\n onExpandedChange,\n pageSize: pageSizeProp,\n defaultPageSize,\n onPageSizeChange,\n pageSizeOptions,\n page: pageProp,\n defaultPage = 1,\n onPageChange,\n manual,\n rowCount,\n onStateChange,\n loading,\n loadingRows = 5,\n error,\n empty,\n layout = \"auto\",\n density,\n virtualized,\n toolbar,\n footer,\n labels: given,\n className,\n}: DataTableProps<T>) {\n const labels = { ...dataTableLabels, ...given };\n const effectiveLayout = virtualized ? \"table\" : layout;\n const s = dataTable({ layout: effectiveLayout, density });\n\n const [sort, setSort] = useControllable<DataTableSort>(sortProp, defaultSort, onSortChange);\n const [filters, setFiltersState] = useControllable<DataTableFilterState>(filtersProp, defaultFilters, onFiltersChange);\n const [selected, setSelected] = useControllable<readonly string[]>(selectedProp, defaultSelected, onSelectedChange as (next: readonly string[]) => void);\n const [groupBy, setGroupBy] = useControllable<readonly string[]>(groupByProp, defaultGroupBy, onGroupByChange as (next: readonly string[]) => void);\n const [visibility, setVisibility] = useControllable<Readonly<Record<string, boolean>>>(visibilityProp, defaultColumnVisibility, onColumnVisibilityChange as (next: Readonly<Record<string, boolean>>) => void);\n const [expanded, setExpanded] = useControllable<Readonly<Record<string, boolean>>>(expandedProp, defaultExpanded, onExpandedChange as (next: Readonly<Record<string, boolean>>) => void);\n const paginated = pageSizeProp !== undefined || defaultPageSize !== undefined;\n const [pageSize, setPageSize] = useControllable<number>(pageSizeProp, defaultPageSize ?? Number.POSITIVE_INFINITY, onPageSizeChange);\n const [page, setPage] = useControllable<number>(pageProp, defaultPage, onPageChange);\n const [sizing, setSizing] = React.useState<Record<string, number>>({});\n const [resizeState, setResizeState] = React.useState<ColumnResizingState | undefined>(undefined);\n\n // One place announces the server's query: after any change of sort, filters, page or page size.\n const announce = (next: Partial<DataTableQuery>) =>\n onStateChange?.({ sort, filters, page: paginated ? page : 1, pageSize: paginated ? pageSize : Number.POSITIVE_INFINITY, ...next });\n const changeSort = (next: DataTableSort) => {\n setSort(next);\n if (paginated && page !== 1) setPage(1);\n announce({ sort: next, page: 1 });\n };\n const changeFilters = (next: DataTableFilterState) => {\n setFiltersState(next);\n if (paginated && page !== 1) setPage(1);\n announce({ filters: next, page: 1 });\n };\n const changePage = (next: number) => {\n setPage(next);\n announce({ page: next });\n };\n const changePageSize = (next: number) => {\n setPageSize(next);\n setPage(1);\n announce({ pageSize: next, page: 1 });\n };\n\n const hasExpander = Boolean(renderDetail || getSubRows);\n const defs = React.useMemo<ColumnDef<typeof features, T>[]>(\n () =>\n columns.map((column) => ({\n id: column.id,\n accessorFn: (row: T) => valueOf(column, row),\n header: column.header,\n enableSorting: Boolean(column.sortable),\n sortFn: sortByValue as never,\n sortUndefined: \"last\",\n enableColumnFilter: Boolean(column.filter),\n filterFn: (column.filter ? FILTERS[column.filter] : FILTERS.text) as never,\n enableGrouping: Boolean(column.groupable),\n enableHiding: column.hideable !== false,\n enableResizing: Boolean(resizing),\n ...(column.width ? { size: column.width } : {}),\n minSize: column.minWidth ?? 64,\n })),\n [columns, resizing],\n );\n\n const pageIndex = Math.max(0, page - 1);\n const table = useTable<typeof features, T>({\n features,\n columns: defs,\n data: rows as T[],\n getRowId: (row) => getRowId(row),\n getSubRows: getSubRows ? (row) => getSubRows(row) as T[] | undefined : undefined,\n getRowCanExpand: renderDetail ? () => true : undefined,\n state: {\n sorting: sort ? [{ id: sort.column, desc: sort.direction === \"descending\" }] : [],\n columnFilters: Object.entries(filters).map(([id, value]) => ({ id, value })),\n rowSelection: Object.fromEntries(selected.map((id) => [id, true])),\n grouping: [...groupBy],\n columnVisibility: { ...visibility },\n expanded: { ...expanded },\n pagination: { pageIndex, pageSize: paginated ? pageSize : Math.max(1, rows.length) },\n columnPinning: { start: [...(pinning?.start ?? [])], end: [...(pinning?.end ?? [])] },\n columnSizing: sizing,\n ...(resizeState ? { columnResizing: resizeState } : {}),\n },\n onSortingChange: (updater) => {\n const [first] = apply(updater, sort ? [{ id: sort.column, desc: sort.direction === \"descending\" }] : []);\n changeSort(first ? { column: first.id, direction: first.desc ? \"descending\" : \"ascending\" } : null);\n },\n onColumnFiltersChange: (updater) => {\n const next = apply(updater, Object.entries(filters).map(([id, value]) => ({ id, value })));\n changeFilters(Object.fromEntries(next.map((f) => [f.id, f.value as FilterValue])));\n },\n onRowSelectionChange: (updater) => {\n const next = apply(updater, Object.fromEntries(selected.map((id) => [id, true])));\n setSelected(Object.keys(next).filter((id) => next[id]));\n },\n onGroupingChange: (updater) => setGroupBy(apply(updater, [...groupBy])),\n onColumnVisibilityChange: (updater) => setVisibility(apply(updater, { ...visibility })),\n onExpandedChange: (updater) => {\n const next = apply(updater, { ...expanded });\n setExpanded(next === true ? Object.fromEntries(table.getRowModel().rows.map((r) => [r.id, true])) : next);\n },\n onColumnSizingChange: (updater) => setSizing((old: Record<string, number>) => apply(updater, old)),\n onColumnResizingChange: (updater) => setResizeState((old) => apply(updater, old as ColumnResizingState)),\n enableSortingRemoval: true,\n sortDescFirst: false,\n manualSorting: manual?.sorting,\n manualFiltering: manual?.filtering,\n manualPagination: manual?.pagination || !paginated,\n rowCount: manual?.pagination ? rowCount : undefined,\n autoResetPageIndex: false,\n autoResetExpanded: false,\n groupedColumnMode: false,\n columnResizeMode: \"onChange\",\n enableRowSelection: (row) => !row.getIsGrouped(),\n });\n\n const visibleColumns = table.getVisibleLeafColumns();\n const byId = new Map(columns.map((c) => [c.id, c]));\n const pageRows = table.getRowModel().rows;\n const leafCount = (manual?.pagination || manual?.filtering) && rowCount !== undefined ? rowCount : table.getFilteredRowModel().rows.length;\n const pageCount = paginated ? Math.max(1, manual?.pagination ? Math.ceil((rowCount ?? rows.length) / pageSize) : table.getPageCount()) : 1;\n // A page beyond the last (the filters shrank the list): show the last one.\n React.useEffect(() => {\n if (paginated && !loading && page > pageCount) changePage(pageCount);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- only when the count of pages drops under the page\n }, [pageCount]);\n\n const selectableIds = pageRows.filter((r) => !r.getIsGrouped()).map((r) => r.id);\n const chosen = new Set(selected);\n const allChosen = selectableIds.length > 0 && selectableIds.every((id) => chosen.has(id));\n const someChosen = selectableIds.some((id) => chosen.has(id));\n const toggleAll = () =>\n setSelected(allChosen ? selected.filter((id) => !selectableIds.includes(id)) : [...new Set([...selected, ...selectableIds])]);\n\n const span = visibleColumns.length + (selectable ? 1 : 0) + (hasExpander ? 1 : 0);\n const fixedLayout = Boolean(resizing || pinning?.start?.length || pinning?.end?.length);\n const nameOf = (row: TableRow<T>) => (rowLabel ? rowLabel(row.original) : row.id);\n\n // Windowing: the rows in view, between two spacer rows that keep the scroll height.\n const scroller = React.useRef<HTMLDivElement>(null);\n const fallbacks = useLayoutFallbacks(virtualized?.height ?? 0, virtualized?.estimateRowHeight ?? 44);\n const virtualizer = useVirtualizer({\n ...fallbacks,\n count: pageRows.length,\n getScrollElement: () => scroller.current,\n estimateSize: () => virtualized?.estimateRowHeight ?? 44,\n overscan: virtualized?.overscan ?? 6,\n initialRect: { width: 0, height: virtualized?.height ?? 0 },\n getItemKey: (index) => pageRows[index]?.id ?? index,\n enabled: Boolean(virtualized) && !loading,\n });\n const windowItems = virtualized && !loading ? virtualizer.getVirtualItems() : null;\n const before = windowItems?.[0]?.start ?? 0;\n const after = windowItems && windowItems.length > 0 ? virtualizer.getTotalSize() - (windowItems.at(-1)?.end ?? 0) : 0;\n\n const pin = (column: TableColumn<T>): { \"data-pin\"?: \"start\" | \"end\"; style?: React.CSSProperties } => {\n const position = column.getIsPinned();\n if (!position) return {};\n const offset = position === \"start\" ? column.getStart(\"start\") : column.getAfter(\"end\");\n return { \"data-pin\": position, style: { [\"--dt-pin\" as string]: `${offset}px` } };\n };\n const width = (column: TableColumn<T>): React.CSSProperties | undefined =>\n fixedLayout || byId.get(column.id)?.width ? { width: column.getSize() } : undefined;\n\n const message = (content: React.ReactNode, alert?: boolean) => (\n <tr role=\"row\" className={s.tr({ className: \"hover:bg-transparent\" })}>\n <td role=\"cell\" colSpan={span} data-card=\"message\" className={s.td({ className: s.message() })}>\n {alert ? <div role=\"alert\">{content}</div> : content}\n </td>\n </tr>\n );\n\n const cardOf = (column: DataTableColumn<T> | undefined) => (column?.card === \"title\" ? \"title\" : column?.card === \"hidden\" ? \"hidden\" : undefined);\n\n const dataCell = (row: TableRow<T>, cell: TableCell<T>, first: boolean) => {\n const column = byId.get(cell.column.id);\n if (!column) return null;\n const { style, ...pinned } = pin(cell.column);\n let content: React.ReactNode;\n if (row.getIsGrouped()) {\n const leaves = row.getLeafRows().filter((r) => !r.getIsGrouped()).map((r) => r.original);\n if (cell.column.id === row.groupingColumnId) {\n const value = display(row.groupingValue as CellValue);\n content = (\n <button type=\"button\" className={s.toggle({ className: \"px-1.5 font-semibold text-fg\" })} aria-expanded={row.getIsExpanded()} onClick={row.getToggleExpandedHandler()}>\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n {labels.group(value, leaves.length)}\n </button>\n );\n } else content = column.aggregate ? column.aggregate(leaves) : null;\n } else content = column.cell ? column.cell(row.original) : display(valueOf(column, row.original));\n return (\n <td\n role=\"cell\"\n key={cell.id}\n data-label={column.header}\n data-card={row.getIsGrouped() ? (cell.column.id === row.groupingColumnId ? \"title\" : column.aggregate ? undefined : \"hidden\") : cardOf(column)}\n {...pinned}\n className={s.td({ className: ALIGN[column.align ?? \"start\"] })}\n style={{ ...style, ...(first && row.depth > 0 && !row.getIsGrouped() ? { paddingInlineStart: `${1 + row.depth * 1.25}rem` } : {}) }}\n >\n {content}\n </td>\n );\n };\n\n const renderRow = (row: TableRow<T>, index: number) => {\n const isChosen = chosen.has(row.id);\n const grouped = row.getIsGrouped();\n const canExpand = !grouped && (renderDetail ? true : row.getCanExpand());\n const cells = row.getVisibleCells();\n const main = (\n <tr\n role=\"row\"\n key={row.id}\n data-index={windowItems ? index : undefined}\n ref={windowItems ? virtualizer.measureElement : undefined}\n aria-rowindex={virtualized ? index + 2 : undefined}\n data-state={isChosen ? \"selected\" : undefined}\n data-group={grouped ? \"\" : undefined}\n className={s.tr({ className: grouped ? s.groupRow() : undefined })}\n >\n {selectable && (\n <td role=\"cell\" className={s.td({ className: s.select() })}>\n {grouped ? null : <Checkbox checked={isChosen} onCheckedChange={() => row.toggleSelected()} aria-label={labels.selectRow(nameOf(row))} />}\n </td>\n )}\n {hasExpander && (\n <td role=\"cell\" className={s.td({ className: s.expander() })}>\n {canExpand ? (\n <button type=\"button\" className={s.toggle()} aria-expanded={row.getIsExpanded()} aria-label={labels.expandRow(nameOf(row))} onClick={row.getToggleExpandedHandler()}>\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n </button>\n ) : null}\n </td>\n )}\n {cells.map((cell, i) => dataCell(row, cell, i === 0))}\n </tr>\n );\n if (!renderDetail || grouped || !row.getIsExpanded() || virtualized) return main;\n return (\n <React.Fragment key={row.id}>\n {main}\n <tr role=\"row\" className={s.tr({ className: \"hover:bg-transparent\" })}>\n <td role=\"cell\" colSpan={span} data-card=\"message\" className={s.td({ className: s.detail() })}>\n {renderDetail(row.original)}\n </td>\n </tr>\n </React.Fragment>\n );\n };\n\n let body: React.ReactNode;\n if (loading) {\n body = Array.from({ length: loadingRows }, (_, i) => (\n <tr role=\"row\" key={`loading-${i}`} aria-hidden className={s.tr()}>\n {selectable && <td role=\"cell\" className={s.td({ className: s.select() })} />}\n {hasExpander && <td role=\"cell\" className={s.td({ className: s.expander() })} />}\n {visibleColumns.map((column) => (\n <td role=\"cell\" key={column.id} data-card={cardOf(byId.get(column.id))} className={s.td()}>\n <div className={s.skeleton()} />\n </td>\n ))}\n </tr>\n ));\n } else if (error) body = message(error, true);\n else if (pageRows.length === 0) body = message(empty ?? labels.empty);\n else if (windowItems) body = windowItems.map((item) => renderRow(pageRows[item.index] as TableRow<T>, item.index));\n else body = pageRows.map((row, i) => renderRow(row, i));\n\n const headers = table.getHeaderGroups().at(-1)?.headers ?? [];\n const tableElement = (\n <table\n role=\"table\"\n aria-busy={loading || undefined}\n aria-rowcount={virtualized ? pageRows.length + 1 : undefined}\n className={s.table({ className: fixedLayout ? \"table-fixed\" : undefined })}\n style={fixedLayout ? { minWidth: table.getTotalSize() + (selectable ? 40 : 0) + (hasExpander ? 40 : 0) } : undefined}\n >\n <caption className={showCaption ? s.caption() : \"sr-only\"}>\n {caption}\n {loading ? <span className=\"sr-only\">{` — ${labels.loading}`}</span> : null}\n </caption>\n <thead role=\"rowgroup\" className={s.thead({ className: virtualized ? \"sticky top-0 z-10 bg-surface-2\" : undefined })}>\n <tr role=\"row\" aria-rowindex={virtualized ? 1 : undefined} className={s.headRow()}>\n {selectable && (\n <th role=\"columnheader\" scope=\"col\" data-keep=\"\" className={s.th({ className: s.select() })}>\n <Checkbox\n checked={allChosen ? true : someChosen ? \"indeterminate\" : false}\n onCheckedChange={toggleAll}\n disabled={loading || selectableIds.length === 0}\n aria-label={labels.selectAll}\n />\n </th>\n )}\n {hasExpander && (\n <th role=\"columnheader\" scope=\"col\" className={s.th({ className: s.expander() })}>\n <span className=\"sr-only\">{labels.details}</span>\n </th>\n )}\n {headers.map((header) => {\n const column = byId.get(header.column.id);\n if (!column) return null;\n const sorted = header.column.getIsSorted();\n const direction: SortDirection | undefined = sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : undefined;\n const { style, ...pinned } = pin(header.column);\n return (\n <th\n role=\"columnheader\"\n scope=\"col\"\n key={header.id}\n aria-sort={column.sortable ? (direction ?? \"none\") : undefined}\n data-keep={column.sortable ? \"\" : undefined}\n {...pinned}\n className={s.th({ className: ALIGN[column.align ?? \"start\"] })}\n style={{ ...style, ...width(header.column) }}\n >\n {column.sortable ? (\n <button type=\"button\" aria-pressed={Boolean(direction)} className={s.sortButton()} onClick={() => header.column.toggleSorting(undefined, false)}>\n {column.header}\n <SortGlyph direction={direction} className=\"h-3.5 w-3.5\" />\n </button>\n ) : column.headerHidden ? (\n <span className=\"sr-only\">{column.header}</span>\n ) : (\n column.header\n )}\n {resizing && header.column.getCanResize() ? (\n <ResizeHandle\n label={labels.resize(column.header)}\n size={header.column.getSize()}\n min={column.minWidth ?? 64}\n resizing={header.column.getIsResizing()}\n className={s.resizer()}\n onPointerStart={header.getResizeHandler()}\n onSize={(size) => setSizing((old: Record<string, number>) => ({ ...old, [header.column.id]: size }))}\n />\n ) : null}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody role=\"rowgroup\" className={s.tbody()}>\n {before > 0 && (\n <tr aria-hidden style={{ height: before }}>\n <td colSpan={span} />\n </tr>\n )}\n {body}\n {after > 0 && (\n <tr aria-hidden style={{ height: after }}>\n <td colSpan={span} />\n </tr>\n )}\n </tbody>\n </table>\n );\n\n const context: Context = {\n table: table as unknown as DataTableInstance<object>,\n columns: columns as readonly DataTableColumn<object>[],\n labels,\n filters,\n setFilters: changeFilters,\n resultCount: leafCount,\n exportRows: () => table.getSortedRowModel().flatRows.filter((r) => !r.getIsGrouped()).map((r) => r.original),\n manualFiltering: Boolean(manual?.filtering),\n };\n\n const pagination = paginated ? (\n <Pagination\n page={Math.min(page, pageCount)}\n pageCount={pageCount}\n onPageChange={changePage}\n pageSize={pageSizeOptions ? pageSize : undefined}\n pageSizeOptions={pageSizeOptions}\n onPageSizeChange={pageSizeOptions ? changePageSize : undefined}\n disabled={loading}\n labels={labels.pagination}\n />\n ) : null;\n\n return (\n <DataTableContext.Provider value={context}>\n <div className={s.root({ className })}>\n {toolbar ? <div className={s.toolbar()}>{toolbar}</div> : null}\n {virtualized ? (\n // A scrolling region the keyboard can reach: Tab lands on it, the arrows scroll it.\n <div\n ref={scroller}\n tabIndex={0}\n role=\"region\"\n aria-label={caption}\n className={s.scroller({ className: \"overflow-y-auto focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\" })}\n style={{ height: virtualized.height }}\n >\n {tableElement}\n </div>\n ) : (\n <div className={s.scroller()}>{tableElement}</div>\n )}\n {pagination || footer ? (\n <div className={s.footer({ className: \"flex flex-col gap-3\" })}>\n {pagination}\n {footer}\n </div>\n ) : null}\n </div>\n </DataTableContext.Provider>\n );\n}\n\n/** The handle on a header's edge: drag it, or focus it and use ← → (Shift for larger steps). */\nfunction ResizeHandle({\n label,\n size,\n min,\n resizing,\n className,\n onPointerStart,\n onSize,\n}: {\n label: string;\n size: number;\n min: number;\n resizing: boolean;\n className: string;\n onPointerStart: (event: unknown) => void;\n onSize: (size: number) => void;\n}) {\n return (\n // A focusable separator is interactive (WAI-ARIA \"window splitter\"): its value is the width, the arrows change it.\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={label}\n aria-valuenow={Math.round(size)}\n aria-valuemin={min}\n tabIndex={0}\n data-resizing={resizing ? \"\" : undefined}\n className={className}\n onMouseDown={onPointerStart}\n onTouchStart={onPointerStart}\n onClick={(event) => event.stopPropagation()}\n onKeyDown={(event) => {\n const step = event.shiftKey ? 48 : 16;\n if (event.key === \"ArrowLeft\") onSize(Math.max(min, size - step));\n else if (event.key === \"ArrowRight\") onSize(size + step);\n else return;\n event.preventDefault();\n }}\n />\n );\n}\n"
668
+ "content": "// Client (the table's state, the windowing of long lists). A real `<table>` with a caption and column headers\n// (`scope=\"col\"`, `aria-sort` on the sorted one) that turns into a stack of cards in a narrow container — one DOM, the\n// roles written out so that the change of `display` never loses the table's semantics. The engine is TanStack Table v9\n// (engine.ts): sorting, filters and facets, grouping, expanding, pagination, selection, visibility, pinning, resizing —\n// in the browser, or on the server (`manual` + `onStateChange`). Long lists render by what is in view (`virtualized`).\nimport { useTable } from \"@tanstack/react-table\";\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { Checkbox } from \"../checkbox\";\nimport { cn } from \"../cn\";\nimport { ChevronGlyph, SortGlyph } from \"../glyphs\";\nimport { useControllable } from \"../media\";\nimport { Pagination, type PaginationLabels } from \"../pagination\";\nimport { useLayoutFallbacks, useVirtualizer } from \"../virtual/virtual\";\nimport {\n type CellValue,\n type ColumnDef,\n type ColumnResizingState,\n type DataTableFilterState,\n type DataTableInstance,\n features,\n FILTERS,\n type FilterValue,\n type RowData,\n sortByValue,\n type TableCell,\n type TableColumn,\n type TableRow,\n} from \"./engine\";\n\nexport const dataTable = tv({\n slots: {\n root: \"@container w-full overflow-clip rounded-xl border border-border-default bg-surface-1\",\n toolbar: \"border-b border-border-default p-3\",\n scroller: \"w-full overflow-x-auto\",\n table: \"w-full border-collapse text-left text-sm text-fg\",\n caption: \"px-4 pt-4 pb-2 text-left text-sm font-semibold text-fg\",\n thead: \"bg-surface-2/60\",\n headRow: \"border-b border-border-default\",\n th: \"relative whitespace-nowrap px-4 py-3 text-xs font-semibold text-fg-secondary\",\n sortButton:\n \"-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring aria-[pressed=true]:text-fg\",\n resizer:\n \"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-transparent hover:bg-border-strong \" +\n \"focus-visible:bg-accent focus-visible:outline-hidden data-[resizing]:bg-accent\",\n tbody: \"\",\n tr: \"border-b border-border-subtle transition-colors last:border-b-0 hover:bg-surface-2/50 data-[state=selected]:bg-accent-soft\",\n groupRow: \"bg-surface-2/40 font-semibold\",\n td: \"px-4 py-3 align-middle\",\n select: \"w-10\",\n expander: \"w-10\",\n toggle:\n \"inline-flex h-7 min-w-7 items-center justify-center gap-1.5 rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring [&[aria-expanded=true]>svg]:rotate-90 [&>svg]:transition-transform motion-reduce:[&>svg]:transition-none\",\n detail: \"bg-surface-0 px-4 py-4\",\n message: \"px-4 py-10 text-center text-sm text-fg-secondary\",\n skeleton: \"h-4 w-full max-w-40 animate-pulse rounded bg-surface-3 motion-reduce:animate-none\",\n footer: \"border-t border-border-default px-4 py-3\",\n },\n variants: {\n layout: {\n table: {\n th: \"data-[pin]:sticky data-[pin]:z-[2] data-[pin]:bg-surface-2 data-[pin=start]:left-(--dt-pin) data-[pin=end]:right-(--dt-pin)\",\n td: \"data-[pin]:sticky data-[pin]:z-[1] data-[pin]:bg-surface-1 data-[pin=start]:left-(--dt-pin) data-[pin=end]:right-(--dt-pin)\",\n },\n // Below 36rem of container: cards. The sortable headers stay, as a row of sort buttons above the cards.\n auto: {\n table: \"@max-xl:block\",\n caption: \"@max-xl:block\",\n thead: \"@max-xl:sr-only @max-xl:bg-transparent @max-xl:has-[[data-keep]]:not-sr-only @max-xl:has-[[data-keep]]:block\",\n headRow: \"@max-xl:flex @max-xl:flex-wrap @max-xl:items-center @max-xl:gap-2 @max-xl:px-3 @max-xl:py-2\",\n th:\n \"@max-xl:sr-only @max-xl:data-[keep]:not-sr-only @max-xl:data-[keep]:block @max-xl:p-0 \" +\n \"@xl:data-[pin]:sticky @xl:data-[pin]:z-[2] @xl:data-[pin]:bg-surface-2 @xl:data-[pin=start]:left-(--dt-pin) @xl:data-[pin=end]:right-(--dt-pin)\",\n tbody: \"@max-xl:grid @max-xl:gap-3 @max-xl:p-3\",\n tr: \"@max-xl:relative @max-xl:grid @max-xl:gap-1.5 @max-xl:rounded-xl @max-xl:border @max-xl:border-border-default @max-xl:bg-surface-0 @max-xl:p-4 @max-xl:last:border-b\",\n td:\n \"@max-xl:flex @max-xl:items-baseline @max-xl:justify-between @max-xl:gap-4 @max-xl:p-0 @max-xl:text-right \" +\n \"@max-xl:before:shrink-0 @max-xl:before:text-xs @max-xl:before:font-semibold @max-xl:before:text-fg-secondary @max-xl:before:content-[attr(data-label)] \" +\n \"@max-xl:data-[card=title]:order-first @max-xl:data-[card=title]:pr-8 @max-xl:data-[card=title]:text-left @max-xl:data-[card=title]:text-base @max-xl:data-[card=title]:font-semibold @max-xl:data-[card=title]:before:content-none \" +\n \"@max-xl:data-[card=hidden]:hidden @max-xl:data-[card=message]:block @max-xl:data-[card=message]:text-center @max-xl:data-[card=message]:before:content-none \" +\n \"@xl:data-[pin]:sticky @xl:data-[pin]:z-[1] @xl:data-[pin]:bg-surface-1 @xl:data-[pin=start]:left-(--dt-pin) @xl:data-[pin=end]:right-(--dt-pin)\",\n select: \"@max-xl:absolute @max-xl:right-3 @max-xl:top-3 @max-xl:w-auto @max-xl:before:content-none\",\n expander: \"@max-xl:before:content-none\",\n },\n cards: {\n table: \"block\",\n caption: \"block\",\n thead: \"sr-only bg-transparent has-[[data-keep]]:not-sr-only has-[[data-keep]]:block\",\n headRow: \"flex flex-wrap items-center gap-2 px-3 py-2\",\n th: \"sr-only p-0 data-[keep]:not-sr-only data-[keep]:block\",\n tbody: \"grid gap-3 p-3\",\n tr: \"relative grid gap-1.5 rounded-xl border border-border-default bg-surface-0 p-4 last:border-b\",\n td:\n \"flex items-baseline justify-between gap-4 p-0 text-right before:shrink-0 before:text-xs before:font-semibold before:text-fg-secondary before:content-[attr(data-label)] \" +\n \"data-[card=title]:order-first data-[card=title]:pr-8 data-[card=title]:text-left data-[card=title]:text-base data-[card=title]:font-semibold data-[card=title]:before:content-none \" +\n \"data-[card=hidden]:hidden data-[card=message]:block data-[card=message]:text-center data-[card=message]:before:content-none\",\n select: \"absolute right-3 top-3 w-auto before:content-none\",\n expander: \"before:content-none\",\n },\n },\n density: {\n comfortable: {},\n compact: { th: \"py-2\", td: \"py-1.5\" },\n },\n // The header stays in view while the page scrolls (table layouts only; `--dt-top` clears a fixed bar).\n sticky: { true: {}, false: {} },\n // Zebra rows (the table layouts); the cards keep their frame.\n striped: { true: {}, false: {} },\n // Typed columns: one height per row (`typed`); untyped tables keep the height of their content.\n typed: { true: {}, false: {} },\n },\n compoundVariants: [\n { layout: \"table\", sticky: true, class: { th: \"sticky top-(--dt-top,0px) z-[3] bg-surface-2\" } },\n { layout: \"auto\", sticky: true, class: { th: \"@xl:sticky @xl:top-(--dt-top,0px) @xl:z-[3] @xl:bg-surface-2\" } },\n { layout: \"table\", striped: true, class: { tr: \"even:bg-surface-2/40\" } },\n { layout: \"auto\", striped: true, class: { tr: \"@xl:even:bg-surface-2/40\" } },\n // One row height per density (a two-line row — a name and its id — is the tallest).\n { layout: \"table\", density: \"comfortable\", typed: true, class: { td: \"h-15\" } },\n { layout: \"auto\", density: \"comfortable\", typed: true, class: { td: \"@xl:h-15\" } },\n { layout: \"table\", density: \"compact\", typed: true, class: { td: \"h-12\" } },\n { layout: \"auto\", density: \"compact\", typed: true, class: { td: \"@xl:h-12\" } },\n ],\n defaultVariants: { layout: \"auto\", density: \"comfortable\", sticky: false, striped: false, typed: false },\n});\n\n// Column priorities. `priority: 1` (default) is always in the row; a higher number leaves the row when the table's own\n// width (a container query, not the window's) falls under its tier and goes to the row's detail — the same\n// `@container` that turns the table into cards. Written out in full: Tailwind finds a class only as a whole string.\n// priority 2 → 42rem · 3 → 48rem · 4 → 56rem · 5 → 64rem · 6 → 72rem · 7 → 80rem\n// On the cards (under 36rem), priority 2 stays on the card's face and 3+ go to its detail.\ntype Priority = 2 | 3 | 4 | 5 | 6 | 7;\ntype ByLayout = { auto: Record<Priority, string>; table: Record<Priority, string>; cards: Record<Priority, string> };\nconst NO_CLASS: Record<Priority, string> = { 2: \"\", 3: \"\", 4: \"\", 5: \"\", 6: \"\", 7: \"\" };\n/** The cell of a column that leaves the row. */\nconst LEAVES: ByLayout = {\n auto: { 2: \"@xl:@max-2xl:hidden!\", 3: \"@max-3xl:hidden!\", 4: \"@max-4xl:hidden!\", 5: \"@max-5xl:hidden!\", 6: \"@max-6xl:hidden!\", 7: \"@max-7xl:hidden!\" },\n table: { 2: \"@max-2xl:hidden!\", 3: \"@max-3xl:hidden!\", 4: \"@max-4xl:hidden!\", 5: \"@max-5xl:hidden!\", 6: \"@max-6xl:hidden!\", 7: \"@max-7xl:hidden!\" },\n cards: { 2: \"\", 3: \"hidden!\", 4: \"hidden!\", 5: \"hidden!\", 6: \"hidden!\", 7: \"hidden!\" },\n};\n/** Its header (the cards keep the sort buttons). */\nconst LEAVES_HEAD: ByLayout = {\n auto: { 2: \"@xl:@max-2xl:hidden!\", 3: \"@xl:@max-3xl:hidden!\", 4: \"@xl:@max-4xl:hidden!\", 5: \"@xl:@max-5xl:hidden!\", 6: \"@xl:@max-6xl:hidden!\", 7: \"@xl:@max-7xl:hidden!\" },\n table: LEAVES.table,\n cards: NO_CLASS,\n};\n/** Its line in the row's detail: shown exactly when the cell is not. */\nconst IN_DETAIL: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:grid\", 3: \"hidden @max-3xl:grid\", 4: \"hidden @max-4xl:grid\", 5: \"hidden @max-5xl:grid\", 6: \"hidden @max-6xl:grid\", 7: \"hidden @max-7xl:grid\" },\n table: { 2: \"hidden @max-2xl:grid\", 3: \"hidden @max-3xl:grid\", 4: \"hidden @max-4xl:grid\", 5: \"hidden @max-5xl:grid\", 6: \"hidden @max-6xl:grid\", 7: \"hidden @max-7xl:grid\" },\n cards: { 2: \"hidden\", 3: \"grid\", 4: \"grid\", 5: \"grid\", 6: \"grid\", 7: \"grid\" },\n};\n/** The button that opens the detail, and its panel, keyed by the highest priority of the table: seen only when something left the row. */\nconst TOGGLE: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:inline-flex\", 3: \"hidden @max-3xl:inline-flex\", 4: \"hidden @max-4xl:inline-flex\", 5: \"hidden @max-5xl:inline-flex\", 6: \"hidden @max-6xl:inline-flex\", 7: \"hidden @max-7xl:inline-flex\" },\n table: { 2: \"hidden @max-2xl:inline-flex\", 3: \"hidden @max-3xl:inline-flex\", 4: \"hidden @max-4xl:inline-flex\", 5: \"hidden @max-5xl:inline-flex\", 6: \"hidden @max-6xl:inline-flex\", 7: \"hidden @max-7xl:inline-flex\" },\n cards: { 2: \"hidden\", 3: \"inline-flex\", 4: \"inline-flex\", 5: \"inline-flex\", 6: \"inline-flex\", 7: \"inline-flex\" },\n};\nconst PANEL: ByLayout = {\n auto: { 2: \"hidden @xl:@max-2xl:block\", 3: \"hidden @max-3xl:block\", 4: \"hidden @max-4xl:block\", 5: \"hidden @max-5xl:block\", 6: \"hidden @max-6xl:block\", 7: \"hidden @max-7xl:block\" },\n table: { 2: \"hidden @max-2xl:block\", 3: \"hidden @max-3xl:block\", 4: \"hidden @max-4xl:block\", 5: \"hidden @max-5xl:block\", 6: \"hidden @max-6xl:block\", 7: \"hidden @max-7xl:block\" },\n cards: { 2: \"hidden\", 3: \"block\", 4: \"block\", 5: \"block\", 6: \"block\", 7: \"block\" },\n};\n\nexport type DataTableColumnType = \"title\" | \"badge\" | \"number\" | \"link\" | \"date\" | \"status\" | \"text\";\n\n// What each type does to its cell. Sizes apply to the table layouts only (a card is not a column).\n// title: the column that takes the slack, truncated, full value on hover · badge, status, number, date: as wide as\n// their content, never stretched · link: truncated · text: wraps, two lines.\nconst TYPE_CELL: Record<DataTableColumnType, ByLayoutString> = {\n title: { auto: \"@xl:w-full @xl:max-w-0 @xl:min-w-44\", table: \"w-full max-w-0 min-w-44\", cards: \"\" },\n badge: { auto: \"@xl:w-px\", table: \"w-px\", cards: \"\" },\n status: { auto: \"@xl:w-px @xl:whitespace-nowrap\", table: \"w-px whitespace-nowrap\", cards: \"\" },\n number: { auto: \"@xl:w-px @xl:whitespace-nowrap tabular-nums\", table: \"w-px whitespace-nowrap tabular-nums\", cards: \"tabular-nums\" },\n date: { auto: \"@xl:w-px @xl:whitespace-nowrap tabular-nums\", table: \"w-px whitespace-nowrap tabular-nums\", cards: \"tabular-nums\" },\n link: { auto: \"@xl:min-w-28 @xl:max-w-56\", table: \"min-w-28 max-w-56\", cards: \"\" },\n text: { auto: \"@xl:min-w-36\", table: \"min-w-36\", cards: \"\" },\n};\ntype ByLayoutString = { auto: string; table: string; cards: string };\nconst CLAMP = { 1: \"line-clamp-1\", 2: \"line-clamp-2\", 3: \"line-clamp-3\" } as const;\n\nexport type SortDirection = \"ascending\" | \"descending\";\nexport type DataTableSort = { column: string; direction: SortDirection } | null;\n\n/** What the server needs to fetch a page (`manual` mode): given to `onStateChange` at each change. */\nexport type DataTableQuery = {\n /** The sorted column, or `null`. */\n sort: DataTableSort;\n /** The filters in force, by column id. */\n filters: DataTableFilterState;\n /** The page, from 1 (1 without pagination). */\n page: number;\n /** Rows per page (`Infinity` without pagination). */\n pageSize: number;\n};\n\nexport type DataTableColumn<T> = {\n /** A key of the column (and of the row, for the default value). */\n id: string;\n /** The column's name, passed translated: the header, the label of the value in a card, the filter's name. */\n header: string;\n /** What a cell shows. Default: the value as text. */\n cell?: (row: T) => React.ReactNode;\n /** The value sorted, filtered, grouped and exported. Default `row[id]`. */\n value?: (row: T) => CellValue;\n /** The header becomes a sort button (ascending → descending → none). */\n sortable?: boolean;\n /** How `DataTableFilters` filters it: `text` (a search), `facet` (values to choose), `range` (numbers between bounds). */\n filter?: \"text\" | \"facet\" | \"range\";\n /** The choices of a `facet` filter, with their words (and counts from the server). Default: the values found in the rows. */\n options?: readonly { value: string; label: string; count?: number }[];\n /** The rows can be grouped by this column (`groupBy`). */\n groupable?: boolean;\n /** What a group's row shows in this column, from the group's rows (a sum, a count). */\n aggregate?: (rows: readonly T[]) => React.ReactNode;\n /** Text alignment: `end` for amounts and counts. */\n align?: \"start\" | \"center\" | \"end\";\n /** The width, in px (the starting width when `resizing`). */\n width?: number;\n /** The narrowest the column may be, in px (also what a resized one may shrink to). Default 64. */\n minWidth?: number;\n /** In a card: `title` (the heading of the card), `label` (default: \"Header value\"), `hidden`. */\n card?: \"title\" | \"label\" | \"hidden\";\n /** Hides the header visually (an actions column), keeping it for screen readers. */\n headerHidden?: boolean;\n /** Listed in `DataTableColumnsMenu`, so a person can hide it. Default `true`. */\n hideable?: boolean;\n /**\n * What the column holds, which sizes and aligns it: `title` (the name, takes the slack, truncated, `subtitle` under it in\n * mono — and the card's heading), `badge` · `status` (as wide as the badge, never stretched), `number` (end-aligned,\n * tabular), `date`, `link` (truncated, `href`), `text` (wraps, two lines). Untyped: as before.\n */\n type?: DataTableColumnType;\n /**\n * 1 (default) always in the row; 2 to 7 leave it when the table is narrower than 42 · 48 · 56 · 64 · 72 · 80 rem and\n * go to the row's detail (a chevron opens it); under 36 rem (cards) 2 stays on the card, 3+ go to its detail.\n */\n priority?: 1 | 2 | 3 | 4 | 5 | 6 | 7;\n /** `title`: the line under the name (an id), in mono. */\n subtitle?: (row: T) => React.ReactNode;\n /** `link`: where the value leads. */\n href?: (row: T) => string | undefined;\n /** `number`: the unit after the value (muted). */\n unit?: string;\n /** The widest the column may be in the table layouts, in px; longer text is clamped, the full value on hover. */\n maxWidth?: number;\n /** One line only, truncated (the full value on hover). */\n nowrap?: boolean;\n /** Lines of text kept before the ellipsis (default 2 for `text`). */\n clamp?: 1 | 2 | 3;\n};\n\nexport type DataTableLabels = {\n /** The checkbox of the header (\"Select all rows\"). */\n selectAll: string;\n /** The checkbox of a row: `(name) => \"Select \" + name`, the name from `rowLabel`. */\n selectRow: (name: string) => string;\n /** Read while the rows load (\"Loading\"). */\n loading: string;\n /** The default empty state (\"Nothing to show\"). */\n empty: string;\n /** The header of the column of the expand buttons, for screen readers (\"Details\"). */\n details: string;\n /** The button that opens a row: `(name) => \"Show details of \" + name`. */\n expandRow: (name: string) => string;\n /** The button of a group's row: `(value, count) => value + \", \" + count + \" rows\"`. */\n group: (value: string, count: number) => string;\n /** The handle that resizes a column: `(header) => \"Resize \" + header`. */\n resize: (header: string) => string;\n /** The count of results under the filters: `(count) => count + \" rows\"`. */\n results: (count: number) => string;\n /** The accessible name of the filters' bar (\"Filter the table\"). */\n filters: string;\n /** \"Clear filters\". */\n clearFilters: string;\n /** A filter's chip remove button: `(label) => \"Remove \" + label`. */\n removeFilter: (label: string) => string;\n /** The words of a filter in force: `(header, value) => header + \": \" + value`. */\n activeFilter: (header: string, value: string) => string;\n /** A `text` filter's field: `(header) => \"Search \" + header`. */\n search: (header: string) => string;\n /** The lower bound of a `range` filter: `(header) => header + \" from\"`. */\n rangeFrom: (header: string) => string;\n /** The upper bound of a `range` filter: `(header) => header + \" to\"`. */\n rangeTo: (header: string) => string;\n /** The button of the columns menu (\"Columns\"). */\n columns: string;\n /** The button that opens what left the row: `(name) => \"Show more about \" + name`. */\n showMore: (name: string) => string;\n /** The export button (\"Export CSV\"). */\n export: string;\n /** The words of the pagination under the table. */\n pagination?: Partial<PaginationLabels>;\n};\n\nexport const dataTableLabels: DataTableLabels = {\n selectAll: \"Select all rows\",\n selectRow: (name) => `Select ${name}`,\n loading: \"Loading\",\n empty: \"Nothing to show\",\n details: \"Details\",\n expandRow: (name) => `Show details of ${name}`,\n group: (value, count) => `${value}, ${count} ${count === 1 ? \"row\" : \"rows\"}`,\n resize: (header) => `Resize ${header}`,\n results: (count) => `${count.toLocaleString(\"en\")} ${count === 1 ? \"row\" : \"rows\"}`,\n filters: \"Filter the table\",\n clearFilters: \"Clear filters\",\n removeFilter: (label) => `Remove ${label}`,\n activeFilter: (header, value) => `${header}: ${value}`,\n search: (header) => `Search ${header.toLocaleLowerCase()}`,\n rangeFrom: (header) => `${header} from`,\n rangeTo: (header) => `${header} to`,\n columns: \"Columns\",\n showMore: (name) => `Show more about ${name}`,\n export: \"Export CSV\",\n};\n\nexport type DataTableProps<T> = {\n /** The rows (stable: module scope, state or a query's data). Sorted, filtered and paginated here unless `manual`. */\n rows: readonly T[];\n /** The columns, in order (stable: module scope or `useMemo`). */\n columns: readonly DataTableColumn<T>[];\n /** A stable id per row: the React key, the selection's value, the expansion's key. */\n getRowId: (row: T) => string;\n /** What the table lists (\"Jobs\", \"Your videos\"): its `<caption>`, hidden unless `showCaption`. */\n caption: string;\n /** Shows the caption above the table. */\n showCaption?: boolean;\n\n /** The sorted column (controlled), `null` for none. */\n sort?: DataTableSort;\n /** The sort at first, uncontrolled. */\n defaultSort?: DataTableSort;\n /** Called when a header asks for a new sort. */\n onSortChange?: (sort: DataTableSort) => void;\n\n /** The filters in force (controlled), by column id — what `DataTableFilters` edits. */\n filters?: DataTableFilterState;\n /** The filters at first, uncontrolled. */\n defaultFilters?: DataTableFilterState;\n /** Called with the new filters. */\n onFiltersChange?: (filters: DataTableFilterState) => void;\n\n /** Adds a checkbox per row and one in the header. */\n selectable?: boolean;\n /** The ids selected (controlled). */\n selected?: readonly string[];\n /** The ids selected at first, uncontrolled. */\n defaultSelected?: readonly string[];\n /** Called with the new selection. */\n onSelectedChange?: (selected: string[]) => void;\n /** The name of a row in its labels (checkbox, expand button). Default: its id. */\n rowLabel?: (row: T) => string;\n\n /** The columns the rows are grouped by, in order (controlled) — each `groupable`. */\n groupBy?: readonly string[];\n /** The grouping at first, uncontrolled. */\n defaultGroupBy?: readonly string[];\n /** Called with the new grouping. */\n onGroupByChange?: (groupBy: string[]) => void;\n\n /** Visible (`true`) or hidden (`false`) columns, by id (controlled); a column not listed is visible. */\n columnVisibility?: Readonly<Record<string, boolean>>;\n /** The visibility at first, uncontrolled. */\n defaultColumnVisibility?: Readonly<Record<string, boolean>>;\n /** Called with the new visibility (`DataTableColumnsMenu`). */\n onColumnVisibilityChange?: (visibility: Record<string, boolean>) => void;\n\n /** Columns that stay in view while the table scrolls sideways: at the start (a name) or the end (actions). */\n pinning?: { start?: readonly string[]; end?: readonly string[] };\n /** Columns can be resized (pointer drag, or the arrow keys on the focused handle). */\n resizing?: boolean;\n\n /** The children of a row (a tree): each row with children gets a button that shows them. */\n getSubRows?: (row: T) => readonly T[] | undefined;\n /** A panel under an opened row (the details of a job): each row gets a button that opens it. */\n renderDetail?: (row: T) => React.ReactNode;\n /** The opened rows and groups (controlled), by id. */\n expanded?: Readonly<Record<string, boolean>>;\n /** The opened rows at first, uncontrolled. */\n defaultExpanded?: Readonly<Record<string, boolean>>;\n /** Called with the opened rows. */\n onExpandedChange?: (expanded: Record<string, boolean>) => void;\n\n /** Rows per page (controlled): turns on the pagination under the table. */\n pageSize?: number;\n /** Rows per page at first, uncontrolled: turns on the pagination under the table. */\n defaultPageSize?: number;\n /** Called with the size chosen in the pagination's select (`pageSizeOptions`). */\n onPageSizeChange?: (pageSize: number) => void;\n /** The sizes the pagination offers (`[10, 25, 50]`). */\n pageSizeOptions?: readonly number[];\n /** The page, from 1 (controlled). */\n page?: number;\n /** The page at first, uncontrolled. Default 1. */\n defaultPage?: number;\n /** Called with the page asked for. */\n onPageChange?: (page: number) => void;\n\n /** The server does the work: the rows are shown as given (sorted, filtered, a single page) — fetch in `onStateChange`. */\n manual?: { sorting?: boolean; filtering?: boolean; pagination?: boolean };\n /** The number of rows on the server (`manual.pagination`): the page count and the results' count. */\n rowCount?: number;\n /** Called with the sort, the filters and the page after each change: the query key of a server table. */\n onStateChange?: (query: DataTableQuery) => void;\n\n /** Rows are loading: skeleton rows (`loadingRows` of them) and `aria-busy`. */\n loading?: boolean;\n /** How many skeleton rows while loading. Default 5. */\n loadingRows?: number;\n /** A failure, shown in place of the rows (an `Alert`, a retry button); announced. */\n error?: React.ReactNode;\n /** Shown when there is no row (an `EmptyState`). Default: `labels.empty`. */\n empty?: React.ReactNode;\n\n /** `auto` (default): a table, cards below 36rem of container width. `table` or `cards` to force one. */\n layout?: \"auto\" | \"table\" | \"cards\";\n /** comfortable (default) · compact — one row height per density. */\n density?: \"comfortable\" | \"compact\";\n /** The header stays at the top of the window while the page scrolls (`--dt-top` on the table, or a number of px, clears a fixed bar). */\n stickyHeader?: boolean | number;\n /** Zebra rows. */\n striped?: boolean;\n /**\n * The table never scrolls sideways by default once its columns have a `type` or a `priority`: what does not fit goes to\n * the row's detail, then to cards. `true` opts in to a sideways scroll instead, with fades at the edges.\n */\n scroll?: boolean;\n /** Keeps the first column in view while the table scrolls sideways (with `scroll`). */\n stickyFirstColumn?: boolean;\n /** Renders only the rows in view, in a scrolling body of `height` px; rows are measured (`estimateRowHeight` is a guess). Forces the table layout. */\n virtualized?: { height: number; estimateRowHeight?: number; overscan?: number };\n\n /** Above the table, inside its frame: `DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`, actions. */\n toolbar?: React.ReactNode;\n /** Under the table (after the pagination): a total, a note. */\n footer?: React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<DataTableLabels>;\n /** Classes merged on the root. */\n className?: string;\n};\n\ntype Context = {\n table: DataTableInstance<object>;\n columns: readonly DataTableColumn<object>[];\n labels: DataTableLabels;\n filters: DataTableFilterState;\n setFilters: (filters: DataTableFilterState) => void;\n /** The rows after the filters (all pages), or the server's count. */\n resultCount: number;\n /** The rows to export: filtered and sorted, every page, without the group rows. */\n exportRows: () => readonly unknown[];\n manualFiltering: boolean;\n};\n\nconst DataTableContext = React.createContext<Context | null>(null);\n\n/**\n * The table a component sits in (`toolbar`, or anything under it): the TanStack Table instance (`table` — the escape\n * hatch for what the props do not cover), the columns, the words, the filters and their setter.\n */\nexport function useDataTable<T extends RowData = object>() {\n const context = React.useContext(DataTableContext);\n if (!context) throw new Error(\"useDataTable: call it inside a DataTable (its `toolbar`, `footer` or a cell)\");\n return context as unknown as Omit<Context, \"table\" | \"columns\" | \"exportRows\"> & {\n table: DataTableInstance<T>;\n columns: readonly DataTableColumn<T>[];\n exportRows: () => readonly T[];\n };\n}\n\nconst TRUNCATE = { auto: \"@xl:truncate\", table: \"truncate\", cards: \"\" } as const;\nconst MIN = { auto: \"@xl:min-w-(--dt-min)!\", table: \"min-w-(--dt-min)!\", cards: \"\" } as const;\nconst MAX = { auto: \"@xl:max-w-(--dt-max)!\", table: \"max-w-(--dt-max)!\", cards: \"\" } as const;\n// A sideways scroll asked for (`scroll`): the edges fade where there is more to see.\nconst SHADOWS =\n \"[background:linear-gradient(to_right,var(--kz-surface-1),transparent)_left/24px_100%_no-repeat_local,linear-gradient(to_left,var(--kz-surface-1),transparent)_right/24px_100%_no-repeat_local,linear-gradient(to_right,rgb(0_0_0/0.18),transparent)_left/10px_100%_no-repeat_scroll,linear-gradient(to_left,rgb(0_0_0/0.18),transparent)_right/10px_100%_no-repeat_scroll]\";\nconst ALIGN = { start: \"text-left\", center: \"text-center\", end: \"text-right\" } as const;\nconst NONE: readonly never[] = [];\nconst NO_RECORD: Readonly<Record<string, never>> = {};\nconst apply = <S,>(updater: S | ((old: S) => S), old: S): S => (typeof updater === \"function\" ? (updater as (old: S) => S)(old) : updater);\n\nconst valueOf = <T,>(column: DataTableColumn<T>, row: T): CellValue =>\n column.value ? column.value(row) : ((row as Record<string, unknown>)[column.id] as CellValue);\n\nconst display = (value: CellValue) => (value instanceof Date ? value.toISOString() : String(value ?? \"\"));\n\n/** A table of records — sort, filters and facets, groups, details, pages, selection, visibility, pinning, resizing — on the client or the server, cards on a phone, windowing for long lists. */\nexport function DataTable<T extends RowData>({\n rows,\n columns,\n getRowId,\n caption,\n showCaption,\n sort: sortProp,\n defaultSort = null,\n onSortChange,\n filters: filtersProp,\n defaultFilters = NO_RECORD,\n onFiltersChange,\n selectable,\n selected: selectedProp,\n defaultSelected = NONE,\n onSelectedChange,\n rowLabel,\n groupBy: groupByProp,\n defaultGroupBy = NONE,\n onGroupByChange,\n columnVisibility: visibilityProp,\n defaultColumnVisibility = NO_RECORD,\n onColumnVisibilityChange,\n pinning,\n resizing,\n getSubRows,\n renderDetail,\n expanded: expandedProp,\n defaultExpanded = NO_RECORD,\n onExpandedChange,\n pageSize: pageSizeProp,\n defaultPageSize,\n onPageSizeChange,\n pageSizeOptions,\n page: pageProp,\n defaultPage = 1,\n onPageChange,\n manual,\n rowCount,\n onStateChange,\n loading,\n loadingRows = 5,\n error,\n empty,\n layout = \"auto\",\n density,\n stickyHeader,\n striped,\n scroll,\n stickyFirstColumn,\n virtualized,\n toolbar,\n footer,\n labels: given,\n className,\n}: DataTableProps<T>) {\n const labels = { ...dataTableLabels, ...given };\n const effectiveLayout = virtualized ? \"table\" : layout;\n const L = effectiveLayout;\n const typed = columns.some((c) => c.type || c.priority);\n const s = dataTable({ layout: L, density, sticky: Boolean(stickyHeader), striped: Boolean(striped), typed });\n\n const [sort, setSort] = useControllable<DataTableSort>(sortProp, defaultSort, onSortChange);\n const [filters, setFiltersState] = useControllable<DataTableFilterState>(filtersProp, defaultFilters, onFiltersChange);\n const [selected, setSelected] = useControllable<readonly string[]>(selectedProp, defaultSelected, onSelectedChange as (next: readonly string[]) => void);\n const [groupBy, setGroupBy] = useControllable<readonly string[]>(groupByProp, defaultGroupBy, onGroupByChange as (next: readonly string[]) => void);\n const [visibility, setVisibility] = useControllable<Readonly<Record<string, boolean>>>(visibilityProp, defaultColumnVisibility, onColumnVisibilityChange as (next: Readonly<Record<string, boolean>>) => void);\n const [expanded, setExpanded] = useControllable<Readonly<Record<string, boolean>>>(expandedProp, defaultExpanded, onExpandedChange as (next: Readonly<Record<string, boolean>>) => void);\n const paginated = pageSizeProp !== undefined || defaultPageSize !== undefined;\n const [pageSize, setPageSize] = useControllable<number>(pageSizeProp, defaultPageSize ?? Number.POSITIVE_INFINITY, onPageSizeChange);\n const [page, setPage] = useControllable<number>(pageProp, defaultPage, onPageChange);\n const [sizing, setSizing] = React.useState<Record<string, number>>({});\n const [resizeState, setResizeState] = React.useState<ColumnResizingState | undefined>(undefined);\n\n // One place announces the server's query: after any change of sort, filters, page or page size.\n const announce = (next: Partial<DataTableQuery>) =>\n onStateChange?.({ sort, filters, page: paginated ? page : 1, pageSize: paginated ? pageSize : Number.POSITIVE_INFINITY, ...next });\n const changeSort = (next: DataTableSort) => {\n setSort(next);\n if (paginated && page !== 1) setPage(1);\n announce({ sort: next, page: 1 });\n };\n const changeFilters = (next: DataTableFilterState) => {\n setFiltersState(next);\n if (paginated && page !== 1) setPage(1);\n announce({ filters: next, page: 1 });\n };\n const changePage = (next: number) => {\n setPage(next);\n announce({ page: next });\n };\n const changePageSize = (next: number) => {\n setPageSize(next);\n setPage(1);\n announce({ pageSize: next, page: 1 });\n };\n\n const hasExpander = Boolean(renderDetail || getSubRows);\n // Columns that can leave the row: their lines go to the row's detail, opened by a chevron in the first cell.\n const priorityOf = (column: DataTableColumn<T>): 1 | Priority => (column.priority && column.priority > 1 ? (column.priority as Priority) : 1);\n const maxPriority = columns.reduce<1 | Priority>((max, column) => (priorityOf(column) > max ? priorityOf(column) : max), 1);\n const inlineToggle = maxPriority > 1 && !hasExpander;\n const detailed = Boolean(renderDetail) || inlineToggle;\n const defs = React.useMemo<ColumnDef<typeof features, T>[]>(\n () =>\n columns.map((column) => ({\n id: column.id,\n accessorFn: (row: T) => valueOf(column, row),\n header: column.header,\n enableSorting: Boolean(column.sortable),\n sortFn: sortByValue as never,\n sortUndefined: \"last\",\n enableColumnFilter: Boolean(column.filter),\n filterFn: (column.filter ? FILTERS[column.filter] : FILTERS.text) as never,\n enableGrouping: Boolean(column.groupable),\n enableHiding: column.hideable !== false,\n enableResizing: Boolean(resizing),\n ...(column.width ? { size: column.width } : {}),\n minSize: column.minWidth ?? 64,\n })),\n [columns, resizing],\n );\n\n const firstId = columns[0]?.id;\n const pinned = stickyFirstColumn && firstId && !pinning?.start?.length ? { ...pinning, start: [firstId] } : pinning;\n const pageIndex = Math.max(0, page - 1);\n const table = useTable<typeof features, T>({\n features,\n columns: defs,\n data: rows as T[],\n getRowId: (row) => getRowId(row),\n getSubRows: getSubRows ? (row) => getSubRows(row) as T[] | undefined : undefined,\n getRowCanExpand: detailed ? () => true : undefined,\n state: {\n sorting: sort ? [{ id: sort.column, desc: sort.direction === \"descending\" }] : [],\n columnFilters: Object.entries(filters).map(([id, value]) => ({ id, value })),\n rowSelection: Object.fromEntries(selected.map((id) => [id, true])),\n grouping: [...groupBy],\n columnVisibility: { ...visibility },\n expanded: { ...expanded },\n pagination: { pageIndex, pageSize: paginated ? pageSize : Math.max(1, rows.length) },\n columnPinning: { start: [...(pinned?.start ?? [])], end: [...(pinned?.end ?? [])] },\n columnSizing: sizing,\n ...(resizeState ? { columnResizing: resizeState } : {}),\n },\n onSortingChange: (updater) => {\n const [first] = apply(updater, sort ? [{ id: sort.column, desc: sort.direction === \"descending\" }] : []);\n changeSort(first ? { column: first.id, direction: first.desc ? \"descending\" : \"ascending\" } : null);\n },\n onColumnFiltersChange: (updater) => {\n const next = apply(updater, Object.entries(filters).map(([id, value]) => ({ id, value })));\n changeFilters(Object.fromEntries(next.map((f) => [f.id, f.value as FilterValue])));\n },\n onRowSelectionChange: (updater) => {\n const next = apply(updater, Object.fromEntries(selected.map((id) => [id, true])));\n setSelected(Object.keys(next).filter((id) => next[id]));\n },\n onGroupingChange: (updater) => setGroupBy(apply(updater, [...groupBy])),\n onColumnVisibilityChange: (updater) => setVisibility(apply(updater, { ...visibility })),\n onExpandedChange: (updater) => {\n const next = apply(updater, { ...expanded });\n setExpanded(next === true ? Object.fromEntries(table.getRowModel().rows.map((r) => [r.id, true])) : next);\n },\n onColumnSizingChange: (updater) => setSizing((old: Record<string, number>) => apply(updater, old)),\n onColumnResizingChange: (updater) => setResizeState((old) => apply(updater, old as ColumnResizingState)),\n enableSortingRemoval: true,\n sortDescFirst: false,\n manualSorting: manual?.sorting,\n manualFiltering: manual?.filtering,\n manualPagination: manual?.pagination || !paginated,\n rowCount: manual?.pagination ? rowCount : undefined,\n autoResetPageIndex: false,\n autoResetExpanded: false,\n groupedColumnMode: false,\n columnResizeMode: \"onChange\",\n enableRowSelection: (row) => !row.getIsGrouped(),\n });\n\n const visibleColumns = table.getVisibleLeafColumns();\n const byId = new Map(columns.map((c) => [c.id, c]));\n const pageRows = table.getRowModel().rows;\n const leafCount = (manual?.pagination || manual?.filtering) && rowCount !== undefined ? rowCount : table.getFilteredRowModel().rows.length;\n const pageCount = paginated ? Math.max(1, manual?.pagination ? Math.ceil((rowCount ?? rows.length) / pageSize) : table.getPageCount()) : 1;\n // A page beyond the last (the filters shrank the list): show the last one.\n React.useEffect(() => {\n if (paginated && !loading && page > pageCount) changePage(pageCount);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- only when the count of pages drops under the page\n }, [pageCount]);\n\n const selectableIds = pageRows.filter((r) => !r.getIsGrouped()).map((r) => r.id);\n const chosen = new Set(selected);\n const allChosen = selectableIds.length > 0 && selectableIds.every((id) => chosen.has(id));\n const someChosen = selectableIds.some((id) => chosen.has(id));\n const toggleAll = () =>\n setSelected(allChosen ? selected.filter((id) => !selectableIds.includes(id)) : [...new Set([...selected, ...selectableIds])]);\n\n const span = visibleColumns.length + (selectable ? 1 : 0) + (hasExpander ? 1 : 0);\n const fixedLayout = Boolean(resizing || pinned?.start?.length || pinned?.end?.length);\n const sideways = scroll ?? (!typed && !stickyHeader);\n const nameOf = (row: TableRow<T>) => (rowLabel ? rowLabel(row.original) : row.id);\n\n // Windowing: the rows in view, between two spacer rows that keep the scroll height.\n const scroller = React.useRef<HTMLDivElement>(null);\n const fallbacks = useLayoutFallbacks(virtualized?.height ?? 0, virtualized?.estimateRowHeight ?? 44);\n const virtualizer = useVirtualizer({\n ...fallbacks,\n count: pageRows.length,\n getScrollElement: () => scroller.current,\n estimateSize: () => virtualized?.estimateRowHeight ?? 44,\n overscan: virtualized?.overscan ?? 6,\n initialRect: { width: 0, height: virtualized?.height ?? 0 },\n getItemKey: (index) => pageRows[index]?.id ?? index,\n enabled: Boolean(virtualized) && !loading,\n });\n const windowItems = virtualized && !loading ? virtualizer.getVirtualItems() : null;\n const before = windowItems?.[0]?.start ?? 0;\n const after = windowItems && windowItems.length > 0 ? virtualizer.getTotalSize() - (windowItems.at(-1)?.end ?? 0) : 0;\n\n const pin = (column: TableColumn<T>): { \"data-pin\"?: \"start\" | \"end\"; style?: React.CSSProperties } => {\n const position = column.getIsPinned();\n if (!position) return {};\n const offset = position === \"start\" ? column.getStart(\"start\") : column.getAfter(\"end\");\n return { \"data-pin\": position, style: { [\"--dt-pin\" as string]: `${offset}px` } };\n };\n const width = (column: TableColumn<T>): React.CSSProperties | undefined =>\n fixedLayout || byId.get(column.id)?.width ? { width: column.getSize() } : undefined;\n\n const message = (content: React.ReactNode, alert?: boolean) => (\n <tr role=\"row\" className={s.tr({ className: \"hover:bg-transparent\" })}>\n <td role=\"cell\" colSpan={span} data-card=\"message\" className={s.td({ className: s.message() })}>\n {alert ? <div role=\"alert\">{content}</div> : content}\n </td>\n </tr>\n );\n\n const cardOf = (column: DataTableColumn<T> | undefined) =>\n column?.card === \"title\" || (column && !column.card && column.type === \"title\") ? \"title\" : column?.card === \"hidden\" ? \"hidden\" : undefined;\n\n // What a cell shows: the column's `cell`, or its value by type.\n const contentOf = (column: DataTableColumn<T>, original: T): React.ReactNode => {\n if (column.cell) return column.cell(original);\n const value = valueOf(column, original);\n const text = column.type === \"date\" && value instanceof Date ? value.toISOString().slice(0, 10) : display(value);\n const cut = TRUNCATE[L];\n switch (column.type) {\n case \"title\": {\n const sub = column.subtitle?.(original);\n return (\n <span className=\"block min-w-0\">\n <span className={cn(\"block font-semibold text-fg\", cut)} title={text}>\n {text}\n </span>\n {sub ? <span className={cn(\"block font-mono text-xs text-fg-tertiary\", cut)}>{sub}</span> : null}\n </span>\n );\n }\n case \"number\":\n return (\n <span>\n {typeof value === \"number\" ? value.toLocaleString(\"en\") : text}\n {column.unit ? <span className=\"ms-1 text-fg-secondary\">{column.unit}</span> : null}\n </span>\n );\n case \"link\": {\n const href = column.href?.(original);\n return href ? (\n <a href={href} title={text} className={cn(\"block text-fg-accent underline-offset-2 hover:underline\", cut)}>\n {text}\n </a>\n ) : (\n <span className={cn(\"block\", cut)} title={text}>\n {text}\n </span>\n );\n }\n default: {\n const lines = column.clamp ?? (column.type === \"text\" ? 2 : undefined);\n if (!column.nowrap && !lines) return text;\n return (\n <span className={column.nowrap ? cn(\"block\", cut || \"truncate\") : CLAMP[lines as 1 | 2 | 3]} title={text}>\n {text}\n </span>\n );\n }\n }\n };\n\n const dataCell = (row: TableRow<T>, cell: TableCell<T>, first: boolean) => {\n const column = byId.get(cell.column.id);\n if (!column) return null;\n const { style, ...pinnedProps } = pin(cell.column);\n let content: React.ReactNode;\n if (row.getIsGrouped()) {\n const leaves = row.getLeafRows().filter((r) => !r.getIsGrouped()).map((r) => r.original);\n if (cell.column.id === row.groupingColumnId) {\n const value = display(row.groupingValue as CellValue);\n content = (\n <button type=\"button\" className={s.toggle({ className: \"px-1.5 font-semibold text-fg\" })} aria-expanded={row.getIsExpanded()} onClick={row.getToggleExpandedHandler()}>\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n {labels.group(value, leaves.length)}\n </button>\n );\n } else content = column.aggregate ? column.aggregate(leaves) : null;\n } else {\n content = contentOf(column, row.original);\n if (first && inlineToggle) {\n content = (\n <span className=\"flex min-w-0 items-center gap-1\">\n <button\n type=\"button\"\n className={s.toggle({ className: TOGGLE[L][maxPriority as Priority] })}\n aria-expanded={row.getIsExpanded()}\n aria-label={labels.showMore(nameOf(row))}\n onClick={row.getToggleExpandedHandler()}\n >\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n </button>\n <span className=\"min-w-0 flex-1\">{content}</span>\n </span>\n );\n }\n }\n const level = priorityOf(column);\n const type = column.type;\n const cellClass = [\n ALIGN[column.align ?? (type === \"number\" ? \"end\" : \"start\")],\n type ? TYPE_CELL[type][L] : \"\",\n level > 1 ? LEAVES[L][level as Priority] : \"\",\n (type || column.priority) && column.minWidth !== undefined ? MIN[L] : \"\",\n column.maxWidth !== undefined ? MAX[L] : \"\",\n ].join(\" \");\n return (\n <td\n role=\"cell\"\n key={cell.id}\n data-label={column.header}\n data-card={row.getIsGrouped() ? (cell.column.id === row.groupingColumnId ? \"title\" : column.aggregate ? undefined : \"hidden\") : cardOf(column)}\n {...pinnedProps}\n className={s.td({ className: cellClass })}\n style={{\n ...style,\n ...(column.minWidth !== undefined ? { [\"--dt-min\" as string]: `${column.minWidth}px` } : {}),\n ...(column.maxWidth !== undefined ? { [\"--dt-max\" as string]: `${column.maxWidth}px` } : {}),\n ...(first && row.depth > 0 && !row.getIsGrouped() ? { paddingInlineStart: `${1 + row.depth * 1.25}rem` } : {}),\n }}\n >\n {content}\n </td>\n );\n };\n\n const renderRow = (row: TableRow<T>, index: number) => {\n const isChosen = chosen.has(row.id);\n const grouped = row.getIsGrouped();\n const canExpand = !grouped && (renderDetail ? true : row.getCanExpand());\n const showDetail = detailed && !grouped && row.getIsExpanded() && !virtualized;\n const cells = row.getVisibleCells();\n const main = (\n <tr\n role=\"row\"\n data-index={windowItems ? index : undefined}\n ref={windowItems ? virtualizer.measureElement : undefined}\n aria-rowindex={virtualized ? index + 2 : undefined}\n data-state={isChosen ? \"selected\" : undefined}\n data-group={grouped ? \"\" : undefined}\n className={s.tr({ className: grouped ? s.groupRow() : undefined })}\n >\n {selectable && (\n <td role=\"cell\" className={s.td({ className: s.select() })}>\n {grouped ? null : <Checkbox checked={isChosen} onCheckedChange={() => row.toggleSelected()} aria-label={labels.selectRow(nameOf(row))} />}\n </td>\n )}\n {hasExpander && (\n <td role=\"cell\" className={s.td({ className: s.expander() })}>\n {canExpand ? (\n <button type=\"button\" className={s.toggle()} aria-expanded={row.getIsExpanded()} aria-label={labels.expandRow(nameOf(row))} onClick={row.getToggleExpandedHandler()}>\n <ChevronGlyph direction=\"right\" className=\"h-4 w-4\" />\n </button>\n ) : null}\n </td>\n )}\n {cells.map((cell, i) => dataCell(row, cell, i === 0))}\n </tr>\n );\n // One Fragment keyed by the row either way: opening a row keeps its button (and the focus on it).\n if (!showDetail) return <React.Fragment key={row.id}>{main}</React.Fragment>;\n const left = visibleColumns.filter((c) => priorityOf(byId.get(c.id) as DataTableColumn<T>) > 1);\n return (\n <React.Fragment key={row.id}>\n {main}\n <tr role=\"row\" className={s.tr({ className: \"hover:bg-transparent\" })}>\n <td role=\"cell\" colSpan={span} data-card=\"message\" className={s.td({ className: renderDetail ? s.detail() : \"bg-surface-0 p-0 text-left\" })}>\n {left.length > 0 ? (\n <dl className={cn(\"m-0 grid gap-3 text-left\", renderDetail ? \"mb-4\" : cn(\"px-4 py-4\", PANEL[L][maxPriority as Priority]))}>\n {left.map((c) => {\n const column = byId.get(c.id) as DataTableColumn<T>;\n return (\n <div key={c.id} className={cn(IN_DETAIL[L][priorityOf(column) as Priority], \"grid-cols-[minmax(5rem,8rem)_minmax(0,1fr)] items-baseline gap-3\")}>\n <dt className=\"text-xs font-semibold text-fg-secondary\">{column.header}</dt>\n <dd className=\"m-0 min-w-0 text-sm text-fg\">{contentOf(column, row.original)}</dd>\n </div>\n );\n })}\n </dl>\n ) : null}\n {renderDetail ? renderDetail(row.original) : null}\n </td>\n </tr>\n </React.Fragment>\n );\n };\n\n let body: React.ReactNode;\n if (loading) {\n body = Array.from({ length: loadingRows }, (_, i) => (\n <tr role=\"row\" key={`loading-${i}`} aria-hidden className={s.tr()}>\n {selectable && <td role=\"cell\" className={s.td({ className: s.select() })} />}\n {hasExpander && <td role=\"cell\" className={s.td({ className: s.expander() })} />}\n {visibleColumns.map((column) => (\n <td role=\"cell\" key={column.id} data-card={cardOf(byId.get(column.id))} className={s.td()}>\n <div className={s.skeleton()} />\n </td>\n ))}\n </tr>\n ));\n } else if (error) body = message(error, true);\n else if (pageRows.length === 0) body = message(empty ?? labels.empty);\n else if (windowItems) body = windowItems.map((item) => renderRow(pageRows[item.index] as TableRow<T>, item.index));\n else body = pageRows.map((row, i) => renderRow(row, i));\n\n const headers = table.getHeaderGroups().at(-1)?.headers ?? [];\n const tableElement = (\n <table\n role=\"table\"\n aria-busy={loading || undefined}\n aria-rowcount={virtualized ? pageRows.length + 1 : undefined}\n className={s.table({ className: fixedLayout ? \"table-fixed\" : undefined })}\n style={fixedLayout ? { minWidth: table.getTotalSize() + (selectable ? 40 : 0) + (hasExpander ? 40 : 0) } : undefined}\n >\n <caption className={showCaption ? s.caption() : \"sr-only\"}>\n {caption}\n {loading ? <span className=\"sr-only\">{` — ${labels.loading}`}</span> : null}\n </caption>\n <thead role=\"rowgroup\" className={s.thead({ className: virtualized ? \"sticky top-0 z-10 bg-surface-2\" : undefined })}>\n <tr role=\"row\" aria-rowindex={virtualized ? 1 : undefined} className={s.headRow()}>\n {selectable && (\n <th role=\"columnheader\" scope=\"col\" data-keep=\"\" className={s.th({ className: s.select() })}>\n <Checkbox\n checked={allChosen ? true : someChosen ? \"indeterminate\" : false}\n onCheckedChange={toggleAll}\n disabled={loading || selectableIds.length === 0}\n aria-label={labels.selectAll}\n />\n </th>\n )}\n {hasExpander && (\n <th role=\"columnheader\" scope=\"col\" className={s.th({ className: s.expander() })}>\n <span className=\"sr-only\">{labels.details}</span>\n </th>\n )}\n {headers.map((header) => {\n const column = byId.get(header.column.id);\n if (!column) return null;\n const sorted = header.column.getIsSorted();\n const direction: SortDirection | undefined = sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : undefined;\n const { style, ...pinnedProps } = pin(header.column);\n const level = priorityOf(column);\n return (\n <th\n role=\"columnheader\"\n scope=\"col\"\n key={header.id}\n aria-sort={column.sortable ? (direction ?? \"none\") : undefined}\n data-keep={column.sortable ? \"\" : undefined}\n {...pinnedProps}\n className={s.th({ className: cn(ALIGN[column.align ?? (column.type === \"number\" ? \"end\" : \"start\")], level > 1 && LEAVES_HEAD[L][level as Priority]) })}\n style={{ ...style, ...(typeof stickyHeader === \"number\" ? { [\"--dt-top\" as string]: `${stickyHeader}px` } : {}), ...width(header.column) }}\n >\n {column.sortable ? (\n <button type=\"button\" aria-pressed={Boolean(direction)} className={s.sortButton()} onClick={() => header.column.toggleSorting(undefined, false)}>\n {column.header}\n <SortGlyph direction={direction} className=\"h-3.5 w-3.5\" />\n </button>\n ) : column.headerHidden ? (\n <span className=\"sr-only\">{column.header}</span>\n ) : (\n column.header\n )}\n {resizing && header.column.getCanResize() ? (\n <ResizeHandle\n label={labels.resize(column.header)}\n size={header.column.getSize()}\n min={column.minWidth ?? 64}\n resizing={header.column.getIsResizing()}\n className={s.resizer()}\n onPointerStart={header.getResizeHandler()}\n onSize={(size) => setSizing((old: Record<string, number>) => ({ ...old, [header.column.id]: size }))}\n />\n ) : null}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody role=\"rowgroup\" className={s.tbody()}>\n {before > 0 && (\n <tr aria-hidden style={{ height: before }}>\n <td colSpan={span} />\n </tr>\n )}\n {body}\n {after > 0 && (\n <tr aria-hidden style={{ height: after }}>\n <td colSpan={span} />\n </tr>\n )}\n </tbody>\n </table>\n );\n\n const context: Context = {\n table: table as unknown as DataTableInstance<object>,\n columns: columns as readonly DataTableColumn<object>[],\n labels,\n filters,\n setFilters: changeFilters,\n resultCount: leafCount,\n exportRows: () => table.getSortedRowModel().flatRows.filter((r) => !r.getIsGrouped()).map((r) => r.original),\n manualFiltering: Boolean(manual?.filtering),\n };\n\n const pagination = paginated ? (\n <Pagination\n page={Math.min(page, pageCount)}\n pageCount={pageCount}\n onPageChange={changePage}\n pageSize={pageSizeOptions ? pageSize : undefined}\n pageSizeOptions={pageSizeOptions}\n onPageSizeChange={pageSizeOptions ? changePageSize : undefined}\n disabled={loading}\n labels={labels.pagination}\n />\n ) : null;\n\n return (\n <DataTableContext.Provider value={context}>\n <div className={s.root({ className })}>\n {toolbar ? <div className={s.toolbar()}>{toolbar}</div> : null}\n {virtualized ? (\n // A scrolling region the keyboard can reach: Tab lands on it, the arrows scroll it.\n <div\n ref={scroller}\n tabIndex={0}\n role=\"region\"\n aria-label={caption}\n className={s.scroller({ className: \"overflow-y-auto focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring\" })}\n style={{ height: virtualized.height }}\n >\n {tableElement}\n </div>\n ) : (\n <div className={s.scroller({ className: sideways ? (scroll ? SHADOWS : undefined) : \"overflow-x-clip\" })}>{tableElement}</div>\n )}\n {pagination || footer ? (\n <div className={s.footer({ className: \"flex flex-col gap-3\" })}>\n {pagination}\n {footer}\n </div>\n ) : null}\n </div>\n </DataTableContext.Provider>\n );\n}\n\n/** The handle on a header's edge: drag it, or focus it and use ← → (Shift for larger steps). */\nfunction ResizeHandle({\n label,\n size,\n min,\n resizing,\n className,\n onPointerStart,\n onSize,\n}: {\n label: string;\n size: number;\n min: number;\n resizing: boolean;\n className: string;\n onPointerStart: (event: unknown) => void;\n onSize: (size: number) => void;\n}) {\n return (\n // A focusable separator is interactive (WAI-ARIA \"window splitter\"): its value is the width, the arrows change it.\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label={label}\n aria-valuenow={Math.round(size)}\n aria-valuemin={min}\n tabIndex={0}\n data-resizing={resizing ? \"\" : undefined}\n className={className}\n onMouseDown={onPointerStart}\n onTouchStart={onPointerStart}\n onClick={(event) => event.stopPropagation()}\n onKeyDown={(event) => {\n const step = event.shiftKey ? 48 : 16;\n if (event.key === \"ArrowLeft\") onSize(Math.max(min, size - step));\n else if (event.key === \"ArrowRight\") onSize(size + step);\n else return;\n event.preventDefault();\n }}\n />\n );\n}\n"
615
669
  },
616
670
  {
617
671
  "path": "data-table/engine.ts",
@@ -623,7 +677,7 @@
623
677
  },
624
678
  {
625
679
  "path": "data-table/meta.ts",
626
- "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Data table\",\n summary:\n \"A table of records on TanStack Table v9 — sort, filters and facets, groups, details, pages, selection, column visibility, pinning and resizing — in the browser or on the server, cards on a phone, windowing for long lists.\",\n status: \"beta\",\n category: \"data-display\",\n platforms: \"web\",\n whenToUse: [\n \"To compare records on the same columns: jobs, videos of a studio, users of an admin, invoices, payouts.\",\n \"When people sort, filter, group, select several rows for a bulk action, or export what they see.\",\n \"In server mode (`manual` + `onStateChange`) when the list lives in a database: the API sorts, filters and pages.\",\n \"With `virtualized` for a long list loaded at once (logs, 50,000 events) that must stay smooth.\",\n ],\n whenNotToUse: [\n { when: \"For a few figures side by side: stats.\", use: \"stat\" },\n { when: \"For a gallery of media where the picture matters more than the columns.\", use: \"virtual\" },\n { when: \"For a list on a phone app: FlashList, through the native list.\", use: \"list\" },\n ],\n bestPractices: [\n \"Keep `rows` and `columns` stable (module scope, state, a query's data, `useMemo`): a new array at each render recomputes every model.\",\n \"Give every column a `header` (it labels the value in a card and names its filter) and the row's name a `card: \\\"title\\\"` column.\",\n \"Put `DataTableFilters` in `toolbar`; mark the columns it filters with `filter: \\\"text\\\" | \\\"facet\\\" | \\\"range\\\"`, and give a server table's facets their `options`.\",\n \"Server mode: keep the `DataTableQuery` from `onStateChange` in state (or the address), put it in the query key, and pass `rowCount` — `placeholderData: keepPreviousData` keeps the page while the next one loads.\",\n \"Say what failed in `error` with a way to retry; say what to do next in `empty` (an `EmptyState` with an action).\",\n \"Align amounts and counts to the end (`align: \\\"end\\\"`) in tabular numbers; pin the name column of a wide table.\",\n \"Need something the props do not cover? `useDataTable()` gives the TanStack instance — never import `@tanstack/*` in a product.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Reaches the toolbar, the sort buttons of the headers, the resize handles, the checkboxes, the expand buttons and the links of the cells.\" },\n { keys: \"Space\", action: \"Toggles the focused checkbox, sorts by the focused header, or opens the focused row or group.\" },\n { keys: \"Arrow keys\", action: \"On a focused resize handle, narrow or widen the column (Shift for larger steps).\" },\n ],\n notes: [\n \"A `table` with a `caption` (hidden unless `showCaption`) and `scope=\\\"col\\\"` headers; the roles are written out, so the cards keep the table's semantics.\",\n \"The sorted column carries `aria-sort`; a sortable header is a button that says its state with `aria-pressed`.\",\n \"An expand button (a row's details, a group, a tree) says its state with `aria-expanded` and names the row; a group's button says its value and its count.\",\n \"A resize handle is a focusable `separator` named after its column, with its width as `aria-valuenow`.\",\n \"The filters' bar is a named group; the count of results is announced politely when it changes; each filter in force is a chip with a remove button.\",\n \"While loading the table is `aria-busy` and its caption says so; an error is announced (`role=\\\"alert\\\"`).\",\n \"Windowed rows carry `aria-rowindex` and the table `aria-rowcount`: a screen reader knows where it is in the whole list; the scrolling body is a focusable region.\",\n ],\n },\n related: [\"filter-bar\", \"pagination\", \"virtual\", \"checkbox\", \"empty-state\"],\n web: {\n imports: [\"DataTable\", \"DataTableFilters\", \"DataTableColumnsMenu\", \"DataTableExport\", \"useDataTable\"],\n examples: [\n { name: \"jobs\", title: \"Jobs\", description: \"Sortable columns, a status badge, row selection for a bulk action.\" },\n { name: \"filters\", title: \"Filters, columns, export\", description: \"A search, facets with their counts, a range; the filters as chips; hide columns; export the rows shown as CSV.\" },\n { name: \"server\", title: \"Server mode\", description: \"Sort, filters and page go to the API through `krizakaFetch` and `defineKeys` of @krizaka/query; the previous page stays while the next one loads.\" },\n { name: \"paginated\", title: \"Pages\", description: \"Client pagination under the table, with the rows per page to choose.\" },\n { name: \"grouped\", title: \"Groups\", description: \"`groupBy`: a row per group with its count and its total (`aggregate`), opened to see its rows.\" },\n { name: \"details\", title: \"Details\", description: \"`renderDetail`: a button per row opens a panel under it.\" },\n { name: \"pinned\", title: \"Pinned and resizable\", description: \"A wide table: the name pinned at the start, columns resized by drag or by the arrow keys.\" },\n { name: \"cards\", title: \"Cards on a phone\", description: \"`layout=\\\"cards\\\"`: each row a card, the title on top — what `auto` does in a narrow container.\" },\n { name: \"loading\", title: \"Loading\", description: \"Skeleton rows while the first page loads, the table busy.\" },\n { name: \"empty\", title: \"Empty\", description: \"No record yet: an empty state with the next step.\" },\n { name: \"error\", title: \"Error\", description: \"The request failed: an alert with a retry, in place of the rows.\" },\n { name: \"virtualized\", title: \"50,000 rows\", description: \"`virtualized`: only the rows in view are rendered and measured, the header stays.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
680
+ "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Data table\",\n summary:\n \"A table of records on TanStack Table v9 — sort, filters and facets, groups, details, pages, selection, column visibility, pinning and resizing — in the browser or on the server, cards on a phone, windowing for long lists.\",\n status: \"beta\",\n category: \"data-display\",\n platforms: \"web\",\n whenToUse: [\n \"To compare records on the same columns: jobs, videos of a studio, users of an admin, invoices, payouts.\",\n \"When people sort, filter, group, select several rows for a bulk action, or export what they see.\",\n \"In server mode (`manual` + `onStateChange`) when the list lives in a database: the API sorts, filters and pages.\",\n \"With `virtualized` for a long list loaded at once (logs, 50,000 events) that must stay smooth.\",\n ],\n whenNotToUse: [\n { when: \"For a few figures side by side: stats.\", use: \"stat\" },\n { when: \"For a gallery of media where the picture matters more than the columns.\", use: \"virtual\" },\n { when: \"For a list on a phone app: FlashList, through the native list.\", use: \"list\" },\n ],\n bestPractices: [\n \"Keep `rows` and `columns` stable (module scope, state, a query's data, `useMemo`): a new array at each render recomputes every model.\",\n \"Give every column a `header` (it labels the value in a card and names its filter) and the row's name a `card: \\\"title\\\"` column.\",\n \"Put `DataTableFilters` in `toolbar`; mark the columns it filters with `filter: \\\"text\\\" | \\\"facet\\\" | \\\"range\\\"`, and give a server table's facets their `options`.\",\n \"Server mode: keep the `DataTableQuery` from `onStateChange` in state (or the address), put it in the query key, and pass `rowCount` — `placeholderData: keepPreviousData` keeps the page while the next one loads.\",\n \"Say what failed in `error` with a way to retry; say what to do next in `empty` (an `EmptyState` with an action).\",\n \"Type the columns (`type`: title · badge · status · number · date · link · text) and give the secondary ones a `priority` (2 to 7): the table then never scrolls sideways — columns leave the row for its detail, then the rows become cards. Scrolling is an opt-in (`scroll`).\",\n \"A `title` column takes the slack and truncates (full value on hover); badges, statuses, numbers and dates are as wide as their content. Do not give a title column a `maxWidth`.\",\n \"Align amounts and counts to the end (`align: \\\"end\\\"`) in tabular numbers; pin the name column of a wide table.\",\n \"Need something the props do not cover? `useDataTable()` gives the TanStack instance — never import `@tanstack/*` in a product.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Reaches the toolbar, the sort buttons of the headers, the resize handles, the checkboxes, the expand buttons and the links of the cells.\" },\n { keys: \"Space\", action: \"Toggles the focused checkbox, sorts by the focused header, or opens the focused row or group.\" },\n { keys: \"Arrow keys\", action: \"On a focused resize handle, narrow or widen the column (Shift for larger steps).\" },\n ],\n notes: [\n \"A `table` with a `caption` (hidden unless `showCaption`) and `scope=\\\"col\\\"` headers; the roles are written out, so the cards keep the table's semantics.\",\n \"The sorted column carries `aria-sort`; a sortable header is a button that says its state with `aria-pressed`.\",\n \"An expand button (a row's details, a group, a tree) says its state with `aria-expanded` and names the row; a group's button says its value and its count.\",\n \"A resize handle is a focusable `separator` named after its column, with its width as `aria-valuenow`.\",\n \"The filters' bar is a named group; the count of results is announced politely when it changes; each filter in force is a chip with a remove button.\",\n \"While loading the table is `aria-busy` and its caption says so; an error is announced (`role=\\\"alert\\\"`).\",\n \"Windowed rows carry `aria-rowindex` and the table `aria-rowcount`: a screen reader knows where it is in the whole list; the scrolling body is a focusable region.\",\n ],\n },\n related: [\"filter-bar\", \"pagination\", \"virtual\", \"checkbox\", \"empty-state\"],\n web: {\n imports: [\"DataTable\", \"DataTableFilters\", \"DataTableColumnsMenu\", \"DataTableExport\", \"useDataTable\"],\n examples: [\n { name: \"catalogue\", title: \"Models catalogue\", description: \"Eight typed columns that never overflow: priorities move the maker, licence, context and date into the row's detail as the table narrows, then cards under 36 rem.\" },\n { name: \"invoices\", title: \"Invoice list\", description: \"Title, status and amount always in the row; dates and contact in the detail on a narrow screen; sticky header and zebra rows.\" },\n { name: \"runs\", title: \"Admin runs\", description: \"An admin console: filters, columns menu and CSV export in the toolbar, selection, a range filter on the duration, the error in the detail.\" },\n { name: \"jobs\", title: \"Jobs\", description: \"Sortable columns, a status badge, row selection for a bulk action.\" },\n { name: \"filters\", title: \"Filters, columns, export\", description: \"A search, facets with their counts, a range; the filters as chips; hide columns; export the rows shown as CSV.\" },\n { name: \"server\", title: \"Server mode\", description: \"Sort, filters and page go to the API through `krizakaFetch` and `defineKeys` of @krizaka/query; the previous page stays while the next one loads.\" },\n { name: \"paginated\", title: \"Pages\", description: \"Client pagination under the table, with the rows per page to choose.\" },\n { name: \"grouped\", title: \"Groups\", description: \"`groupBy`: a row per group with its count and its total (`aggregate`), opened to see its rows.\" },\n { name: \"details\", title: \"Details\", description: \"`renderDetail`: a button per row opens a panel under it.\" },\n { name: \"pinned\", title: \"Pinned and resizable\", description: \"A wide table: the name pinned at the start, columns resized by drag or by the arrow keys.\" },\n { name: \"cards\", title: \"Cards on a phone\", description: \"`layout=\\\"cards\\\"`: each row a card, the title on top — what `auto` does in a narrow container.\" },\n { name: \"loading\", title: \"Loading\", description: \"Skeleton rows while the first page loads, the table busy.\" },\n { name: \"empty\", title: \"Empty\", description: \"No record yet: an empty state with the next step.\" },\n { name: \"error\", title: \"Error\", description: \"The request failed: an alert with a retry, in place of the rows.\" },\n { name: \"virtualized\", title: \"50,000 rows\", description: \"`virtualized`: only the rows in view are rendered and measured, the header stays.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
627
681
  },
628
682
  {
629
683
  "path": "data-table/toolbar.tsx",
@@ -649,6 +703,7 @@
649
703
  "registryDependencies": [
650
704
  "button",
651
705
  "checkbox",
706
+ "cn",
652
707
  "dropdown-menu",
653
708
  "field",
654
709
  "filter-bar",
@@ -657,8 +712,8 @@
657
712
  "virtual"
658
713
  ],
659
714
  "demo": {
660
- "path": "examples/data-table/jobs.tsx",
661
- "content": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Job = { id: string; name: string; model: string; status: \"done\" | \"running\" | \"failed\"; seconds: number };\nconst jobs: Job[] = [\n { id: \"job-4821\", name: \"Product shots, autumn\", model: \"Flux 1.1\", status: \"done\", seconds: 42 },\n { id: \"job-4822\", name: \"Podcast transcript\", model: \"Whisper large\", status: \"running\", seconds: 318 },\n { id: \"job-4823\", name: \"Trailer voice-over\", model: \"XTTS v2\", status: \"failed\", seconds: 7 },\n { id: \"job-4824\", name: \"Catalogue summaries\", model: \"Llama 3.3\", status: \"done\", seconds: 96 },\n];\nconst tone = { done: \"success\", running: \"accent\", failed: \"danger\" } as const;\n\nconst columns: DataTableColumn<Job>[] = [\n { id: \"name\", header: \"Job\", sortable: true, card: \"title\" },\n { id: \"model\", header: \"Model\", sortable: true },\n { id: \"status\", header: \"Status\", cell: (job) => <Badge tone={tone[job.status]}>{job.status}</Badge> },\n { id: \"seconds\", header: \"Duration\", sortable: true, align: \"end\", cell: (job) => `${job.seconds} s` },\n];\n\n// Sort by a header, select rows for a bulk action; cards below 36rem of width.\nexport default function DataTableJobs() {\n return (\n <DataTable\n caption=\"Jobs\"\n rows={jobs}\n columns={columns}\n getRowId={(job) => job.id}\n rowLabel={(job) => job.name}\n defaultSort={{ column: \"seconds\", direction: \"descending\" }}\n selectable\n defaultSelected={[\"job-4823\"]}\n />\n );\n}\n"
715
+ "path": "examples/data-table/catalogue.tsx",
716
+ "content": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { DataTable, type DataTableColumn, DataTableFilters } from \"@krizaka/ui/data-table\";\n\ntype Model = { id: string; name: string; maker: string; does: string; where: string; status: string; licence: string; context: number; verified: string };\nconst L = \"https://www.apache.org/licenses/LICENSE-2.0\";\nconst models: Model[] = [\n { id: \"black-forest-labs/flux.1-schnell\", name: \"FLUX.1 schnell\", maker: \"Black Forest Labs\", does: \"Image\", where: \"Local\", status: \"Default\", licence: \"Apache-2.0\", context: 0, verified: \"2026-10-02\" },\n { id: \"meta-llama/llama-3.3-70b-instruct\", name: \"Llama 3.3 70B Instruct\", maker: \"Meta\", does: \"Chat\", where: \"Local\", status: \"Default\", licence: \"Llama 3.3 Community Licence\", context: 131072, verified: \"2026-10-09\" },\n { id: \"anthropic/claude-sonnet\", name: \"Claude Sonnet\", maker: \"Anthropic\", does: \"Chat\", where: \"Provider\", status: \"Available\", licence: \"Provider terms\", context: 200000, verified: \"2026-10-12\" },\n { id: \"lightricks/ltx-video\", name: \"LTX-Video\", maker: \"Lightricks\", does: \"Video\", where: \"Local\", status: \"Planned\", licence: \"OpenRAIL-M\", context: 0, verified: \"\" },\n];\nconst tone = { Default: \"success\", Available: \"neutral\", Planned: \"warning\" } as const;\n// The model, its capability, where it runs and its status stay in the row; the rest leaves it as the table narrows.\nconst columns: DataTableColumn<Model>[] = [\n { id: \"name\", header: \"Model\", type: \"title\", sortable: true, filter: \"text\", subtitle: (m) => m.id },\n { id: \"does\", header: \"Capability\", type: \"badge\", sortable: true, filter: \"facet\", cell: (m) => <Badge size=\"sm\">{m.does}</Badge> },\n { id: \"where\", header: \"Runs where\", type: \"badge\", filter: \"facet\", priority: 2, cell: (m) => <Badge size=\"sm\" tone={m.where === \"Local\" ? \"accent\" : \"neutral\"}>{m.where}</Badge> },\n { id: \"status\", header: \"Status\", type: \"status\", sortable: true, filter: \"facet\", cell: (m) => <Badge size=\"sm\" tone={tone[m.status as keyof typeof tone]}>{m.status}</Badge> },\n { id: \"maker\", header: \"Maker\", type: \"text\", clamp: 1, priority: 3 },\n { id: \"licence\", header: \"Licence\", type: \"link\", priority: 4, href: () => L },\n { id: \"context\", header: \"Context\", type: \"number\", unit: \"tokens\", priority: 5, value: (m) => m.context, cell: (m) => (m.context ? m.context.toLocaleString(\"en\") : \"—\") },\n { id: \"verified\", header: \"Verified\", type: \"date\", priority: 5, cell: (m) => m.verified || \"never\" },\n];\n\n// Eight columns, any width, no overflow: the chevron opens what left the row; under 36 rem each row is a card.\nexport default function DataTableCatalogue() {\n return <DataTable caption=\"Models\" rows={models} columns={columns} getRowId={(m) => m.id} rowLabel={(m) => m.name} density=\"compact\" toolbar={<DataTableFilters />} />;\n}\n"
662
717
  },
663
718
  "props": [
664
719
  {
@@ -767,7 +822,7 @@
767
822
  "name": "density",
768
823
  "type": "\"comfortable\" | \"compact\"",
769
824
  "default": null,
770
- "description": "comfortable (default) · compact.",
825
+ "description": "comfortable (default) · compact — one row height per density.",
771
826
  "required": false
772
827
  },
773
828
  {
@@ -973,6 +1028,13 @@
973
1028
  "description": "The name of a row in its labels (checkbox, expand button). Default: its id.",
974
1029
  "required": false
975
1030
  },
1031
+ {
1032
+ "name": "scroll",
1033
+ "type": "boolean",
1034
+ "default": null,
1035
+ "description": "The table never scrolls sideways by default once its columns have a `type` or a `priority`: what does not fit goes to\nthe row's detail, then to cards. `true` opts in to a sideways scroll instead, with fades at the edges.",
1036
+ "required": false
1037
+ },
976
1038
  {
977
1039
  "name": "selectable",
978
1040
  "type": "boolean",
@@ -1001,6 +1063,27 @@
1001
1063
  "description": "The sorted column (controlled), `null` for none.",
1002
1064
  "required": false
1003
1065
  },
1066
+ {
1067
+ "name": "stickyFirstColumn",
1068
+ "type": "boolean",
1069
+ "default": null,
1070
+ "description": "Keeps the first column in view while the table scrolls sideways (with `scroll`).",
1071
+ "required": false
1072
+ },
1073
+ {
1074
+ "name": "stickyHeader",
1075
+ "type": "number | boolean",
1076
+ "default": null,
1077
+ "description": "The header stays at the top of the window while the page scrolls (`--dt-top` on the table, or a number of px, clears a fixed bar).",
1078
+ "required": false
1079
+ },
1080
+ {
1081
+ "name": "striped",
1082
+ "type": "boolean",
1083
+ "default": null,
1084
+ "description": "Zebra rows.",
1085
+ "required": false
1086
+ },
1004
1087
  {
1005
1088
  "name": "toolbar",
1006
1089
  "type": "ReactNode",
@@ -0,0 +1,4 @@
1
+ import type { ComponentType } from "react";
2
+ /** A named example of the @krizaka/ui registry; `defaultOpen` opens what it opens (a dialog, a menu, toasts) at load. */
3
+ declare const Example: ComponentType<{ defaultOpen?: boolean }>;
4
+ export default Example;
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ // registry/examples/data-table/catalogue.tsx
4
+ import { Badge } from "@krizaka/ui/badge";
5
+ import { DataTable, DataTableFilters } from "@krizaka/ui/data-table";
6
+ import { jsx } from "react/jsx-runtime";
7
+ var L = "https://www.apache.org/licenses/LICENSE-2.0";
8
+ var models = [
9
+ { id: "black-forest-labs/flux.1-schnell", name: "FLUX.1 schnell", maker: "Black Forest Labs", does: "Image", where: "Local", status: "Default", licence: "Apache-2.0", context: 0, verified: "2026-10-02" },
10
+ { id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B Instruct", maker: "Meta", does: "Chat", where: "Local", status: "Default", licence: "Llama 3.3 Community Licence", context: 131072, verified: "2026-10-09" },
11
+ { id: "anthropic/claude-sonnet", name: "Claude Sonnet", maker: "Anthropic", does: "Chat", where: "Provider", status: "Available", licence: "Provider terms", context: 2e5, verified: "2026-10-12" },
12
+ { id: "lightricks/ltx-video", name: "LTX-Video", maker: "Lightricks", does: "Video", where: "Local", status: "Planned", licence: "OpenRAIL-M", context: 0, verified: "" }
13
+ ];
14
+ var tone = { Default: "success", Available: "neutral", Planned: "warning" };
15
+ var columns = [
16
+ { id: "name", header: "Model", type: "title", sortable: true, filter: "text", subtitle: (m) => m.id },
17
+ { id: "does", header: "Capability", type: "badge", sortable: true, filter: "facet", cell: (m) => /* @__PURE__ */ jsx(Badge, { size: "sm", children: m.does }) },
18
+ { id: "where", header: "Runs where", type: "badge", filter: "facet", priority: 2, cell: (m) => /* @__PURE__ */ jsx(Badge, { size: "sm", tone: m.where === "Local" ? "accent" : "neutral", children: m.where }) },
19
+ { id: "status", header: "Status", type: "status", sortable: true, filter: "facet", cell: (m) => /* @__PURE__ */ jsx(Badge, { size: "sm", tone: tone[m.status], children: m.status }) },
20
+ { id: "maker", header: "Maker", type: "text", clamp: 1, priority: 3 },
21
+ { id: "licence", header: "Licence", type: "link", priority: 4, href: () => L },
22
+ { id: "context", header: "Context", type: "number", unit: "tokens", priority: 5, value: (m) => m.context, cell: (m) => m.context ? m.context.toLocaleString("en") : "\u2014" },
23
+ { id: "verified", header: "Verified", type: "date", priority: 5, cell: (m) => m.verified || "never" }
24
+ ];
25
+ function DataTableCatalogue() {
26
+ return /* @__PURE__ */ jsx(DataTable, { caption: "Models", rows: models, columns, getRowId: (m) => m.id, rowLabel: (m) => m.name, density: "compact", toolbar: /* @__PURE__ */ jsx(DataTableFilters, {}) });
27
+ }
28
+ export {
29
+ DataTableCatalogue as default
30
+ };