@juwel-development/design-system 3.9.1 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +9 -298
  2. package/dist/design-system.js +1070 -605
  3. package/dist/index.css +1 -1
  4. package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +92 -0
  5. package/dist/types/Arrangement/ColumnLayout/ColumnLayoutCompositionError.d.ts +3 -0
  6. package/dist/types/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.d.ts +3 -0
  7. package/dist/types/Arrangement/FieldRow/FieldRow.d.ts +87 -0
  8. package/dist/types/Arrangement/FieldRow/FieldRowCompositionError.d.ts +3 -0
  9. package/dist/types/Arrangement/FieldRow/FieldRowConfigurationError.d.ts +3 -0
  10. package/dist/types/Arrangement/FieldRow/IControlEdge.d.ts +6 -0
  11. package/dist/types/Arrangement/FieldRow/alignControlEdges.d.ts +7 -0
  12. package/dist/types/Display/Box/Box.d.ts +41 -0
  13. package/dist/types/Display/Brandmark/Brandmark.d.ts +1 -1
  14. package/dist/types/Display/DefinitionList/DefinitionList.d.ts +59 -9
  15. package/dist/types/Display/DefinitionList/DefinitionListConfigurationError.d.ts +3 -0
  16. package/dist/types/Display/Figure/Figure.d.ts +2 -2
  17. package/dist/types/Display/Icon/Icon.d.ts +45 -0
  18. package/dist/types/Display/Table/Table.d.ts +118 -8
  19. package/dist/types/Display/Table/TableConfigurationError.d.ts +3 -0
  20. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
  21. package/dist/types/Display/Typography/H1/H1.d.ts +3 -2
  22. package/dist/types/Display/Typography/H2/H2.d.ts +3 -2
  23. package/dist/types/Display/Typography/H3/H3.d.ts +3 -2
  24. package/dist/types/Display/Typography/H4/H4.d.ts +3 -2
  25. package/dist/types/Display/Typography/H5/H5.d.ts +3 -2
  26. package/dist/types/Display/Typography/H6/H6.d.ts +3 -2
  27. package/dist/types/Display/Typography/Note/Note.d.ts +1 -1
  28. package/dist/types/Display/Typography/P/P.d.ts +3 -2
  29. package/dist/types/Display/Typography/Prose/Prose.d.ts +1 -1
  30. package/dist/types/Interaction/Button/Button.d.ts +25 -3
  31. package/dist/types/Interaction/Tabs/Tabs.d.ts +31 -6
  32. package/dist/types/Layout/Header/Header.d.ts +67 -8
  33. package/dist/types/Layout/ScrollContainer/ScrollContainer.d.ts +37 -0
  34. package/dist/types/Layout/Section/Section.d.ts +1 -1
  35. package/dist/types/Layout/Sidebar/Sidebar.d.ts +4 -0
  36. package/dist/types/Theme/Palette.d.ts +31 -7
  37. package/dist/types/index.d.ts +6 -0
  38. package/package.json +1 -1
  39. package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +249 -0
  40. package/src/Arrangement/ColumnLayout/ColumnLayoutCompositionError.ts +8 -0
  41. package/src/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.ts +6 -0
  42. package/src/Arrangement/FieldRow/FieldRow.tsx +313 -0
  43. package/src/Arrangement/FieldRow/FieldRowCompositionError.ts +6 -0
  44. package/src/Arrangement/FieldRow/FieldRowConfigurationError.ts +6 -0
  45. package/src/Arrangement/FieldRow/IControlEdge.ts +6 -0
  46. package/src/Arrangement/FieldRow/alignControlEdges.ts +35 -0
  47. package/src/Display/Box/Box.tsx +77 -0
  48. package/src/Display/Checklist/Checklist.tsx +1 -1
  49. package/src/Display/DefinitionList/DefinitionList.tsx +164 -22
  50. package/src/Display/DefinitionList/DefinitionListConfigurationError.ts +6 -0
  51. package/src/Display/Icon/Icon.tsx +113 -0
  52. package/src/Display/Table/Table.tsx +434 -44
  53. package/src/Display/Table/TableConfigurationError.ts +6 -0
  54. package/src/Display/Typography/H1/H1.tsx +3 -2
  55. package/src/Display/Typography/H2/H2.tsx +3 -2
  56. package/src/Display/Typography/H3/H3.tsx +3 -2
  57. package/src/Display/Typography/H4/H4.tsx +3 -2
  58. package/src/Display/Typography/H5/H5.tsx +3 -2
  59. package/src/Display/Typography/H6/H6.tsx +3 -2
  60. package/src/Display/Typography/P/P.tsx +3 -2
  61. package/src/Display/Typography/Prose/Prose.tsx +3 -3
  62. package/src/Interaction/Button/Button.tsx +47 -17
  63. package/src/Interaction/Input/Input.tsx +1 -1
  64. package/src/Interaction/MultiSelect/MultiSelect.tsx +1 -1
  65. package/src/Interaction/NumberInput/NumberInput.tsx +1 -1
  66. package/src/Interaction/Select/Select.tsx +1 -1
  67. package/src/Interaction/Tabs/Tabs.tsx +38 -11
  68. package/src/Interaction/TextArea/TextArea.tsx +1 -1
  69. package/src/Layout/Dialog/Dialog.tsx +4 -3
  70. package/src/Layout/Header/Header.tsx +139 -39
  71. package/src/Layout/PageHead/PageHead.tsx +6 -5
  72. package/src/Layout/ScrollContainer/ScrollContainer.tsx +149 -0
  73. package/src/Layout/Sidebar/Sidebar.tsx +8 -4
  74. package/src/Theme/Palette.ts +37 -9
  75. package/src/Theme/renderTokens.ts +101 -5
  76. package/src/index.ts +6 -0
  77. package/src/tokens.css +68 -4
  78. package/src/tokens.dark.css +66 -4
  79. package/src/tokens.light.css +64 -2
@@ -2,19 +2,15 @@ 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 recipe on the <header>. It sets the label type role - the "small grotesk, letter-spaced, muted"
6
- // the issue described, whose "never grows past 1rem" was a role wearing a number, so no size literal
7
- // appears here (#14). The shell's air is one value in every direction: --space-region above, below and
8
- // (on the nav) between, --gutter across, so the bar aligns with every inset Section. The current-page
9
- // treatment keys on the attribute, not on a component: [&_[aria-current=page]] compiles to specificity
10
- // 0,2,0 and beats Link's quiet text-muted (0,1,0) with no !important and no import, so the current item
11
- // sits at foreground - the colour every other item reaches only on hover. `edge` draws the bottom
12
- // hairline in `rule`, the one weight a page's boundaries share with a Section join, and defaults to
13
- // `rule` - the conventional header - so a ruleless shell opts out. Colours are semantic tokens re-pointed
14
- // by `.dark`, so no `dark:` class.
5
+ // The recipe on the <header>. It sets the label type role - whose "never grows past 1rem" was a role
6
+ // wearing a number, so no size literal appears here (#14) - and one air value in every direction:
7
+ // --space-region above and below, --gutter across, so the bar aligns with every inset Section. The
8
+ // current-page treatment keys on the attribute, not on a component: [&_[aria-current=page]] compiles
9
+ // to specificity 0,2,0 and beats Link's quiet text-muted (0,1,0) with no !important and no import.
10
+ // Colours are semantic tokens re-pointed by `.dark`, so no `dark:` class.
15
11
  const header = cva(
16
12
  [
17
- 'flex items-baseline justify-between',
13
+ 'flex items-baseline',
18
14
  'font-secondary text-label leading-label tracking-label text-muted',
19
15
  'py-[var(--space-region)] px-[var(--gutter)]',
20
16
  '[&_[aria-current=page]]:text-foreground',
@@ -25,26 +21,48 @@ const header = cva(
25
21
  none: '',
26
22
  rule: 'border-b border-solid border-rule',
27
23
  },
24
+ // Read off the slots the caller filled, never set by a caller (see IHeaderProps). `navigation` is
25
+ // the arrangement the bar always had. `statusAction` breaks into lines instead of overflowing:
26
+ // --space-region along a line, the role the nav gaps its items with, and --space-stack between
27
+ // lines, Cluster's rule for a wrapped row (docs/adr/0008).
28
+ mode: {
29
+ navigation: 'justify-between',
30
+ statusAction:
31
+ 'flex-wrap gap-x-[var(--space-region)] gap-y-[var(--space-stack)]',
32
+ },
28
33
  },
29
- defaultVariants: { edge: 'rule' },
34
+ defaultVariants: { edge: 'rule', mode: 'navigation' },
30
35
  },
31
36
  );
32
37
 
33
- // Not a second recipe - the standing slot has nothing to vary, and the standard allows a component
34
- // one cva() (design-system-components.md §4), which is the bar's own above. A named constant beside
35
- // the nav's inline class string, so the comment has something to sit on.
36
- //
37
- // The height floor is the nav's own line box, written from the two tokens the recipe above sets the
38
- // header from, so the declared floor and the rendered line cannot drift (#81). `shrink-0` because an
39
- // explicit min-width replaces a flex item's automatic minimum - without it the bar squeezes the slot
40
- // below its content and wraps the place name the floor exists to keep on one line.
38
+ // Not a second recipe - no slot has anything to vary, and the standard allows a component one cva()
39
+ // (design-system-components.md §4), which is the bar's own above. The standing floor is the nav's own
40
+ // line box, written from the two tokens the recipe sets the header from, so floor and rendered line
41
+ // cannot drift (#81); `shrink-0` because an explicit min-width replaces a flex item's automatic one.
41
42
  const standingSlot = [
42
43
  'inline-flex shrink-0 items-center',
43
44
  'min-h-[calc(var(--text-label)*var(--leading-label))]',
44
45
  'min-w-[var(--standing-min-width)]',
45
46
  ].join(' ');
46
47
 
47
- export interface IHeaderProps extends VariantProps<typeof header> {
48
+ const navSlot = 'flex flex-wrap items-baseline gap-[var(--space-region)]';
49
+
50
+ // The readout may shrink below its longest word once it has a line of its own, and an unbroken token
51
+ // then breaks inside the slot rather than widening the page. No role, no name, no live region.
52
+ const statusSlot = 'min-w-0 wrap-anywhere';
53
+
54
+ // One auto margin puts the action on the end edge whatever the line holds - beside the readout, or
55
+ // alone once the bar has broken. It is not `shrink-0`: a Button narrower than its label wraps the
56
+ // label itself (#119), where a bar refusing to shrink it would overflow the page instead.
57
+ const actionSlot = 'ms-auto';
58
+
59
+ interface IHeaderShellProps extends Omit<VariantProps<typeof header>, 'mode'> {
60
+ testId?: string;
61
+ }
62
+
63
+ /** The navigation bar: the mode every existing caller is in, and the default. A standing link and a
64
+ * `<nav>`, and never a status or an action slot - the other shape is `IHeaderStatusActionProps`. */
65
+ export interface IHeaderNavigationProps extends IHeaderShellProps {
48
66
  /** The standing link: a place name, a mark, or a home link. The consumer supplies the whole anchor -
49
67
  * Header renders no link of its own. A place name uses `<Link treatment="quiet" href="/">…</Link>`; a
50
68
  * mark uses `<Link treatment="graphic" href="/"><Brandmark …/></Link>`, whose `graphic` treatment
@@ -54,16 +72,56 @@ export interface IHeaderProps extends VariantProps<typeof header> {
54
72
  navName?: string;
55
73
  /** The nav links. */
56
74
  children?: ReactNode;
57
- testId?: string;
75
+ status?: never;
76
+ action?: never;
58
77
  }
59
78
 
79
+ /** The status/action bar: a bar that reports and acts rather than navigates, for a product whose
80
+ * shell carries a readout and a control and no links. It has no standing link and no `<nav>` - a
81
+ * button is not navigation - and the two shapes cannot be mixed: the compiler rejects a call that
82
+ * hands this bar a standing link, a nav name or nav children. */
83
+ export interface IHeaderStatusActionProps extends IHeaderShellProps {
84
+ /** The readout - a date, a balance, a short note, or a `Cluster` of them - at the start edge, first
85
+ * in reading order. A plain `div` with no role, no name and no live region: what it holds carries its
86
+ * own semantics, and whether a change is announced is the consumer's decision, made by wrapping its
87
+ * own live region. Long and unbroken wording wraps inside the slot. Omitted, nothing renders. */
88
+ status?: ReactNode;
89
+ /** The control - a `Button`, or a `Cluster` of them - at the end edge, last in reading and keyboard
90
+ * order, on whichever line it lands when the bar breaks. A plain `div` like `status`. Header never
91
+ * decides whether the control is available: pass it disabled, or withhold it (`{ready && <Button/>}`
92
+ * renders no box). Omitted, nothing renders. */
93
+ action?: ReactNode;
94
+ standing?: never;
95
+ navName?: never;
96
+ children?: never;
97
+ }
98
+
99
+ export type IHeaderProps = IHeaderNavigationProps | IHeaderStatusActionProps;
100
+
101
+ // What React paints nothing for: `undefined`, `null`, a boolean and the empty string. A withheld slot
102
+ // gets no box, so a status-only or action-only bar carries no empty one.
103
+ const isPainted = (slot: ReactNode): boolean =>
104
+ slot !== undefined &&
105
+ slot !== null &&
106
+ typeof slot !== 'boolean' &&
107
+ slot !== '';
108
+
60
109
  /**
61
- * The shell's top edge: a standing link and a nav, at the label type role. It renders the banner
62
- * landmark and a single `<nav>`, arranging nothing beyond the two slots, so it works with no hydration.
110
+ * The shell's top edge, at the label type role, in one of two mutually exclusive modes. The
111
+ * **navigation bar** - the default, and what every existing caller renders - is a standing link and a
112
+ * single `<nav>`. The **status/action bar** is a readout at the start edge and a control at the end
113
+ * edge, both plain boxes with no landmark of their own and no `<nav>` anywhere. The props type is a
114
+ * union of the two shapes, so a call that mixes them does not compile. Either bar arranges nothing
115
+ * beyond its slots and works with no hydration.
63
116
  *
64
117
  * @Guarantees — enforced on every render
65
118
  * - The header is set at the label role and carries no size of its own: `font-secondary text-label
66
119
  * leading-label tracking-label text-muted`, so it never grows past that role whatever the page font size.
120
+ * - The shell's air is one value above, below and between: `--space-region` vertically, between the
121
+ * nav's items and between the status/action bar's slots along a line, `--gutter` across, so the bar
122
+ * aligns with every inset `Section`. It is never sticky and needs no JavaScript.
123
+ *
124
+ * The navigation bar, unchanged by #125:
67
125
  * - The nav's line box is the library's own rather than the consuming document's: the header leads
68
126
  * itself at the label role, so the height it declares for its slot is the height it renders.
69
127
  * - The standing slot is floored, never fixed: its minimum height is that same line box - the label
@@ -73,10 +131,24 @@ export interface IHeaderProps extends VariantProps<typeof header> {
73
131
  * grows the slot, on one line, and is never clamped or wrapped.
74
132
  * - A nav item marked `aria-current="page"` renders at `foreground` whatever supplied it - the treatment
75
133
  * keys on the attribute, not on `Link`, so it holds against a bare anchor or any component.
76
- * - The shell's air is one value above, below and between: `--space-region` vertically and between nav
77
- * items, `--gutter` across, so the bar aligns with every inset `Section`.
78
- * - It is never sticky and needs no JavaScript: there is no `sticky` variant and nothing to hydrate.
79
- * - Omitting `navName` emits no `aria-label` at all, not an empty one.
134
+ * - The standing slot and the `<nav>` are the bar's two children whatever the caller passes, the nav
135
+ * flush with the end content edge and absorbing narrowing by wrapping its links. Omitting `navName`
136
+ * emits no `aria-label` at all, not an empty one.
137
+ *
138
+ * The status/action bar:
139
+ * - It renders no `<nav>`, no standing slot, no role, no name and no live region of its own. Each
140
+ * filled slot is one `div` directly inside the banner; an omitted or withheld slot (`false`, `null`)
141
+ * renders no box and reserves no space, so a status-only bar starts with its readout and an action-only
142
+ * bar is the control alone at the end edge.
143
+ * - Reading and keyboard order is status, then action, and the DOM order is the same at every width.
144
+ * - With both fitting, the readout sits at the start content edge and the control at the end content
145
+ * edge on one line. When they cannot share a line, the control drops below the readout and stays
146
+ * flush with the end edge; a readout then takes the whole line and wraps inside it, unbroken wording
147
+ * included, so the bar never widens the page and never clips.
148
+ * - Breaking is CSS alone: a width change re-flows the same elements, so descendant state and focus
149
+ * survive it.
150
+ * - What the consumer passes keeps its semantics: a heading stays a heading, a live region stays one, a
151
+ * disabled control stays disabled. Header adds, removes and announces nothing.
80
152
  *
81
153
  * @CallerMustEnsure — the component cannot see these and does not check them
82
154
  * - A mark that should *fill* the standing slot is given a **definite width** by whoever placed it -
@@ -89,6 +161,10 @@ export interface IHeaderProps extends VariantProps<typeof header> {
89
161
  * width; `flex: 1 1 0; min-width: 0` only collapses the mark when the bar is already out of room, so
90
162
  * it is not the rule to reach for. The library cannot apply either rule for you: the same width on a
91
163
  * place name would clamp the name and wrap it.
164
+ * - Set the type of what fills a status/action slot: the bar sets the label role, so a bare string in
165
+ * `status` reads as a label, while a `P` or a `Note` wears its own role and a `Button` its own.
166
+ * - Whether a changing readout interrupts is yours to decide: wrap your own live region inside `status`
167
+ * where a change must be announced, and leave it out where it must not. Header takes neither side.
92
168
  *
93
169
  * @UXGuidelines
94
170
  * - Name the nav with `navName` once the page has more than one navigation landmark - a footer nav will
@@ -97,21 +173,45 @@ export interface IHeaderProps extends VariantProps<typeof header> {
97
173
  * `aria-current`, so set `current` on the `Link` and style nothing yourself.
98
174
  * - The nav does not collapse into a menu: a disclosure needs JavaScript, so on a narrow viewport the
99
175
  * items wrap. A mobile menu is out of scope, not a follow-up.
176
+ * - A bar that reports and acts is a status/action bar, never a navigation bar with a button among its
177
+ * links: a Balance line or a Continue button inside a `<nav>` is announced as navigation. A product
178
+ * whose shell needs links as well as a control has two bars, not one.
179
+ * - Several readouts or several actions go in a `Cluster` inside the slot: `gap="stack"` keeps them one
180
+ * group, and the Cluster wraps inside the slot before the bar runs out of room.
100
181
  */
101
182
  export const Header: FunctionComponent<IHeaderProps> = ({
102
183
  edge,
184
+ testId,
103
185
  standing,
104
186
  navName,
105
187
  children,
106
- testId,
107
- }) => (
108
- <header className={header({ edge })} data-testid={testId}>
109
- <div className={standingSlot}>{standing}</div>
110
- <nav
111
- aria-label={navName}
112
- className={'flex flex-wrap items-baseline gap-[var(--space-region)]'}
188
+ status,
189
+ action,
190
+ }) => {
191
+ // A slot the caller named decides the mode, even one withheld as `false`: `action={ready && …}` is a
192
+ // status/action bar with its control withheld, not a navigation bar with an empty nav.
193
+ const reportsOrActs = status !== undefined || action !== undefined;
194
+ return (
195
+ <header
196
+ className={header({
197
+ edge,
198
+ mode: reportsOrActs ? 'statusAction' : 'navigation',
199
+ })}
200
+ data-testid={testId}
113
201
  >
114
- {children}
115
- </nav>
116
- </header>
117
- );
202
+ {reportsOrActs ? (
203
+ <>
204
+ {isPainted(status) && <div className={statusSlot}>{status}</div>}
205
+ {isPainted(action) && <div className={actionSlot}>{action}</div>}
206
+ </>
207
+ ) : (
208
+ <>
209
+ <div className={standingSlot}>{standing}</div>
210
+ <nav aria-label={navName} className={navSlot}>
211
+ {children}
212
+ </nav>
213
+ </>
214
+ )}
215
+ </header>
216
+ );
217
+ };
@@ -16,20 +16,21 @@ const pageHead = cva(
16
16
  'flex flex-col gap-[var(--space-stack)] py-[var(--space-band)] px-[var(--gutter)]',
17
17
  );
18
18
 
19
- // The scale event: the title role, led and tracked as a large heading, foreground. No measure - the
20
- // head is full-bleed and the consumer keeps titles short.
19
+ // The scale event: the title role, led and tracked as a large heading, foreground, in the heading
20
+ // face every heading reads (#120). No measure - the head is full-bleed and the consumer keeps titles
21
+ // short.
21
22
  const pageHeadTitle = cva(
22
- 'font-primary text-title leading-title tracking-optical text-foreground',
23
+ 'font-heading text-title leading-title tracking-optical text-foreground',
23
24
  );
24
25
 
25
26
  // The standfirst: the lede role, foreground, run one measure wider than the reading column (#18).
26
27
  const pageHeadLede = cva(
27
- 'font-primary text-lede leading-lede text-foreground max-w-[var(--measure-wide)]',
28
+ 'font-body text-lede leading-lede text-foreground max-w-[var(--measure-wide)]',
28
29
  );
29
30
 
30
31
  // The small print: the small role, muted, held to the reading measure like Prose's tail.
31
32
  const pageHeadIntro = cva(
32
- 'font-primary text-small text-muted max-w-[var(--measure)]',
33
+ 'font-body text-small text-muted max-w-[var(--measure)]',
33
34
  );
34
35
 
35
36
  export interface IPageHeadProps {
@@ -0,0 +1,149 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import {
4
+ type FunctionComponent,
5
+ type ReactNode,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+
11
+ // The container paints nothing: no fill, no border, no size of its own. `max-h-full`/`max-w-full`
12
+ // let a sized parent bound it and resolve to nothing under an unsized one, so no viewport bound is
13
+ // invented. A disabled axis is `hidden`, so overflow there is clipped rather than scrolled. The one
14
+ // focus ring is drawn with outline, colour at rest - docs/adr/0002.
15
+ const scrollContainer = cva(
16
+ 'max-h-full max-w-full min-h-0 min-w-0 outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
17
+ {
18
+ variants: {
19
+ axis: {
20
+ both: 'overflow-auto',
21
+ horizontal: 'overflow-x-auto overflow-y-hidden',
22
+ vertical: 'overflow-y-auto overflow-x-hidden',
23
+ },
24
+ },
25
+ defaultVariants: { axis: 'both' },
26
+ },
27
+ );
28
+
29
+ // The content box is `fit-content` floored at the container's width where horizontal scrolling is
30
+ // on, so unwrappable content grows it - which a ResizeObserver can see, since growth inside an
31
+ // overflow box never changes the container's size - and the container's width where only vertical
32
+ // is on, so a Table keeps its own horizontal scroll region. The padding is the focus ring's room.
33
+ const scrollContent = cva(
34
+ 'p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
35
+ {
36
+ variants: {
37
+ axis: {
38
+ both: 'w-fit min-w-full',
39
+ horizontal: 'w-fit min-w-full',
40
+ vertical: 'w-full',
41
+ },
42
+ },
43
+ defaultVariants: { axis: 'both' },
44
+ },
45
+ );
46
+
47
+ type Axis = NonNullable<VariantProps<typeof scrollContainer>['axis']>;
48
+
49
+ const isOverflowing = (element: HTMLElement, axis: Axis): boolean => {
50
+ const horizontally = element.scrollWidth > element.clientWidth;
51
+ const vertically = element.scrollHeight > element.clientHeight;
52
+ return (
53
+ (axis !== 'vertical' && horizontally) ||
54
+ (axis !== 'horizontal' && vertically)
55
+ );
56
+ };
57
+
58
+ // A tab stop only while an enabled axis overflows (WCAG 2.1.1 wants the scroll container itself
59
+ // focusable when nothing inside is): reachable until measured, so server markup is operable before
60
+ // hydration, and kept reachable while it holds focus itself, since dropping tabindex from the
61
+ // focused element would let the browser relocate focus - the one thing a re-measure must not do.
62
+ const useOverflow = (
63
+ container: { current: HTMLElement | null },
64
+ content: { current: HTMLElement | null },
65
+ axis: Axis,
66
+ ): boolean => {
67
+ const [isScrollable, setIsScrollable] = useState(true);
68
+ useLayoutEffect(() => {
69
+ const element = container.current;
70
+ if (element === null) return;
71
+ const measure = () =>
72
+ setIsScrollable(
73
+ isOverflowing(element, axis) || element === document.activeElement,
74
+ );
75
+ measure();
76
+ const observer =
77
+ typeof ResizeObserver === 'undefined'
78
+ ? undefined
79
+ : new ResizeObserver(measure);
80
+ observer?.observe(element);
81
+ if (content.current !== null) observer?.observe(content.current);
82
+ window.addEventListener('resize', measure);
83
+ element.addEventListener('blur', measure);
84
+ return () => {
85
+ observer?.disconnect();
86
+ window.removeEventListener('resize', measure);
87
+ element.removeEventListener('blur', measure);
88
+ };
89
+ }, [container, content, axis]);
90
+ return isScrollable;
91
+ };
92
+
93
+ export interface IScrollContainerProps
94
+ extends VariantProps<typeof scrollContainer> {
95
+ /** The group's accessible name while it can be scrolled. Required: a tab stop with no name is a
96
+ * mystery to a screen reader. The consuming app words it, usually after the heading above. */
97
+ ariaLabel: string;
98
+ children?: ReactNode;
99
+ testId?: string;
100
+ }
101
+
102
+ /**
103
+ * Makes overflowing content reachable along the chosen axes, within the space its parent allocates.
104
+ * It owns the scrolling; the consumer owns the content and the allocation of space.
105
+ *
106
+ * @Guarantees — enforced on every render
107
+ * - Scrolls only an enabled axis and only once content overflows it; overflow on a disabled axis is
108
+ * clipped. Content wraps as it would anywhere else.
109
+ * - Keyboard-reachable - a named `group` with a tab stop - exactly while an enabled axis overflows,
110
+ * static content included; no stop and no group while everything fits. Scrollability is re-read on
111
+ * resize and on content change without moving focus.
112
+ * - Native scrolling: scrollbars, wheel, touch and the browser's own arrow/page keys on the focused
113
+ * container. No key of a control inside it is intercepted and focus is never trapped.
114
+ * - Invents no bound: no height, width or viewport unit of its own. The only space it adds is the
115
+ * focus ring's room around its content, so a focusable child flush with its edge - a Table's
116
+ * scroll region, a button - keeps a visible ring instead of having it clipped at the edge.
117
+ *
118
+ * @CallerMustEnsure
119
+ * - The parent allocates finite space on every axis the container should scroll - a sized box, or a
120
+ * flex/grid item allowed to shrink (`flex: 1 1 0; min-height: 0` in a column). Under an unbounded
121
+ * parent it simply grows with its content, as any block would.
122
+ * - Content fits a disabled axis. Clipping is not a way to hide essential content or controls.
123
+ * - `ariaLabel` is wording the viewer would recognise, typically the heading above the content.
124
+ */
125
+ export const ScrollContainer: FunctionComponent<IScrollContainerProps> = ({
126
+ axis,
127
+ ariaLabel,
128
+ children,
129
+ testId,
130
+ }) => {
131
+ const container = useRef<HTMLDivElement>(null);
132
+ const content = useRef<HTMLDivElement>(null);
133
+ const isScrollable = useOverflow(container, content, axis ?? 'both');
134
+ return (
135
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: the name and the `group` role are set together; biome cannot see the pair
136
+ <div
137
+ ref={container}
138
+ className={scrollContainer({ axis })}
139
+ data-testid={testId}
140
+ role={isScrollable ? 'group' : undefined}
141
+ aria-label={isScrollable ? ariaLabel : undefined}
142
+ tabIndex={isScrollable ? 0 : undefined}
143
+ >
144
+ <div ref={content} className={scrollContent({ axis })}>
145
+ {children}
146
+ </div>
147
+ </div>
148
+ );
149
+ };
@@ -62,12 +62,12 @@ const observeScrollport: RefCallback<HTMLElement> = (navigation) => {
62
62
  };
63
63
 
64
64
  // An entry at the label role, told apart by colour and underline alone - the agreed treatment carries
65
- // no active-background role. Active keeps a persistent underline at the at-rest thickness; usable
66
- // raises one on hover, instantly (underlines are off the motion allowlist, docs/adr/0001); inert is
67
- // muted with no interactive response. The focus ring is the library's one contract (docs/adr/0002).
65
+ // no active-background role. Active keeps a persistent underline; usable raises one on hover, instantly
66
+ // (docs/adr/0001); inert is muted. The focus ring is the one contract (docs/adr/0002). Labels wrap with
67
+ // `overflow-wrap: anywhere`: unlike `break-word` it folds an unbroken word into min-content (#122).
68
68
  const sidebarEntry = cva(
69
69
  [
70
- 'w-full text-left font-secondary text-label tracking-label',
70
+ 'w-full text-left font-secondary text-label tracking-label wrap-anywhere',
71
71
  'underline-offset-[var(--underline-offset)]',
72
72
  'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
73
73
  ].join(' '),
@@ -196,6 +196,10 @@ const SidebarRoot: FunctionComponent<ISidebarRootProps> = ({
196
196
  * - Activating the active entry or an inert one emits nothing; no render emits anything. Entries are
197
197
  * non-submitting `type="button"` buttons with native Tab/Enter/Space behaviour - no tabs/menu model.
198
198
  * - An inert entry stays visible but muted and disabled, so Tab skips it and activation is inert too.
199
+ * - A label is rendered in full inside its entry, in either arrangement: a phrase wraps at its spaces
200
+ * and a word wider than the track breaks within itself. Nothing is truncated, renamed or hidden
201
+ * behind a tooltip, no horizontal scrolling is introduced, and a translated label neither widens
202
+ * the 12rem track nor displaces the content.
199
203
  * - At and above 64rem the nav is a fixed 12rem track, sticky at the top of the scrolling area with no
200
204
  * assumed top-bar offset, capped to the screen/scrolling-area height with independent entry scrolling.
201
205
  * Content sits beside it in `minmax(0,1fr)`, so wide content cannot displace the track.
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * Light and dark are two complete sets of the same roles rather than a set of `dark:` overrides
11
11
  * scattered through the components. A component therefore carries no dark-mode classes at all:
12
- * swapping the `.dark` class re-points the variables underneath it.
12
+ * swapping the `.dark` class re-points the variables underneath it. Complete means `Required`: the
13
+ * two roles the type marks optional are optional for a consumer's palette object, never here.
13
14
  *
14
15
  * The values below are still depot-tracker's brand (primary is its violet). They are carried over
15
16
  * so nothing changed visually during the extraction - a starting point to replace, not a decision.
@@ -121,8 +122,28 @@ export type PaletteTokens = {
121
122
  * constraint stated on `success`. */
122
123
  warning: string;
123
124
  /** The error status tone. Carries the general status-tone contract and 4.5:1-against-`surface`
124
- * constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`. */
125
+ * constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`. It is
126
+ * also Button's destructive fill (#119), identified like `primary` by the fill alone: at least 3:1
127
+ * against `surface` in the same theme, `errorHover` included - which the 4.5:1 text floor already
128
+ * clears - and constrained from the other side by the ink it carries, stated on `errorForeground`.
129
+ * See docs/adr/0011-status-tones-are-general-roles.md, Amendments. */
125
130
  error: string;
131
+ /** The destructive fill's hover step. Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against
132
+ * `surface` in the same theme, and at least 4.5:1 against `errorForeground`, since a hovered
133
+ * control has to stay identifiable and readable too. Not a status tone: it carries no text of its
134
+ * own and the status-tone text floor does not apply to it. Optional in the type so a palette
135
+ * object written before #119 keeps compiling: the shipped stylesheet declares `--color-error-hover`,
136
+ * and a theme that omits the role inherits that default - which pairs with the shipped `error`,
137
+ * so a theme that re-points `error` re-points this too. */
138
+ errorHover?: string;
139
+ /** Text and icons drawn on top of `error` and `errorHover`. Constraint (WCAG 2.2 SC 1.4.3): at
140
+ * least 4.5:1 against both in the same theme, hover included. Optional in the type for the same
141
+ * reason as `errorHover`, with the same obligation: the shipped default is the ink for the
142
+ * shipped `error`. The ink inverts with the theme as `primaryForeground` does, and in light it is
143
+ * pure white rather than slate-50 because the shipped `error` sits exactly on the 4.5:1 floor
144
+ * against white (4.501:1) and slate-50 measures 4.30:1 - under it. Not required against
145
+ * `disabled`, which SC 1.4.3 exempts. */
146
+ errorForeground?: string;
126
147
  /** The informational status tone. Carries the general status-tone contract and
127
148
  * 4.5:1-against-`surface` constraint stated on `success`. */
128
149
  info: string;
@@ -147,7 +168,7 @@ export type PaletteTokens = {
147
168
  * against `#0f172a`, the dark set's own `surface`, sky-600 lands at 4.36 and fails. It also buys
148
169
  * a light theme with white text on one button and black on the one beside it.
149
170
  */
150
- export const light: PaletteTokens = {
171
+ export const light: Required<PaletteTokens> = {
151
172
  surface: '#ffffff',
152
173
  foreground: '#0f172a',
153
174
  muted: '#64748b',
@@ -179,6 +200,8 @@ export const light: PaletteTokens = {
179
200
  success: '#047857',
180
201
  warning: '#b45309',
181
202
  error: '#d63384',
203
+ errorHover: '#be185d',
204
+ errorForeground: '#ffffff',
182
205
  info: '#0e7490',
183
206
  };
184
207
 
@@ -193,11 +216,14 @@ export const light: PaletteTokens = {
193
216
  * under its darker ones. The pair is the two ends of the one neutral ramp the rest of the palette is
194
217
  * already built from - `#f8fafc` is slate-50, `surface` slate-900, `muted` slate-500 - rather than a
195
218
  * new colour arriving for a single job. `#020617` is also the lightest slate step that still admits
196
- * violet-500 and sky-600, which is what leaves this set's `secondary` pair unmoved: slate-900 draws
197
- * 4.22 and 4.36 against them and fails. Why the ink follows the theme at all, and the two routes
198
- * rejected in getting here: see the light set.
219
+ * violet-500: slate-900 draws 4.22 against it and fails. The `secondary` pair sits one ramp step
220
+ * above where it first shipped - sky-500 at rest, sky-400 on hover, where it was sky-600 and sky-500 -
221
+ * because #119 made `secondary` an ink as well as a fill: the outlined Button draws its text and edge
222
+ * in it, and sky-600 measures 4.36:1 against this surface, under the 4.5:1 text floor (now 6.44:1
223
+ * and 8.33:1, both still clearing `secondaryForeground` at 7.28:1 and 9.42:1). Why the ink follows
224
+ * the theme at all, and the two routes rejected in getting here: see the light set.
199
225
  */
200
- export const dark: PaletteTokens = {
226
+ export const dark: Required<PaletteTokens> = {
201
227
  surface: '#0f172a',
202
228
  foreground: '#f8fafc',
203
229
  muted: '#94a3b8',
@@ -215,8 +241,8 @@ export const dark: PaletteTokens = {
215
241
  primaryHover: '#a78bfa',
216
242
  primaryForeground: '#020617',
217
243
 
218
- secondary: '#0284c7',
219
- secondaryHover: '#0ea5e9',
244
+ secondary: '#0ea5e9',
245
+ secondaryHover: '#38bdf8',
220
246
  secondaryForeground: '#020617',
221
247
 
222
248
  disabled: '#475569',
@@ -229,5 +255,7 @@ export const dark: PaletteTokens = {
229
255
  success: '#34d399',
230
256
  warning: '#fbbf24',
231
257
  error: '#f48fb1',
258
+ errorHover: '#f8bbd0',
259
+ errorForeground: '#020617',
232
260
  info: '#22d3ee',
233
261
  };