@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.
- package/README.md +9 -298
- package/dist/design-system.js +1070 -605
- package/dist/index.css +1 -1
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +92 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayoutCompositionError.d.ts +3 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/FieldRow.d.ts +87 -0
- package/dist/types/Arrangement/FieldRow/FieldRowCompositionError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/FieldRowConfigurationError.d.ts +3 -0
- package/dist/types/Arrangement/FieldRow/IControlEdge.d.ts +6 -0
- package/dist/types/Arrangement/FieldRow/alignControlEdges.d.ts +7 -0
- package/dist/types/Display/Box/Box.d.ts +41 -0
- package/dist/types/Display/Brandmark/Brandmark.d.ts +1 -1
- package/dist/types/Display/DefinitionList/DefinitionList.d.ts +59 -9
- package/dist/types/Display/DefinitionList/DefinitionListConfigurationError.d.ts +3 -0
- package/dist/types/Display/Figure/Figure.d.ts +2 -2
- package/dist/types/Display/Icon/Icon.d.ts +45 -0
- package/dist/types/Display/Table/Table.d.ts +118 -8
- package/dist/types/Display/Table/TableConfigurationError.d.ts +3 -0
- package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
- package/dist/types/Display/Typography/H1/H1.d.ts +3 -2
- package/dist/types/Display/Typography/H2/H2.d.ts +3 -2
- package/dist/types/Display/Typography/H3/H3.d.ts +3 -2
- package/dist/types/Display/Typography/H4/H4.d.ts +3 -2
- package/dist/types/Display/Typography/H5/H5.d.ts +3 -2
- package/dist/types/Display/Typography/H6/H6.d.ts +3 -2
- package/dist/types/Display/Typography/Note/Note.d.ts +1 -1
- package/dist/types/Display/Typography/P/P.d.ts +3 -2
- package/dist/types/Display/Typography/Prose/Prose.d.ts +1 -1
- package/dist/types/Interaction/Button/Button.d.ts +25 -3
- package/dist/types/Interaction/Tabs/Tabs.d.ts +31 -6
- package/dist/types/Layout/Header/Header.d.ts +67 -8
- package/dist/types/Layout/ScrollContainer/ScrollContainer.d.ts +37 -0
- package/dist/types/Layout/Section/Section.d.ts +1 -1
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +4 -0
- package/dist/types/Theme/Palette.d.ts +31 -7
- package/dist/types/index.d.ts +6 -0
- package/package.json +1 -1
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +249 -0
- package/src/Arrangement/ColumnLayout/ColumnLayoutCompositionError.ts +8 -0
- package/src/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.ts +6 -0
- package/src/Arrangement/FieldRow/FieldRow.tsx +313 -0
- package/src/Arrangement/FieldRow/FieldRowCompositionError.ts +6 -0
- package/src/Arrangement/FieldRow/FieldRowConfigurationError.ts +6 -0
- package/src/Arrangement/FieldRow/IControlEdge.ts +6 -0
- package/src/Arrangement/FieldRow/alignControlEdges.ts +35 -0
- package/src/Display/Box/Box.tsx +77 -0
- package/src/Display/Checklist/Checklist.tsx +1 -1
- package/src/Display/DefinitionList/DefinitionList.tsx +164 -22
- package/src/Display/DefinitionList/DefinitionListConfigurationError.ts +6 -0
- package/src/Display/Icon/Icon.tsx +113 -0
- package/src/Display/Table/Table.tsx +434 -44
- package/src/Display/Table/TableConfigurationError.ts +6 -0
- package/src/Display/Typography/H1/H1.tsx +3 -2
- package/src/Display/Typography/H2/H2.tsx +3 -2
- package/src/Display/Typography/H3/H3.tsx +3 -2
- package/src/Display/Typography/H4/H4.tsx +3 -2
- package/src/Display/Typography/H5/H5.tsx +3 -2
- package/src/Display/Typography/H6/H6.tsx +3 -2
- package/src/Display/Typography/P/P.tsx +3 -2
- package/src/Display/Typography/Prose/Prose.tsx +3 -3
- package/src/Interaction/Button/Button.tsx +47 -17
- package/src/Interaction/Input/Input.tsx +1 -1
- package/src/Interaction/MultiSelect/MultiSelect.tsx +1 -1
- package/src/Interaction/NumberInput/NumberInput.tsx +1 -1
- package/src/Interaction/Select/Select.tsx +1 -1
- package/src/Interaction/Tabs/Tabs.tsx +38 -11
- package/src/Interaction/TextArea/TextArea.tsx +1 -1
- package/src/Layout/Dialog/Dialog.tsx +4 -3
- package/src/Layout/Header/Header.tsx +139 -39
- package/src/Layout/PageHead/PageHead.tsx +6 -5
- package/src/Layout/ScrollContainer/ScrollContainer.tsx +149 -0
- package/src/Layout/Sidebar/Sidebar.tsx +8 -4
- package/src/Theme/Palette.ts +37 -9
- package/src/Theme/renderTokens.ts +101 -5
- package/src/index.ts +6 -0
- package/src/tokens.css +68 -4
- package/src/tokens.dark.css +66 -4
- 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
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
162
|
+
defaultVariants: { variant: 'value', align: 'left' },
|
|
44
163
|
},
|
|
45
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
102
|
-
//
|
|
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
|
-
|
|
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> = ({
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|