@eifi1/ui-kit 0.7.1 → 0.8.1

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 (222) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +3 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +3 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +17 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +54 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +28 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +6 -2
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +94 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +53 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +13 -2
  198. package/src/components/ui.tsx +270 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. package/src/wizard.ts +3 -2
@@ -20,7 +20,7 @@ import {
20
20
  } from "./data-table-filters";
21
21
  import type { ColumnFilter, FilterValue } from "./data-table-filters";
22
22
  import { nextSorts } from "./data-table-sort";
23
- import type { SortState } from "./data-table-sort";
23
+ import type { SortCycle, SortDir, SortState } from "./data-table-sort";
24
24
  import { FilterPopover } from "./data-table-filter-popover";
25
25
  import { Pagination } from "./data-table-pagination";
26
26
  import { SwipeableRow, type SwipeAction } from "./swipeable-row";
@@ -68,6 +68,14 @@ export interface DataTableColumn<T> {
68
68
  * see without reaching into React elements it deliberately treats as opaque.
69
69
  */
70
70
  noRowLink?: boolean;
71
+ /**
72
+ * The direction this column sorts in on its FIRST click. `"desc"` for money and
73
+ * count columns, where the rows worth reading are the large ones and an ascending
74
+ * first click opens on the zero rows (keksdose's aggregated report tables). The
75
+ * rest of the cycle follows from it — see {@link DataTableProps.sortCycle}.
76
+ * Default `"asc"`.
77
+ */
78
+ firstSort?: SortDir;
71
79
  }
72
80
 
73
81
  /** A column resize in flight. Held in a ref rather than in state: the live width is
@@ -306,8 +314,72 @@ export interface DataTableProps<T> {
306
314
  * "hide upcoming/scheduled" toggle).
307
315
  */
308
316
  leadingRow?: ReactNode;
317
+ /**
318
+ * Row height. `"compact"` is lenkbank's in-card tables: five `text-xs` tables with
319
+ * `px-2 py-1` cells sitting inside cards, which at the default padding came out at
320
+ * twice their height and pushed the card's own content below the fold — the whole
321
+ * reason they were still hand-rolled `<table>`s. It tightens the header, the cells,
322
+ * the selection checkboxes, the pager and the phone cards together, so a compact
323
+ * table is compact everywhere rather than a dense body under a roomy header.
324
+ *
325
+ * Defaults to `"comfortable"`, which is the table every caller has today.
326
+ */
327
+ density?: DataTableDensity;
328
+ /**
329
+ * What a repeated click on a sorted header does. `"tri"` (the default, and the only
330
+ * behaviour before 0.8.0): first direction → the other → unsorted. `"toggle"`: first
331
+ * direction ⇄ the other, never unsorted — for a report table that is always ranked
332
+ * by something. Each column's first direction is its own `firstSort`. `aria-sort`
333
+ * and the spoken "Sorted by …" follow whatever the click produced, so they are
334
+ * right under either cycle.
335
+ */
336
+ sortCycle?: SortCycle;
337
+ /**
338
+ * The desktop table's power-user chrome, as one switch. `"full"` (default) is the
339
+ * table as it has always been. `"minimal"` is a compact report table in a half-width
340
+ * card: no column-settings rail, no resize handles, no multi-sort (and so no
341
+ * "Shift-click to add a sort" tooltip) — the three pieces that cost width and
342
+ * attention and that a ten-row summary never needs. Each can still be set on its
343
+ * own below; an explicit boolean wins over the preset.
344
+ */
345
+ chrome?: DataTableChrome;
346
+ /**
347
+ * The vertical "Columns (n/m)" rail and its show/hide + auto-size panel. Default:
348
+ * on, unless `chrome="minimal"`. With it off, every column is shown and a hidden
349
+ * set persisted under `storageKey` is ignored — there would be no way to get a
350
+ * column back.
351
+ */
352
+ columnSettings?: boolean;
353
+ /**
354
+ * The drag handles on the header's trailing edges. Default: on, unless
355
+ * `chrome="minimal"`. With it off, widths persisted under `storageKey` are ignored
356
+ * for the same reason as above: nothing could reset them.
357
+ */
358
+ resizable?: boolean;
359
+ /**
360
+ * Shift-click adds a secondary sort criterion. Default: on, unless
361
+ * `chrome="minimal"`. With it off, a shift-click sorts like a plain click and the
362
+ * header drops the tooltip that advertises the gesture.
363
+ */
364
+ multiSort?: boolean;
365
+ /**
366
+ * The card around the table — border, surface, radius, shadow. Set false to sit the
367
+ * table inside the app's own card, under its own caption and CSV button, without a
368
+ * frame inside a frame. The table's layout is unchanged; only the chrome goes.
369
+ * Default true.
370
+ */
371
+ frame?: boolean;
372
+ /** Extra classes for the table's root element (the card, or with `frame={false}`
373
+ * the plain wrapper). Merged last, so a caller's margin or width wins. */
374
+ className?: string;
309
375
  }
310
376
 
377
+ /** See {@link DataTableProps.chrome}. */
378
+ export type DataTableChrome = "full" | "minimal";
379
+
380
+ /** See {@link DataTableProps.density}. */
381
+ export type DataTableDensity = "comfortable" | "compact";
382
+
311
383
  export type FilterState = Record<string, FilterValue>;
312
384
  export type { SortState };
313
385
 
@@ -389,6 +461,48 @@ function columnLabel<T>(col: DataTableColumn<T>): string {
389
461
  return typeof col.header === "string" ? col.header : col.key;
390
462
  }
391
463
 
464
+ /**
465
+ * What counts as a control of its own inside a clickable row: a click or key press
466
+ * that lands on one of these belongs to it, not to the row's `onRowClick`. The row's
467
+ * own link (`rowHref`, marked `data-row-link`) is the exception — it IS the row.
468
+ */
469
+ const OWN_CONTROL =
470
+ 'a[href],button,input,select,textarea,label,summary,[role="button"],[role="link"],[role="checkbox"],[role="switch"],[role="menuitem"],[role="option"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
471
+
472
+ /** Whether an event on `row` started on a control nested inside it. */
473
+ function fromOwnControl(target: EventTarget | null, row: HTMLElement): boolean {
474
+ if (!(target instanceof Element)) return false;
475
+ const hit = target.closest(OWN_CONTROL);
476
+ return !!hit && hit !== row && row.contains(hit) && !hit.hasAttribute("data-row-link");
477
+ }
478
+
479
+ /** Arrow/Home/End between the focusable rows of one `<tbody>` — the roving half of
480
+ * the keyboard-activatable rows (see the row's `onKeyDown`). */
481
+ function moveRowFocus(from: HTMLElement, key: string): boolean {
482
+ if (!(key in ROW_NAV_KEYS)) return false;
483
+ const body = from.parentElement;
484
+ if (!body) return false;
485
+ const rows = Array.from(body.children).filter(
486
+ (el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute("data-row-nav"),
487
+ );
488
+ const i = rows.indexOf(from);
489
+ const target =
490
+ key === "ArrowDown"
491
+ ? rows[i + 1]
492
+ : key === "ArrowUp"
493
+ ? rows[i - 1]
494
+ : key === "Home"
495
+ ? rows[0]
496
+ : key === "End"
497
+ ? rows[rows.length - 1]
498
+ : undefined;
499
+ // At either end the key is still consumed, or ArrowDown on the last row would
500
+ // scroll the page out from under a focused row.
501
+ target?.focus();
502
+ return true;
503
+ }
504
+ const ROW_NAV_KEYS = { ArrowDown: 1, ArrowUp: 1, Home: 1, End: 1 } as const;
505
+
392
506
  /** A click the APP owns. Anything else — middle, ⌘/Ctrl, Shift, Alt — belongs to
393
507
  * the browser, and the whole feature is not touching it. */
394
508
  const isPlainLeftClick = (e: React.MouseEvent) =>
@@ -501,7 +615,20 @@ export function DataTable<T>({
501
615
  mobileCard,
502
616
  mobileSwipeActions,
503
617
  leadingRow,
618
+ density = "comfortable",
619
+ sortCycle = "tri",
620
+ chrome = "full",
621
+ columnSettings: columnSettingsProp,
622
+ resizable: resizableProp,
623
+ multiSort: multiSortProp,
624
+ frame = true,
625
+ className,
504
626
  }: DataTableProps<T>) {
627
+ const compact = density === "compact";
628
+ const minimal = chrome === "minimal";
629
+ const columnSettings = columnSettingsProp ?? !minimal;
630
+ const resizable = resizableProp ?? !minimal;
631
+ const multiSort = multiSortProp ?? !minimal;
505
632
  const isServer = !!serverPagination;
506
633
  const isLoading = !!serverPagination?.isLoading;
507
634
  // prop > provider > English, through the table's own resolver rather than
@@ -538,9 +665,9 @@ export function DataTable<T>({
538
665
  setPage,
539
666
  pageSize,
540
667
  setPageSize,
541
- widths,
668
+ widths: storedWidths,
542
669
  setWidths,
543
- hiddenCols,
670
+ hiddenCols: storedHiddenCols,
544
671
  setHiddenCols,
545
672
  showSettings,
546
673
  setShowSettings,
@@ -555,6 +682,16 @@ export function DataTable<T>({
555
682
  filtersProp,
556
683
  });
557
684
 
685
+ // With the controls that change them switched off, persisted widths and hidden
686
+ // columns are ignored rather than applied: a table cannot be left in a state the
687
+ // user has no control to get out of. They stay in storage untouched, so turning
688
+ // the control back on brings them back.
689
+ const widths = useMemo(() => (resizable ? storedWidths : {}), [resizable, storedWidths]);
690
+ const hiddenCols = useMemo(
691
+ () => (columnSettings ? storedHiddenCols : new Set<string>()),
692
+ [columnSettings, storedHiddenCols],
693
+ );
694
+
558
695
  // Every call site builds `columns` inline — `columns={[{ key: "name", … }]}` right
559
696
  // there in the JSX — so the array is a new object on every render of the page around
560
697
  // the table, and a `useMemo` keyed on its identity memoises nothing. The three below
@@ -664,8 +801,14 @@ export function DataTable<T>({
664
801
  ? sorted
665
802
  : sorted.slice(safePage * effectivePageSize, safePage * effectivePageSize + effectivePageSize);
666
803
 
667
- const toggleSort = (key: string, additive: boolean) => {
668
- const next = nextSorts(sorts, key, additive);
804
+ const toggleSort = (key: string, shiftKey: boolean) => {
805
+ const col = columns.find((c) => c.key === key);
806
+ // The cycle is the table's, the first direction the column's. The announcement
807
+ // below reads the RESULT, so it can never describe a step the cycle did not take.
808
+ const next = nextSorts(sorts, key, multiSort && shiftKey, {
809
+ firstDir: col?.firstSort,
810
+ cycle: sortCycle,
811
+ });
669
812
  if (onSortsChange) onSortsChange(next);
670
813
  else setInternalSorts(next);
671
814
 
@@ -673,7 +816,6 @@ export function DataTable<T>({
673
816
  // criterion BEHIND the existing one, and answering "Sorted by Date" to a click on
674
817
  // Name misreports what the click did. The third click drops the column entirely,
675
818
  // which is the state a user is most likely to have reached by accident.
676
- const col = columns.find((c) => c.key === key);
677
819
  const name = col ? columnLabel(col) : key;
678
820
  const entry = next.find((s) => s.key === key);
679
821
  announce(
@@ -970,6 +1112,33 @@ export function DataTable<T>({
970
1112
  // header was clicked addresses a different row afterwards — and the range then ran
971
1113
  // from a row the user had never touched.
972
1114
  const selectionAnchor = useRef<string | number | null>(null);
1115
+ // ---- Keyboard-activatable rows ----
1116
+ //
1117
+ // A desktop row with `onRowClick` opened on a mouse click and on nothing else: no
1118
+ // tab stop, no Enter, no focus ring (keksdose's report tables hand-rolled all three,
1119
+ // which is half of why they could not move onto this table). The row now takes
1120
+ // focus itself, as a ROVING tab stop: one row of the table is in the tab order
1121
+ // (the last one focused, else the first), ArrowUp/ArrowDown/Home/End move between
1122
+ // rows, Enter and Space activate.
1123
+ //
1124
+ // Why the `<tr>` rather than the two alternatives:
1125
+ // * `role="button"` on the row would replace its `row` role, and the cells with
1126
+ // it — a screen reader would stop reading the table as a table.
1127
+ // * A button or link in one cell is what `rowHref` already is where the row HAS a
1128
+ // URL. Without one, a synthetic button would have to wrap caller-rendered cell
1129
+ // content, which nests the controls those cells carry (status toggles, links)
1130
+ // inside it — illegal markup, and the reason the mobile card is a div.
1131
+ // A focused `<tr>` keeps its role and reads out its cells. Roving rather than a tab
1132
+ // stop per row, so a 100-row page does not put 100 stops between the header and
1133
+ // the pager.
1134
+ const rowsFocusable = !!onRowClick;
1135
+ const [focusRowKey, setFocusRowKey] = useState<string | number | null>(null);
1136
+ const tabRowKey =
1137
+ focusRowKey !== null && slice.some((r) => rowKey(r) === focusRowKey)
1138
+ ? focusRowKey
1139
+ : slice.length
1140
+ ? rowKey(slice[0])
1141
+ : null;
973
1142
  /** The checkbox click that drew a range, so its `onChange` can be told apart. */
974
1143
  const rangeClick = useRef<Event | null>(null);
975
1144
  const canSelect = (row: T) => !!selection && (selection.isSelectable?.(row) ?? true);
@@ -1031,7 +1200,8 @@ export function DataTable<T>({
1031
1200
  const swipeEnabled = !!swipe && !expanded;
1032
1201
  const href = mobileCardLinkable ? rowHref?.(row) : undefined;
1033
1202
  const cardClass = cn(
1034
- "w-full px-4 py-3 text-start flex items-center gap-3",
1203
+ "w-full text-start flex items-center",
1204
+ compact ? "px-3 py-2 gap-2 text-sm" : "px-4 py-3 gap-3",
1035
1205
  // Live #320's other half: the card acknowledges the touch before the sheet
1036
1206
  // arrives. On a cold route the data can take a beat, and an unacknowledged tap
1037
1207
  // reads as "did that register?" — which is most of what "abrupt" means here.
@@ -1051,14 +1221,19 @@ export function DataTable<T>({
1051
1221
  <>
1052
1222
  {/* The card's own content keeps the column it always had; the chevron sits
1053
1223
  beside it rather than inside, so a caller's `mobileCard` is untouched. */}
1054
- <div className="flex min-w-0 flex-1 flex-col gap-2">
1224
+ <div className={cn("flex min-w-0 flex-1 flex-col", compact ? "gap-1" : "gap-2")}>
1055
1225
  {mobileCard ? (
1056
1226
  mobileCard(row)
1057
1227
  ) : (
1058
1228
  <>
1059
1229
  {mobilePrimaryCol && <div className="font-medium">{mobilePrimaryCol.cell(row)}</div>}
1060
1230
  {mobileSecondaryColumns.length > 0 && (
1061
- <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
1231
+ <dl
1232
+ className={cn(
1233
+ "grid grid-cols-[auto_1fr] gap-x-3",
1234
+ compact ? "gap-y-0.5 text-xs" : "gap-y-1 text-sm",
1235
+ )}
1236
+ >
1062
1237
  {mobileSecondaryColumns.map((col) => (
1063
1238
  <Fragment key={col.key}>
1064
1239
  <dt className="text-xs uppercase tracking-wide text-[var(--text-muted)] self-center">
@@ -1124,6 +1299,8 @@ export function DataTable<T>({
1124
1299
  onKeyDown={
1125
1300
  interactive
1126
1301
  ? (e) => {
1302
+ // A key press inside a nested control is that control's.
1303
+ if (e.target !== e.currentTarget) return;
1127
1304
  if (e.key === "Enter" || e.key === " ") {
1128
1305
  e.preventDefault();
1129
1306
  onRowClick!(row);
@@ -1147,7 +1324,12 @@ export function DataTable<T>({
1147
1324
  body
1148
1325
  )}
1149
1326
  {expansion && !mobileExpandAsDialog && (
1150
- <div className="px-4 py-3 bg-[var(--bg-surface-2)] border-t border-[var(--border)]">
1327
+ <div
1328
+ className={cn(
1329
+ "bg-[var(--bg-surface-2)] border-t border-[var(--border)]",
1330
+ compact ? "px-3 py-2" : "px-4 py-3",
1331
+ )}
1332
+ >
1151
1333
  {expansion}
1152
1334
  </div>
1153
1335
  )}
@@ -1168,8 +1350,23 @@ export function DataTable<T>({
1168
1350
  }, [])
1169
1351
  : null;
1170
1352
 
1353
+ // `frame={false}` swaps the Card for a bare wrapper carrying the same hooks and
1354
+ // layout classes, so everything inside renders identically either way.
1355
+ const Root = frame ? FramedRoot : PlainRoot;
1356
+
1171
1357
  return (
1172
- <Card flush className={cn("overflow-clip", fillHeight && "md:flex md:flex-1 md:flex-col md:min-h-0")}>
1358
+ <Root
1359
+ // A styling and testing hook: a host's own cell content can follow the table's
1360
+ // density (`group-data-[density=compact]/table:…`) without being told twice.
1361
+ data-density={density}
1362
+ data-frame={frame ? undefined : "none"}
1363
+ className={cn(
1364
+ "group/table",
1365
+ frame ? "overflow-clip" : "min-w-0",
1366
+ fillHeight && "md:flex md:flex-1 md:flex-col md:min-h-0",
1367
+ className,
1368
+ )}
1369
+ >
1173
1370
  {/* Outside both viewport branches on purpose. A screen reader subscribes to a
1174
1371
  live region when it encounters it, so one that appears together with its
1175
1372
  first message is usually missed — and crossing the md breakpoint swaps the
@@ -1214,7 +1411,12 @@ export function DataTable<T>({
1214
1411
  ))
1215
1412
  : mobileSlice.map(renderMobileRow)}
1216
1413
  {mobileSlice.length === 0 && (
1217
- <li className="px-4 py-6 text-center text-sm text-[var(--text-muted)]">
1414
+ <li
1415
+ className={cn(
1416
+ "text-center text-[var(--text-muted)]",
1417
+ compact ? "px-3 py-3 text-xs" : "px-4 py-6 text-sm",
1418
+ )}
1419
+ >
1218
1420
  {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? "—")}
1219
1421
  </li>
1220
1422
  )}
@@ -1241,6 +1443,7 @@ export function DataTable<T>({
1241
1443
  onPageSize={serverPagination!.onPageSizeChange}
1242
1444
  labels={labels}
1243
1445
  locale={locale}
1446
+ density={density}
1244
1447
  />
1245
1448
  )}
1246
1449
  {/* The row's own first column is this dialog's title — the same cell the card
@@ -1274,7 +1477,7 @@ export function DataTable<T>({
1274
1477
  the rows that are about to be replaced, and the dimmed body says the same
1275
1478
  to the eye. The pager stays live — see `ServerPagination.isLoading`. */}
1276
1479
  <table
1277
- className="w-full text-sm"
1480
+ className={cn("w-full", compact ? "text-xs" : "text-sm")}
1278
1481
  aria-label={labels.table}
1279
1482
  aria-busy={isLoading || undefined}
1280
1483
  >
@@ -1292,11 +1495,14 @@ export function DataTable<T>({
1292
1495
  {selection && (
1293
1496
  <th
1294
1497
  scope="col"
1295
- className="sticky top-0 z-10 w-10 bg-[var(--bg-surface-2)] px-3 py-2 align-middle backdrop-blur-sm"
1498
+ className={cn(
1499
+ "sticky top-0 z-10 bg-[var(--bg-surface-2)] align-middle backdrop-blur-sm",
1500
+ compact ? "w-8 px-2 py-1" : "w-10 px-3 py-2",
1501
+ )}
1296
1502
  >
1297
1503
  <input
1298
1504
  type="checkbox"
1299
- className="size-4 align-middle accent-indigo-600"
1505
+ className={cn("align-middle accent-indigo-600", compact ? "size-3.5" : "size-4")}
1300
1506
  checked={selection.allSelected}
1301
1507
  ref={(el) => {
1302
1508
  if (el) el.indeterminate = selection.someSelected && !selection.allSelected;
@@ -1350,7 +1556,8 @@ export function DataTable<T>({
1350
1556
  }
1351
1557
  style={width ? { width, minWidth: width, maxWidth: width } : undefined}
1352
1558
  className={cn(
1353
- "relative px-3 py-2 font-medium align-middle whitespace-nowrap",
1559
+ "relative font-medium align-middle whitespace-nowrap",
1560
+ compact ? "px-2 py-1" : "px-3 py-2",
1354
1561
  // Keep the header visible while the user scrolls the page.
1355
1562
  // Each <th> carries its own bg so the row doesn't render
1356
1563
  // transparent over the data rows underneath.
@@ -1364,7 +1571,7 @@ export function DataTable<T>({
1364
1571
  isEndAligned && "flex-row-reverse",
1365
1572
  )}
1366
1573
  >
1367
- <Tooltip label={sortable ? labels.sortHint : undefined} portal>
1574
+ <Tooltip label={sortable && multiSort ? labels.sortHint : undefined} portal>
1368
1575
  <button
1369
1576
  type="button"
1370
1577
  onClick={(e) => sortable && toggleSort(col.key, e.shiftKey)}
@@ -1423,6 +1630,7 @@ export function DataTable<T>({
1423
1630
  </Popover>
1424
1631
  )}
1425
1632
  </div>
1633
+ {resizable && (
1426
1634
  <span
1427
1635
  role="separator"
1428
1636
  aria-orientation="vertical"
@@ -1444,6 +1652,7 @@ export function DataTable<T>({
1444
1652
  // trailing edge, which is its left one in a right-to-left table.
1445
1653
  className="absolute end-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 rtl:translate-x-1/2 cursor-col-resize select-none touch-none hover:bg-[var(--brand-bg-hover)]"
1446
1654
  />
1655
+ )}
1447
1656
  </th>
1448
1657
  );
1449
1658
  })}
@@ -1464,18 +1673,55 @@ export function DataTable<T>({
1464
1673
  const expansion = expanded ? expandedRow?.(row) : null;
1465
1674
  const rowInteractive = !!onRowClick || !!selection;
1466
1675
  const href = rowHref?.(row);
1676
+ const key = rowKey(row);
1467
1677
  return (
1468
- <Fragment key={rowKey(row)}>
1678
+ <Fragment key={key}>
1469
1679
  <tr
1470
1680
  {...cleanAttrs(rowAttributes?.(row))}
1681
+ data-row-nav={rowsFocusable ? "" : undefined}
1682
+ tabIndex={rowsFocusable ? (key === tabRowKey ? 0 : -1) : undefined}
1683
+ aria-expanded={rowsFocusable && expandedRow ? expanded : undefined}
1684
+ onFocus={
1685
+ rowsFocusable
1686
+ ? (e) => {
1687
+ if (e.target === e.currentTarget) setFocusRowKey(key);
1688
+ }
1689
+ : undefined
1690
+ }
1691
+ onKeyDown={
1692
+ rowsFocusable
1693
+ ? (e) => {
1694
+ // Only the row's own key presses: Enter on a nested button
1695
+ // or link is that control's, and Space in a nested input
1696
+ // is a space.
1697
+ if (e.target !== e.currentTarget) return;
1698
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
1699
+ if (e.key === "Enter" || e.key === " ") {
1700
+ // Space would otherwise scroll the table.
1701
+ e.preventDefault();
1702
+ onRowClick!(row);
1703
+ return;
1704
+ }
1705
+ if (e.shiftKey) return;
1706
+ if (moveRowFocus(e.currentTarget, e.key)) e.preventDefault();
1707
+ }
1708
+ : undefined
1709
+ }
1471
1710
  className={cn(
1472
1711
  "border-t border-[var(--border)]",
1473
1712
  rowInteractive && "cursor-pointer hover:bg-[var(--bg-hover)]",
1713
+ rowsFocusable &&
1714
+ "focus-visible:bg-[var(--bg-hover)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--brand)]",
1474
1715
  rowClassName?.(row),
1475
1716
  )}
1476
1717
  onClick={
1477
1718
  rowInteractive
1478
1719
  ? (e) => {
1720
+ // A click on a control inside the row (a button, a link,
1721
+ // an input the cell renders) is that control's — it must
1722
+ // not also open the row. Cells no longer have to
1723
+ // `stopPropagation` to get that.
1724
+ if (fromOwnControl(e.target, e.currentTarget)) return;
1479
1725
  // Modifier-clicks anywhere in the row drive
1480
1726
  // selection instead of expanding it (feedback #289):
1481
1727
  // Ctrl/Cmd toggles a single row, Shift selects the
@@ -1507,7 +1753,7 @@ export function DataTable<T>({
1507
1753
  >
1508
1754
  {selection && (
1509
1755
  <td
1510
- className="w-10 px-3 py-2 align-top"
1756
+ className={cn("align-top", compact ? "w-8 px-2 py-1" : "w-10 px-3 py-2")}
1511
1757
  // Don't let selecting a row also trigger the row click
1512
1758
  // (which expands/edits it).
1513
1759
  onClick={(e) => e.stopPropagation()}
@@ -1515,7 +1761,7 @@ export function DataTable<T>({
1515
1761
  {canSelect(row) && (
1516
1762
  <input
1517
1763
  type="checkbox"
1518
- className="size-4 accent-indigo-600"
1764
+ className={cn("accent-indigo-600", compact ? "size-3.5" : "size-4")}
1519
1765
  checked={selection.isSelected(row)}
1520
1766
  // Shift+click selects the range from the last toggled
1521
1767
  // row. `preventDefault` puts the box back, but it does NOT
@@ -1555,7 +1801,8 @@ export function DataTable<T>({
1555
1801
  data-col={col.key}
1556
1802
  style={width ? { width, minWidth: width, maxWidth: width } : undefined}
1557
1803
  className={cn(
1558
- "px-3 py-2 align-top",
1804
+ "align-top",
1805
+ compact ? "px-2 py-1" : "px-3 py-2",
1559
1806
  width && "overflow-hidden text-ellipsis",
1560
1807
  logicalAlign(col.className),
1561
1808
  )}
@@ -1564,7 +1811,7 @@ export function DataTable<T>({
1564
1811
  // No `onActivate`: the click is left to bubble to the
1565
1812
  // <tr> handler above, which already owns expand-vs-select.
1566
1813
  // Calling it here as well would toggle the row twice.
1567
- <RowLink href={href} className="block">
1814
+ <RowLink href={href} className="block" data-row-link="">
1568
1815
  {col.cell(row)}
1569
1816
  </RowLink>
1570
1817
  ) : (
@@ -1588,7 +1835,9 @@ export function DataTable<T>({
1588
1835
  perturb the columns; the div then fills to the full row
1589
1836
  width only at paint time, and long text wraps within it. */}
1590
1837
  <td colSpan={totalColSpan} className="p-0">
1591
- <div className="w-0 min-w-full px-3 py-3">{expansion}</div>
1838
+ <div className={cn("w-0 min-w-full", compact ? "px-2 py-2" : "px-3 py-3")}>
1839
+ {expansion}
1840
+ </div>
1592
1841
  </td>
1593
1842
  </tr>
1594
1843
  )}
@@ -1599,7 +1848,7 @@ export function DataTable<T>({
1599
1848
  <tr>
1600
1849
  <td
1601
1850
  colSpan={totalColSpan}
1602
- className="px-3 py-4 text-center text-[var(--text-muted)]"
1851
+ className={cn("text-center text-[var(--text-muted)]", compact ? "px-2 py-2" : "px-3 py-4")}
1603
1852
  >
1604
1853
  {/* Nothing is not the same as not-yet: "no results" while the first
1605
1854
  page is still in flight is a claim the table cannot make. */}
@@ -1627,9 +1876,12 @@ export function DataTable<T>({
1627
1876
  }
1628
1877
  labels={labels}
1629
1878
  locale={locale}
1879
+ density={density}
1630
1880
  />
1631
1881
  )}
1632
1882
  </div>
1883
+ {columnSettings && (
1884
+ <>
1633
1885
  <Tooltip label={columnsCountLabel} portal>
1634
1886
  <button
1635
1887
  type="button"
@@ -1710,12 +1962,29 @@ export function DataTable<T>({
1710
1962
  </div>
1711
1963
  </div>
1712
1964
  </div>
1965
+ </>
1966
+ )}
1713
1967
  </div>
1714
1968
  )}
1969
+ </Root>
1970
+ );
1971
+ }
1972
+
1973
+ /** The framed root: the kit's flush Card, as every table had before `frame`. */
1974
+ function FramedRoot({ children, ...rest }: React.HTMLAttributes<HTMLDivElement>) {
1975
+ return (
1976
+ <Card flush {...rest}>
1977
+ {children}
1715
1978
  </Card>
1716
1979
  );
1717
1980
  }
1718
1981
 
1982
+ /** The unframed root (`frame={false}`): no border, surface, radius or shadow — the
1983
+ * host's own card supplies them. */
1984
+ function PlainRoot(props: React.HTMLAttributes<HTMLDivElement>) {
1985
+ return <div {...props} />;
1986
+ }
1987
+
1719
1988
  /** The loading row's content: a spinner beside the word, the word for everyone. */
1720
1989
  function LoadingText({ label }: { label: string }) {
1721
1990
  return (