@juwel-development/design-system 3.11.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.
Files changed (66) hide show
  1. package/README.md +16 -3
  2. package/dist/design-system.js +1029 -540
  3. package/dist/styles.css +2 -0
  4. package/dist/styles.dark.css +2 -0
  5. package/dist/styles.light.css +2 -0
  6. package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +38 -18
  7. package/dist/types/Display/Callout/Callout.d.ts +31 -0
  8. package/dist/types/Display/Icon/Icon.d.ts +20 -24
  9. package/dist/types/Display/Meter/Meter.d.ts +5 -6
  10. package/dist/types/Display/Rule/Rule.d.ts +22 -0
  11. package/dist/types/Display/Table/Table.d.ts +57 -7
  12. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
  13. package/dist/types/Display/Typography/H1/H1.d.ts +1 -1
  14. package/dist/types/Display/Typography/H2/H2.d.ts +1 -1
  15. package/dist/types/Display/Typography/H3/H3.d.ts +1 -1
  16. package/dist/types/Display/Typography/H4/H4.d.ts +1 -1
  17. package/dist/types/Display/Typography/H5/H5.d.ts +1 -1
  18. package/dist/types/Display/Typography/H6/H6.d.ts +1 -1
  19. package/dist/types/Display/Typography/Note/Note.d.ts +16 -3
  20. package/dist/types/Display/Typography/P/P.d.ts +15 -2
  21. package/dist/types/Display/Typography/Prose/Prose.d.ts +14 -6
  22. package/dist/types/Interaction/Button/Button.d.ts +10 -4
  23. package/dist/types/Interaction/Slider/Slider.d.ts +3 -0
  24. package/dist/types/Interaction/Stepper/Stepper.d.ts +59 -0
  25. package/dist/types/Interaction/Stepper/StepperCompositionError.d.ts +3 -0
  26. package/dist/types/Interaction/Switch/Switch.d.ts +20 -0
  27. package/dist/types/Interaction/Tabs/Tabs.d.ts +24 -3
  28. package/dist/types/Layout/Dialog/Dialog.d.ts +11 -3
  29. package/dist/types/Layout/Form/FormConfigurationError.d.ts +3 -0
  30. package/dist/types/Layout/Pane/Pane.d.ts +55 -0
  31. package/dist/types/Layout/Shell/Shell.d.ts +39 -0
  32. package/dist/types/Layout/Sidebar/Sidebar.d.ts +16 -2
  33. package/dist/types/Theme/Palette.d.ts +11 -4
  34. package/dist/types/index.d.ts +21 -1
  35. package/package.json +18 -8
  36. package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +86 -28
  37. package/src/Display/Callout/Callout.tsx +64 -0
  38. package/src/Display/Icon/Icon.tsx +53 -13
  39. package/src/Display/Meter/Meter.tsx +12 -13
  40. package/src/Display/Rule/Rule.tsx +31 -0
  41. package/src/Display/Table/Table.tsx +189 -24
  42. package/src/Display/Typography/Note/Note.tsx +27 -11
  43. package/src/Display/Typography/P/P.tsx +29 -4
  44. package/src/Display/Typography/Prose/Prose.tsx +44 -19
  45. package/src/Interaction/Button/Button.tsx +11 -4
  46. package/src/Interaction/Slider/Slider.tsx +3 -5
  47. package/src/Interaction/Stepper/Stepper.tsx +307 -0
  48. package/src/Interaction/Stepper/StepperCompositionError.ts +6 -0
  49. package/src/Interaction/Switch/Switch.tsx +64 -0
  50. package/src/Interaction/Tabs/Tabs.tsx +44 -10
  51. package/src/Layout/Dialog/Dialog.tsx +19 -6
  52. package/src/Layout/Form/Form.tsx +2 -1
  53. package/src/Layout/Form/FormConfigurationError.ts +6 -0
  54. package/src/Layout/Pane/Pane.tsx +159 -0
  55. package/src/Layout/Shell/Shell.tsx +124 -0
  56. package/src/Layout/Sidebar/Sidebar.tsx +31 -7
  57. package/src/Theme/Palette.ts +17 -6
  58. package/src/Theme/renderTokens.ts +61 -12
  59. package/src/index.ts +21 -2
  60. package/src/styles.css +5 -6
  61. package/src/styles.dark.css +5 -6
  62. package/src/styles.light.css +5 -6
  63. package/src/tokens.css +54 -9
  64. package/src/tokens.dark.css +51 -8
  65. package/src/tokens.light.css +49 -6
  66. 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: a long table is bounded by the ScrollContainer a consumer puts around it.
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]:pb-3 [&_caption]:text-left [&_caption]:font-secondary [&_caption]:text-label [&_caption]:tracking-label [&_caption]:text-muted',
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: { density: DEFAULT_DENSITY },
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 useHorizontalOverflow = (
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 [table.current, caption.current]) {
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 = useHorizontalOverflow(
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({ density })}
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
- className={table({ density })}
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. No vertical bound is ever set:
505
- * a long table sits inside a `ScrollContainer` with `axis="vertical"`, which owns that axis.
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. Nothing a row holds moves a boundary: filtering, sorting, paging, long or short content,
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 never truncates, hides or resizes content.
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 device: the secondary family at the small role, because docs/adr/0004 fixes
6
- // --font-secondary as the face labels are set in and an annotation labels rather than reads. It
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: { color: 'foreground' },
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, and it carries neither the tracking nor the weight that make an `Eyebrow`.
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 font-weight, no measure and no margin under any prop.
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: { color: 'foreground' },
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> = ({ children, color, testId }) => (
46
- <p className={p({ color })} data-testid={testId}>
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
  );