@krizaka/ui 2.10.0 → 3.1.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.
Files changed (264) hide show
  1. package/README.md +4 -2
  2. package/dist/audio-player.js +1 -1
  3. package/dist/{chunk-EUPTXKH7.js → chunk-BRKMQ6JV.js} +2 -1
  4. package/dist/chunk-FDCMBWUU.js +168 -0
  5. package/dist/chunk-FDCMBWUU.js.map +1 -0
  6. package/dist/chunk-HCNOOPZE.js +210 -0
  7. package/dist/chunk-HCNOOPZE.js.map +1 -0
  8. package/dist/{chunk-X6TW3X6Y.js → chunk-LCZXS6AK.js} +2 -2
  9. package/dist/chunk-OGJI5X4W.js +157 -0
  10. package/dist/chunk-OGJI5X4W.js.map +1 -0
  11. package/dist/combobox.js +2 -2
  12. package/dist/data-table.d.ts +284 -24
  13. package/dist/data-table.js +914 -121
  14. package/dist/data-table.js.map +1 -1
  15. package/dist/date-picker.js +1 -1
  16. package/dist/filter-bar.d.ts +12 -12
  17. package/dist/filter-bar.js +9 -145
  18. package/dist/filter-bar.js.map +1 -1
  19. package/dist/form/server.d.ts +96 -0
  20. package/dist/form/server.js +82 -0
  21. package/dist/form/server.js.map +1 -0
  22. package/dist/form.d.ts +503 -0
  23. package/dist/form.js +760 -0
  24. package/dist/form.js.map +1 -0
  25. package/dist/image-cropper.js +1 -1
  26. package/dist/media-capture.js +1 -1
  27. package/dist/native/form.d.ts +376 -0
  28. package/dist/native/form.js +650 -0
  29. package/dist/native/form.js.map +1 -0
  30. package/dist/native/list.d.ts +95 -0
  31. package/dist/native/list.js +135 -0
  32. package/dist/native/list.js.map +1 -0
  33. package/dist/native.d.ts +113 -2
  34. package/dist/native.js +618 -388
  35. package/dist/native.js.map +1 -1
  36. package/dist/search-field.js +6 -160
  37. package/dist/search-field.js.map +1 -1
  38. package/dist/tag-input.js +2 -2
  39. package/dist/video-player.js +1 -1
  40. package/dist/video-trimmer.js +1 -1
  41. package/dist/virtual.d.ts +140 -0
  42. package/dist/virtual.js +19 -0
  43. package/dist/virtual.js.map +1 -0
  44. package/package.json +111 -8
  45. package/registry/achievement.json +1 -1
  46. package/registry/action-bar.json +1 -1
  47. package/registry/alert.json +1 -1
  48. package/registry/app-shell.json +1 -1
  49. package/registry/argument-card.json +1 -1
  50. package/registry/audio-player.json +1 -1
  51. package/registry/auth-layout.json +1 -1
  52. package/registry/avatar.json +1 -1
  53. package/registry/badge.json +1 -1
  54. package/registry/button.json +1 -1
  55. package/registry/card-grid.json +1 -1
  56. package/registry/card.json +1 -1
  57. package/registry/catalogue-page.json +1 -1
  58. package/registry/changelog-page.json +1 -1
  59. package/registry/checkbox.json +89 -4
  60. package/registry/chip-link.json +1 -1
  61. package/registry/chip.json +1 -1
  62. package/registry/choice-grid.json +1 -1
  63. package/registry/code-block.json +1 -1
  64. package/registry/combobox.json +1 -1
  65. package/registry/command.json +1 -1
  66. package/registry/component-page.json +1 -1
  67. package/registry/confirm-button.json +1 -1
  68. package/registry/contact-page.json +1 -1
  69. package/registry/continue-prompt.json +1 -1
  70. package/registry/countdown.json +1 -1
  71. package/registry/cta-panel.json +1 -1
  72. package/registry/dashboard-layout.json +1 -1
  73. package/registry/data-table.json +643 -49
  74. package/registry/date-picker.json +142 -4
  75. package/registry/decision-list.json +1 -1
  76. package/registry/detail-layout.json +1 -1
  77. package/registry/dialog.json +1 -1
  78. package/registry/docs-article-page.json +1 -1
  79. package/registry/docs-hub-page.json +1 -1
  80. package/registry/docs-layout.json +1 -1
  81. package/registry/dropdown-menu.json +1 -1
  82. package/registry/empty-state.json +1 -1
  83. package/registry/error-page.json +1 -1
  84. package/registry/examples/checkbox/native/terms.d.ts +4 -0
  85. package/registry/examples/checkbox/native/terms.dark.png +0 -0
  86. package/registry/examples/checkbox/native/terms.js +26 -0
  87. package/registry/examples/checkbox/native/terms.light.png +0 -0
  88. package/registry/examples/checkbox/native/terms.tsx +28 -0
  89. package/registry/examples/data-table/details.d.ts +4 -0
  90. package/registry/examples/data-table/details.js +33 -0
  91. package/registry/examples/data-table/details.tsx +31 -0
  92. package/registry/examples/data-table/filters.d.ts +4 -0
  93. package/registry/examples/data-table/filters.js +40 -0
  94. package/registry/examples/data-table/filters.tsx +40 -0
  95. package/registry/examples/data-table/grouped.d.ts +4 -0
  96. package/registry/examples/data-table/grouped.js +24 -0
  97. package/registry/examples/data-table/grouped.tsx +23 -0
  98. package/registry/examples/data-table/paginated.d.ts +4 -0
  99. package/registry/examples/data-table/paginated.js +23 -0
  100. package/registry/examples/data-table/paginated.tsx +22 -0
  101. package/registry/examples/data-table/pinned.d.ts +4 -0
  102. package/registry/examples/data-table/pinned.js +26 -0
  103. package/registry/examples/data-table/pinned.tsx +25 -0
  104. package/registry/examples/data-table/server.d.ts +4 -0
  105. package/registry/examples/data-table/server.js +40 -0
  106. package/registry/examples/data-table/server.tsx +40 -0
  107. package/registry/examples/data-table/virtualized.js +2 -2
  108. package/registry/examples/data-table/virtualized.tsx +3 -3
  109. package/registry/examples/date-picker/native/birth-date.d.ts +4 -0
  110. package/registry/examples/date-picker/native/birth-date.dark.png +0 -0
  111. package/registry/examples/date-picker/native/birth-date.js +30 -0
  112. package/registry/examples/date-picker/native/birth-date.light.png +0 -0
  113. package/registry/examples/date-picker/native/birth-date.tsx +27 -0
  114. package/registry/examples/field/native/default.d.ts +4 -0
  115. package/registry/examples/field/native/default.dark.png +0 -0
  116. package/registry/examples/field/native/default.js +29 -0
  117. package/registry/examples/field/native/default.light.png +0 -0
  118. package/registry/examples/field/native/default.tsx +23 -0
  119. package/registry/examples/form/amount.d.ts +4 -0
  120. package/registry/examples/form/amount.js +30 -0
  121. package/registry/examples/form/amount.tsx +36 -0
  122. package/registry/examples/form/audience.d.ts +4 -0
  123. package/registry/examples/form/audience.js +29 -0
  124. package/registry/examples/form/audience.tsx +31 -0
  125. package/registry/examples/form/native/amount.d.ts +4 -0
  126. package/registry/examples/form/native/amount.dark.png +0 -0
  127. package/registry/examples/form/native/amount.js +26 -0
  128. package/registry/examples/form/native/amount.light.png +0 -0
  129. package/registry/examples/form/native/amount.tsx +30 -0
  130. package/registry/examples/form/native/audience.d.ts +4 -0
  131. package/registry/examples/form/native/audience.dark.png +0 -0
  132. package/registry/examples/form/native/audience.js +24 -0
  133. package/registry/examples/form/native/audience.light.png +0 -0
  134. package/registry/examples/form/native/audience.tsx +24 -0
  135. package/registry/examples/form/native/profile.d.ts +4 -0
  136. package/registry/examples/form/native/profile.dark.png +0 -0
  137. package/registry/examples/form/native/profile.js +33 -0
  138. package/registry/examples/form/native/profile.light.png +0 -0
  139. package/registry/examples/form/native/profile.tsx +36 -0
  140. package/registry/examples/form/native/settings.d.ts +4 -0
  141. package/registry/examples/form/native/settings.dark.png +0 -0
  142. package/registry/examples/form/native/settings.js +25 -0
  143. package/registry/examples/form/native/settings.light.png +0 -0
  144. package/registry/examples/form/native/settings.tsx +23 -0
  145. package/registry/examples/form/native/sign-up.d.ts +4 -0
  146. package/registry/examples/form/native/sign-up.dark.png +0 -0
  147. package/registry/examples/form/native/sign-up.js +38 -0
  148. package/registry/examples/form/native/sign-up.light.png +0 -0
  149. package/registry/examples/form/native/sign-up.tsx +34 -0
  150. package/registry/examples/form/native/upload.d.ts +4 -0
  151. package/registry/examples/form/native/upload.dark.png +0 -0
  152. package/registry/examples/form/native/upload.js +23 -0
  153. package/registry/examples/form/native/upload.light.png +0 -0
  154. package/registry/examples/form/native/upload.tsx +22 -0
  155. package/registry/examples/form/profile.d.ts +4 -0
  156. package/registry/examples/form/profile.js +35 -0
  157. package/registry/examples/form/profile.tsx +40 -0
  158. package/registry/examples/form/report.d.ts +4 -0
  159. package/registry/examples/form/report.js +30 -0
  160. package/registry/examples/form/report.tsx +30 -0
  161. package/registry/examples/form/settings.d.ts +4 -0
  162. package/registry/examples/form/settings.js +30 -0
  163. package/registry/examples/form/settings.tsx +31 -0
  164. package/registry/examples/form/sign-up.d.ts +4 -0
  165. package/registry/examples/form/sign-up.js +38 -0
  166. package/registry/examples/form/sign-up.tsx +37 -0
  167. package/registry/examples/form/upload.d.ts +4 -0
  168. package/registry/examples/form/upload.js +32 -0
  169. package/registry/examples/form/upload.tsx +32 -0
  170. package/registry/examples/list/native/default.d.ts +4 -0
  171. package/registry/examples/list/native/default.dark.png +0 -0
  172. package/registry/examples/list/native/default.js +33 -0
  173. package/registry/examples/list/native/default.light.png +0 -0
  174. package/registry/examples/list/native/default.tsx +31 -0
  175. package/registry/examples/list/native/infinite.d.ts +4 -0
  176. package/registry/examples/list/native/infinite.dark.png +0 -0
  177. package/registry/examples/list/native/infinite.js +30 -0
  178. package/registry/examples/list/native/infinite.light.png +0 -0
  179. package/registry/examples/list/native/infinite.tsx +35 -0
  180. package/registry/examples/list/native/states.d.ts +4 -0
  181. package/registry/examples/list/native/states.dark.png +0 -0
  182. package/registry/examples/list/native/states.js +26 -0
  183. package/registry/examples/list/native/states.light.png +0 -0
  184. package/registry/examples/list/native/states.tsx +21 -0
  185. package/registry/examples/virtual/feed.d.ts +4 -0
  186. package/registry/examples/virtual/feed.js +26 -0
  187. package/registry/examples/virtual/feed.tsx +24 -0
  188. package/registry/examples/virtual/gallery.d.ts +4 -0
  189. package/registry/examples/virtual/gallery.js +31 -0
  190. package/registry/examples/virtual/gallery.tsx +28 -0
  191. package/registry/examples/virtual/messages.d.ts +4 -0
  192. package/registry/examples/virtual/messages.js +28 -0
  193. package/registry/examples/virtual/messages.tsx +28 -0
  194. package/registry/field.json +104 -4
  195. package/registry/file-upload.json +1 -1
  196. package/registry/filter-bar.json +1 -1
  197. package/registry/form.json +2940 -0
  198. package/registry/gallery-page.json +1 -1
  199. package/registry/glow-card.json +1 -1
  200. package/registry/goal-gauge.json +1 -1
  201. package/registry/how-it-works-page.json +1 -1
  202. package/registry/image-cropper.json +1 -1
  203. package/registry/index.json +86 -11
  204. package/registry/iso.json +1 -1
  205. package/registry/kbd.json +1 -1
  206. package/registry/landing-page.json +1 -1
  207. package/registry/landscape.json +1 -1
  208. package/registry/legal-page.json +1 -1
  209. package/registry/link-card.json +1 -1
  210. package/registry/list.json +249 -0
  211. package/registry/loader.json +1 -1
  212. package/registry/longform-page.json +1 -1
  213. package/registry/marketing-layout.json +1 -1
  214. package/registry/marquee.json +1 -1
  215. package/registry/mascot-quote.json +1 -1
  216. package/registry/mascot-state.json +2 -2
  217. package/registry/mascots.json +2 -2
  218. package/registry/media-capture.json +1 -1
  219. package/registry/meter.json +1 -1
  220. package/registry/nav-bar.json +1 -1
  221. package/registry/number-ticker.json +1 -1
  222. package/registry/page-header.json +1 -1
  223. package/registry/page-hero.json +1 -1
  224. package/registry/page.json +1 -1
  225. package/registry/pagination.json +1 -1
  226. package/registry/pointer-light.json +1 -1
  227. package/registry/popover.json +1 -1
  228. package/registry/pricing-page.json +1 -1
  229. package/registry/product-page.json +1 -1
  230. package/registry/profile-card.json +1 -1
  231. package/registry/progress.json +1 -1
  232. package/registry/promise-card.json +1 -1
  233. package/registry/radio-group.json +1 -1
  234. package/registry/scroll-stage.json +1 -1
  235. package/registry/search-field.json +1 -1
  236. package/registry/section-backdrop.json +1 -1
  237. package/registry/separator.json +1 -1
  238. package/registry/settings-layout.json +1 -1
  239. package/registry/skeleton.json +1 -1
  240. package/registry/slider.json +1 -1
  241. package/registry/slot.json +1 -1
  242. package/registry/spinner.json +1 -1
  243. package/registry/split-view.json +1 -1
  244. package/registry/stage-banner.json +1 -1
  245. package/registry/stat.json +1 -1
  246. package/registry/status-page.json +1 -1
  247. package/registry/stepper.json +1 -1
  248. package/registry/switch.json +1 -1
  249. package/registry/tabs.json +1 -1
  250. package/registry/tag-input.json +1 -1
  251. package/registry/terminal-replay.json +1 -1
  252. package/registry/theme.json +1 -1
  253. package/registry/toast.json +1 -1
  254. package/registry/toc.json +1 -1
  255. package/registry/tooltip.json +1 -1
  256. package/registry/video-player.json +1 -1
  257. package/registry/video-trimmer.json +1 -1
  258. package/registry/virtual.json +569 -0
  259. package/skills/data-table/SKILL.md +56 -0
  260. package/skills/form/SKILL.md +44 -0
  261. package/skills/native-list/SKILL.md +39 -0
  262. package/skills/virtual/SKILL.md +35 -0
  263. /package/dist/{chunk-EUPTXKH7.js.map → chunk-BRKMQ6JV.js.map} +0 -0
  264. /package/dist/{chunk-X6TW3X6Y.js.map → chunk-LCZXS6AK.js.map} +0 -0
@@ -2,15 +2,16 @@
2
2
  "name": "data-table",
3
3
  "type": "primitive",
4
4
  "title": "Data table",
5
- "summary": "A table of records — sort, selection, loading, empty and error states — that turns into cards on a phone and renders only the rows in view when there are thousands.",
6
- "description": "A table of records — sort, selection, loading, empty and error states — that turns into cards on a phone and renders only the rows in view when there are thousands.",
5
+ "summary": "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.",
6
+ "description": "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.",
7
7
  "status": "beta",
8
8
  "category": "data-display",
9
9
  "platforms": "web",
10
10
  "whenToUse": [
11
- "To compare records on the same columns: jobs, videos of a studio, users of an admin, invoices.",
12
- "When people sort, select several rows for a bulk action, or scan a status column.",
13
- "With `virtualize` for a long list loaded at once (logs, 5,000 events) that must stay smooth."
11
+ "To compare records on the same columns: jobs, videos of a studio, users of an admin, invoices, payouts.",
12
+ "When people sort, filter, group, select several rows for a bulk action, or export what they see.",
13
+ "In server mode (`manual` + `onStateChange`) when the list lives in a database: the API sorts, filters and pages.",
14
+ "With `virtualized` for a long list loaded at once (logs, 50,000 events) that must stay smooth."
14
15
  ],
15
16
  "whenNotToUse": [
16
17
  {
@@ -18,49 +19,62 @@
18
19
  "use": "stat"
19
20
  },
20
21
  {
21
- "when": "For a gallery of media where the picture matters more than the columns: a grid of cards.",
22
- "use": "card"
22
+ "when": "For a gallery of media where the picture matters more than the columns.",
23
+ "use": "virtual"
24
+ },
25
+ {
26
+ "when": "For a list on a phone app: FlashList, through the native list.",
27
+ "use": "list"
23
28
  }
24
29
  ],
25
30
  "bestPractices": [
26
- "Give every column a `header` (it labels the value in a card) and the row's name a `card: \"title\"` column.",
27
- "Sort on the server (`manualSort` + `onSortChange`) when the table shows a page of a larger list.",
28
- "Put a `Pagination` in `footer` and a `FilterBar` above; keep the filters and the page in the address.",
31
+ "Keep `rows` and `columns` stable (module scope, state, a query's data, `useMemo`): a new array at each render recomputes every model.",
32
+ "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.",
33
+ "Put `DataTableFilters` in `toolbar`; mark the columns it filters with `filter: \"text\" | \"facet\" | \"range\"`, and give a server table's facets their `options`.",
34
+ "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.",
29
35
  "Say what failed in `error` with a way to retry; say what to do next in `empty` (an `EmptyState` with an action).",
30
- "Align amounts and counts to the end (`align: \"end\"`) in tabular numbers."
36
+ "Align amounts and counts to the end (`align: \"end\"`) in tabular numbers; pin the name column of a wide table.",
37
+ "Need something the props do not cover? `useDataTable()` gives the TanStack instance — never import `@tanstack/*` in a product."
31
38
  ],
32
39
  "accessibility": {
33
40
  "keyboard": [
34
41
  {
35
42
  "keys": "Tab",
36
- "action": "Reaches the sort buttons of the headers, the checkboxes and the links of the cells."
43
+ "action": "Reaches the toolbar, the sort buttons of the headers, the resize handles, the checkboxes, the expand buttons and the links of the cells."
37
44
  },
38
45
  {
39
46
  "keys": "Space",
40
- "action": "Toggles the focused checkbox, or sorts by the focused header."
47
+ "action": "Toggles the focused checkbox, sorts by the focused header, or opens the focused row or group."
48
+ },
49
+ {
50
+ "keys": "Arrow keys",
51
+ "action": "On a focused resize handle, narrow or widen the column (Shift for larger steps)."
41
52
  }
42
53
  ],
43
54
  "notes": [
44
55
  "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.",
45
56
  "The sorted column carries `aria-sort`; a sortable header is a button that says its state with `aria-pressed`.",
57
+ "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.",
58
+ "A resize handle is a focusable `separator` named after its column, with its width as `aria-valuenow`.",
59
+ "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.",
46
60
  "While loading the table is `aria-busy` and its caption says so; an error is announced (`role=\"alert\"`).",
47
61
  "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."
48
62
  ]
49
63
  },
50
64
  "related": [
51
- "pagination",
52
65
  "filter-bar",
66
+ "pagination",
67
+ "virtual",
53
68
  "checkbox",
54
- "empty-state",
55
- "skeleton"
69
+ "empty-state"
56
70
  ],
57
71
  "web": {
58
72
  "entry": "@krizaka/ui/data-table",
59
- "import": "import { DataTable } from \"@krizaka/ui/data-table\";",
73
+ "import": "import { DataTable, DataTableFilters, DataTableColumnsMenu, DataTableExport, useDataTable } from \"@krizaka/ui/data-table\";",
60
74
  "props": [
61
75
  {
62
76
  "component": "DataTable",
63
- "description": "A table of records: sort, selection, loading / empty / error, cards on a phone, windowing for long lists.",
77
+ "description": "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.",
64
78
  "props": [
65
79
  {
66
80
  "name": "caption",
@@ -73,21 +87,21 @@
73
87
  "name": "columns",
74
88
  "type": "readonly DataTableColumn<T>[]",
75
89
  "default": null,
76
- "description": "The columns, in order.",
90
+ "description": "The columns, in order (stable: module scope or `useMemo`).",
77
91
  "required": true
78
92
  },
79
93
  {
80
94
  "name": "getRowId",
81
95
  "type": "(row: T) => string",
82
96
  "default": null,
83
- "description": "A stable id per row: the React key and the selection's value.",
97
+ "description": "A stable id per row: the React key, the selection's value, the expansion's key.",
84
98
  "required": true
85
99
  },
86
100
  {
87
101
  "name": "rows",
88
102
  "type": "readonly T[]",
89
103
  "default": null,
90
- "description": "The rows, in the order the server gave (sorted here unless `manualSort`).",
104
+ "description": "The rows (stable: module scope, state or a query's data). Sorted, filtered and paginated here unless `manual`.",
91
105
  "required": true
92
106
  },
93
107
  {
@@ -97,6 +111,55 @@
97
111
  "description": "Classes merged on the root.",
98
112
  "required": false
99
113
  },
114
+ {
115
+ "name": "columnVisibility",
116
+ "type": "Readonly<Record<string, boolean>>",
117
+ "default": null,
118
+ "description": "Visible (`true`) or hidden (`false`) columns, by id (controlled); a column not listed is visible.",
119
+ "required": false
120
+ },
121
+ {
122
+ "name": "defaultColumnVisibility",
123
+ "type": "Readonly<Record<string, boolean>>",
124
+ "default": "{}",
125
+ "description": "The visibility at first, uncontrolled.",
126
+ "required": false
127
+ },
128
+ {
129
+ "name": "defaultExpanded",
130
+ "type": "Readonly<Record<string, boolean>>",
131
+ "default": "{}",
132
+ "description": "The opened rows at first, uncontrolled.",
133
+ "required": false
134
+ },
135
+ {
136
+ "name": "defaultFilters",
137
+ "type": "Readonly<Record<string, FilterValue>>",
138
+ "default": "{}",
139
+ "description": "The filters at first, uncontrolled.",
140
+ "required": false
141
+ },
142
+ {
143
+ "name": "defaultGroupBy",
144
+ "type": "readonly string[]",
145
+ "default": "[]",
146
+ "description": "The grouping at first, uncontrolled.",
147
+ "required": false
148
+ },
149
+ {
150
+ "name": "defaultPage",
151
+ "type": "number",
152
+ "default": "1",
153
+ "description": "The page at first, uncontrolled. Default 1.",
154
+ "required": false
155
+ },
156
+ {
157
+ "name": "defaultPageSize",
158
+ "type": "number",
159
+ "default": null,
160
+ "description": "Rows per page at first, uncontrolled: turns on the pagination under the table.",
161
+ "required": false
162
+ },
100
163
  {
101
164
  "name": "defaultSelected",
102
165
  "type": "readonly string[]",
@@ -132,11 +195,39 @@
132
195
  "description": "A failure, shown in place of the rows (an `Alert`, a retry button); announced.",
133
196
  "required": false
134
197
  },
198
+ {
199
+ "name": "expanded",
200
+ "type": "Readonly<Record<string, boolean>>",
201
+ "default": null,
202
+ "description": "The opened rows and groups (controlled), by id.",
203
+ "required": false
204
+ },
205
+ {
206
+ "name": "filters",
207
+ "type": "Readonly<Record<string, FilterValue>>",
208
+ "default": null,
209
+ "description": "The filters in force (controlled), by column id — what `DataTableFilters` edits.",
210
+ "required": false
211
+ },
135
212
  {
136
213
  "name": "footer",
137
214
  "type": "ReactNode",
138
215
  "default": null,
139
- "description": "Under the table: a `Pagination`, a total.",
216
+ "description": "Under the table (after the pagination): a total, a note.",
217
+ "required": false
218
+ },
219
+ {
220
+ "name": "getSubRows",
221
+ "type": "((row: T) => readonly T[])",
222
+ "default": null,
223
+ "description": "The children of a row (a tree): each row with children gets a button that shows them.",
224
+ "required": false
225
+ },
226
+ {
227
+ "name": "groupBy",
228
+ "type": "readonly string[]",
229
+ "default": null,
230
+ "description": "The columns the rows are grouped by, in order (controlled) — each `groupable`.",
140
231
  "required": false
141
232
  },
142
233
  {
@@ -168,10 +259,52 @@
168
259
  "required": false
169
260
  },
170
261
  {
171
- "name": "manualSort",
172
- "type": "boolean",
262
+ "name": "manual",
263
+ "type": "{ sorting?: boolean; filtering?: boolean; pagination?: boolean | undefined; } | undefined",
264
+ "default": null,
265
+ "description": "The server does the work: the rows are shown as given (sorted, filtered, a single page) — fetch in `onStateChange`.",
266
+ "required": false
267
+ },
268
+ {
269
+ "name": "onColumnVisibilityChange",
270
+ "type": "((visibility: Record<string, boolean>) => void)",
271
+ "default": null,
272
+ "description": "Called with the new visibility (`DataTableColumnsMenu`).",
273
+ "required": false
274
+ },
275
+ {
276
+ "name": "onExpandedChange",
277
+ "type": "((expanded: Record<string, boolean>) => void)",
278
+ "default": null,
279
+ "description": "Called with the opened rows.",
280
+ "required": false
281
+ },
282
+ {
283
+ "name": "onFiltersChange",
284
+ "type": "((filters: Readonly<Record<string, FilterValue>>) => void)",
285
+ "default": null,
286
+ "description": "Called with the new filters.",
287
+ "required": false
288
+ },
289
+ {
290
+ "name": "onGroupByChange",
291
+ "type": "((groupBy: string[]) => void)",
292
+ "default": null,
293
+ "description": "Called with the new grouping.",
294
+ "required": false
295
+ },
296
+ {
297
+ "name": "onPageChange",
298
+ "type": "((page: number) => void)",
173
299
  "default": null,
174
- "description": "The server sorts (`onSortChange` fetches): the rows are shown as given.",
300
+ "description": "Called with the page asked for.",
301
+ "required": false
302
+ },
303
+ {
304
+ "name": "onPageSizeChange",
305
+ "type": "((pageSize: number) => void)",
306
+ "default": null,
307
+ "description": "Called with the size chosen in the pagination's select (`pageSizeOptions`).",
175
308
  "required": false
176
309
  },
177
310
  {
@@ -188,11 +321,67 @@
188
321
  "description": "Called when a header asks for a new sort.",
189
322
  "required": false
190
323
  },
324
+ {
325
+ "name": "onStateChange",
326
+ "type": "((query: DataTableQuery) => void)",
327
+ "default": null,
328
+ "description": "Called with the sort, the filters and the page after each change: the query key of a server table.",
329
+ "required": false
330
+ },
331
+ {
332
+ "name": "page",
333
+ "type": "number",
334
+ "default": null,
335
+ "description": "The page, from 1 (controlled).",
336
+ "required": false
337
+ },
338
+ {
339
+ "name": "pageSize",
340
+ "type": "number",
341
+ "default": null,
342
+ "description": "Rows per page (controlled): turns on the pagination under the table.",
343
+ "required": false
344
+ },
345
+ {
346
+ "name": "pageSizeOptions",
347
+ "type": "readonly number[]",
348
+ "default": null,
349
+ "description": "The sizes the pagination offers (`[10, 25, 50]`).",
350
+ "required": false
351
+ },
352
+ {
353
+ "name": "pinning",
354
+ "type": "{ start?: readonly string[]; end?: readonly string[]; } | undefined",
355
+ "default": null,
356
+ "description": "Columns that stay in view while the table scrolls sideways: at the start (a name) or the end (actions).",
357
+ "required": false
358
+ },
359
+ {
360
+ "name": "renderDetail",
361
+ "type": "((row: T) => ReactNode)",
362
+ "default": null,
363
+ "description": "A panel under an opened row (the details of a job): each row gets a button that opens it.",
364
+ "required": false
365
+ },
366
+ {
367
+ "name": "resizing",
368
+ "type": "boolean",
369
+ "default": null,
370
+ "description": "Columns can be resized (pointer drag, or the arrow keys on the focused handle).",
371
+ "required": false
372
+ },
373
+ {
374
+ "name": "rowCount",
375
+ "type": "number",
376
+ "default": null,
377
+ "description": "The number of rows on the server (`manual.pagination`): the page count and the results' count.",
378
+ "required": false
379
+ },
191
380
  {
192
381
  "name": "rowLabel",
193
382
  "type": "((row: T) => string)",
194
383
  "default": null,
195
- "description": "The name of a row in its checkbox's label (default: its id).",
384
+ "description": "The name of a row in its labels (checkbox, expand button). Default: its id.",
196
385
  "required": false
197
386
  },
198
387
  {
@@ -224,10 +413,91 @@
224
413
  "required": false
225
414
  },
226
415
  {
227
- "name": "virtualize",
228
- "type": "{ rowHeight: number; height: number; overscan?: number; }",
416
+ "name": "toolbar",
417
+ "type": "ReactNode",
418
+ "default": null,
419
+ "description": "Above the table, inside its frame: `DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`, actions.",
420
+ "required": false
421
+ },
422
+ {
423
+ "name": "virtualized",
424
+ "type": "{ height: number; estimateRowHeight?: number; overscan?: number; } | undefined",
425
+ "default": null,
426
+ "description": "Renders only the rows in view, in a scrolling body of `height` px; rows are measured (`estimateRowHeight` is a guess). Forces the table layout.",
427
+ "required": false
428
+ }
429
+ ]
430
+ },
431
+ {
432
+ "component": "DataTableColumnsMenu",
433
+ "description": "A menu of the `hideable` columns, each a checkbox: the person chooses what the table shows.",
434
+ "props": [
435
+ {
436
+ "name": "className",
437
+ "type": "string",
438
+ "default": null,
439
+ "description": "Classes merged on the button.",
440
+ "required": false
441
+ },
442
+ {
443
+ "name": "defaultOpen",
444
+ "type": "boolean",
445
+ "default": null,
446
+ "description": "Opens the menu at first (a story, a test).",
447
+ "required": false
448
+ }
449
+ ]
450
+ },
451
+ {
452
+ "component": "DataTableExport",
453
+ "description": "Downloads what the table shows — the filtered and sorted rows of every page, the visible columns — as CSV.",
454
+ "props": [
455
+ {
456
+ "name": "className",
457
+ "type": "string",
458
+ "default": null,
459
+ "description": "Classes merged on the button.",
460
+ "required": false
461
+ },
462
+ {
463
+ "name": "filename",
464
+ "type": "string",
465
+ "default": "export.csv",
466
+ "description": "The file's name. Default `\"export.csv\"`.",
467
+ "required": false
468
+ },
469
+ {
470
+ "name": "onExport",
471
+ "type": "((csv: string) => void)",
472
+ "default": null,
473
+ "description": "Receives the CSV instead of downloading it (send it to the server, a test).",
474
+ "required": false
475
+ }
476
+ ]
477
+ },
478
+ {
479
+ "component": "DataTableFilters",
480
+ "description": "The filters of the table, from its columns: a search per `text` column, a menu of values (with their counts) per\n`facet` column, two bounds per `range` column; the filters in force as removable chips, \"clear all\", and the count\nof results, announced.",
481
+ "props": [
482
+ {
483
+ "name": "children",
484
+ "type": "ReactNode",
485
+ "default": null,
486
+ "description": "More controls in the bar (a date range picker, a toggle).",
487
+ "required": false
488
+ },
489
+ {
490
+ "name": "className",
491
+ "type": "string",
229
492
  "default": null,
230
- "description": "Renders only the rows in view (fixed `rowHeight`, a scrolling body of `height` px). Forces the table layout.",
493
+ "description": "Classes merged on the bar.",
494
+ "required": false
495
+ },
496
+ {
497
+ "name": "debounce",
498
+ "type": "number",
499
+ "default": "250",
500
+ "description": "The search's wait after the last key, in ms, before the filter applies (a request, in server mode). Default 250.",
231
501
  "required": false
232
502
  }
233
503
  ]
@@ -242,6 +512,54 @@
242
512
  "module": "@krizaka/ui/registry/examples/data-table/jobs",
243
513
  "code": "\"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"
244
514
  },
515
+ {
516
+ "name": "filters",
517
+ "title": "Filters, columns, export",
518
+ "description": "A search, facets with their counts, a range; the filters as chips; hide columns; export the rows shown as CSV.",
519
+ "path": "examples/data-table/filters.tsx",
520
+ "module": "@krizaka/ui/registry/examples/data-table/filters",
521
+ "code": "\"use client\";\n\nimport { DataTable, type DataTableColumn, DataTableColumnsMenu, DataTableExport, DataTableFilters } from \"@krizaka/ui/data-table\";\n\ntype Video = { id: string; title: string; creator: string; status: string; views: number };\nconst videos: Video[] = Array.from({ length: 36 }, (_, i) => ({\n id: `v${i + 1}`,\n title: [\"Sunset over Montréal\", \"Behind the scenes\", \"Studio tour\", \"Live Q&A replay\"][i % 4]! + ` #${i + 1}`,\n creator: [\"Elena Varga\", \"Noah Petit\", \"Maya Chen\"][i % 3]!,\n status: [\"Public\", \"Followers\", \"Draft\"][i % 3 === 2 ? 2 : i % 2]!,\n views: (i * 7919) % 20_000,\n}));\nconst columns: DataTableColumn<Video>[] = [\n { id: \"title\", header: \"Title\", sortable: true, card: \"title\", filter: \"text\" },\n { id: \"creator\", header: \"Creator\", filter: \"facet\" },\n { id: \"status\", header: \"Visibility\", filter: \"facet\" },\n { id: \"views\", header: \"Views\", sortable: true, align: \"end\", filter: \"range\", cell: (v) => v.views.toLocaleString(\"en\") },\n];\n\n// A search, facets with their counts, a range, the filters as chips; hide columns, export what is shown.\nexport default function DataTableFiltersExample() {\n return (\n <DataTable\n caption=\"Videos\"\n rows={videos}\n columns={columns}\n getRowId={(v) => v.id}\n defaultPageSize={8}\n toolbar={\n <div className=\"flex flex-col gap-3\">\n <DataTableFilters />\n <div className=\"flex justify-end gap-2\">\n <DataTableColumnsMenu />\n <DataTableExport filename=\"videos.csv\" />\n </div>\n </div>\n }\n />\n );\n}\n"
522
+ },
523
+ {
524
+ "name": "server",
525
+ "title": "Server mode",
526
+ "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.",
527
+ "path": "examples/data-table/server.tsx",
528
+ "module": "@krizaka/ui/registry/examples/data-table/server",
529
+ "code": "\"use client\";\n\nimport { configureKrizakaFetch, defineKeys, keepPreviousData, krizakaFetch, KrizakaQueryProvider, useQuery } from \"@krizaka/query\";\nimport { DataTable, type DataTableColumn, DataTableFilters, type DataTableQuery } from \"@krizaka/ui/data-table\";\nimport { useState } from \"react\";\n\ntype Payout = { id: string; creator: string; status: string; amount: number };\nconst ALL: Payout[] = Array.from({ length: 240 }, (_, i) => ({ id: `po-${1000 + i}`, creator: [\"Elena Varga\", \"Noah Petit\", \"Maya Chen\"][i % 3]!, status: [\"paid\", \"pending\", \"failed\"][i % 4 % 3]!, amount: ((i * 7919) % 90_000) / 100 }));\n// The API, faked in the page: it sorts, filters and pages — the table shows what it answers.\nasync function fakeApi(input: RequestInfo | URL) {\n const q = new URL(String(input), \"https://api.example\").searchParams;\n const [by, dir] = (q.get(\"sort\") ?? \"id:ascending\").split(\":\") as [keyof Payout, string];\n const rows = ALL.filter((p) => !q.get(\"status\") || q.get(\"status\")!.split(\",\").includes(p.status)).sort((a, b) => (a[by] > b[by] ? 1 : -1) * (dir === \"descending\" ? -1 : 1));\n const [page, size] = [Number(q.get(\"page\")), Number(q.get(\"size\"))];\n return Response.json({ rows: rows.slice((page - 1) * size, page * size), total: rows.length });\n}\nconst payoutKeys = defineKeys(\"payouts\", { list: (query: DataTableQuery) => [query] });\nconst columns: DataTableColumn<Payout>[] = [\n { id: \"id\", header: \"Payout\", sortable: true, card: \"title\" },\n { id: \"creator\", header: \"Creator\", sortable: true },\n { id: \"status\", header: \"Status\", filter: \"facet\", options: [{ value: \"paid\", label: \"Paid\" }, { value: \"pending\", label: \"Pending\" }, { value: \"failed\", label: \"Failed\" }] },\n { id: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (p) => `$${p.amount.toFixed(2)}` },\n];\nconst NONE: Payout[] = [];\nfunction Payouts() {\n const [query, setQuery] = useState<DataTableQuery>({ sort: null, filters: {}, page: 1, pageSize: 10 });\n const { data, isPending, error } = useQuery({\n queryKey: payoutKeys.list(query),\n queryFn: ({ signal }) => krizakaFetch(\"/api/payouts\", { signal, query: { sort: query.sort && `${query.sort.column}:${query.sort.direction}`, status: query.filters.status as string[] | undefined, page: query.page, size: query.pageSize } }) as Promise<{ rows: Payout[]; total: number }>,\n placeholderData: keepPreviousData,\n meta: { list: true },\n });\n return <DataTable caption=\"Payouts\" rows={data?.rows ?? NONE} columns={columns} getRowId={(p) => p.id} manual={{ sorting: true, filtering: true, pagination: true }} rowCount={data?.total} defaultPageSize={10} onStateChange={setQuery} loading={isPending} error={error?.message} toolbar={<DataTableFilters />} />;\n}\n\n// Server mode: sort, filters and page go to the API (krizakaFetch, keys from defineKeys); the previous page stays while the next loads.\nexport default function DataTableServer() {\n useState(() => configureKrizakaFetch({ fetch: fakeApi as typeof fetch }));\n return <KrizakaQueryProvider><Payouts /></KrizakaQueryProvider>;\n}\n"
530
+ },
531
+ {
532
+ "name": "paginated",
533
+ "title": "Pages",
534
+ "description": "Client pagination under the table, with the rows per page to choose.",
535
+ "path": "examples/data-table/paginated.tsx",
536
+ "module": "@krizaka/ui/registry/examples/data-table/paginated",
537
+ "code": "\"use client\";\n\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Invoice = { id: string; customer: string; issued: string; total: number };\nconst invoices: Invoice[] = Array.from({ length: 87 }, (_, i) => ({\n id: `INV-2026-${String(i + 1).padStart(4, \"0\")}`,\n customer: [\"Atelier Lumen\", \"Northwind Studio\", \"Café Brûlé\", \"Kite & Co\"][i % 4]!,\n issued: `2026-${String(1 + (i % 9)).padStart(2, \"0\")}-${String(1 + (i % 27)).padStart(2, \"0\")}`,\n total: ((i * 4111) % 120_000) / 100,\n}));\nconst columns: DataTableColumn<Invoice>[] = [\n { id: \"id\", header: \"Invoice\", sortable: true, card: \"title\" },\n { id: \"customer\", header: \"Customer\", sortable: true },\n { id: \"issued\", header: \"Issued\", sortable: true },\n { id: \"total\", header: \"Total\", sortable: true, align: \"end\", cell: (r) => `€${r.total.toFixed(2)}` },\n];\n\n// Pages in the browser: the pagination under the table, the rows per page to choose; a sort goes back to page 1.\nexport default function DataTablePaginated() {\n return <DataTable caption=\"Invoices\" rows={invoices} columns={columns} getRowId={(r) => r.id} defaultPageSize={10} pageSizeOptions={[10, 25, 50]} />;\n}\n"
538
+ },
539
+ {
540
+ "name": "grouped",
541
+ "title": "Groups",
542
+ "description": "`groupBy`: a row per group with its count and its total (`aggregate`), opened to see its rows.",
543
+ "path": "examples/data-table/grouped.tsx",
544
+ "module": "@krizaka/ui/registry/examples/data-table/grouped",
545
+ "code": "\"use client\";\n\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Tip = { id: string; creator: string; fan: string; cents: number };\nconst tips: Tip[] = [\n { id: \"t1\", creator: \"Elena Varga\", fan: \"marco_v\", cents: 500 },\n { id: \"t2\", creator: \"Elena Varga\", fan: \"ana.lu\", cents: 1500 },\n { id: \"t3\", creator: \"Noah Petit\", fan: \"kenji\", cents: 300 },\n { id: \"t4\", creator: \"Maya Chen\", fan: \"marco_v\", cents: 2000 },\n { id: \"t5\", creator: \"Noah Petit\", fan: \"zoe\", cents: 700 },\n];\nconst money = (cents: number) => (cents / 100).toLocaleString(\"en\", { style: \"currency\", currency: \"USD\" });\nconst columns: DataTableColumn<Tip>[] = [\n { id: \"creator\", header: \"Creator\", groupable: true, card: \"title\" },\n { id: \"fan\", header: \"From\" },\n { id: \"cents\", header: \"Amount\", align: \"end\", cell: (t) => money(t.cents), aggregate: (rows) => money(rows.reduce((sum, t) => sum + t.cents, 0)) },\n];\n\n// Grouped by creator: a row per group with its count and its total, opened to see the tips.\nexport default function DataTableGrouped() {\n return <DataTable caption=\"Tips this week\" rows={tips} columns={columns} getRowId={(t) => t.id} defaultGroupBy={[\"creator\"]} defaultExpanded={{ \"creator:Elena Varga\": true }} />;\n}\n"
546
+ },
547
+ {
548
+ "name": "details",
549
+ "title": "Details",
550
+ "description": "`renderDetail`: a button per row opens a panel under it.",
551
+ "path": "examples/data-table/details.tsx",
552
+ "module": "@krizaka/ui/registry/examples/data-table/details",
553
+ "code": "\"use client\";\n\nimport { Badge } from \"@krizaka/ui/badge\";\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Request = { id: string; creator: string; kind: string; status: \"pending\" | \"approved\"; note: string };\nconst requests: Request[] = [\n { id: \"v-1042\", creator: \"Elena Varga\", kind: \"Identity check\", status: \"pending\", note: \"Passport, expires 2031. Selfie matches.\" },\n { id: \"v-1041\", creator: \"Noah Petit\", kind: \"Payout account\", status: \"pending\", note: \"IBAN FR76 •••• 0412, holder name matches.\" },\n { id: \"v-1039\", creator: \"Maya Chen\", kind: \"Identity check\", status: \"approved\", note: \"Approved by Sam on Oct 11.\" },\n];\nconst columns: DataTableColumn<Request>[] = [\n { id: \"creator\", header: \"Creator\", card: \"title\" },\n { id: \"kind\", header: \"Request\" },\n { id: \"status\", header: \"Status\", cell: (r) => <Badge tone={r.status === \"pending\" ? \"warning\" : \"success\"}>{r.status}</Badge> },\n];\n\n// A button per row opens a panel under it: the details, without leaving the list.\nexport default function DataTableDetails() {\n return (\n <DataTable\n caption=\"Verification requests\"\n rows={requests}\n columns={columns}\n getRowId={(r) => r.id}\n rowLabel={(r) => r.creator}\n defaultExpanded={{ \"v-1042\": true }}\n renderDetail={(r) => <p className=\"text-sm text-fg-secondary\">{r.note}</p>}\n />\n );\n}\n"
554
+ },
555
+ {
556
+ "name": "pinned",
557
+ "title": "Pinned and resizable",
558
+ "description": "A wide table: the name pinned at the start, columns resized by drag or by the arrow keys.",
559
+ "path": "examples/data-table/pinned.tsx",
560
+ "module": "@krizaka/ui/registry/examples/data-table/pinned",
561
+ "code": "\"use client\";\n\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Model = { id: string; name: string; provider: string; input: number; output: number; context: number; latency: number; region: string };\nconst models: Model[] = [\n { id: \"m1\", name: \"Llama 3.3 70B\", provider: \"Self-hosted\", input: 0.23, output: 0.4, context: 128, latency: 420, region: \"eu-west\" },\n { id: \"m2\", name: \"Mistral Large\", provider: \"Mistral\", input: 2, output: 6, context: 128, latency: 380, region: \"eu-west\" },\n { id: \"m3\", name: \"Qwen 2.5 Coder\", provider: \"Self-hosted\", input: 0.12, output: 0.2, context: 32, latency: 210, region: \"eu-central\" },\n];\nconst usd = (n: number) => `$${n.toFixed(2)}`;\nconst columns: DataTableColumn<Model>[] = [\n { id: \"name\", header: \"Model\", width: 180, card: \"title\" },\n { id: \"provider\", header: \"Provider\", width: 140 },\n { id: \"input\", header: \"Input / 1M\", width: 130, align: \"end\", cell: (m) => usd(m.input) },\n { id: \"output\", header: \"Output / 1M\", width: 130, align: \"end\", cell: (m) => usd(m.output) },\n { id: \"context\", header: \"Context\", width: 110, align: \"end\", cell: (m) => `${m.context}k` },\n { id: \"latency\", header: \"Latency\", width: 110, align: \"end\", cell: (m) => `${m.latency} ms` },\n { id: \"region\", header: \"Region\", width: 130 },\n];\n\n// A wide table: the name stays at the start while it scrolls sideways; drag (or ← →) a header's edge to resize.\nexport default function DataTablePinned() {\n return <DataTable caption=\"Model pricing\" rows={models} columns={columns} getRowId={(m) => m.id} pinning={{ start: [\"name\"] }} resizing layout=\"table\" />;\n}\n"
562
+ },
245
563
  {
246
564
  "name": "cards",
247
565
  "title": "Cards on a phone",
@@ -276,27 +594,39 @@
276
594
  },
277
595
  {
278
596
  "name": "virtualized",
279
- "title": "5,000 rows",
280
- "description": "`virtualize`: only the rows in view are rendered, the header stays.",
597
+ "title": "50,000 rows",
598
+ "description": "`virtualized`: only the rows in view are rendered and measured, the header stays.",
281
599
  "path": "examples/data-table/virtualized.tsx",
282
600
  "module": "@krizaka/ui/registry/examples/data-table/virtualized",
283
- "code": "\"use client\";\n\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Event = { id: string; kind: string; at: string };\nconst events: Event[] = Array.from({ length: 5000 }, (_, i) => ({\n id: `evt-${i + 1}`,\n kind: [\"view\", \"tip\", \"follow\", \"comment\"][i % 4]!,\n at: `2026-10-${String(1 + (i % 11)).padStart(2, \"0\")} ${String(i % 24).padStart(2, \"0\")}:00`,\n}));\nconst columns: DataTableColumn<Event>[] = [\n { id: \"id\", header: \"Event\", sortable: true },\n { id: \"kind\", header: \"Kind\", sortable: true },\n { id: \"at\", header: \"At\", align: \"end\" },\n];\n\n// 5,000 rows, ~20 rendered: the body scrolls, the header stays.\nexport default function DataTableVirtualized() {\n return <DataTable caption=\"Events\" rows={events} columns={columns} getRowId={(e) => e.id} virtualize={{ rowHeight: 44, height: 320 }} density=\"compact\" />;\n}\n"
601
+ "code": "\"use client\";\n\nimport { DataTable, type DataTableColumn } from \"@krizaka/ui/data-table\";\n\ntype Event = { id: string; kind: string; at: string };\nconst events: Event[] = Array.from({ length: 50_000 }, (_, i) => ({\n id: `evt-${i + 1}`,\n kind: [\"view\", \"tip\", \"follow\", \"comment\"][i % 4]!,\n at: `2026-10-${String(1 + (i % 11)).padStart(2, \"0\")} ${String(i % 24).padStart(2, \"0\")}:00`,\n}));\nconst columns: DataTableColumn<Event>[] = [\n { id: \"id\", header: \"Event\", sortable: true },\n { id: \"kind\", header: \"Kind\", sortable: true },\n { id: \"at\", header: \"At\", align: \"end\" },\n];\n\n// 50,000 rows, ~20 rendered: the body scrolls, the header stays, the sort runs on all of them.\nexport default function DataTableVirtualized() {\n return <DataTable caption=\"Events\" rows={events} columns={columns} getRowId={(e) => e.id} virtualized={{ height: 320, estimateRowHeight: 37 }} density=\"compact\" />;\n}\n"
284
602
  }
285
603
  ]
286
604
  },
287
605
  "native": null,
288
606
  "files": [
607
+ {
608
+ "path": "data-table/csv.ts",
609
+ "content": "// CSV (RFC 4180) from rows and columns: the header of each column, then each row's values — what `value` returns (or\n// `row[id]`), never the rendered cell. Dates as ISO 8601; a value that a spreadsheet would run as a formula is quoted\n// with a leading apostrophe (CSV injection).\nimport type { CellValue } from \"./engine\";\n\ntype CsvColumn<T> = { id: string; header: string; value?: (row: T) => CellValue };\n\nconst FORMULA = /^[=+\\-@\\t\\r]/;\n\nfunction field(value: CellValue): string {\n let text = value == null ? \"\" : value instanceof Date ? value.toISOString() : String(value);\n if (typeof value === \"string\" && FORMULA.test(text)) text = `'${text}`;\n return /[\",\\n\\r;]/.test(text) || text !== text.trim() ? `\"${text.replace(/\"/g, '\"\"')}\"` : text;\n}\n\n/** The rows as CSV text: a header line, then one line per row, separated by `\\r\\n`. */\nexport function dataTableCsv<T>(rows: readonly T[], columns: readonly CsvColumn<T>[], separator = \",\"): string {\n const lines = [columns.map((column) => field(column.header)).join(separator)];\n for (const row of rows) {\n lines.push(columns.map((column) => field(column.value ? column.value(row) : ((row as Record<string, unknown>)[column.id] as CellValue))).join(separator));\n }\n return lines.join(\"\\r\\n\");\n}\n"
610
+ },
289
611
  {
290
612
  "path": "data-table/data-table.tsx",
291
- "content": "// Client (sort and selection 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. Selection through\n// `@krizaka/ui/checkbox`; loading, empty and error states in the body; optional windowing for thousands of rows.\nimport * as React from \"react\";\nimport { tv } from \"tailwind-variants\";\n\nimport { Checkbox } from \"../checkbox\";\nimport { SortGlyph } from \"../glyphs\";\nimport { useControllable } from \"../media\";\n\nexport const dataTable = tv({\n slots: {\n root: \"@container w-full overflow-hidden rounded-xl border border-border-default bg-surface-1\",\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: \"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 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 td: \"px-4 py-3 align-middle\",\n select: \"w-10\",\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 // 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: \"@max-xl:sr-only @max-xl:data-[keep]:not-sr-only @max-xl:data-[keep]:block @max-xl:p-0\",\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 select: \"@max-xl:absolute @max-xl:right-3 @max-xl:top-3 @max-xl:w-auto @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 },\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;\ntype Sortable = string | number | boolean | Date | null | undefined;\n\nexport type DataTableColumn<T> = {\n /** A key of the column (and of the row, for the default cell and sort). */\n id: string;\n /** The column's name, passed translated: the header, and the label of the value in a card. */\n header: string;\n /** What a cell shows. Default: `value(row)`, or `row[id]`. */\n cell?: (row: T) => React.ReactNode;\n /** The value sorted on (and shown by default): a string, a number, a date. Default `row[id]`. */\n value?: (row: T) => Sortable;\n /** The header becomes a sort button (ascending → descending → none). */\n sortable?: boolean;\n /** Text alignment: `end` for amounts and counts. */\n align?: \"start\" | \"center\" | \"end\";\n /** A CSS width (`\"8rem\"`, `\"30%\"`). */\n width?: string;\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};\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};\n\nexport const dataTableLabels: DataTableLabels = {\n selectAll: \"Select all rows\",\n selectRow: (name) => `Select ${name}`,\n loading: \"Loading\",\n empty: \"Nothing to show\",\n};\n\nexport type DataTableProps<T> = {\n /** The rows, in the order the server gave (sorted here unless `manualSort`). */\n rows: readonly T[];\n /** The columns, in order. */\n columns: readonly DataTableColumn<T>[];\n /** A stable id per row: the React key and the selection's value. */\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 /** 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 /** The server sorts (`onSortChange` fetches): the rows are shown as given. */\n manualSort?: boolean;\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 checkbox's label (default: its id). */\n rowLabel?: (row: T) => string;\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 /** `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 (fixed `rowHeight`, a scrolling body of `height` px). Forces the table layout. */\n virtualize?: { rowHeight: number; height: number; overscan?: number };\n /** Under the table: a `Pagination`, a total. */\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\nfunction compare(a: Sortable, b: Sortable): number {\n if (a == null || a === \"\") return b == null || b === \"\" ? 0 : 1;\n if (b == null || b === \"\") return -1;\n if (a instanceof Date || b instanceof Date) return Number(a) - Number(b);\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: \"base\" });\n}\n\nconst nextSort = (sort: DataTableSort, column: string): DataTableSort =>\n sort?.column !== column ? { column, direction: \"ascending\" } : sort.direction === \"ascending\" ? { column, direction: \"descending\" } : null;\n\nconst ALIGN = { start: \"text-left\", center: \"text-center\", end: \"text-right\" } as const;\n\n/** A table of records: sort, selection, loading / empty / error, cards on a phone, windowing for long lists. */\nexport function DataTable<T>({\n rows,\n columns,\n getRowId,\n caption,\n showCaption,\n sort: sortProp,\n defaultSort = null,\n onSortChange,\n manualSort,\n selectable,\n selected: selectedProp,\n defaultSelected = [],\n onSelectedChange,\n rowLabel,\n loading,\n loadingRows = 5,\n error,\n empty,\n layout = \"auto\",\n density,\n virtualize,\n footer,\n labels: given,\n className,\n}: DataTableProps<T>) {\n const labels = { ...dataTableLabels, ...given };\n const s = dataTable({ layout: virtualize ? \"table\" : layout, density });\n const [sort, setSort] = useControllable<DataTableSort>(sortProp, defaultSort, onSortChange);\n const [selected, setSelected] = useControllable<readonly string[]>(selectedProp, defaultSelected, onSelectedChange as (next: readonly string[]) => void);\n const [scrollTop, setScrollTop] = React.useState(0);\n\n const valueOf = (column: DataTableColumn<T>, row: T): Sortable =>\n column.value ? column.value(row) : ((row as Record<string, unknown>)[column.id] as Sortable);\n\n const sorted = React.useMemo(() => {\n const column = sort && columns.find((c) => c.id === sort.column);\n if (!column || manualSort) return rows;\n const factor = sort.direction === \"ascending\" ? 1 : -1;\n return [...rows].sort((a, b) => factor * compare(valueOf(column, a), valueOf(column, b)));\n // eslint-disable-next-line react-hooks/exhaustive-deps -- valueOf only reads `column`\n }, [rows, columns, sort, manualSort]);\n\n const ids = sorted.map(getRowId);\n const chosen = new Set(selected);\n const allChosen = ids.length > 0 && ids.every((id) => chosen.has(id));\n const someChosen = ids.some((id) => chosen.has(id));\n const toggleAll = () => setSelected(allChosen ? selected.filter((id) => !ids.includes(id)) : [...new Set([...selected, ...ids])]);\n const toggle = (id: string) => setSelected(chosen.has(id) ? selected.filter((x) => x !== id) : [...selected, id]);\n\n const span = columns.length + (selectable ? 1 : 0);\n let windowed = sorted;\n let before = 0;\n let after = 0;\n let offset = 0;\n if (virtualize && !loading) {\n const overscan = virtualize.overscan ?? 6;\n const start = Math.max(0, Math.floor(scrollTop / virtualize.rowHeight) - overscan);\n const end = Math.min(sorted.length, Math.ceil((scrollTop + virtualize.height) / virtualize.rowHeight) + overscan);\n windowed = sorted.slice(start, end);\n before = start * virtualize.rowHeight;\n after = (sorted.length - end) * virtualize.rowHeight;\n offset = start;\n }\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 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 {columns.map((column) => (\n <td role=\"cell\" key={column.id} data-card={column.card === \"title\" ? \"title\" : column.card === \"hidden\" ? \"hidden\" : undefined} 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 (sorted.length === 0) body = message(empty ?? labels.empty);\n else {\n body = windowed.map((row, i) => {\n const id = getRowId(row);\n const isChosen = chosen.has(id);\n return (\n <tr\n role=\"row\"\n key={id}\n aria-rowindex={virtualize ? offset + i + 2 : undefined}\n data-state={isChosen ? \"selected\" : undefined}\n className={s.tr()}\n style={virtualize ? { height: virtualize.rowHeight } : undefined}\n >\n {selectable && (\n <td role=\"cell\" className={s.td({ className: s.select() })}>\n <Checkbox checked={isChosen} onCheckedChange={() => toggle(id)} aria-label={labels.selectRow(rowLabel ? rowLabel(row) : id)} />\n </td>\n )}\n {columns.map((column) => (\n <td\n role=\"cell\"\n key={column.id}\n data-label={column.header}\n data-card={column.card === \"title\" ? \"title\" : column.card === \"hidden\" ? \"hidden\" : undefined}\n className={s.td({ className: ALIGN[column.align ?? \"start\"] })}\n >\n {column.cell ? column.cell(row) : String(valueOf(column, row) ?? \"\")}\n </td>\n ))}\n </tr>\n );\n });\n }\n\n const table = (\n <table\n role=\"table\"\n aria-busy={loading || undefined}\n aria-rowcount={virtualize ? sorted.length + 1 : undefined}\n className={s.table()}\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: virtualize ? \"sticky top-0 z-10 bg-surface-2\" : undefined })}>\n <tr role=\"row\" aria-rowindex={virtualize ? 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 || ids.length === 0}\n aria-label={labels.selectAll}\n />\n </th>\n )}\n {columns.map((column) => {\n const direction = sort?.column === column.id ? sort.direction : undefined;\n return (\n <th\n role=\"columnheader\"\n scope=\"col\"\n key={column.id}\n aria-sort={column.sortable ? (direction ?? \"none\") : undefined}\n data-keep={column.sortable ? \"\" : undefined}\n className={s.th({ className: ALIGN[column.align ?? \"start\"] })}\n style={column.width ? { width: column.width } : undefined}\n >\n {column.sortable ? (\n <button type=\"button\" aria-pressed={Boolean(direction)} className={s.sortButton()} onClick={() => setSort(nextSort(sort, column.id))}>\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 </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 return (\n <div className={s.root({ className })}>\n {virtualize ? (\n // A scrolling region the keyboard can reach: Tab lands on it, the arrows scroll it.\n <div\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: virtualize.height }}\n onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}\n >\n {table}\n </div>\n ) : (\n <div className={s.scroller()}>{table}</div>\n )}\n {footer ? <div className={s.footer()}>{footer}</div> : null}\n </div>\n );\n}\n"
613
+ "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"
614
+ },
615
+ {
616
+ "path": "data-table/engine.ts",
617
+ "content": "// The engine of DataTable: TanStack Table v9 behind the Krizaka facade (ADR-001 — a product imports\n// `@krizaka/ui/data-table`, never `@tanstack/*`). The features are registered once, at module scope (stable input),\n// and the Krizaka API (`sort`, `filters`, `selected`, `page`… in the app's words) is translated to TanStack's state\n// here — nothing else of the component knows TanStack's shapes.\nimport {\n type Cell,\n type Column,\n type ColumnDef,\n columnFacetingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n columnPinningFeature,\n columnResizingFeature,\n type columnResizingState as ColumnResizingState,\n columnSizingFeature,\n columnVisibilityFeature,\n createExpandedRowModel,\n createFacetedMinMaxValues,\n createFacetedRowModel,\n createFacetedUniqueValues,\n createFilteredRowModel,\n createGroupedRowModel,\n createPaginatedRowModel,\n createSortedRowModel,\n type FilterFn,\n type ReactTable,\n type Row,\n type RowData,\n rowExpandingFeature,\n rowPaginationFeature,\n rowSelectionFeature,\n rowSortingFeature,\n type SortFn,\n tableFeatures,\n} from \"@tanstack/react-table\";\n\nexport const features = tableFeatures({\n columnFilteringFeature,\n filteredRowModel: createFilteredRowModel(),\n columnFacetingFeature,\n facetedRowModel: createFacetedRowModel(),\n facetedUniqueValues: createFacetedUniqueValues(),\n facetedMinMaxValues: createFacetedMinMaxValues(),\n columnGroupingFeature,\n groupedRowModel: createGroupedRowModel(),\n rowSortingFeature,\n sortedRowModel: createSortedRowModel(),\n rowExpandingFeature,\n expandedRowModel: createExpandedRowModel(),\n rowPaginationFeature,\n paginatedRowModel: createPaginatedRowModel(),\n rowSelectionFeature,\n columnVisibilityFeature,\n columnPinningFeature,\n columnSizingFeature,\n columnResizingFeature,\n});\n\nexport type Features = typeof features;\n/** The TanStack Table instance behind a DataTable — the escape hatch of `useDataTable()`. */\nexport type DataTableInstance<T extends RowData> = ReactTable<Features, T>;\nexport type TableRow<T extends RowData> = Row<Features, T>;\nexport type TableCell<T extends RowData> = Cell<Features, T, unknown>;\nexport type TableColumn<T extends RowData> = Column<Features, T, unknown>;\n\n/** What a cell holds for sorting, filtering and the CSV export: a string, a number, a date, a boolean, or nothing. */\nexport type CellValue = string | number | boolean | Date | null | undefined;\n\n/** A filter's value: a text (`text`), the values chosen (`facet`), or bounds, either open (`range`). */\nexport type FilterValue = string | readonly string[] | readonly [number | null, number | null];\n/** The filters in force, by column id. */\nexport type DataTableFilterState = Readonly<Record<string, FilterValue>>;\n\nconst empty = (value: CellValue) => value == null || value === \"\";\n\n/** The order of two values: empty last, dates and numbers by value, text in natural order (\"item 2\" < \"item 10\"). */\nexport function compareValues(a: CellValue, b: CellValue): number {\n if (empty(a)) return empty(b) ? 0 : 1;\n if (empty(b)) return -1;\n if (a instanceof Date || b instanceof Date) return Number(a) - Number(b);\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: \"base\" });\n}\n\nexport const sortByValue: SortFn<Features, never> = (a, b, id) => compareValues(a.getValue(id) as CellValue, b.getValue(id) as CellValue);\n\n/** A text filter: the value contains the words, case and accents ignored. */\nexport const filterText: FilterFn<Features, never> = (row, id, query: string) =>\n String(row.getValue(id) ?? \"\")\n .normalize(\"NFD\")\n .toLocaleLowerCase()\n .includes(query.normalize(\"NFD\").toLocaleLowerCase().trim());\nfilterText.autoRemove = (value: unknown) => typeof value !== \"string\" || value.trim() === \"\";\n\n/** A facet filter: the value is one of those chosen. */\nexport const filterFacet: FilterFn<Features, never> = (row, id, chosen: readonly string[]) => chosen.includes(String(row.getValue(id) ?? \"\"));\nfilterFacet.autoRemove = (value: unknown) => !Array.isArray(value) || value.length === 0;\n\n/** A range filter: the value (a number or a date) lies within the bounds, either of which may be open. */\nexport const filterRange: FilterFn<Features, never> = (row, id, [min, max]: readonly [number | null, number | null]) => {\n const raw = row.getValue(id) as CellValue;\n if (empty(raw)) return false;\n const value = Number(raw);\n return (min == null || value >= min) && (max == null || value <= max);\n};\nfilterRange.autoRemove = (value: unknown) => !Array.isArray(value) || (value[0] == null && value[1] == null);\n\nexport const FILTERS = { text: filterText, facet: filterFacet, range: filterRange } as const;\n\nexport type { ColumnDef, ColumnResizingState, RowData };\n"
292
618
  },
293
619
  {
294
620
  "path": "data-table/index.ts",
295
- "content": "/** @krizaka/ui/data-table — `DataTable` (sort, selection, loading / empty / error, cards on a phone, windowing), `dataTable` (the slots). Client (\"use client\"). */\nexport {\n DataTable,\n dataTable,\n type DataTableColumn,\n type DataTableLabels,\n dataTableLabels,\n type DataTableProps,\n type DataTableSort,\n type SortDirection,\n} from \"./data-table\";\n"
621
+ "content": "/** @krizaka/ui/data-table — `DataTable` (TanStack Table v9: sort, filters and facets, groups, details, pages, selection, visibility, pinning, resizing, server mode, windowing, cards on a phone), its toolbar (`DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`), `useDataTable` (the TanStack instance), `dataTableCsv`, `dataTable` (the slots). Client (\"use client\"). */\nexport { dataTableCsv } from \"./csv\";\nexport {\n DataTable,\n dataTable,\n type DataTableColumn,\n type DataTableLabels,\n dataTableLabels,\n type DataTableProps,\n type DataTableQuery,\n type DataTableSort,\n type SortDirection,\n useDataTable,\n} from \"./data-table\";\nexport { type CellValue, compareValues, type DataTableFilterState, type DataTableInstance, type FilterValue } from \"./engine\";\nexport {\n DataTableColumnsMenu,\n type DataTableColumnsMenuProps,\n DataTableExport,\n type DataTableExportProps,\n DataTableFilters,\n type DataTableFiltersProps,\n} from \"./toolbar\";\n"
296
622
  },
297
623
  {
298
624
  "path": "data-table/meta.ts",
299
- "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Data table\",\n summary: \"A table of records — sort, selection, loading, empty and error states — that turns into cards on a phone and renders only the rows in view when there are thousands.\",\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.\",\n \"When people sort, select several rows for a bulk action, or scan a status column.\",\n \"With `virtualize` for a long list loaded at once (logs, 5,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: a grid of cards.\", use: \"card\" },\n ],\n bestPractices: [\n \"Give every column a `header` (it labels the value in a card) and the row's name a `card: \\\"title\\\"` column.\",\n \"Sort on the server (`manualSort` + `onSortChange`) when the table shows a page of a larger list.\",\n \"Put a `Pagination` in `footer` and a `FilterBar` above; keep the filters and the page in the address.\",\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.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"Reaches the sort buttons of the headers, the checkboxes and the links of the cells.\" },\n { keys: \"Space\", action: \"Toggles the focused checkbox, or sorts by the focused header.\" },\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 \"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: [\"pagination\", \"filter-bar\", \"checkbox\", \"empty-state\", \"skeleton\"],\n web: {\n imports: [\"DataTable\"],\n examples: [\n { name: \"jobs\", title: \"Jobs\", description: \"Sortable columns, a status badge, row selection for a bulk action.\" },\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: \"5,000 rows\", description: \"`virtualize`: only the rows in view are rendered, the header stays.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
625
+ "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"
626
+ },
627
+ {
628
+ "path": "data-table/toolbar.tsx",
629
+ "content": "// The toolbar of a DataTable — its filters (a FilterBar built from the columns), the columns menu and the CSV export —\n// connected to the table they sit in through `useDataTable()`. Words come from the table's `labels`.\nimport * as React from \"react\";\n\nimport { Button } from \"../button\";\nimport { DropdownMenu } from \"../dropdown-menu\";\nimport { Input } from \"../field\";\nimport { type ActiveFilter, FilterBar, FilterMenu } from \"../filter-bar\";\nimport { ChevronGlyph } from \"../glyphs\";\nimport { SearchField } from \"../search-field\";\nimport { dataTableCsv } from \"./csv\";\nimport { type DataTableColumn, useDataTable } from \"./data-table\";\nimport type { FilterValue } from \"./engine\";\n\nexport type DataTableFiltersProps = {\n /** The search's wait after the last key, in ms, before the filter applies (a request, in server mode). Default 250. */\n debounce?: number;\n /** More controls in the bar (a date range picker, a toggle). */\n children?: React.ReactNode;\n /** Classes merged on the bar. */\n className?: string;\n};\n\nconst asRange = (value: FilterValue | undefined): [number | null, number | null] =>\n Array.isArray(value) && value.length === 2 && (typeof value[0] !== \"string\" || typeof value[1] !== \"string\") ? [value[0] as number | null, value[1] as number | null] : [null, null];\nconst asList = (value: FilterValue | undefined): readonly string[] => (Array.isArray(value) ? (value as readonly string[]) : []);\nconst number = (text: string) => (text.trim() === \"\" || Number.isNaN(Number(text)) ? null : Number(text));\n\n/**\n * The filters of the table, from its columns: a search per `text` column, a menu of values (with their counts) per\n * `facet` column, two bounds per `range` column; the filters in force as removable chips, \"clear all\", and the count\n * of results, announced.\n */\nexport function DataTableFilters({ debounce = 250, children, className }: DataTableFiltersProps) {\n const { table, columns, labels, filters, setFilters, resultCount, manualFiltering } = useDataTable();\n const set = (id: string, value: FilterValue | null) => {\n const next = { ...filters };\n if (value === null || value === \"\" || (Array.isArray(value) && (value.length === 0 || (value.length === 2 && value[0] == null && value[1] == null)))) delete next[id];\n else next[id] = value;\n setFilters(next);\n };\n const filterable = columns.filter((column) => column.filter);\n const optionsOf = (column: DataTableColumn<object>) => {\n if (column.options) return column.options;\n // The values found in the rows, with their counts — the other filters applied (TanStack's facets).\n const facets = manualFiltering ? new Map<unknown, number>() : (table.getColumn(column.id)?.getFacetedUniqueValues() ?? new Map<unknown, number>());\n return [...facets.entries()]\n .filter(([value]) => value != null && value !== \"\")\n .map(([value, count]) => ({ value: String(value), label: String(value), count }))\n .sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));\n };\n const active: ActiveFilter[] = filterable.flatMap((column) => {\n const value = filters[column.id];\n if (value === undefined) return [];\n let text: string;\n if (column.filter === \"facet\") {\n const options = optionsOf(column);\n text = asList(value).map((v) => options.find((o) => o.value === v)?.label ?? v).join(\", \");\n } else if (column.filter === \"range\") {\n const [min, max] = asRange(value);\n text = `${min ?? \"…\"}–${max ?? \"…\"}`;\n } else text = String(value);\n const label = labels.activeFilter(column.header, text);\n return [{ id: column.id, label, onRemove: () => set(column.id, null) }];\n });\n return (\n <FilterBar\n label={labels.filters}\n active={active}\n onClearAll={() => setFilters({})}\n clearLabel={labels.clearFilters}\n removeLabel={labels.removeFilter}\n results={labels.results(resultCount)}\n className={className}\n >\n {filterable.map((column) => {\n if (column.filter === \"text\")\n return (\n <SearchField\n key={column.id}\n label={labels.search(column.header)}\n placeholder={labels.search(column.header)}\n value={typeof filters[column.id] === \"string\" ? undefined : \"\"}\n defaultValue={typeof filters[column.id] === \"string\" ? (filters[column.id] as string) : undefined}\n debounce={debounce}\n onSearch={(query) => set(column.id, query)}\n landmark={false}\n size=\"sm\"\n className=\"min-w-48 flex-1\"\n />\n );\n if (column.filter === \"facet\")\n return (\n <FilterMenu key={column.id} label={column.header} options={optionsOf(column)} value={asList(filters[column.id])} onValueChange={(value) => set(column.id, value)} />\n );\n const [min, max] = asRange(filters[column.id]);\n const bounds = manualFiltering ? undefined : table.getColumn(column.id)?.getFacetedMinMaxValues();\n return (\n <span key={column.id} className=\"inline-flex items-center gap-1.5\">\n <Input\n type=\"number\"\n inputMode=\"decimal\"\n aria-label={labels.rangeFrom(column.header)}\n placeholder={bounds ? String(bounds[0]) : labels.rangeFrom(column.header)}\n value={min ?? \"\"}\n onChange={(event) => set(column.id, [number(event.currentTarget.value), max])}\n className=\"h-11 w-28\"\n />\n <span aria-hidden className=\"text-fg-secondary\">\n –\n </span>\n <Input\n type=\"number\"\n inputMode=\"decimal\"\n aria-label={labels.rangeTo(column.header)}\n placeholder={bounds ? String(bounds[1]) : labels.rangeTo(column.header)}\n value={max ?? \"\"}\n onChange={(event) => set(column.id, [min, number(event.currentTarget.value)])}\n className=\"h-11 w-28\"\n />\n </span>\n );\n })}\n {children}\n </FilterBar>\n );\n}\n\nexport type DataTableColumnsMenuProps = {\n /** Opens the menu at first (a story, a test). */\n defaultOpen?: boolean;\n /** Classes merged on the button. */\n className?: string;\n};\n\n/** A menu of the `hideable` columns, each a checkbox: the person chooses what the table shows. */\nexport function DataTableColumnsMenu({ defaultOpen, className }: DataTableColumnsMenuProps) {\n const { table, columns, labels } = useDataTable();\n return (\n <DropdownMenu.Root defaultOpen={defaultOpen} modal={false}>\n <DropdownMenu.Trigger asChild>\n <Button variant=\"outline\" size=\"sm\" className={className}>\n {labels.columns}\n <ChevronGlyph className=\"h-4 w-4\" />\n </Button>\n </DropdownMenu.Trigger>\n <DropdownMenu.Content align=\"end\" aria-label={labels.columns}>\n {columns\n .filter((column) => column.hideable !== false)\n .map((column) => {\n const instance = table.getColumn(column.id);\n return (\n <DropdownMenu.CheckboxItem\n key={column.id}\n checked={instance?.getIsVisible() ?? true}\n onSelect={(event) => event.preventDefault()}\n onCheckedChange={(checked) => instance?.toggleVisibility(Boolean(checked))}\n >\n {column.header}\n </DropdownMenu.CheckboxItem>\n );\n })}\n </DropdownMenu.Content>\n </DropdownMenu.Root>\n );\n}\n\nexport type DataTableExportProps = {\n /** The file's name. Default `\"export.csv\"`. */\n filename?: string;\n /** Receives the CSV instead of downloading it (send it to the server, a test). */\n onExport?: (csv: string) => void;\n /** Classes merged on the button. */\n className?: string;\n};\n\n/** Downloads what the table shows — the filtered and sorted rows of every page, the visible columns — as CSV. */\nexport function DataTableExport({ filename = \"export.csv\", onExport, className }: DataTableExportProps) {\n const { table, columns, labels, exportRows } = useDataTable();\n const run = () => {\n const visible = columns.filter((column) => table.getColumn(column.id)?.getIsVisible() !== false);\n const csv = dataTableCsv(exportRows(), visible);\n if (onExport) return onExport(csv);\n const url = URL.createObjectURL(new Blob([\"\", csv], { type: \"text/csv;charset=utf-8\" }));\n const link = Object.assign(document.createElement(\"a\"), { href: url, download: filename });\n link.click();\n setTimeout(() => URL.revokeObjectURL(url), 0);\n };\n return (\n <Button variant=\"outline\" size=\"sm\" className={className} onClick={run}>\n {labels.export}\n </Button>\n );\n}\n"
300
630
  },
301
631
  {
302
632
  "path": "glyphs.tsx",
@@ -308,14 +638,22 @@
308
638
  },
309
639
  {
310
640
  "path": "meta.ts",
311
- "content": "// The documentation of a component, carried by its code: each primitive has a `meta.ts` beside its source\n// (`src/<name>/meta.ts`; a component that exists only in React Native: `src/native/meta/<name>.ts`). Never built into\n// the package: `scripts/build-registry.mjs` reads them into `registry/<name>.json`, which krizaka.com renders as\n// /docs/ui/<name>, and `scripts/registry.test.mjs` fails when one is missing or incomplete.\n//\n// Why a typed module and not JSDoc tags: the fields are lists and links (`related`, `whenNotToUse[].use`, examples),\n// which a type checks and a comment does not; the file is plain data (erasable TypeScript), so Node reads it without a\n// compiler, and an editor completes it.\n\n/** Every documented component: the web primitives (one entry of the package each) and the native-only ones. */\nexport type ComponentName =\n | \"achievement\"\n | \"action-bar\"\n | \"alert\"\n | \"app-shell\"\n | \"argument-card\"\n | \"audio-player\"\n | \"auth-layout\"\n | \"avatar\"\n | \"badge\"\n | \"button\"\n | \"card\"\n | \"card-grid\"\n | \"catalogue-page\"\n | \"changelog-page\"\n | \"checkbox\"\n | \"chip\"\n | \"chip-link\"\n | \"choice-grid\"\n | \"cn\"\n | \"code-block\"\n | \"combobox\"\n | \"command\"\n | \"component-page\"\n | \"confirm-button\"\n | \"contact-page\"\n | \"continue-prompt\"\n | \"countdown\"\n | \"cta-panel\"\n | \"dashboard-layout\"\n | \"data-table\"\n | \"date-picker\"\n | \"decision-list\"\n | \"detail-layout\"\n | \"dialog\"\n | \"docs-article-page\"\n | \"docs-hub-page\"\n | \"docs-layout\"\n | \"dropdown-menu\"\n | \"empty-state\"\n | \"error-page\"\n | \"field\"\n | \"file-upload\"\n | \"filter-bar\"\n | \"gallery-page\"\n | \"glow-card\"\n | \"goal-gauge\"\n | \"how-it-works-page\"\n | \"image-cropper\"\n | \"iso\"\n | \"kbd\"\n | \"landing-page\"\n | \"landscape\"\n | \"legal-page\"\n | \"link-card\"\n | \"loader\"\n | \"longform-page\"\n | \"marketing-layout\"\n | \"marquee\"\n | \"mascot-quote\"\n | \"mascot-state\"\n | \"mascots\"\n | \"media-capture\"\n | \"meter\"\n | \"nav-bar\"\n | \"number-ticker\"\n | \"page\"\n | \"page-header\"\n | \"page-hero\"\n | \"pagination\"\n | \"pointer-light\"\n | \"popover\"\n | \"pricing-page\"\n | \"product-page\"\n | \"profile-card\"\n | \"progress\"\n | \"promise-card\"\n | \"radio-group\"\n | \"scroll-stage\"\n | \"search-field\"\n | \"section-backdrop\"\n | \"segmented\"\n | \"separator\"\n | \"settings-layout\"\n | \"skeleton\"\n | \"slider\"\n | \"slot\"\n | \"spinner\"\n | \"split-view\"\n | \"stage-banner\"\n | \"stat\"\n | \"status-page\"\n | \"stepper\"\n | \"switch\"\n | \"tabs\"\n | \"tag-input\"\n | \"terminal-replay\"\n | \"theme\"\n | \"toast\"\n | \"toc\"\n | \"tooltip\"\n | \"txt\"\n | \"video-player\"\n | \"video-trimmer\";\n\n/** Where a component exists: `@krizaka/ui/<name>` (web), `@krizaka/ui/native` (React Native), or both. */\nexport type Platform = \"web\" | \"native\" | \"both\";\n\n/** `stable`: its API only changes in a major. `beta`: it may still change in a minor. */\nexport type Status = \"stable\" | \"beta\";\n\n/**\n * The group of the catalogue. `media`: video, sound, camera, files, cropping. `blocks`: the page blocks — the frame,\n * the hero, sections, cards that lead or argue, decisions, code, calls to act — that every page of a site is made of.\n * `layouts`: the shells of whole screens — an application, a dashboard, settings, sign-in, an object, a list and its\n * item, a site's page, a documentation page — each with its screens. `templates`: the page templates — whole pages of a\n * site (a landing, a product, a docs hub, a long read, a changelog, pricing, status, an error…), each a distinct\n * composition of the blocks, words and content as props.\n */\nexport type Category = \"actions\" | \"forms\" | \"navigation\" | \"overlays\" | \"feedback\" | \"data-display\" | \"media\" | \"blocks\" | \"layouts\" | \"templates\" | \"layout\" | \"foundations\";\n\n/**\n * A named example: the file `registry/examples/<component>/<name>.tsx` (web) or\n * `registry/examples/<component>/native/<name>.tsx` (React Native) — at most 40 lines, imported by the stories and\n * shown (live, with its code) by the documentation.\n */\nexport interface Example {\n /** The file name, kebab-case (`primary`, `icon-button`). */\n readonly name: string;\n /** A short title (`Primary`, `Icon button`). */\n readonly title: string;\n /** One or two sentences: when this variant is the right one. */\n readonly description: string;\n /**\n * A template: a whole screen of a product composed from the platform (a layout and its blocks, real words, its phone\n * version thought out), copied as a starting point — at most 160 lines, listed on krizaka.com/docs/ui/templates.\n */\n readonly template?: boolean;\n}\n\n/** One platform's side of a component. */\nexport interface PlatformDocs {\n /** The names to import (`[\"Button\", \"IconButton\"]`): from `@krizaka/ui/<name>` on the web, `@krizaka/ui/native` in React Native. */\n readonly imports: readonly string[];\n /** The examples, in reading order; the first one is the component's preview. */\n readonly examples: readonly Example[];\n}\n\n/** The React Native side: the same concept, and what differs from the web API. */\nexport interface NativeDocs extends PlatformDocs {\n /**\n * The entry to import from when it is not `@krizaka/ui/native`: a component that needs an Expo module (an optional\n * peer) has its own, `@krizaka/ui/native/<name>`, so an app that does not use it never installs the module.\n */\n readonly entry?: string;\n /** What differs from the web API (`label` instead of children…); empty for a native-only component. */\n readonly differences: readonly string[];\n}\n\nexport interface KeyboardInteraction {\n /** The keys, as written on a keyboard: `Space`, `Enter`, `Arrow keys`, `Esc`, `Tab`. */\n readonly keys: string;\n /** What they do. */\n readonly action: string;\n}\n\nexport interface ComponentMeta {\n /** The display name: `Button`, `Dropdown menu`. */\n readonly title: string;\n /** One sentence: what it is. */\n readonly summary: string;\n readonly status: Status;\n readonly category: Category;\n /** Must match the sides below: `both` ⇔ `web` and `native`. */\n readonly platforms: Platform;\n /** When to reach for it: concrete situations, one per item. */\n readonly whenToUse: readonly string[];\n /** When not to — and what to use instead (`use`), when another component is the answer. */\n readonly whenNotToUse: readonly { readonly when: string; readonly use?: ComponentName }[];\n /** How to use it well: wording, placement, composition. */\n readonly bestPractices: readonly string[];\n readonly accessibility: {\n /** The keyboard, when it does more than Tab and Enter. */\n readonly keyboard: readonly KeyboardInteraction[];\n /** Roles, names and announcements: what the component does, what the app must provide. */\n readonly notes: readonly string[];\n };\n /** The components a reader of this page should know about. */\n readonly related: readonly ComponentName[];\n readonly web?: PlatformDocs;\n readonly native?: NativeDocs;\n}\n"
641
+ "content": "// The documentation of a component, carried by its code: each primitive has a `meta.ts` beside its source\n// (`src/<name>/meta.ts`; a component that exists only in React Native: `src/native/meta/<name>.ts`). Never built into\n// the package: `scripts/build-registry.mjs` reads them into `registry/<name>.json`, which krizaka.com renders as\n// /docs/ui/<name>, and `scripts/registry.test.mjs` fails when one is missing or incomplete.\n//\n// Why a typed module and not JSDoc tags: the fields are lists and links (`related`, `whenNotToUse[].use`, examples),\n// which a type checks and a comment does not; the file is plain data (erasable TypeScript), so Node reads it without a\n// compiler, and an editor completes it.\n\n/** Every documented component: the web primitives (one entry of the package each) and the native-only ones. */\nexport type ComponentName =\n | \"achievement\"\n | \"action-bar\"\n | \"alert\"\n | \"app-shell\"\n | \"argument-card\"\n | \"audio-player\"\n | \"auth-layout\"\n | \"avatar\"\n | \"badge\"\n | \"button\"\n | \"card\"\n | \"card-grid\"\n | \"catalogue-page\"\n | \"changelog-page\"\n | \"checkbox\"\n | \"chip\"\n | \"chip-link\"\n | \"choice-grid\"\n | \"cn\"\n | \"code-block\"\n | \"combobox\"\n | \"command\"\n | \"component-page\"\n | \"confirm-button\"\n | \"contact-page\"\n | \"continue-prompt\"\n | \"countdown\"\n | \"cta-panel\"\n | \"dashboard-layout\"\n | \"data-table\"\n | \"date-picker\"\n | \"decision-list\"\n | \"detail-layout\"\n | \"dialog\"\n | \"docs-article-page\"\n | \"docs-hub-page\"\n | \"docs-layout\"\n | \"dropdown-menu\"\n | \"empty-state\"\n | \"error-page\"\n | \"field\"\n | \"file-upload\"\n | \"filter-bar\"\n | \"form\"\n | \"gallery-page\"\n | \"glow-card\"\n | \"goal-gauge\"\n | \"how-it-works-page\"\n | \"image-cropper\"\n | \"iso\"\n | \"kbd\"\n | \"landing-page\"\n | \"landscape\"\n | \"legal-page\"\n | \"link-card\"\n | \"list\"\n | \"loader\"\n | \"longform-page\"\n | \"marketing-layout\"\n | \"marquee\"\n | \"mascot-quote\"\n | \"mascot-state\"\n | \"mascots\"\n | \"media-capture\"\n | \"meter\"\n | \"nav-bar\"\n | \"number-ticker\"\n | \"page\"\n | \"page-header\"\n | \"page-hero\"\n | \"pagination\"\n | \"pointer-light\"\n | \"popover\"\n | \"pricing-page\"\n | \"product-page\"\n | \"profile-card\"\n | \"progress\"\n | \"promise-card\"\n | \"radio-group\"\n | \"scroll-stage\"\n | \"search-field\"\n | \"section-backdrop\"\n | \"segmented\"\n | \"separator\"\n | \"settings-layout\"\n | \"skeleton\"\n | \"slider\"\n | \"slot\"\n | \"spinner\"\n | \"split-view\"\n | \"stage-banner\"\n | \"stat\"\n | \"status-page\"\n | \"stepper\"\n | \"switch\"\n | \"tabs\"\n | \"tag-input\"\n | \"terminal-replay\"\n | \"theme\"\n | \"toast\"\n | \"toc\"\n | \"tooltip\"\n | \"txt\"\n | \"video-player\"\n | \"video-trimmer\"\n | \"virtual\";\n\n/** Where a component exists: `@krizaka/ui/<name>` (web), `@krizaka/ui/native` (React Native), or both. */\nexport type Platform = \"web\" | \"native\" | \"both\";\n\n/** `stable`: its API only changes in a major. `beta`: it may still change in a minor. */\nexport type Status = \"stable\" | \"beta\";\n\n/**\n * The group of the catalogue. `media`: video, sound, camera, files, cropping. `blocks`: the page blocks — the frame,\n * the hero, sections, cards that lead or argue, decisions, code, calls to act — that every page of a site is made of.\n * `layouts`: the shells of whole screens — an application, a dashboard, settings, sign-in, an object, a list and its\n * item, a site's page, a documentation page — each with its screens. `templates`: the page templates — whole pages of a\n * site (a landing, a product, a docs hub, a long read, a changelog, pricing, status, an error…), each a distinct\n * composition of the blocks, words and content as props.\n */\nexport type Category = \"actions\" | \"forms\" | \"navigation\" | \"overlays\" | \"feedback\" | \"data-display\" | \"media\" | \"blocks\" | \"layouts\" | \"templates\" | \"layout\" | \"foundations\";\n\n/**\n * A named example: the file `registry/examples/<component>/<name>.tsx` (web) or\n * `registry/examples/<component>/native/<name>.tsx` (React Native) — at most 40 lines, imported by the stories and\n * shown (live, with its code) by the documentation.\n */\nexport interface Example {\n /** The file name, kebab-case (`primary`, `icon-button`). */\n readonly name: string;\n /** A short title (`Primary`, `Icon button`). */\n readonly title: string;\n /** One or two sentences: when this variant is the right one. */\n readonly description: string;\n /**\n * A template: a whole screen of a product composed from the platform (a layout and its blocks, real words, its phone\n * version thought out), copied as a starting point — at most 160 lines, listed on krizaka.com/docs/ui/templates.\n */\n readonly template?: boolean;\n}\n\n/** One platform's side of a component. */\nexport interface PlatformDocs {\n /** The names to import (`[\"Button\", \"IconButton\"]`): from `@krizaka/ui/<name>` on the web, `@krizaka/ui/native` in React Native. */\n readonly imports: readonly string[];\n /** The examples, in reading order; the first one is the component's preview. */\n readonly examples: readonly Example[];\n}\n\n/** The React Native side: the same concept, and what differs from the web API. */\nexport interface NativeDocs extends PlatformDocs {\n /**\n * The entry to import from when it is not `@krizaka/ui/native`: a component that needs an Expo module (an optional\n * peer) has its own, `@krizaka/ui/native/<name>`, so an app that does not use it never installs the module.\n */\n readonly entry?: string;\n /** What differs from the web API (`label` instead of children…); empty for a native-only component. */\n readonly differences: readonly string[];\n}\n\nexport interface KeyboardInteraction {\n /** The keys, as written on a keyboard: `Space`, `Enter`, `Arrow keys`, `Esc`, `Tab`. */\n readonly keys: string;\n /** What they do. */\n readonly action: string;\n}\n\nexport interface ComponentMeta {\n /** The display name: `Button`, `Dropdown menu`. */\n readonly title: string;\n /** One sentence: what it is. */\n readonly summary: string;\n readonly status: Status;\n readonly category: Category;\n /** Must match the sides below: `both` ⇔ `web` and `native`. */\n readonly platforms: Platform;\n /** When to reach for it: concrete situations, one per item. */\n readonly whenToUse: readonly string[];\n /** When not to — and what to use instead (`use`), when another component is the answer. */\n readonly whenNotToUse: readonly { readonly when: string; readonly use?: ComponentName }[];\n /** How to use it well: wording, placement, composition. */\n readonly bestPractices: readonly string[];\n readonly accessibility: {\n /** The keyboard, when it does more than Tab and Enter. */\n readonly keyboard: readonly KeyboardInteraction[];\n /** Roles, names and announcements: what the component does, what the app must provide. */\n readonly notes: readonly string[];\n };\n /** The components a reader of this page should know about. */\n readonly related: readonly ComponentName[];\n readonly web?: PlatformDocs;\n readonly native?: NativeDocs;\n}\n"
312
642
  }
313
643
  ],
314
644
  "dependencies": [
645
+ "@tanstack/react-table@9.2.8",
315
646
  "tailwind-variants@^3.3.1"
316
647
  ],
317
648
  "registryDependencies": [
318
- "checkbox"
649
+ "button",
650
+ "checkbox",
651
+ "dropdown-menu",
652
+ "field",
653
+ "filter-bar",
654
+ "pagination",
655
+ "search-field",
656
+ "virtual"
319
657
  ],
320
658
  "demo": {
321
659
  "path": "examples/data-table/jobs.tsx",
@@ -324,7 +662,7 @@
324
662
  "props": [
325
663
  {
326
664
  "component": "DataTable",
327
- "description": "A table of records: sort, selection, loading / empty / error, cards on a phone, windowing for long lists.",
665
+ "description": "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.",
328
666
  "props": [
329
667
  {
330
668
  "name": "caption",
@@ -337,21 +675,21 @@
337
675
  "name": "columns",
338
676
  "type": "readonly DataTableColumn<T>[]",
339
677
  "default": null,
340
- "description": "The columns, in order.",
678
+ "description": "The columns, in order (stable: module scope or `useMemo`).",
341
679
  "required": true
342
680
  },
343
681
  {
344
682
  "name": "getRowId",
345
683
  "type": "(row: T) => string",
346
684
  "default": null,
347
- "description": "A stable id per row: the React key and the selection's value.",
685
+ "description": "A stable id per row: the React key, the selection's value, the expansion's key.",
348
686
  "required": true
349
687
  },
350
688
  {
351
689
  "name": "rows",
352
690
  "type": "readonly T[]",
353
691
  "default": null,
354
- "description": "The rows, in the order the server gave (sorted here unless `manualSort`).",
692
+ "description": "The rows (stable: module scope, state or a query's data). Sorted, filtered and paginated here unless `manual`.",
355
693
  "required": true
356
694
  },
357
695
  {
@@ -361,6 +699,55 @@
361
699
  "description": "Classes merged on the root.",
362
700
  "required": false
363
701
  },
702
+ {
703
+ "name": "columnVisibility",
704
+ "type": "Readonly<Record<string, boolean>>",
705
+ "default": null,
706
+ "description": "Visible (`true`) or hidden (`false`) columns, by id (controlled); a column not listed is visible.",
707
+ "required": false
708
+ },
709
+ {
710
+ "name": "defaultColumnVisibility",
711
+ "type": "Readonly<Record<string, boolean>>",
712
+ "default": "{}",
713
+ "description": "The visibility at first, uncontrolled.",
714
+ "required": false
715
+ },
716
+ {
717
+ "name": "defaultExpanded",
718
+ "type": "Readonly<Record<string, boolean>>",
719
+ "default": "{}",
720
+ "description": "The opened rows at first, uncontrolled.",
721
+ "required": false
722
+ },
723
+ {
724
+ "name": "defaultFilters",
725
+ "type": "Readonly<Record<string, FilterValue>>",
726
+ "default": "{}",
727
+ "description": "The filters at first, uncontrolled.",
728
+ "required": false
729
+ },
730
+ {
731
+ "name": "defaultGroupBy",
732
+ "type": "readonly string[]",
733
+ "default": "[]",
734
+ "description": "The grouping at first, uncontrolled.",
735
+ "required": false
736
+ },
737
+ {
738
+ "name": "defaultPage",
739
+ "type": "number",
740
+ "default": "1",
741
+ "description": "The page at first, uncontrolled. Default 1.",
742
+ "required": false
743
+ },
744
+ {
745
+ "name": "defaultPageSize",
746
+ "type": "number",
747
+ "default": null,
748
+ "description": "Rows per page at first, uncontrolled: turns on the pagination under the table.",
749
+ "required": false
750
+ },
364
751
  {
365
752
  "name": "defaultSelected",
366
753
  "type": "readonly string[]",
@@ -396,11 +783,39 @@
396
783
  "description": "A failure, shown in place of the rows (an `Alert`, a retry button); announced.",
397
784
  "required": false
398
785
  },
786
+ {
787
+ "name": "expanded",
788
+ "type": "Readonly<Record<string, boolean>>",
789
+ "default": null,
790
+ "description": "The opened rows and groups (controlled), by id.",
791
+ "required": false
792
+ },
793
+ {
794
+ "name": "filters",
795
+ "type": "Readonly<Record<string, FilterValue>>",
796
+ "default": null,
797
+ "description": "The filters in force (controlled), by column id — what `DataTableFilters` edits.",
798
+ "required": false
799
+ },
399
800
  {
400
801
  "name": "footer",
401
802
  "type": "ReactNode",
402
803
  "default": null,
403
- "description": "Under the table: a `Pagination`, a total.",
804
+ "description": "Under the table (after the pagination): a total, a note.",
805
+ "required": false
806
+ },
807
+ {
808
+ "name": "getSubRows",
809
+ "type": "((row: T) => readonly T[])",
810
+ "default": null,
811
+ "description": "The children of a row (a tree): each row with children gets a button that shows them.",
812
+ "required": false
813
+ },
814
+ {
815
+ "name": "groupBy",
816
+ "type": "readonly string[]",
817
+ "default": null,
818
+ "description": "The columns the rows are grouped by, in order (controlled) — each `groupable`.",
404
819
  "required": false
405
820
  },
406
821
  {
@@ -432,10 +847,52 @@
432
847
  "required": false
433
848
  },
434
849
  {
435
- "name": "manualSort",
436
- "type": "boolean",
850
+ "name": "manual",
851
+ "type": "{ sorting?: boolean; filtering?: boolean; pagination?: boolean | undefined; } | undefined",
852
+ "default": null,
853
+ "description": "The server does the work: the rows are shown as given (sorted, filtered, a single page) — fetch in `onStateChange`.",
854
+ "required": false
855
+ },
856
+ {
857
+ "name": "onColumnVisibilityChange",
858
+ "type": "((visibility: Record<string, boolean>) => void)",
859
+ "default": null,
860
+ "description": "Called with the new visibility (`DataTableColumnsMenu`).",
861
+ "required": false
862
+ },
863
+ {
864
+ "name": "onExpandedChange",
865
+ "type": "((expanded: Record<string, boolean>) => void)",
437
866
  "default": null,
438
- "description": "The server sorts (`onSortChange` fetches): the rows are shown as given.",
867
+ "description": "Called with the opened rows.",
868
+ "required": false
869
+ },
870
+ {
871
+ "name": "onFiltersChange",
872
+ "type": "((filters: Readonly<Record<string, FilterValue>>) => void)",
873
+ "default": null,
874
+ "description": "Called with the new filters.",
875
+ "required": false
876
+ },
877
+ {
878
+ "name": "onGroupByChange",
879
+ "type": "((groupBy: string[]) => void)",
880
+ "default": null,
881
+ "description": "Called with the new grouping.",
882
+ "required": false
883
+ },
884
+ {
885
+ "name": "onPageChange",
886
+ "type": "((page: number) => void)",
887
+ "default": null,
888
+ "description": "Called with the page asked for.",
889
+ "required": false
890
+ },
891
+ {
892
+ "name": "onPageSizeChange",
893
+ "type": "((pageSize: number) => void)",
894
+ "default": null,
895
+ "description": "Called with the size chosen in the pagination's select (`pageSizeOptions`).",
439
896
  "required": false
440
897
  },
441
898
  {
@@ -452,11 +909,67 @@
452
909
  "description": "Called when a header asks for a new sort.",
453
910
  "required": false
454
911
  },
912
+ {
913
+ "name": "onStateChange",
914
+ "type": "((query: DataTableQuery) => void)",
915
+ "default": null,
916
+ "description": "Called with the sort, the filters and the page after each change: the query key of a server table.",
917
+ "required": false
918
+ },
919
+ {
920
+ "name": "page",
921
+ "type": "number",
922
+ "default": null,
923
+ "description": "The page, from 1 (controlled).",
924
+ "required": false
925
+ },
926
+ {
927
+ "name": "pageSize",
928
+ "type": "number",
929
+ "default": null,
930
+ "description": "Rows per page (controlled): turns on the pagination under the table.",
931
+ "required": false
932
+ },
933
+ {
934
+ "name": "pageSizeOptions",
935
+ "type": "readonly number[]",
936
+ "default": null,
937
+ "description": "The sizes the pagination offers (`[10, 25, 50]`).",
938
+ "required": false
939
+ },
940
+ {
941
+ "name": "pinning",
942
+ "type": "{ start?: readonly string[]; end?: readonly string[]; } | undefined",
943
+ "default": null,
944
+ "description": "Columns that stay in view while the table scrolls sideways: at the start (a name) or the end (actions).",
945
+ "required": false
946
+ },
947
+ {
948
+ "name": "renderDetail",
949
+ "type": "((row: T) => ReactNode)",
950
+ "default": null,
951
+ "description": "A panel under an opened row (the details of a job): each row gets a button that opens it.",
952
+ "required": false
953
+ },
954
+ {
955
+ "name": "resizing",
956
+ "type": "boolean",
957
+ "default": null,
958
+ "description": "Columns can be resized (pointer drag, or the arrow keys on the focused handle).",
959
+ "required": false
960
+ },
961
+ {
962
+ "name": "rowCount",
963
+ "type": "number",
964
+ "default": null,
965
+ "description": "The number of rows on the server (`manual.pagination`): the page count and the results' count.",
966
+ "required": false
967
+ },
455
968
  {
456
969
  "name": "rowLabel",
457
970
  "type": "((row: T) => string)",
458
971
  "default": null,
459
- "description": "The name of a row in its checkbox's label (default: its id).",
972
+ "description": "The name of a row in its labels (checkbox, expand button). Default: its id.",
460
973
  "required": false
461
974
  },
462
975
  {
@@ -488,10 +1001,91 @@
488
1001
  "required": false
489
1002
  },
490
1003
  {
491
- "name": "virtualize",
492
- "type": "{ rowHeight: number; height: number; overscan?: number; }",
1004
+ "name": "toolbar",
1005
+ "type": "ReactNode",
1006
+ "default": null,
1007
+ "description": "Above the table, inside its frame: `DataTableFilters`, `DataTableColumnsMenu`, `DataTableExport`, actions.",
1008
+ "required": false
1009
+ },
1010
+ {
1011
+ "name": "virtualized",
1012
+ "type": "{ height: number; estimateRowHeight?: number; overscan?: number; } | undefined",
493
1013
  "default": null,
494
- "description": "Renders only the rows in view (fixed `rowHeight`, a scrolling body of `height` px). Forces the table layout.",
1014
+ "description": "Renders only the rows in view, in a scrolling body of `height` px; rows are measured (`estimateRowHeight` is a guess). Forces the table layout.",
1015
+ "required": false
1016
+ }
1017
+ ]
1018
+ },
1019
+ {
1020
+ "component": "DataTableColumnsMenu",
1021
+ "description": "A menu of the `hideable` columns, each a checkbox: the person chooses what the table shows.",
1022
+ "props": [
1023
+ {
1024
+ "name": "className",
1025
+ "type": "string",
1026
+ "default": null,
1027
+ "description": "Classes merged on the button.",
1028
+ "required": false
1029
+ },
1030
+ {
1031
+ "name": "defaultOpen",
1032
+ "type": "boolean",
1033
+ "default": null,
1034
+ "description": "Opens the menu at first (a story, a test).",
1035
+ "required": false
1036
+ }
1037
+ ]
1038
+ },
1039
+ {
1040
+ "component": "DataTableExport",
1041
+ "description": "Downloads what the table shows — the filtered and sorted rows of every page, the visible columns — as CSV.",
1042
+ "props": [
1043
+ {
1044
+ "name": "className",
1045
+ "type": "string",
1046
+ "default": null,
1047
+ "description": "Classes merged on the button.",
1048
+ "required": false
1049
+ },
1050
+ {
1051
+ "name": "filename",
1052
+ "type": "string",
1053
+ "default": "export.csv",
1054
+ "description": "The file's name. Default `\"export.csv\"`.",
1055
+ "required": false
1056
+ },
1057
+ {
1058
+ "name": "onExport",
1059
+ "type": "((csv: string) => void)",
1060
+ "default": null,
1061
+ "description": "Receives the CSV instead of downloading it (send it to the server, a test).",
1062
+ "required": false
1063
+ }
1064
+ ]
1065
+ },
1066
+ {
1067
+ "component": "DataTableFilters",
1068
+ "description": "The filters of the table, from its columns: a search per `text` column, a menu of values (with their counts) per\n`facet` column, two bounds per `range` column; the filters in force as removable chips, \"clear all\", and the count\nof results, announced.",
1069
+ "props": [
1070
+ {
1071
+ "name": "children",
1072
+ "type": "ReactNode",
1073
+ "default": null,
1074
+ "description": "More controls in the bar (a date range picker, a toggle).",
1075
+ "required": false
1076
+ },
1077
+ {
1078
+ "name": "className",
1079
+ "type": "string",
1080
+ "default": null,
1081
+ "description": "Classes merged on the bar.",
1082
+ "required": false
1083
+ },
1084
+ {
1085
+ "name": "debounce",
1086
+ "type": "number",
1087
+ "default": "250",
1088
+ "description": "The search's wait after the last key, in ms, before the filter applies (a request, in server mode). Default 250.",
495
1089
  "required": false
496
1090
  }
497
1091
  ]