@juwel-development/design-system 3.9.1 → 3.11.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 (79) hide show
  1. package/README.md +9 -298
  2. package/dist/design-system.js +1070 -605
  3. package/dist/index.css +1 -1
  4. package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +92 -0
  5. package/dist/types/Arrangement/ColumnLayout/ColumnLayoutCompositionError.d.ts +3 -0
  6. package/dist/types/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.d.ts +3 -0
  7. package/dist/types/Arrangement/FieldRow/FieldRow.d.ts +87 -0
  8. package/dist/types/Arrangement/FieldRow/FieldRowCompositionError.d.ts +3 -0
  9. package/dist/types/Arrangement/FieldRow/FieldRowConfigurationError.d.ts +3 -0
  10. package/dist/types/Arrangement/FieldRow/IControlEdge.d.ts +6 -0
  11. package/dist/types/Arrangement/FieldRow/alignControlEdges.d.ts +7 -0
  12. package/dist/types/Display/Box/Box.d.ts +41 -0
  13. package/dist/types/Display/Brandmark/Brandmark.d.ts +1 -1
  14. package/dist/types/Display/DefinitionList/DefinitionList.d.ts +59 -9
  15. package/dist/types/Display/DefinitionList/DefinitionListConfigurationError.d.ts +3 -0
  16. package/dist/types/Display/Figure/Figure.d.ts +2 -2
  17. package/dist/types/Display/Icon/Icon.d.ts +45 -0
  18. package/dist/types/Display/Table/Table.d.ts +118 -8
  19. package/dist/types/Display/Table/TableConfigurationError.d.ts +3 -0
  20. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
  21. package/dist/types/Display/Typography/H1/H1.d.ts +3 -2
  22. package/dist/types/Display/Typography/H2/H2.d.ts +3 -2
  23. package/dist/types/Display/Typography/H3/H3.d.ts +3 -2
  24. package/dist/types/Display/Typography/H4/H4.d.ts +3 -2
  25. package/dist/types/Display/Typography/H5/H5.d.ts +3 -2
  26. package/dist/types/Display/Typography/H6/H6.d.ts +3 -2
  27. package/dist/types/Display/Typography/Note/Note.d.ts +1 -1
  28. package/dist/types/Display/Typography/P/P.d.ts +3 -2
  29. package/dist/types/Display/Typography/Prose/Prose.d.ts +1 -1
  30. package/dist/types/Interaction/Button/Button.d.ts +25 -3
  31. package/dist/types/Interaction/Tabs/Tabs.d.ts +31 -6
  32. package/dist/types/Layout/Header/Header.d.ts +67 -8
  33. package/dist/types/Layout/ScrollContainer/ScrollContainer.d.ts +37 -0
  34. package/dist/types/Layout/Section/Section.d.ts +1 -1
  35. package/dist/types/Layout/Sidebar/Sidebar.d.ts +4 -0
  36. package/dist/types/Theme/Palette.d.ts +31 -7
  37. package/dist/types/index.d.ts +6 -0
  38. package/package.json +1 -1
  39. package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +249 -0
  40. package/src/Arrangement/ColumnLayout/ColumnLayoutCompositionError.ts +8 -0
  41. package/src/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.ts +6 -0
  42. package/src/Arrangement/FieldRow/FieldRow.tsx +313 -0
  43. package/src/Arrangement/FieldRow/FieldRowCompositionError.ts +6 -0
  44. package/src/Arrangement/FieldRow/FieldRowConfigurationError.ts +6 -0
  45. package/src/Arrangement/FieldRow/IControlEdge.ts +6 -0
  46. package/src/Arrangement/FieldRow/alignControlEdges.ts +35 -0
  47. package/src/Display/Box/Box.tsx +77 -0
  48. package/src/Display/Checklist/Checklist.tsx +1 -1
  49. package/src/Display/DefinitionList/DefinitionList.tsx +164 -22
  50. package/src/Display/DefinitionList/DefinitionListConfigurationError.ts +6 -0
  51. package/src/Display/Icon/Icon.tsx +113 -0
  52. package/src/Display/Table/Table.tsx +434 -44
  53. package/src/Display/Table/TableConfigurationError.ts +6 -0
  54. package/src/Display/Typography/H1/H1.tsx +3 -2
  55. package/src/Display/Typography/H2/H2.tsx +3 -2
  56. package/src/Display/Typography/H3/H3.tsx +3 -2
  57. package/src/Display/Typography/H4/H4.tsx +3 -2
  58. package/src/Display/Typography/H5/H5.tsx +3 -2
  59. package/src/Display/Typography/H6/H6.tsx +3 -2
  60. package/src/Display/Typography/P/P.tsx +3 -2
  61. package/src/Display/Typography/Prose/Prose.tsx +3 -3
  62. package/src/Interaction/Button/Button.tsx +47 -17
  63. package/src/Interaction/Input/Input.tsx +1 -1
  64. package/src/Interaction/MultiSelect/MultiSelect.tsx +1 -1
  65. package/src/Interaction/NumberInput/NumberInput.tsx +1 -1
  66. package/src/Interaction/Select/Select.tsx +1 -1
  67. package/src/Interaction/Tabs/Tabs.tsx +38 -11
  68. package/src/Interaction/TextArea/TextArea.tsx +1 -1
  69. package/src/Layout/Dialog/Dialog.tsx +4 -3
  70. package/src/Layout/Header/Header.tsx +139 -39
  71. package/src/Layout/PageHead/PageHead.tsx +6 -5
  72. package/src/Layout/ScrollContainer/ScrollContainer.tsx +149 -0
  73. package/src/Layout/Sidebar/Sidebar.tsx +8 -4
  74. package/src/Theme/Palette.ts +37 -9
  75. package/src/Theme/renderTokens.ts +101 -5
  76. package/src/index.ts +6 -0
  77. package/src/tokens.css +68 -4
  78. package/src/tokens.dark.css +66 -4
  79. package/src/tokens.light.css +64 -2
@@ -1,17 +1,33 @@
1
1
  import type { VariantProps } from 'class-variance-authority';
2
2
  import { cva } from 'class-variance-authority';
3
- import type { FunctionComponent, ReactNode } from 'react';
3
+ import {
4
+ type CSSProperties,
5
+ type FunctionComponent,
6
+ type KeyboardEvent,
7
+ type MouseEvent,
8
+ type ReactNode,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+ import type { Observable, Subject } from 'rxjs';
14
+ import { TableConfigurationError } from './TableConfigurationError';
4
15
 
5
16
  // Rules are the layout. One recipe styles the whole table from its wrapper, so the block reads as a
6
17
  // table from two rule weights and nothing else: no cell borders, no fill, no zebra, no hover. Colours
7
18
  // are semantic tokens re-pointed by `.dark`, so no selector carries a `dark:` class. The responsive
8
19
  // behaviour is keyed on the wrapper's `data-notes`, so a server renders the right markup with no
9
20
  // hydration and the mode is one attribute a stylesheet and a test can both read.
21
+ const DEFAULT_DENSITY = 'comfortable';
22
+
10
23
  const table = cva(
11
24
  [
12
- // With no note column the wrapper becomes a horizontally scrollable region (see Root); the scroll
13
- // sits here so the table keeps its table formatting context and the figures stay column-aligned.
14
- '[&:not([data-notes])]:overflow-x-auto',
25
+ // Residual horizontal overflow scrolls in every mode (#114); the scroll sits on the wrapper so
26
+ // the table keeps its table formatting context and the figures stay column-aligned. No vertical
27
+ // bound: a long table is bounded by the ScrollContainer a consumer puts around it.
28
+ 'overflow-x-auto',
29
+ // The wrapper takes the one focus ring when it is keyboard-reachable - docs/adr/0002.
30
+ 'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
15
31
  // The table: full width, collapsed borders so adjacent row rules meet as one line, text flush left.
16
32
  '[&>table]:w-full [&>table]:border-collapse [&>table]:text-left',
17
33
  // The required caption, rendered first, as the table's label in the tracked grotesk device.
@@ -20,34 +36,136 @@ const table = cva(
20
36
  // is promoted to the heavier `rule` colour, and that heavier line is what reads as a table not a list.
21
37
  '[&>table>*:nth-child(2)>tr:first-child]:border-rule',
22
38
  // notes="supplementary": the note column leaves the page for everyone, sighted or not, below 48rem.
23
- '[&[data-notes=supplementary]_[data-variant=note]]:max-md:hidden',
39
+ // Every narrow-viewport rule is off once an allocation is declared (#115): a declared comparison
40
+ // keeps all of its columns and tabular rows, and the residual overflow scrolls instead.
41
+ '[&[data-notes=supplementary]:not([data-columns])_[data-variant=note]]:max-md:hidden',
24
42
  // notes="content": the note is the content, so each row stacks into a single column below 48rem.
25
- '[&[data-notes=content]>table]:max-md:block',
26
- '[&[data-notes=content]_thead]:max-md:block [&[data-notes=content]_tbody]:max-md:block [&[data-notes=content]_tfoot]:max-md:block',
27
- '[&[data-notes=content]_tr]:max-md:block [&[data-notes=content]_td]:max-md:block [&[data-notes=content]_th]:max-md:block',
43
+ '[&[data-notes=content]:not([data-columns])>table]:max-md:block',
44
+ '[&[data-notes=content]:not([data-columns])_thead]:max-md:block [&[data-notes=content]:not([data-columns])_tbody]:max-md:block [&[data-notes=content]:not([data-columns])_tfoot]:max-md:block',
45
+ '[&[data-notes=content]:not([data-columns])_tr]:max-md:block [&[data-notes=content]:not([data-columns])_td]:max-md:block [&[data-notes=content]:not([data-columns])_th]:max-md:block',
46
+ // A declared allocation (#115) lays the table out as a grid whose tracks are the allocation, each
47
+ // row group and row a column subgrid of it, so every cell sits on boundaries no row's content can
48
+ // move and the browser's track algorithm does the floors and the redistribution. Rows stay boxes,
49
+ // so their rules keep painting; the caption spans the tracks. Child combinators keep a nested table out.
50
+ '[&[data-columns]>table]:grid [&[data-columns]>table]:grid-cols-[var(--table-columns)]',
51
+ '[&[data-columns]>table>caption]:col-span-full',
52
+ '[&[data-columns]>table>*:not(caption)]:col-span-full [&[data-columns]>table>*:not(caption)]:grid [&[data-columns]>table>*:not(caption)]:grid-cols-subgrid',
53
+ '[&[data-columns]>table>*>tr]:col-span-full [&[data-columns]>table>*>tr]:grid [&[data-columns]>table>*>tr]:grid-cols-subgrid',
54
+ // Text wraps inside its allocation, an unbroken run included, so it never contributes a width;
55
+ // a control that cannot wrap keeps its own width, which the allocation has to hold.
56
+ '[&[data-columns]>table>*>tr>td]:wrap-anywhere [&[data-columns]>table>*>tr>th]:wrap-anywhere',
57
+ // A table carrying a selection input anywhere gives every row's first cell the ordinary cell inset
58
+ // instead of the flush edge - head and foot included - so the selected row's marker bar (Row) has
59
+ // room inside the cell and no column shifts as the selection moves (#113). The inset is the
60
+ // density's, so a compact table keeps the same room it gives every other cell.
61
+ '[&:has([aria-selected])_tr>*:first-child]:pl-[var(--table-cell-padding-inline)]',
62
+ // A table with an interactive row makes ring room the way Tabs does: padding holds the wrapper's
63
+ // edge (and the scroll clip, when it is the region) off a focused row's outline, the negative
64
+ // margin hands the room back to the page, so the ring sits outside the row and never covers the
65
+ // marker at its leading edge. Written from the two ring tokens so it cannot drift from the ring.
66
+ '[&:has(tr[tabindex])]:p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
67
+ '[&:has(tr[tabindex])]:m-[calc(-1*(var(--focus-ring-width)+var(--focus-ring-offset)))]',
68
+ '[&:has(tr[tabindex])]:scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
28
69
  ].join(' '),
70
+ {
71
+ variants: {
72
+ // Density publishes the two cell insets the cells read, so head and body move together and the
73
+ // theme's comfortable or compact pair is the only source (docs/adr/0008, Amendments, #115).
74
+ density: {
75
+ comfortable:
76
+ '[--table-cell-padding-inline:var(--table-cell-inset-inline)] [--table-cell-padding-block:var(--table-cell-inset-block)]',
77
+ compact:
78
+ '[--table-cell-padding-inline:var(--table-cell-inset-inline-compact)] [--table-cell-padding-block:var(--table-cell-inset-block-compact)]',
79
+ },
80
+ },
81
+ defaultVariants: { density: DEFAULT_DENSITY },
82
+ },
83
+ );
84
+
85
+ // One hairline above every row, in `border`; Root promotes the first to `rule` and the last row takes
86
+ // no bottom rule, so the block stays open at the foot. Selection is a `foreground` marker bar on the
87
+ // first cell's pseudo-element, keyed on aria-selected as in Tabs; no fill, no hover. The ring is the
88
+ // shared one (docs/adr/0002), outside the row at the token offset so it never covers the marker.
89
+ const tableRow = cva(
90
+ [
91
+ 'border-t border-solid border-border',
92
+ '[&[aria-selected=true]>*:first-child]:relative',
93
+ '[&[aria-selected=true]>*:first-child]:before:absolute [&[aria-selected=true]>*:first-child]:before:inset-y-0 [&[aria-selected=true]>*:first-child]:before:left-0',
94
+ '[&[aria-selected=true]>*:first-child]:before:border-l-[length:var(--table-selection-marker-thickness)] [&[aria-selected=true]>*:first-child]:before:border-solid [&[aria-selected=true]>*:first-child]:before:border-foreground',
95
+ ].join(' '),
96
+ {
97
+ variants: {
98
+ interactive: {
99
+ true: 'cursor-pointer outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
100
+ false: '',
101
+ },
102
+ },
103
+ defaultVariants: { interactive: false },
104
+ },
29
105
  );
30
106
 
31
- // One hairline above every row, in `border`. Root promotes the first row's colour to `rule`; the last
32
- // row takes no bottom rule, so the block stays open at the foot - the difference from a closed list.
33
- const tableRow = cva('border-t border-solid border-border');
34
-
35
- // A value is serif with real tabular figures; a note is the muted grotesk. Both sit at the small role,
36
- // which carries the enforced 15px floor below which figures stop comparing column to column.
37
- const tableCell = cva('px-4 py-2 text-small first:pl-0 last:pr-0', {
38
- variants: {
39
- variant: {
40
- value: 'font-primary text-foreground tabular-nums',
41
- note: 'font-secondary text-muted',
107
+ // What a row body is not: a control with an operation of its own, or anything inside one. An
108
+ // activation that starts there is the control's, never the row's, so a consumer stops no propagation.
109
+ const NESTED_CONTROL_SELECTOR = [
110
+ 'a[href]',
111
+ 'button',
112
+ 'input',
113
+ 'select',
114
+ 'textarea',
115
+ 'summary',
116
+ 'label',
117
+ '[contenteditable]',
118
+ '[tabindex]',
119
+ '[role="button"]',
120
+ '[role="link"]',
121
+ '[role="checkbox"]',
122
+ '[role="radio"]',
123
+ '[role="switch"]',
124
+ '[role="menuitem"]',
125
+ '[role="menuitemcheckbox"]',
126
+ '[role="menuitemradio"]',
127
+ '[role="tab"]',
128
+ '[role="option"]',
129
+ '[role="treeitem"]',
130
+ '[role="combobox"]',
131
+ '[role="textbox"]',
132
+ '[role="searchbox"]',
133
+ '[role="slider"]',
134
+ '[role="spinbutton"]',
135
+ ].join(', ');
136
+
137
+ const isFromNestedControl = (
138
+ row: HTMLTableRowElement,
139
+ target: EventTarget,
140
+ ): boolean => {
141
+ if (!(target instanceof Element)) {
142
+ return false;
143
+ }
144
+ const control = target.closest(NESTED_CONTROL_SELECTOR);
145
+ return control !== null && control !== row && row.contains(control);
146
+ };
147
+
148
+ const ACTIVATION_KEYS: ReadonlySet<string> = new Set(['Enter', ' ']);
149
+
150
+ // A value is the body face with real tabular figures; a note is the muted secondary face. Both sit
151
+ // at the small role, which carries the enforced 15px floor below which figures stop comparing.
152
+ const tableCell = cva(
153
+ 'px-[var(--table-cell-padding-inline)] py-[var(--table-cell-padding-block)] text-small first:pl-0 last:pr-0',
154
+ {
155
+ variants: {
156
+ variant: {
157
+ value: 'font-body text-foreground tabular-nums',
158
+ note: 'font-secondary text-muted',
159
+ },
160
+ align: { left: 'text-left', right: 'text-right', center: 'text-center' },
42
161
  },
43
- align: { left: 'text-left', right: 'text-right', center: 'text-center' },
162
+ defaultVariants: { variant: 'value', align: 'left' },
44
163
  },
45
- defaultVariants: { variant: 'value', align: 'left' },
46
- });
164
+ );
47
165
 
48
166
  // The label: the tracked muted grotesk, at the label role. Carried by both scopes (column and row).
49
167
  const tableHeaderCell = cva(
50
- 'px-4 py-2 font-secondary font-medium text-label text-muted tracking-label first:pl-0 last:pr-0',
168
+ 'px-[var(--table-cell-padding-inline)] py-[var(--table-cell-padding-block)] font-secondary font-medium text-label text-muted tracking-label first:pl-0 last:pr-0',
51
169
  {
52
170
  variants: {
53
171
  align: { left: 'text-left', right: 'text-right', center: 'text-center' },
@@ -56,54 +174,186 @@ const tableHeaderCell = cva(
56
174
  },
57
175
  );
58
176
 
59
- interface ITableRootProps {
177
+ /** The width roles a column may take, as its fixed width or as a floor. Each names a column job the
178
+ * consumer specification attests - the subject's name, a short comparison fact, a tabular figure
179
+ * with its unit, one action control - and is read from the theme as `--table-column-<role>`. */
180
+ type TableColumnWidthRole = 'name' | 'fact' | 'figure' | 'action';
181
+
182
+ /**
183
+ * One column's allocation of a table's width, independent of the rows currently displayed: either
184
+ * fixed at a named width role, or a positive share of the width the fixed columns leave, with an
185
+ * optional named minimum as its floor. Declared once on `Table.Root`, in cell order.
186
+ */
187
+ export type TableColumnAllocation =
188
+ | {
189
+ /** The role whose width this column takes exactly, at any available width. */
190
+ readonly width: TableColumnWidthRole;
191
+ readonly weight?: never;
192
+ /** A floor: a `width` smaller than its minimum resolves to the minimum. */
193
+ readonly minWidth?: TableColumnWidthRole;
194
+ }
195
+ | {
196
+ /** This column's share of the width the fixed columns leave, relative to its siblings' weights:
197
+ * a positive finite number. `3` beside `1` is three quarters and one quarter of it. */
198
+ readonly weight: number;
199
+ readonly width?: never;
200
+ /** The least this column takes. While a minimum holds a column, the other proportional columns
201
+ * share what is left; with none, the share may shrink to nothing. */
202
+ readonly minWidth?: TableColumnWidthRole;
203
+ };
204
+
205
+ // React's CSSProperties is closed over known properties; the one custom property the recipe reads
206
+ // is declared here so the style object stays typed without an assertion.
207
+ type TableRootStyle = CSSProperties & { '--table-columns'?: string };
208
+
209
+ const widthOf = (role: TableColumnWidthRole): string =>
210
+ `var(--table-column-${role})`;
211
+
212
+ // One grid track per definition: a fixed role, floored by max() when it has a minimum so it holds at
213
+ // any width; a share as minmax(floor, weight fr), which is the browser's own floor-and-redistribute.
214
+ const trackOf = (column: TableColumnAllocation, index: number): string => {
215
+ const minimum =
216
+ column.minWidth === undefined ? undefined : widthOf(column.minWidth);
217
+ if (column.width !== undefined) {
218
+ const width = widthOf(column.width);
219
+ return minimum === undefined ? width : `max(${minimum}, ${width})`;
220
+ }
221
+ if (!Number.isFinite(column.weight) || column.weight <= 0) {
222
+ throw new TableConfigurationError(
223
+ `column ${index + 1} needs a positive finite weight (got ${column.weight})`,
224
+ );
225
+ }
226
+ return `minmax(${minimum ?? '0'}, ${column.weight}fr)`;
227
+ };
228
+
229
+ const trackListOf = (
230
+ columns: readonly TableColumnAllocation[] | undefined,
231
+ ): string | undefined =>
232
+ columns === undefined || columns.length === 0
233
+ ? undefined
234
+ : columns.map(trackOf).join(' ');
235
+
236
+ export interface ITableRootProps extends VariantProps<typeof table> {
60
237
  /** The table's accessible name. Rendered as the first child; always present. */
61
238
  caption: string;
62
239
  /** What the note column is. Governs narrow-viewport behaviour; omit when there is none. */
63
240
  notes?: 'supplementary' | 'content';
241
+ /** The columns' allocations, in the order the cells of every row are written. Omit it and widths
242
+ * follow content as before; declare it and every row shares one allocation the rows' content
243
+ * cannot move, and the narrow-viewport `notes` behaviour is replaced by residual scrolling. */
244
+ columns?: readonly TableColumnAllocation[];
64
245
  children?: ReactNode;
65
246
  testId?: string;
66
247
  }
67
248
 
68
- interface ITableSectionProps {
249
+ export interface ITableSectionProps {
69
250
  children?: ReactNode;
70
251
  }
71
252
 
72
- interface ITableRowProps {
253
+ export interface ITableRowProps {
73
254
  children?: ReactNode;
74
255
  testId?: string;
256
+ /** Emits once per activation of the row body - a click, or Enter or Space while the row has
257
+ * focus. Its presence is what makes the row interactive: a tab stop, a visible focus ring and a
258
+ * pointer cursor. Nested links and controls keep their own operations and never emit here.
259
+ * Activation changes nothing about the row; the consumer decides what the request means. */
260
+ onClick$?: Subject<void>;
261
+ /** The consumer's selection for this row. Rendered as `aria-selected` and the marker bar; omitted
262
+ * or not yet emitted means unselected. Selection is independent of `onClick$`: a selected row
263
+ * may be noninteractive, and an interactive row may be unselected. */
264
+ isSelected$?: Observable<boolean>;
75
265
  }
76
266
 
77
- interface ITableCellProps extends VariantProps<typeof tableCell> {
267
+ export interface ITableCellProps extends VariantProps<typeof tableCell> {
78
268
  children?: ReactNode;
79
269
  }
80
270
 
81
- interface ITableHeaderCellProps extends VariantProps<typeof tableHeaderCell> {
271
+ export interface ITableHeaderCellProps
272
+ extends VariantProps<typeof tableHeaderCell> {
82
273
  /** Explicit, never inferred from Head/Body position - inference would need render-time context. */
83
274
  scope: 'row' | 'col';
275
+ /** The order the column is *currently* displayed in, as accessibility metadata only. Omit it on a
276
+ * column that is not sortable; set it on the one ordered column. Changing it neither reorders rows
277
+ * nor triggers anything - the consumer owns the sort and composes the action in `children`. */
278
+ ariaSort?: 'none' | 'ascending' | 'descending' | 'other';
84
279
  children?: ReactNode;
85
280
  }
86
281
 
282
+ // With a note column the wrapper is a named group with a tab stop only while the table overflows it
283
+ // (WCAG 2.1.1): reachable until measured, so server markup is operable before hydration, and while
284
+ // it holds focus, since dropping tabindex from the focused element relocates it. The table and the
285
+ // caption are observed too: under an allocation the caption, spanning every track, is what grows.
286
+ const useHorizontalOverflow = (
287
+ wrapper: { current: HTMLElement | null },
288
+ table: { current: HTMLElement | null },
289
+ caption: { current: HTMLElement | null },
290
+ ): boolean => {
291
+ const [isOverflowing, setIsOverflowing] = useState(true);
292
+ useLayoutEffect(() => {
293
+ const element = wrapper.current;
294
+ if (element === null) return;
295
+ const measure = () =>
296
+ setIsOverflowing(
297
+ element.scrollWidth > element.clientWidth ||
298
+ element === document.activeElement,
299
+ );
300
+ measure();
301
+ const observer =
302
+ typeof ResizeObserver === 'undefined'
303
+ ? undefined
304
+ : new ResizeObserver(measure);
305
+ observer?.observe(element);
306
+ for (const observed of [table.current, caption.current]) {
307
+ if (observed !== null) observer?.observe(observed);
308
+ }
309
+ window.addEventListener('resize', measure);
310
+ element.addEventListener('blur', measure);
311
+ return () => {
312
+ observer?.disconnect();
313
+ window.removeEventListener('resize', measure);
314
+ element.removeEventListener('blur', measure);
315
+ };
316
+ }, [wrapper, table, caption]);
317
+ return isOverflowing;
318
+ };
319
+
87
320
  const TableRoot: FunctionComponent<ITableRootProps> = ({
88
321
  caption,
89
322
  notes,
323
+ columns,
324
+ density,
90
325
  children,
91
326
  testId,
92
327
  }) => {
328
+ const wrapper = useRef<HTMLDivElement>(null);
329
+ const tableElement = useRef<HTMLTableElement>(null);
330
+ const captionElement = useRef<HTMLTableCaptionElement>(null);
331
+ const trackList = trackListOf(columns);
332
+ const isOverflowing = useHorizontalOverflow(
333
+ wrapper,
334
+ tableElement,
335
+ captionElement,
336
+ );
337
+ const style: TableRootStyle | undefined =
338
+ trackList === undefined ? undefined : { '--table-columns': trackList };
339
+ const columnCount = trackList === undefined ? undefined : columns?.length;
93
340
  const content = (
94
- <table>
95
- <caption>{caption}</caption>
341
+ <table ref={tableElement} role={'table'}>
342
+ <caption ref={captionElement}>{caption}</caption>
96
343
  {children}
97
344
  </table>
98
345
  );
99
346
  // No note column: the wrapper is a labelled region (a named `section`) so the horizontally
100
347
  // scrolled table is keyboard-operable - WCAG 2.1.1 needs the scroll container itself focusable,
101
- // there being no focusable cell content to carry it. With a note column there is nothing to
102
- // scroll to, so the wrapper stays a plain grouping element.
348
+ // there being no focusable cell content to carry it. With a note column the wrapper is a plain
349
+ // grouping element that becomes a named, reachable group only while the table overflows it.
103
350
  if (notes === undefined) {
104
351
  return (
105
352
  <section
106
- className={table()}
353
+ className={table({ density })}
354
+ style={style}
355
+ data-columns={columnCount}
356
+ data-density={density ?? DEFAULT_DENSITY}
107
357
  data-testid={testId}
108
358
  aria-label={caption}
109
359
  // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be keyboard-operable (WCAG 2.1.1)
@@ -114,36 +364,110 @@ const TableRoot: FunctionComponent<ITableRootProps> = ({
114
364
  );
115
365
  }
116
366
  return (
117
- <div className={table()} data-notes={notes} data-testid={testId}>
367
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and the `group` role are set together; biome cannot see the pair
368
+ <div
369
+ ref={wrapper}
370
+ className={table({ density })}
371
+ style={style}
372
+ data-notes={notes}
373
+ data-columns={columnCount}
374
+ data-density={density ?? DEFAULT_DENSITY}
375
+ data-testid={testId}
376
+ role={isOverflowing ? 'group' : undefined}
377
+ aria-label={isOverflowing ? caption : undefined}
378
+ tabIndex={isOverflowing ? 0 : undefined}
379
+ >
118
380
  {content}
119
381
  </div>
120
382
  );
121
383
  };
122
384
 
385
+ // Every member states its table role explicitly, so the semantics survive the display changes the
386
+ // recipe makes - the allocation's grid (#115) and the stacked `content` notes - in any engine. Chrome
387
+ // keeps the implicit roles under `display: grid` (measured through CDP); WebKit's native tree could
388
+ // not be read, so nothing depends on it. biome.json excepts this file from noRedundantRoles for it.
123
389
  const TableHead: FunctionComponent<ITableSectionProps> = ({ children }) => (
124
- <thead>{children}</thead>
390
+ <thead role={'rowgroup'}>{children}</thead>
125
391
  );
126
392
 
127
393
  const TableBody: FunctionComponent<ITableSectionProps> = ({ children }) => (
128
- <tbody>{children}</tbody>
394
+ <tbody role={'rowgroup'}>{children}</tbody>
129
395
  );
130
396
 
131
397
  const TableFooter: FunctionComponent<ITableSectionProps> = ({ children }) => (
132
- <tfoot>{children}</tfoot>
398
+ <tfoot role={'rowgroup'}>{children}</tfoot>
133
399
  );
134
400
 
135
- const TableRow: FunctionComponent<ITableRowProps> = ({ children, testId }) => (
136
- <tr className={tableRow()} data-testid={testId}>
137
- {children}
138
- </tr>
139
- );
401
+ const TableRow: FunctionComponent<ITableRowProps> = ({
402
+ children,
403
+ testId,
404
+ onClick$,
405
+ isSelected$,
406
+ }) => {
407
+ const [isSelected, setIsSelected] = useState(false);
408
+ // Reset, then follow: a replaced source reads unselected until it emits, and the subscription's
409
+ // teardown is the row's (docs/adr/0013). A layout effect so a replaying source paints in the same
410
+ // frame as the row, never a frame unselected first.
411
+ useLayoutEffect(() => {
412
+ setIsSelected(false);
413
+ const subscription = isSelected$?.subscribe((value) =>
414
+ setIsSelected(value),
415
+ );
416
+ return () => subscription?.unsubscribe();
417
+ }, [isSelected$]);
418
+
419
+ const requestByPointer = (event: MouseEvent<HTMLTableRowElement>): void => {
420
+ if (!isFromNestedControl(event.currentTarget, event.target)) {
421
+ onClick$?.next();
422
+ }
423
+ };
424
+
425
+ // Keys reach the row only when it is the focused element itself: a key pressed on a nested
426
+ // control bubbles here too, and that press is the control's. Space scrolls the page by default
427
+ // and a held key repeats; one press is one request.
428
+ const requestByKey = (event: KeyboardEvent<HTMLTableRowElement>): void => {
429
+ if (
430
+ event.target !== event.currentTarget ||
431
+ !ACTIVATION_KEYS.has(event.key)
432
+ ) {
433
+ return;
434
+ }
435
+ if (event.key === ' ') {
436
+ event.preventDefault();
437
+ }
438
+ if (!event.repeat) {
439
+ onClick$?.next();
440
+ }
441
+ };
442
+
443
+ const interactive = onClick$ !== undefined;
444
+ return (
445
+ <tr
446
+ role={'row'}
447
+ className={tableRow({ interactive })}
448
+ data-testid={testId}
449
+ tabIndex={interactive ? 0 : undefined}
450
+ aria-selected={isSelected$ === undefined ? undefined : isSelected}
451
+ onClick={interactive ? requestByPointer : undefined}
452
+ onKeyDown={interactive ? requestByKey : undefined}
453
+ >
454
+ {children}
455
+ </tr>
456
+ );
457
+ };
140
458
 
141
459
  const TableHeaderCell: FunctionComponent<ITableHeaderCellProps> = ({
142
460
  scope,
143
461
  align,
462
+ ariaSort,
144
463
  children,
145
464
  }) => (
146
- <th scope={scope} className={tableHeaderCell({ align })}>
465
+ <th
466
+ role={scope === 'row' ? 'rowheader' : 'columnheader'}
467
+ scope={scope}
468
+ aria-sort={ariaSort}
469
+ className={tableHeaderCell({ align })}
470
+ >
147
471
  {children}
148
472
  </th>
149
473
  );
@@ -154,6 +478,7 @@ const TableCell: FunctionComponent<ITableCellProps> = ({
154
478
  children,
155
479
  }) => (
156
480
  <td
481
+ role={'cell'}
157
482
  className={tableCell({ variant, align })}
158
483
  data-variant={variant ?? 'value'}
159
484
  >
@@ -172,9 +497,74 @@ const TableCell: FunctionComponent<ITableCellProps> = ({
172
497
  * - The block reads as a table from two rule weights alone: the heavier `rule` above the first row,
173
498
  * `border` hairlines between rows, and no bottom rule on the last. No cell borders, fill, zebra or hover.
174
499
  * - `Cell variant="value"` sets tabular figures; `variant="note"` does not. Both at the 15px small role.
175
- * - `HeaderCell` emits the `scope` it is given; none is inferred.
500
+ * - `HeaderCell` emits the `scope` it is given; none is inferred. It emits `ariaSort` the same way:
501
+ * the attribute states the displayed order and the component never orders, cycles or requests one.
502
+ * - Residual horizontal overflow scrolls in every `notes` mode, with no opt-in. The wrapper is
503
+ * keyboard-reachable while there is something to scroll - always, as a named region, with no note
504
+ * column; as a named group only once the table overflows, with one. No vertical bound is ever set:
505
+ * a long table sits inside a `ScrollContainer` with `axis="vertical"`, which owns that axis.
506
+ * - `Root columns` declares every column's allocation once, in cell order, and every row shares it:
507
+ * a fixed column takes its named width role at any available width; proportional columns share
508
+ * the width the fixed ones leave by their weights, each floored at its named minimum, and while a
509
+ * minimum holds one column the others share what is left. A fixed width below its minimum is the
510
+ * minimum. Nothing a row holds moves a boundary: filtering, sorting, paging, long or short content,
511
+ * an empty body and its repopulation all leave the allocation as it was. Only the definitions, the
512
+ * theme's `--table-column-*` values and the available width can. All-fixed columns do not stretch.
513
+ * - With `columns` declared, no narrow-viewport rule applies whatever `notes` says: the note column
514
+ * stays, rows stay tabular, and what does not fit scrolls in the wrapper as above. Text wraps inside
515
+ * its allocation, an unbroken run included; the table never truncates, hides or resizes content.
516
+ * Without `columns`, widths follow content and both `notes` behaviours are exactly as before.
517
+ * - `Root density` insets every header and body cell from one theme pair: `comfortable` (the
518
+ * default, the former spacing exactly) or `compact`, per table. It moves no type size and no
519
+ * control's own dimensions. A non-positive or non-finite `weight` throws
520
+ * {@link TableConfigurationError}, loud and early.
521
+ * - A `Row` given `onClick$` is interactive: a tab stop with the shared focus ring, activated by a
522
+ * click on its body or by Enter or Space while focused, emitting exactly once per activation. A
523
+ * nested link, button or other control - and anything inside one - performs its own operation and
524
+ * never activates the row. Without `onClick$` the row body is inert and adds no tab stop; nested
525
+ * controls stay operable. Activation never changes selection.
526
+ * - A `Row` given `isSelected$` carries `aria-selected` and, when true, the marker bar along its
527
+ * leading edge in `foreground` - a shape, not a colour, and not the focus ring. Omitted or not yet
528
+ * emitted reads unselected; a replaced source reads unselected until it emits; unmounting
529
+ * unsubscribes. The selection input is independent of `onClick$`, so a row may be selected and
530
+ * noninteractive, interactive and unselected, or both. Rendering and selection changes emit nothing.
531
+ * - The row stays a `tr` in a `table`: no grid role, no arrow-key navigation. `aria-selected` is a
532
+ * WAI-ARIA 1.2 state of `row` and valid here, but Chromium exposes a row's selected state only
533
+ * inside a `grid`, so Chrome and Edge screen readers do not announce it on these rows (accepted
534
+ * limitation, #113). The marker bar is the one guaranteed selection cue.
535
+ * - A table holding a selection input anywhere insets every row's first cell by the cell padding,
536
+ * head and foot included, so the marker has room and no column shifts as the selection moves. A
537
+ * table holding an interactive row makes ring room around itself, so a focused row's ring is never
538
+ * clipped by the scroll region. A table with neither keeps its static geometry exactly.
539
+ *
540
+ * @CallerMustEnsure — the component cannot see these and does not check them
541
+ * - Every row writes exactly as many cells as there are `columns`, in the same order. A row with
542
+ * fewer leaves tracks empty; one with more breaks onto a second line of its own row.
543
+ * - A cell whose content cannot wrap - a `Button`, an `Input`, an image - sits in a column whose
544
+ * fixed width or minimum holds it: the `action` role holds one standard control at comfortable
545
+ * density. The allocation never widens for content, so an under-allocated control overflows its
546
+ * cell rather than moving its neighbours.
547
+ * - A cell's `align` and `variant` are the consumer's as before; an allocation sets neither.
548
+ * - Each row's `onClick$` and `isSelected$` are tied to that row's stable identity, so a reordered or
549
+ * temporarily removed row keeps its association. Table holds no identity, registry or policy, and
550
+ * removing a row never asks the consumer to clear or replace its selection.
551
+ * - What an activation means - select, open, toggle - is the consumer's answer, given by rerendering
552
+ * from its own state. A request left unanswered leaves the rendered selection unchanged.
553
+ * - An interactive row announces no verb of its own; the caption, a row header or a nested link
554
+ * should make the row's purpose plain.
176
555
  *
177
556
  * @UXGuidelines
557
+ * - Allocate by job, not by measurement: the subject's `name` first, proportional with a minimum so it
558
+ * wraps rather than vanishes; comparison facts proportional at `fact`; figures fixed at `figure`,
559
+ * right-aligned; the action column fixed at `action`, last. A theme that re-points a role moves
560
+ * every table using it, which is the point of naming the role rather than the width.
561
+ * - `compact` is for a dense comparison the viewer scans, not for fitting more in: it changes air,
562
+ * not type, so a table that overflows at `comfortable` mostly still overflows at `compact`.
563
+ * - A sortable column is composed, not configured: a `Button variant="plain"` inside the `HeaderCell`
564
+ * carries the label and an `Icon` (`sort`, `sort-ascending`, `sort-descending`) that matches the
565
+ * order the consumer currently displays, and `ariaSort` on the same cell says so to assistive
566
+ * technology. Only the ordered column carries `ariaSort`; an action-only column carries no sort
567
+ * control. Until the consumer's data arrives, both icon and `ariaSort` keep stating the old order.
178
568
  * - `align` is a cell property but reads as a column one: set the same `align` on a `HeaderCell` and
179
569
  * every `Cell` beneath it, and keep them in sync - the component cannot align a column for you.
180
570
  * - Choose `notes` by what the note column *is*: `"supplementary"` drops it below 48rem for everyone
@@ -0,0 +1,6 @@
1
+ export class TableConfigurationError extends Error {
2
+ constructor(reason: string) {
3
+ super(`Table configuration is invalid: ${reason}`);
4
+ this.name = 'TableConfigurationError';
5
+ }
6
+ }
@@ -9,7 +9,7 @@ import type { FunctionComponent, ReactNode } from 'react';
9
9
  // semantic token re-pointed by `.dark`, so no variant carries a `dark:` class. The measure is base and
10
10
  // not a variant because the role fixes it and a caller picks nothing (docs/adr/0008, #87).
11
11
  const h1 = cva(
12
- 'font-primary text-display leading-display tracking-optical max-w-[var(--measure-display)]',
12
+ 'font-heading text-display leading-display tracking-optical max-w-[var(--measure-display)]',
13
13
  {
14
14
  variants: {
15
15
  color: {
@@ -35,7 +35,8 @@ interface IH1Props extends VariantProps<typeof h1> {
35
35
  *
36
36
  * @Guarantees — enforced on every render
37
37
  * - Renders an `h1`; its outline level and the display role are one choice, not two (docs/adr/0005).
38
- * - Reads `--font-primary`, sized by `--text-display`, led by `--leading-display` and optically
38
+ * - Reads `--font-heading`, the heading family that follows `--font-primary` until a theme
39
+ * re-points it (#120), sized by `--text-display`, led by `--leading-display` and optically
39
40
  * corrected by `--tracking-optical`, the large-type correction every role from title up carries.
40
41
  * - Bounded at `--measure-display`, the display role's own measure — narrower than the reading column
41
42
  * because bigger type wants fewer characters per line (docs/adr/0004). The bound is the recipe's,
@@ -7,7 +7,7 @@ import type { FunctionComponent, ReactNode } from 'react';
7
7
  // weight class. --tracking-optical is the large-type correction and the title role is where it starts
8
8
  // (#57), so an h2 and the page head's h1 - the same role - are tracked alike, and h3 down is not.
9
9
  // Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
10
- const h2 = cva('font-primary text-title leading-title tracking-optical', {
10
+ const h2 = cva('font-heading text-title leading-title tracking-optical', {
11
11
  variants: {
12
12
  color: {
13
13
  foreground: 'text-foreground',
@@ -31,7 +31,8 @@ interface IH2Props extends VariantProps<typeof h2> {
31
31
  *
32
32
  * @Guarantees — enforced on every render
33
33
  * - Renders an `h2`; its outline level and the title role are one choice, not two (docs/adr/0005).
34
- * - Reads `--font-primary`, sized by `--text-title`, led by `--leading-title` and optically corrected
34
+ * - Reads `--font-heading`, the heading family that follows `--font-primary` until a theme
35
+ * re-points it (#120), sized by `--text-title`, led by `--leading-title` and optically corrected
35
36
  * by `--tracking-optical` — the title role is the smallest role that carries it, so `H3` and below
36
37
  * take none.
37
38
  * - `color` selects `foreground`, `muted`, `success`, `warning`, `error` or `info`; nothing else