@juwel-development/design-system 3.10.0 → 4.0.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 +16 -3
- package/dist/design-system.js +1038 -528
- package/dist/styles.css +2 -0
- package/dist/styles.dark.css +2 -0
- package/dist/styles.light.css +2 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +38 -18
- package/dist/types/Display/Callout/Callout.d.ts +31 -0
- package/dist/types/Display/Icon/Icon.d.ts +31 -16
- package/dist/types/Display/Meter/Meter.d.ts +5 -6
- package/dist/types/Display/Rule/Rule.d.ts +22 -0
- package/dist/types/Display/Table/Table.d.ts +57 -7
- package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
- package/dist/types/Display/Typography/H1/H1.d.ts +1 -1
- package/dist/types/Display/Typography/H2/H2.d.ts +1 -1
- package/dist/types/Display/Typography/H3/H3.d.ts +1 -1
- package/dist/types/Display/Typography/H4/H4.d.ts +1 -1
- package/dist/types/Display/Typography/H5/H5.d.ts +1 -1
- package/dist/types/Display/Typography/H6/H6.d.ts +1 -1
- package/dist/types/Display/Typography/Note/Note.d.ts +16 -3
- package/dist/types/Display/Typography/P/P.d.ts +15 -2
- package/dist/types/Display/Typography/Prose/Prose.d.ts +14 -6
- package/dist/types/Interaction/Button/Button.d.ts +13 -5
- package/dist/types/Interaction/Slider/Slider.d.ts +3 -0
- package/dist/types/Interaction/Stepper/Stepper.d.ts +59 -0
- package/dist/types/Interaction/Stepper/StepperCompositionError.d.ts +3 -0
- package/dist/types/Interaction/Switch/Switch.d.ts +20 -0
- package/dist/types/Interaction/Tabs/Tabs.d.ts +24 -3
- package/dist/types/Layout/Dialog/Dialog.d.ts +11 -3
- package/dist/types/Layout/Form/FormConfigurationError.d.ts +3 -0
- package/dist/types/Layout/Pane/Pane.d.ts +55 -0
- package/dist/types/Layout/Shell/Shell.d.ts +39 -0
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +16 -2
- package/dist/types/Theme/Palette.d.ts +11 -4
- package/dist/types/index.d.ts +21 -1
- package/package.json +18 -8
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +86 -28
- package/src/Display/Callout/Callout.tsx +64 -0
- package/src/Display/Icon/Icon.tsx +111 -21
- package/src/Display/Meter/Meter.tsx +12 -13
- package/src/Display/Rule/Rule.tsx +31 -0
- package/src/Display/Table/Table.tsx +189 -24
- package/src/Display/Typography/Note/Note.tsx +27 -11
- package/src/Display/Typography/P/P.tsx +29 -4
- package/src/Display/Typography/Prose/Prose.tsx +44 -19
- package/src/Interaction/Button/Button.tsx +14 -5
- package/src/Interaction/Slider/Slider.tsx +3 -5
- package/src/Interaction/Stepper/Stepper.tsx +307 -0
- package/src/Interaction/Stepper/StepperCompositionError.ts +6 -0
- package/src/Interaction/Switch/Switch.tsx +64 -0
- package/src/Interaction/Tabs/Tabs.tsx +44 -10
- package/src/Layout/Dialog/Dialog.tsx +19 -6
- package/src/Layout/Form/Form.tsx +2 -1
- package/src/Layout/Form/FormConfigurationError.ts +6 -0
- package/src/Layout/Pane/Pane.tsx +159 -0
- package/src/Layout/Shell/Shell.tsx +124 -0
- package/src/Layout/Sidebar/Sidebar.tsx +31 -7
- package/src/Theme/Palette.ts +17 -6
- package/src/Theme/renderTokens.ts +61 -12
- package/src/index.ts +21 -2
- package/src/styles.css +5 -6
- package/src/styles.dark.css +5 -6
- package/src/styles.light.css +5 -6
- package/src/tokens.css +54 -9
- package/src/tokens.dark.css +51 -8
- package/src/tokens.light.css +49 -6
- package/dist/index.css +0 -2
|
@@ -2,6 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
|
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
3
|
import {
|
|
4
4
|
type CSSProperties,
|
|
5
|
+
type FocusEvent,
|
|
5
6
|
type FunctionComponent,
|
|
6
7
|
type KeyboardEvent,
|
|
7
8
|
type MouseEvent,
|
|
@@ -14,35 +15,45 @@ import type { Observable, Subject } from 'rxjs';
|
|
|
14
15
|
import { TableConfigurationError } from './TableConfigurationError';
|
|
15
16
|
|
|
16
17
|
// Rules are the layout. One recipe styles the whole table from its wrapper, so the block reads as a
|
|
17
|
-
// table from two rule weights and nothing else: no cell borders, no fill, no zebra, no hover. Colours
|
|
18
|
+
// table from two rule weights and nothing else: no cell borders, no default fill, no zebra, no hover. Colours
|
|
18
19
|
// are semantic tokens re-pointed by `.dark`, so no selector carries a `dark:` class. The responsive
|
|
19
20
|
// behaviour is keyed on the wrapper's `data-notes`, so a server renders the right markup with no
|
|
20
21
|
// hydration and the mode is one attribute a stylesheet and a test can both read.
|
|
21
22
|
const DEFAULT_DENSITY = 'comfortable';
|
|
23
|
+
const DEFAULT_SELECTION_TREATMENT = 'marker';
|
|
22
24
|
|
|
23
25
|
const table = cva(
|
|
24
26
|
[
|
|
25
27
|
// Residual horizontal overflow scrolls in every mode (#114); the scroll sits on the wrapper so
|
|
26
28
|
// the table keeps its table formatting context and the figures stay column-aligned. No vertical
|
|
27
|
-
// bound
|
|
29
|
+
// bound unless bounded is requested; then this same wrapper owns both axes.
|
|
28
30
|
'overflow-x-auto',
|
|
31
|
+
// Content-sized actions need room at both scroll edges, including static rows. The outer
|
|
32
|
+
// inset also protects rings above/below compact cells without changing the row height.
|
|
33
|
+
'[&[data-icon-actions]]:p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
|
|
34
|
+
'[&[data-icon-actions]]:m-[calc(-1*(var(--focus-ring-width)+var(--focus-ring-offset)))]',
|
|
35
|
+
'[&[data-icon-actions]]:scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
|
|
36
|
+
// Overflowing grid tracks extend beyond the table border box; wrapper padding alone does not
|
|
37
|
+
// survive at that trailing scroll edge. Keep ring room inside the outer cells as well.
|
|
38
|
+
'[&[data-icon-actions]>table>*>tr>*:first-child]:pl-[max(var(--table-cell-padding-inline),calc(var(--focus-ring-width)+var(--focus-ring-offset)))]',
|
|
39
|
+
'[&[data-icon-actions]>table>*>tr>*:last-child]:pr-[max(var(--table-cell-padding-inline),calc(var(--focus-ring-width)+var(--focus-ring-offset)))]',
|
|
29
40
|
// The wrapper takes the one focus ring when it is keyboard-reachable - docs/adr/0002.
|
|
30
41
|
'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
31
42
|
// The table: full width, collapsed borders so adjacent row rules meet as one line, text flush left.
|
|
32
43
|
'[&>table]:w-full [&>table]:border-collapse [&>table]:text-left',
|
|
33
44
|
// The required caption, rendered first, as the table's label in the tracked grotesk device.
|
|
34
|
-
'[&_caption]:
|
|
45
|
+
'[&_caption]:text-left [&_caption]:font-secondary [&_caption]:text-label [&_caption]:tracking-label [&_caption]:text-muted',
|
|
35
46
|
// Every row carries a hairline top (Row); the first row - the first row group after the caption -
|
|
36
47
|
// is promoted to the heavier `rule` colour, and that heavier line is what reads as a table not a list.
|
|
37
48
|
'[&>table>*:nth-child(2)>tr:first-child]:border-rule',
|
|
38
49
|
// notes="supplementary": the note column leaves the page for everyone, sighted or not, below 48rem.
|
|
39
50
|
// Every narrow-viewport rule is off once an allocation is declared (#115): a declared comparison
|
|
40
51
|
// 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',
|
|
52
|
+
'[&[data-notes=supplementary]:not([data-columns]):not([data-bounded])_[data-variant=note]]:max-md:hidden',
|
|
42
53
|
// notes="content": the note is the content, so each row stacks into a single column below 48rem.
|
|
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',
|
|
54
|
+
'[&[data-notes=content]:not([data-columns]):not([data-bounded])>table]:max-md:block',
|
|
55
|
+
'[&[data-notes=content]:not([data-columns]):not([data-bounded])_thead]:max-md:block [&[data-notes=content]:not([data-columns]):not([data-bounded])_tbody]:max-md:block [&[data-notes=content]:not([data-columns]):not([data-bounded])_tfoot]:max-md:block',
|
|
56
|
+
'[&[data-notes=content]:not([data-columns]):not([data-bounded])_tr]:max-md:block [&[data-notes=content]:not([data-columns]):not([data-bounded])_td]:max-md:block [&[data-notes=content]:not([data-columns]):not([data-bounded])_th]:max-md:block',
|
|
46
57
|
// A declared allocation (#115) lays the table out as a grid whose tracks are the allocation, each
|
|
47
58
|
// row group and row a column subgrid of it, so every cell sits on boundaries no row's content can
|
|
48
59
|
// move and the browser's track algorithm does the floors and the redistribution. Rows stay boxes,
|
|
@@ -64,11 +75,24 @@ const table = cva(
|
|
|
64
75
|
// margin hands the room back to the page, so the ring sits outside the row and never covers the
|
|
65
76
|
// marker at its leading edge. Written from the two ring tokens so it cannot drift from the ring.
|
|
66
77
|
'[&: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)))]',
|
|
78
|
+
'[&:not([data-bounded]):has(tr[tabindex])]:m-[calc(-1*(var(--focus-ring-width)+var(--focus-ring-offset)))]',
|
|
68
79
|
'[&:has(tr[tabindex])]:scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
|
|
69
80
|
].join(' '),
|
|
70
81
|
{
|
|
71
82
|
variants: {
|
|
83
|
+
bounded: {
|
|
84
|
+
true: 'max-h-full min-h-0 min-w-0 overflow-y-auto overscroll-contain p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))] [&>table>thead]:sticky [&>table>thead]:top-0 [&>table>thead]:z-10 [&>table>thead]:bg-surface [&>table>thead>tr>*]:bg-surface',
|
|
85
|
+
false: '',
|
|
86
|
+
},
|
|
87
|
+
selectionTreatment: {
|
|
88
|
+
marker: '',
|
|
89
|
+
highlight:
|
|
90
|
+
'[&>table>*>tr[aria-selected=true]]:bg-selected-surface [&>table>*>tr[aria-selected=true]]:[--color-error:var(--color-selected-error)]',
|
|
91
|
+
},
|
|
92
|
+
captionVisibility: {
|
|
93
|
+
visible: '[&>table>caption]:pb-3',
|
|
94
|
+
hidden: '[&>table>caption]:sr-only',
|
|
95
|
+
},
|
|
72
96
|
// Density publishes the two cell insets the cells read, so head and body move together and the
|
|
73
97
|
// theme's comfortable or compact pair is the only source (docs/adr/0008, Amendments, #115).
|
|
74
98
|
density: {
|
|
@@ -78,7 +102,11 @@ const table = cva(
|
|
|
78
102
|
'[--table-cell-padding-inline:var(--table-cell-inset-inline-compact)] [--table-cell-padding-block:var(--table-cell-inset-block-compact)]',
|
|
79
103
|
},
|
|
80
104
|
},
|
|
81
|
-
defaultVariants: {
|
|
105
|
+
defaultVariants: {
|
|
106
|
+
density: DEFAULT_DENSITY,
|
|
107
|
+
captionVisibility: 'visible',
|
|
108
|
+
selectionTreatment: DEFAULT_SELECTION_TREATMENT,
|
|
109
|
+
},
|
|
82
110
|
},
|
|
83
111
|
);
|
|
84
112
|
|
|
@@ -185,6 +213,12 @@ type TableColumnWidthRole = 'name' | 'fact' | 'figure' | 'action';
|
|
|
185
213
|
* optional named minimum as its floor. Declared once on `Table.Root`, in cell order.
|
|
186
214
|
*/
|
|
187
215
|
export type TableColumnAllocation =
|
|
216
|
+
| {
|
|
217
|
+
/** Fits the widest rendered action group, without sharing surplus width. */
|
|
218
|
+
readonly width: 'icon-action';
|
|
219
|
+
readonly weight?: never;
|
|
220
|
+
readonly minWidth?: never;
|
|
221
|
+
}
|
|
188
222
|
| {
|
|
189
223
|
/** The role whose width this column takes exactly, at any available width. */
|
|
190
224
|
readonly width: TableColumnWidthRole;
|
|
@@ -212,6 +246,7 @@ const widthOf = (role: TableColumnWidthRole): string =>
|
|
|
212
246
|
// One grid track per definition: a fixed role, floored by max() when it has a minimum so it holds at
|
|
213
247
|
// any width; a share as minmax(floor, weight fr), which is the browser's own floor-and-redistribute.
|
|
214
248
|
const trackOf = (column: TableColumnAllocation, index: number): string => {
|
|
249
|
+
if (column.width === 'icon-action') return 'max-content';
|
|
215
250
|
const minimum =
|
|
216
251
|
column.minWidth === undefined ? undefined : widthOf(column.minWidth);
|
|
217
252
|
if (column.width !== undefined) {
|
|
@@ -234,13 +269,15 @@ const trackListOf = (
|
|
|
234
269
|
: columns.map(trackOf).join(' ');
|
|
235
270
|
|
|
236
271
|
export interface ITableRootProps extends VariantProps<typeof table> {
|
|
272
|
+
/** Opt into parent-bounded scrolling on both axes with a pinned header; short tables keep natural height. */
|
|
273
|
+
bounded?: boolean;
|
|
237
274
|
/** The table's accessible name. Rendered as the first child; always present. */
|
|
238
275
|
caption: string;
|
|
239
276
|
/** What the note column is. Governs narrow-viewport behaviour; omit when there is none. */
|
|
240
277
|
notes?: 'supplementary' | 'content';
|
|
241
278
|
/** The columns' allocations, in the order the cells of every row are written. Omit it and widths
|
|
242
279
|
* 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. */
|
|
280
|
+
* cannot move (except content-sized `icon-action` tracks), and the narrow-viewport `notes` behaviour is replaced by residual scrolling. */
|
|
244
281
|
columns?: readonly TableColumnAllocation[];
|
|
245
282
|
children?: ReactNode;
|
|
246
283
|
testId?: string;
|
|
@@ -283,42 +320,104 @@ export interface ITableHeaderCellProps
|
|
|
283
320
|
// (WCAG 2.1.1): reachable until measured, so server markup is operable before hydration, and while
|
|
284
321
|
// it holds focus, since dropping tabindex from the focused element relocates it. The table and the
|
|
285
322
|
// caption are observed too: under an allocation the caption, spanning every track, is what grows.
|
|
286
|
-
const
|
|
323
|
+
const useOverflow = (
|
|
287
324
|
wrapper: { current: HTMLElement | null },
|
|
288
325
|
table: { current: HTMLElement | null },
|
|
289
326
|
caption: { current: HTMLElement | null },
|
|
327
|
+
bounded: boolean,
|
|
290
328
|
): boolean => {
|
|
291
329
|
const [isOverflowing, setIsOverflowing] = useState(true);
|
|
292
330
|
useLayoutEffect(() => {
|
|
293
331
|
const element = wrapper.current;
|
|
294
332
|
if (element === null) return;
|
|
295
|
-
const measure = () =>
|
|
333
|
+
const measure = () => {
|
|
334
|
+
// WebKit may perform native focus scrolling after focus handlers; scroll padding keeps
|
|
335
|
+
// that second movement below even a wrapped header, rather than undoing focus reveal.
|
|
336
|
+
if (bounded) {
|
|
337
|
+
const headerHeight =
|
|
338
|
+
table.current?.querySelector('thead')?.getBoundingClientRect()
|
|
339
|
+
.height ?? 0;
|
|
340
|
+
const ringRoom =
|
|
341
|
+
Number.parseFloat(getComputedStyle(element).paddingTop) || 0;
|
|
342
|
+
element.style.scrollPaddingTop = `${headerHeight + 2 * ringRoom}px`;
|
|
343
|
+
}
|
|
296
344
|
setIsOverflowing(
|
|
297
345
|
element.scrollWidth > element.clientWidth ||
|
|
346
|
+
(bounded && element.scrollHeight > element.clientHeight) ||
|
|
298
347
|
element === document.activeElement,
|
|
299
348
|
);
|
|
349
|
+
};
|
|
300
350
|
measure();
|
|
301
351
|
const observer =
|
|
302
352
|
typeof ResizeObserver === 'undefined'
|
|
303
353
|
? undefined
|
|
304
354
|
: new ResizeObserver(measure);
|
|
305
355
|
observer?.observe(element);
|
|
306
|
-
for (const observed of [
|
|
356
|
+
for (const observed of [
|
|
357
|
+
table.current,
|
|
358
|
+
caption.current,
|
|
359
|
+
table.current?.querySelector('thead') ?? null,
|
|
360
|
+
]) {
|
|
307
361
|
if (observed !== null) observer?.observe(observed);
|
|
308
362
|
}
|
|
363
|
+
const mutations = new MutationObserver(measure);
|
|
364
|
+
mutations.observe(element, {
|
|
365
|
+
childList: true,
|
|
366
|
+
subtree: true,
|
|
367
|
+
characterData: true,
|
|
368
|
+
});
|
|
309
369
|
window.addEventListener('resize', measure);
|
|
310
370
|
element.addEventListener('blur', measure);
|
|
311
371
|
return () => {
|
|
312
372
|
observer?.disconnect();
|
|
373
|
+
mutations.disconnect();
|
|
374
|
+
if (bounded) element.style.removeProperty('scroll-padding-top');
|
|
313
375
|
window.removeEventListener('resize', measure);
|
|
314
376
|
element.removeEventListener('blur', measure);
|
|
315
377
|
};
|
|
316
|
-
}, [wrapper, table, caption]);
|
|
378
|
+
}, [wrapper, table, caption, bounded]);
|
|
317
379
|
return isOverflowing;
|
|
318
380
|
};
|
|
319
381
|
|
|
382
|
+
const revealFocus = (event: FocusEvent<HTMLDivElement>): void => {
|
|
383
|
+
const wrapper = event.currentTarget;
|
|
384
|
+
const target = event.target;
|
|
385
|
+
if (target === wrapper) return;
|
|
386
|
+
const bounds = wrapper.getBoundingClientRect();
|
|
387
|
+
const header = wrapper.querySelector(':scope > table > thead');
|
|
388
|
+
const targetBounds = target.getBoundingClientRect();
|
|
389
|
+
const ringRoom = Number.parseFloat(getComputedStyle(wrapper).paddingTop) || 0;
|
|
390
|
+
const top =
|
|
391
|
+
bounds.top +
|
|
392
|
+
wrapper.clientTop +
|
|
393
|
+
(header?.getBoundingClientRect().height ?? 0) +
|
|
394
|
+
2 * ringRoom;
|
|
395
|
+
const bottom =
|
|
396
|
+
bounds.top + wrapper.clientTop + wrapper.clientHeight - ringRoom;
|
|
397
|
+
if (!header?.contains(target)) {
|
|
398
|
+
if (targetBounds.top < top) wrapper.scrollTop += targetBounds.top - top;
|
|
399
|
+
else if (targetBounds.bottom > bottom)
|
|
400
|
+
wrapper.scrollTop += targetBounds.bottom - bottom;
|
|
401
|
+
}
|
|
402
|
+
if (targetBounds.left < bounds.left + ringRoom)
|
|
403
|
+
wrapper.scrollLeft += targetBounds.left - bounds.left - ringRoom;
|
|
404
|
+
else if (
|
|
405
|
+
targetBounds.right >
|
|
406
|
+
bounds.left + wrapper.clientLeft + wrapper.clientWidth - ringRoom
|
|
407
|
+
)
|
|
408
|
+
wrapper.scrollLeft +=
|
|
409
|
+
targetBounds.right -
|
|
410
|
+
bounds.left -
|
|
411
|
+
wrapper.clientLeft -
|
|
412
|
+
wrapper.clientWidth +
|
|
413
|
+
ringRoom;
|
|
414
|
+
};
|
|
415
|
+
|
|
320
416
|
const TableRoot: FunctionComponent<ITableRootProps> = ({
|
|
321
417
|
caption,
|
|
418
|
+
captionVisibility,
|
|
419
|
+
bounded,
|
|
420
|
+
selectionTreatment,
|
|
322
421
|
notes,
|
|
323
422
|
columns,
|
|
324
423
|
density,
|
|
@@ -329,10 +428,11 @@ const TableRoot: FunctionComponent<ITableRootProps> = ({
|
|
|
329
428
|
const tableElement = useRef<HTMLTableElement>(null);
|
|
330
429
|
const captionElement = useRef<HTMLTableCaptionElement>(null);
|
|
331
430
|
const trackList = trackListOf(columns);
|
|
332
|
-
const isOverflowing =
|
|
431
|
+
const isOverflowing = useOverflow(
|
|
333
432
|
wrapper,
|
|
334
433
|
tableElement,
|
|
335
434
|
captionElement,
|
|
435
|
+
bounded === true,
|
|
336
436
|
);
|
|
337
437
|
const style: TableRootStyle | undefined =
|
|
338
438
|
trackList === undefined ? undefined : { '--table-columns': trackList };
|
|
@@ -347,13 +447,24 @@ const TableRoot: FunctionComponent<ITableRootProps> = ({
|
|
|
347
447
|
// scrolled table is keyboard-operable - WCAG 2.1.1 needs the scroll container itself focusable,
|
|
348
448
|
// there being no focusable cell content to carry it. With a note column the wrapper is a plain
|
|
349
449
|
// grouping element that becomes a named, reachable group only while the table overflows it.
|
|
350
|
-
if (notes === undefined) {
|
|
450
|
+
if (notes === undefined && !bounded) {
|
|
351
451
|
return (
|
|
352
452
|
<section
|
|
353
|
-
className={table({
|
|
453
|
+
className={table({
|
|
454
|
+
density,
|
|
455
|
+
captionVisibility,
|
|
456
|
+
selectionTreatment,
|
|
457
|
+
bounded,
|
|
458
|
+
})}
|
|
354
459
|
style={style}
|
|
355
460
|
data-columns={columnCount}
|
|
461
|
+
data-icon-actions={
|
|
462
|
+
columns?.some((column) => column.width === 'icon-action') || undefined
|
|
463
|
+
}
|
|
356
464
|
data-density={density ?? DEFAULT_DENSITY}
|
|
465
|
+
data-selection-treatment={
|
|
466
|
+
selectionTreatment ?? DEFAULT_SELECTION_TREATMENT
|
|
467
|
+
}
|
|
357
468
|
data-testid={testId}
|
|
358
469
|
aria-label={caption}
|
|
359
470
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be keyboard-operable (WCAG 2.1.1)
|
|
@@ -365,13 +476,27 @@ const TableRoot: FunctionComponent<ITableRootProps> = ({
|
|
|
365
476
|
}
|
|
366
477
|
return (
|
|
367
478
|
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and the `group` role are set together; biome cannot see the pair
|
|
479
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: focus bubbles from descendants; this reveals their focus without adding an interaction
|
|
368
480
|
<div
|
|
369
481
|
ref={wrapper}
|
|
370
|
-
|
|
482
|
+
onFocus={bounded ? revealFocus : undefined}
|
|
483
|
+
className={table({
|
|
484
|
+
density,
|
|
485
|
+
captionVisibility,
|
|
486
|
+
selectionTreatment,
|
|
487
|
+
bounded,
|
|
488
|
+
})}
|
|
371
489
|
style={style}
|
|
490
|
+
data-bounded={bounded || undefined}
|
|
372
491
|
data-notes={notes}
|
|
373
492
|
data-columns={columnCount}
|
|
493
|
+
data-icon-actions={
|
|
494
|
+
columns?.some((column) => column.width === 'icon-action') || undefined
|
|
495
|
+
}
|
|
374
496
|
data-density={density ?? DEFAULT_DENSITY}
|
|
497
|
+
data-selection-treatment={
|
|
498
|
+
selectionTreatment ?? DEFAULT_SELECTION_TREATMENT
|
|
499
|
+
}
|
|
375
500
|
data-testid={testId}
|
|
376
501
|
role={isOverflowing ? 'group' : undefined}
|
|
377
502
|
aria-label={isOverflowing ? caption : undefined}
|
|
@@ -494,26 +619,43 @@ const TableCell: FunctionComponent<ITableCellProps> = ({
|
|
|
494
619
|
* @Guarantees — enforced on every render
|
|
495
620
|
* - Renders semantic `table`/`thead`/`tbody`/`tfoot`/`tr`/`th`/`td`, and works with JavaScript off.
|
|
496
621
|
* - `Root` renders its required `caption` as the table's first child, so every table is named.
|
|
622
|
+
* `captionVisibility` defaults to `visible`; `hidden` visually clips the caption out of flow,
|
|
623
|
+
* including its spacing, while preserving its native accessible name and the scroll wrapper's name.
|
|
624
|
+
* Hide it only when visible text directly above the table already names what it lists; the
|
|
625
|
+
* enclosing arrangement owns the gap above the table. The consumer still supplies the caption.
|
|
497
626
|
* - The block reads as a table from two rule weights alone: the heavier `rule` above the first row,
|
|
498
|
-
* `border` hairlines between rows, and no bottom rule on the last. No cell borders, fill, zebra or hover.
|
|
627
|
+
* `border` hairlines between rows, and no bottom rule on the last. No cell borders, default fill, zebra or hover.
|
|
499
628
|
* - `Cell variant="value"` sets tabular figures; `variant="note"` does not. Both at the 15px small role.
|
|
500
629
|
* - `HeaderCell` emits the `scope` it is given; none is inferred. It emits `ariaSort` the same way:
|
|
501
630
|
* the attribute states the displayed order and the component never orders, cycles or requests one.
|
|
502
631
|
* - Residual horizontal overflow scrolls in every `notes` mode, with no opt-in. The wrapper is
|
|
503
632
|
* 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.
|
|
505
|
-
* a
|
|
633
|
+
* column; as a named group only once the table overflows, with one. Bounded tables instead expose
|
|
634
|
+
* a named group and keyboard stop only while either axis overflows, retained while focused.
|
|
635
|
+
* - `bounded` makes this same wrapper own both scroll axes within the parent's allocated height.
|
|
636
|
+
* Short tables retain natural height; unsized parents allow natural growth. Only the header pins,
|
|
637
|
+
* on the themed surface; captions scroll normally and keep naming the table. All columns and rows
|
|
638
|
+
* remain tabular at every width, including both notes modes. Unbounded behavior is unchanged.
|
|
639
|
+
* - Focus reveals rows and controls below the pinned header inside the wrapper. Selection updates
|
|
640
|
+
* alone never scroll. The wrapper reserves the shared focus ring's room.
|
|
506
641
|
* - `Root columns` declares every column's allocation once, in cell order, and every row shares it:
|
|
507
642
|
* a fixed column takes its named width role at any available width; proportional columns share
|
|
508
643
|
* the width the fixed ones leave by their weights, each floored at its named minimum, and while a
|
|
509
644
|
* minimum holds one column the others share what is left. A fixed width below its minimum is the
|
|
510
|
-
* minimum.
|
|
645
|
+
* minimum. Except for `icon-action`, nothing a row holds moves a boundary: filtering, sorting, paging, long or short content,
|
|
511
646
|
* an empty body and its repopulation all leave the allocation as it was. Only the definitions, the
|
|
512
647
|
* theme's `--table-column-*` values and the available width can. All-fixed columns do not stretch.
|
|
648
|
+
* - `{ width: 'icon-action' }` fits the widest rendered cell (including its header and insets),
|
|
649
|
+
* takes no surplus width, and may shrink after filtering. Compose multiple plain icon buttons
|
|
650
|
+
* in `Cluster gap="stack"`; max-content track sizing keeps the group on one line.
|
|
513
651
|
* - With `columns` declared, no narrow-viewport rule applies whatever `notes` says: the note column
|
|
514
652
|
* 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
|
|
653
|
+
* its allocation, an unbroken run included; the table does not itself truncate, hide or resize content.
|
|
516
654
|
* Without `columns`, widths follow content and both `notes` behaviours are exactly as before.
|
|
655
|
+
* - Exception: a consumer may mark individual `P` or `Note` content with `truncate`. Only that
|
|
656
|
+
* text stays on one line and ellipsizes at its allocated inline end, in either column layout;
|
|
657
|
+
* its full DOM text remains accessible and does not force the table wider. Unmarked siblings
|
|
658
|
+
* retain their wrapping/overflow behavior; fixed column widths and minimums still hold.
|
|
517
659
|
* - `Root density` insets every header and body cell from one theme pair: `comfortable` (the
|
|
518
660
|
* default, the former spacing exactly) or `compact`, per table. It moves no type size and no
|
|
519
661
|
* control's own dimensions. A non-positive or non-finite `weight` throws
|
|
@@ -528,6 +670,10 @@ const TableCell: FunctionComponent<ITableCellProps> = ({
|
|
|
528
670
|
* emitted reads unselected; a replaced source reads unselected until it emits; unmounting
|
|
529
671
|
* unsubscribes. The selection input is independent of `onClick$`, so a row may be selected and
|
|
530
672
|
* noninteractive, interactive and unselected, or both. Rendering and selection changes emit nothing.
|
|
673
|
+
* - `Root selectionTreatment="highlight"` adds `selectedSurface` behind selected rows only;
|
|
674
|
+
* the default `marker` preserves the former presentation. Neither treatment changes on hover.
|
|
675
|
+
* The marker remains a border in forced colours, where backgrounds may be suppressed; selection
|
|
676
|
+
* stays distinct from the focus outline. A nested table chooses its own treatment.
|
|
531
677
|
* - The row stays a `tr` in a `table`: no grid role, no arrow-key navigation. `aria-selected` is a
|
|
532
678
|
* WAI-ARIA 1.2 state of `row` and valid here, but Chromium exposes a row's selected state only
|
|
533
679
|
* inside a `grid`, so Chrome and Edge screen readers do not announce it on these rows (accepted
|
|
@@ -538,12 +684,31 @@ const TableCell: FunctionComponent<ITableCellProps> = ({
|
|
|
538
684
|
* clipped by the scroll region. A table with neither keeps its static geometry exactly.
|
|
539
685
|
*
|
|
540
686
|
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
687
|
+
* - For bounded mode supply finite usable parent height, with room for the header and readable rows.
|
|
688
|
+
* Compose directly inside `Pane.Body scrollOwner="child"` or `Dialog.Content scrollOwner="child"`:
|
|
689
|
+
* those holders add no scrolling. Intermediate wrappers must preserve the height allocation.
|
|
690
|
+
* Fixed surrounding content must fit; no component promises impossible viewport fitting.
|
|
691
|
+
* - For narrow comparisons use weighted columns without minimums when the content can wrap into
|
|
692
|
+
* their shares. Fixed widths and declared minimums retain their meaning; controls may overflow.
|
|
693
|
+
* - A theme's `selectedSurface` differs from resting/hover surfaces and keeps foreground, muted
|
|
694
|
+
* and link/status text at 4.5:1, and the marker, rule and focus ring at 3:1.
|
|
695
|
+
* Highlighted rows use selectedError for their error ink/fill. Other consumer-composed inks and
|
|
696
|
+
* controls must meet their own contrast constraints against this surface too.
|
|
541
697
|
* - Every row writes exactly as many cells as there are `columns`, in the same order. A row with
|
|
542
698
|
* fewer leaves tracks empty; one with more breaks onto a second line of its own row.
|
|
543
699
|
* - A cell whose content cannot wrap - a `Button`, an `Input`, an image - sits in a column whose
|
|
544
700
|
* fixed width or minimum holds it: the `action` role holds one standard control at comfortable
|
|
545
701
|
* density. The allocation never widens for content, so an under-allocated control overflows its
|
|
546
|
-
* cell rather than moving its neighbours.
|
|
702
|
+
* cell rather than moving its neighbours. The content-sized icon-action allocation is the exception.
|
|
703
|
+
* - Every icon-only Button needs an action-specific `ariaLabel`; leave its nested Icon decorative.
|
|
704
|
+
* Prefer labelled buttons wherever there is room. No tooltip is required. Only plain buttons
|
|
705
|
+
* inherit cell typography without increasing a single-line row's height; ghost keeps its padding.
|
|
706
|
+
* - For icon actions, theme `--space-stack` must be at least the focus width plus offset, so a ring
|
|
707
|
+
* cannot reach its neighbour. Twice the cell block inset must hold that same ring extent
|
|
708
|
+
* between adjacent rows. Keep line height at least one em. The wrapper
|
|
709
|
+
* reserves ring room at scroll boundaries without changing row height.
|
|
710
|
+
* - For truncated text, provide an operable path to the full wording (for example row activation
|
|
711
|
+
* opening the item). Truncation must not hide essential content or interactive descendants.
|
|
547
712
|
* - A cell's `align` and `variant` are the consumer's as before; an allocation sets neither.
|
|
548
713
|
* - Each row's `onClick$` and `isSelected$` are tied to that row's stable identity, so a reordered or
|
|
549
714
|
* temporarily removed row keeps its association. Table holds no identity, registry or policy, and
|
|
@@ -2,15 +2,14 @@ import type { VariantProps } from 'class-variance-authority';
|
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
3
|
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
-
// The annotation
|
|
6
|
-
//
|
|
7
|
-
// defaults to `foreground` where Eyebrow defaults to `muted`: Eyebrow inverts the ordinary default
|
|
8
|
-
// because its device is defined as muted, and an annotation is not - it is foreground *or* muted -
|
|
9
|
-
// so Note follows `P` and keeps the ordinary default. No tracking and no weight: those two
|
|
10
|
-
// are what make an Eyebrow. No measure and no margin: the annotation sits in no reading column, and
|
|
11
|
-
// the page owns the rhythm around it. Colour is re-pointed by `.dark`, so no variant carries `dark:`.
|
|
5
|
+
// The annotation uses the secondary family (docs/adr/0004). Its default weight is inherited;
|
|
6
|
+
// strong emphasis changes weight only. The enclosing arrangement owns measure and spacing.
|
|
12
7
|
const note = cva('font-secondary text-small', {
|
|
13
8
|
variants: {
|
|
9
|
+
emphasis: { regular: '', strong: 'font-strong' },
|
|
10
|
+
// Inline containment removes this text's intrinsic width from automatic table sizing (#132).
|
|
11
|
+
// The block still fills its allocated width; only its own text is clipped, never its siblings.
|
|
12
|
+
truncate: { true: 'min-w-0 truncate [contain:inline-size]', false: '' },
|
|
14
13
|
color: {
|
|
15
14
|
foreground: 'text-foreground',
|
|
16
15
|
muted: 'text-muted',
|
|
@@ -20,7 +19,11 @@ const note = cva('font-secondary text-small', {
|
|
|
20
19
|
info: 'text-info',
|
|
21
20
|
},
|
|
22
21
|
},
|
|
23
|
-
defaultVariants: {
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
color: 'foreground',
|
|
24
|
+
truncate: false,
|
|
25
|
+
emphasis: 'regular',
|
|
26
|
+
},
|
|
24
27
|
});
|
|
25
28
|
|
|
26
29
|
export interface INoteProps extends VariantProps<typeof note> {
|
|
@@ -33,16 +36,27 @@ export interface INoteProps extends VariantProps<typeof note> {
|
|
|
33
36
|
* A short annotation at the small type role in the secondary family — a routing line above a submit
|
|
34
37
|
* button, a privacy line at the point of submission, a line beside a control. Not reading matter:
|
|
35
38
|
* it takes no measure and sits in no reading column, which is what separates it from `Prose.Tail`
|
|
36
|
-
* at the same size
|
|
39
|
+
* at the same size. It carries no tracking and inherits weight unless emphasis is requested.
|
|
37
40
|
*
|
|
38
41
|
* @Guarantees — enforced on every render
|
|
39
42
|
* - Renders a `p`, reading `--font-secondary` and sized by `--text-small`.
|
|
40
43
|
* - `color` selects `foreground` (default), `muted`, `success`, `warning`, `error` or `info`;
|
|
41
44
|
* nothing else paints text. A status tone changes colour only.
|
|
42
|
-
* - Emits no tracking, no
|
|
45
|
+
* - Emits no tracking, no measure and no margin under any prop.
|
|
43
46
|
* - Carries no ARIA role and no live region under any prop.
|
|
47
|
+
* - `truncate` opts into one line with an ellipsis at the inline end, following inherited writing
|
|
48
|
+
* direction. The complete text stays in the DOM and accessibility tree. Omitted or false wraps
|
|
49
|
+
* as before. Marked text can shrink inside ordinary and allocated Table cells.
|
|
50
|
+
* - `emphasis="strong"` reads `--font-weight-strong` without changing size, family or line height.
|
|
51
|
+
* This is visual only: no `strong` element, importance semantics or live announcement is added.
|
|
52
|
+
* Omitted or `regular` leaves weight inherited, exactly as before.
|
|
44
53
|
*
|
|
45
54
|
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
55
|
+
* - Communicate state in words or an accessible name; weight alone must never carry a state.
|
|
56
|
+
* - Allocate a width through the enclosing layout: truncated text does not size its holder.
|
|
57
|
+
* - Make the full wording reachable to sighted readers, for example by opening the item. Never
|
|
58
|
+
* truncate essential content without that path, or put links, controls or line breaks inside
|
|
59
|
+
* truncated text; use text and noninteractive inline phrasing only. No tooltip is added.
|
|
46
60
|
* - The device has three carriers and this primitive is only one: use it for an annotation no other
|
|
47
61
|
* component owns. A form's own message belongs to `Form`'s `note` and a table's to its note cell,
|
|
48
62
|
* each painted by the component that owns it.
|
|
@@ -58,9 +72,11 @@ export interface INoteProps extends VariantProps<typeof note> {
|
|
|
58
72
|
export const Note: FunctionComponent<INoteProps> = ({
|
|
59
73
|
children,
|
|
60
74
|
color,
|
|
75
|
+
emphasis,
|
|
76
|
+
truncate,
|
|
61
77
|
testId,
|
|
62
78
|
}) => (
|
|
63
|
-
<p className={note({ color })} data-testid={testId}>
|
|
79
|
+
<p className={note({ color, emphasis, truncate })} data-testid={testId}>
|
|
64
80
|
{children}
|
|
65
81
|
</p>
|
|
66
82
|
);
|
|
@@ -7,6 +7,10 @@ import type { FunctionComponent, ReactNode } from 'react';
|
|
|
7
7
|
// P owns no reading measure - that belongs to whatever owns the reading column (Prose #21).
|
|
8
8
|
const p = cva('font-body text-body leading-body', {
|
|
9
9
|
variants: {
|
|
10
|
+
emphasis: { regular: '', strong: 'font-strong' },
|
|
11
|
+
// Inline containment removes this text's intrinsic width from automatic table sizing (#132).
|
|
12
|
+
// The block still fills its allocated width; only its own text is clipped, never its siblings.
|
|
13
|
+
truncate: { true: 'min-w-0 truncate [contain:inline-size]', false: '' },
|
|
10
14
|
color: {
|
|
11
15
|
foreground: 'text-foreground',
|
|
12
16
|
muted: 'text-muted',
|
|
@@ -16,10 +20,14 @@ const p = cva('font-body text-body leading-body', {
|
|
|
16
20
|
info: 'text-info',
|
|
17
21
|
},
|
|
18
22
|
},
|
|
19
|
-
defaultVariants: {
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
color: 'foreground',
|
|
25
|
+
truncate: false,
|
|
26
|
+
emphasis: 'regular',
|
|
27
|
+
},
|
|
20
28
|
});
|
|
21
29
|
|
|
22
|
-
interface IPProps extends VariantProps<typeof p> {
|
|
30
|
+
export interface IPProps extends VariantProps<typeof p> {
|
|
23
31
|
children: ReactNode;
|
|
24
32
|
testId?: string;
|
|
25
33
|
}
|
|
@@ -33,8 +41,19 @@ interface IPProps extends VariantProps<typeof p> {
|
|
|
33
41
|
* theme re-points it, #120), sized by `--text-body` and led by `--leading-body`.
|
|
34
42
|
* - `color` selects `foreground`, `muted`, `success`, `warning`, `error` or `info`; nothing else
|
|
35
43
|
* paints text. A status tone changes colour only and adds no announcement semantics.
|
|
44
|
+
* - `truncate` opts into one line with an ellipsis at the inline end, following inherited writing
|
|
45
|
+
* direction. The complete text stays in the DOM and accessibility tree. Omitted or false wraps
|
|
46
|
+
* as before. Marked text can shrink inside ordinary and allocated Table cells.
|
|
47
|
+
* - `emphasis="strong"` reads `--font-weight-strong` without changing size, family or line height.
|
|
48
|
+
* This is visual only: no `strong` element, importance semantics or live announcement is added.
|
|
49
|
+
* Omitted or `regular` leaves weight inherited, exactly as before.
|
|
36
50
|
*
|
|
37
51
|
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
52
|
+
* - Communicate state in words or an accessible name; weight alone must never carry a state.
|
|
53
|
+
* - Allocate a width through the enclosing layout: truncated text does not size its holder.
|
|
54
|
+
* - Make the full wording reachable to sighted readers, for example by opening the item. Never
|
|
55
|
+
* truncate essential content without that path, or put links, controls or line breaks inside
|
|
56
|
+
* truncated text; use text and noninteractive inline phrasing only. No tooltip is added.
|
|
38
57
|
* - Where line length matters, place the paragraph inside whatever bounds the reading measure; `P`
|
|
39
58
|
* does not constrain its own width.
|
|
40
59
|
* - For a paragraph inside a reading column, reach for `Prose.Body`, which is measure-bounded by its
|
|
@@ -42,8 +61,14 @@ interface IPProps extends VariantProps<typeof p> {
|
|
|
42
61
|
* - Status-toned content communicates its status without relying on colour. If a change needs to be
|
|
43
62
|
* announced, the caller owns that behavior; selecting a tone does not create a status event.
|
|
44
63
|
*/
|
|
45
|
-
export const P: FunctionComponent<IPProps> = ({
|
|
46
|
-
|
|
64
|
+
export const P: FunctionComponent<IPProps> = ({
|
|
65
|
+
children,
|
|
66
|
+
color,
|
|
67
|
+
emphasis,
|
|
68
|
+
truncate,
|
|
69
|
+
testId,
|
|
70
|
+
}) => (
|
|
71
|
+
<p className={p({ color, emphasis, truncate })} data-testid={testId}>
|
|
47
72
|
{children}
|
|
48
73
|
</p>
|
|
49
74
|
);
|