@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
@@ -0,0 +1,313 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import {
4
+ createContext,
5
+ type FunctionComponent,
6
+ type ReactNode,
7
+ useContext,
8
+ useLayoutEffect,
9
+ useRef,
10
+ } from 'react';
11
+ import { alignControlEdges } from './alignControlEdges';
12
+ import { FieldRowCompositionError } from './FieldRowCompositionError';
13
+ import { FieldRowConfigurationError } from './FieldRowConfigurationError';
14
+ import type { IControlEdge } from './IControlEdge';
15
+
16
+ // Root's one recipe: a wrapping flex row whose items sit on their line's top edge, so the padding
17
+ // the alignment pass writes on an item is the only thing that moves its control. The row gap is
18
+ // Cluster's (docs/adr/0008): along a line the two attested roles, between wrapped lines `stack`.
19
+ const fieldRowRoot = cva(
20
+ 'flex flex-wrap items-start gap-y-[var(--space-stack)]',
21
+ {
22
+ variants: {
23
+ gap: {
24
+ stack: 'gap-x-[var(--space-stack)]',
25
+ region: 'gap-x-[var(--space-region)]',
26
+ },
27
+ },
28
+ defaultVariants: { gap: 'stack' },
29
+ },
30
+ );
31
+
32
+ // Field's one recipe: a share of its row from nothing, so growth is proportional to weight alone.
33
+ // The weight and the token-named minimum are the two values ADR 0008 lets a caller choose, and
34
+ // they are written on the item's style because a recipe cannot hold a caller's number or name.
35
+ const fieldRowField = cva('shrink basis-0');
36
+
37
+ // Actions' one recipe, keyed by part: the item takes its content width and never a share of the
38
+ // row, capped at the row so the group inside it wraps its buttons only once it cannot fit alone.
39
+ const fieldRowActions = cva('', {
40
+ variants: {
41
+ part: {
42
+ item: 'min-w-0 max-w-full flex-none',
43
+ group: 'flex flex-wrap items-end gap-[var(--space-stack)]',
44
+ },
45
+ },
46
+ defaultVariants: { part: 'item' },
47
+ });
48
+
49
+ const TOKEN_NAME = /^--[A-Za-z0-9_-]+$/;
50
+
51
+ const ROOT_ITEMS = ':scope > [data-field-row-item]';
52
+
53
+ type FieldRowContract = {
54
+ align: () => void;
55
+ };
56
+
57
+ const FieldRowContext = createContext<FieldRowContract | undefined>(undefined);
58
+
59
+ const useFieldRowContract = (member: string): FieldRowContract => {
60
+ const contract = useContext(FieldRowContext);
61
+ if (contract === undefined) {
62
+ throw new FieldRowCompositionError(member);
63
+ }
64
+ return contract;
65
+ };
66
+
67
+ const assertWeight = (weight: number): void => {
68
+ if (!(Number.isFinite(weight) && weight > 0)) {
69
+ throw new FieldRowConfigurationError(
70
+ `weight must be a positive finite number, got ${weight}`,
71
+ );
72
+ }
73
+ };
74
+
75
+ const assertTokenName = (minWidth: string): void => {
76
+ if (!TOKEN_NAME.test(minWidth)) {
77
+ throw new FieldRowConfigurationError(
78
+ `minWidth must name a CSS custom property such as --search-field-min-width, got ${JSON.stringify(minWidth)}`,
79
+ );
80
+ }
81
+ };
82
+
83
+ // The control is whatever the field's own label labels - the association every field guarantees -
84
+ // so the alignment boundary is the field's public contract and never its box structure. A control
85
+ // positioned absolutely is drawn as the box it is positioned in: MultiSelect's trigger spans its
86
+ // field inside the border, and the border is the edge a viewer aligns.
87
+ const labelledControl = (content: Element): Element | undefined => {
88
+ const id = content.querySelector('label[for]')?.getAttribute('for');
89
+ if (!id) {
90
+ return undefined;
91
+ }
92
+ const control = content.ownerDocument.getElementById(id);
93
+ return control !== null && content.contains(control) ? control : undefined;
94
+ };
95
+
96
+ const isPositioned = (element: Element): boolean =>
97
+ getComputedStyle(element).position !== 'static';
98
+
99
+ const controlBox = (control: Element, content: Element): Element => {
100
+ if (getComputedStyle(control).position !== 'absolute') {
101
+ return control;
102
+ }
103
+ let box = control.parentElement;
104
+ while (box !== null && box !== content && !isPositioned(box)) {
105
+ box = box.parentElement;
106
+ }
107
+ return box ?? control;
108
+ };
109
+
110
+ const measure = (item: Element): IControlEdge => {
111
+ const top = item.getBoundingClientRect().top;
112
+ const content = item.firstElementChild;
113
+ if (content === null) {
114
+ return { top, controlEdge: 0 };
115
+ }
116
+ const contentBounds = content.getBoundingClientRect();
117
+ const control = labelledControl(content);
118
+ const edge =
119
+ control === undefined
120
+ ? contentBounds.bottom
121
+ : controlBox(control, content).getBoundingClientRect().bottom;
122
+ return { top, controlEdge: edge - contentBounds.top };
123
+ };
124
+
125
+ // Reads every item's line and control edge in one pass, then writes only the paddings that
126
+ // changed: a converged row is read-only, so the observer that called this sees no further resize.
127
+ const alignItems = (root: HTMLElement): void => {
128
+ const items = Array.from(root.querySelectorAll<HTMLElement>(ROOT_ITEMS));
129
+ const paddings = alignControlEdges(items.map(measure));
130
+ items.forEach((item, index) => {
131
+ const padding = paddings[index] ?? 0;
132
+ const current = Number.parseFloat(item.style.paddingTop) || 0;
133
+ if (Math.abs(current - padding) > 0.01) {
134
+ item.style.paddingTop = padding === 0 ? '' : `${padding}px`;
135
+ }
136
+ });
137
+ };
138
+
139
+ export interface IFieldRowRootProps extends VariantProps<typeof fieldRowRoot> {
140
+ /** `FieldRow.Field` and `FieldRow.Actions` members, in reading order. */
141
+ children?: ReactNode;
142
+ testId?: string;
143
+ }
144
+
145
+ const FieldRowRoot: FunctionComponent<IFieldRowRootProps> = ({
146
+ gap,
147
+ children,
148
+ testId,
149
+ }) => {
150
+ const rootRef = useRef<HTMLDivElement>(null);
151
+ const observerRef = useRef<ResizeObserver | undefined>(undefined);
152
+ const observedRef = useRef<WeakSet<Element>>(new WeakSet());
153
+
154
+ const align = (): void => {
155
+ if (rootRef.current) {
156
+ alignItems(rootRef.current);
157
+ }
158
+ };
159
+
160
+ const observe = (element: Element | undefined): void => {
161
+ if (element && observerRef.current && !observedRef.current.has(element)) {
162
+ observedRef.current.add(element);
163
+ observerRef.current.observe(element);
164
+ }
165
+ };
166
+
167
+ // Every render re-measures before paint, and every item's content and label are observed from
168
+ // then on: a label wrapping under a narrower holder or a message appearing changes their size,
169
+ // never the padded item's, so the observer never sees its own write and the loop settles. The
170
+ // label is watched on its own because it can grow exactly as a message below the control goes.
171
+ useLayoutEffect(() => {
172
+ const root = rootRef.current;
173
+ if (root === null) {
174
+ return;
175
+ }
176
+ alignItems(root);
177
+ if (typeof ResizeObserver === 'undefined') {
178
+ return;
179
+ }
180
+ observerRef.current ??= new ResizeObserver(() => alignItems(root));
181
+ for (const item of root.querySelectorAll(ROOT_ITEMS)) {
182
+ const content = item.firstElementChild ?? undefined;
183
+ observe(content);
184
+ observe(content?.querySelector('label[for]') ?? undefined);
185
+ }
186
+ });
187
+
188
+ useLayoutEffect(() => () => observerRef.current?.disconnect(), []);
189
+
190
+ return (
191
+ <div ref={rootRef} className={fieldRowRoot({ gap })} data-testid={testId}>
192
+ <FieldRowContext value={{ align }}>{children}</FieldRowContext>
193
+ </div>
194
+ );
195
+ };
196
+
197
+ export interface IFieldRowFieldProps {
198
+ /** One existing labelled field - `Input`, `NumberInput`, `Select`, `MultiSelect` or `TextArea`. */
199
+ children?: ReactNode;
200
+ /** The field's share of its row relative to the other fields on it. Positive and finite; `1`
201
+ * when omitted, so fields share a row equally unless one says otherwise. */
202
+ weight?: number;
203
+ /** The name of the consumer's theme token holding this field's minimum readable width, such as
204
+ * `--search-field-min-width`. A name, never a length, an expression or a `var()`. */
205
+ minWidth: `--${string}`;
206
+ testId?: string;
207
+ }
208
+
209
+ const FieldRowField: FunctionComponent<IFieldRowFieldProps> = ({
210
+ children,
211
+ weight = 1,
212
+ minWidth,
213
+ testId,
214
+ }) => {
215
+ const { align } = useFieldRowContract('Field');
216
+ assertWeight(weight);
217
+ assertTokenName(minWidth);
218
+ // A member re-rendered on its own - from a consumer's state - re-measures as Root's render does.
219
+ useLayoutEffect(() => align());
220
+ return (
221
+ <div
222
+ data-field-row-item
223
+ className={fieldRowField()}
224
+ data-testid={testId}
225
+ style={{
226
+ flexGrow: weight,
227
+ minWidth: `min(var(${minWidth}), 100%)`,
228
+ }}
229
+ >
230
+ <div>{children}</div>
231
+ </div>
232
+ );
233
+ };
234
+
235
+ export interface IFieldRowActionsProps {
236
+ /** The consumer's action buttons, in reading order. */
237
+ children?: ReactNode;
238
+ testId?: string;
239
+ }
240
+
241
+ const FieldRowActions: FunctionComponent<IFieldRowActionsProps> = ({
242
+ children,
243
+ testId,
244
+ }) => {
245
+ const { align } = useFieldRowContract('Actions');
246
+ useLayoutEffect(() => align());
247
+ return (
248
+ <div data-field-row-item className={fieldRowActions()} data-testid={testId}>
249
+ <div className={fieldRowActions({ part: 'group' })}>{children}</div>
250
+ </div>
251
+ );
252
+ };
253
+
254
+ /**
255
+ * A wrapping row of labelled fields and the shared actions that trail them, aligned on the bottom
256
+ * edges of the controls themselves rather than on the fields' boxes - so a label that wraps, an
257
+ * optional marker, a hint or an error on one field never pushes its neighbours' controls out of
258
+ * line. It owns that one arrangement and nothing else: no form, no landmark, no filter meaning,
259
+ * no wording, and no outer space. Composed from `Root`, a `Field` around each existing labelled
260
+ * field, and `Actions` around the consumer's buttons (docs/adr/0008, Amendments).
261
+ *
262
+ * @Guarantees — enforced on every render
263
+ * - `Root` renders a `div` with no role and no margin; the members render unmodified inside their
264
+ * items in content order, so reading and keyboard order are the content's.
265
+ * - On each row, every control's bottom edge and the actions' bottom edge meet the deepest one.
266
+ * The control is the element a field's own label labels - the association every field already
267
+ * guarantees - so a taller control, a `TextArea` or a group of wrapped buttons keeps its height
268
+ * and the others come down to it. Labels, markers, hints and errors are never the anchor.
269
+ * - Items wrap progressively at the available width of the holder, not the viewport: a row holds
270
+ * what fits every field's minimum, and each row aligns its controls independently. A field alone
271
+ * on a row narrower than its minimum fits that row rather than overflowing it.
272
+ * - Each row's width is shared among its fields in proportion to their `weight`s - equal by
273
+ * default - after the gaps and the actions' content width are reserved. Minimums come from the
274
+ * consumer's theme tokens named by `minWidth`, resolved live, so a theme change re-wraps. A
275
+ * field whose share would fall below its minimum keeps the minimum, and the others share the
276
+ * rest by weight.
277
+ * - The actions stay one trailing group: they move to the next row together, and their buttons
278
+ * wrap inside the group only once the group cannot fit on a row by itself, still in order.
279
+ * - `gap` selects which space role separates items along a row: `stack` (the default), the
280
+ * sibling gap of controls that belong together, or `region`. Wrapped rows and the buttons
281
+ * inside the actions are always `--space-stack`, as `Cluster` fixes its wrapped lines.
282
+ * - Reflow - a resize, a message appearing, a label changing, a field omitted or taken in - keeps
283
+ * every retained control mounted, with its value, selection and focus; nothing is re-keyed.
284
+ * Alignment is measured before paint on every render and again whenever an item's content
285
+ * resizes, so no frame paints a misaligned row. An omitted field reserves no space.
286
+ * - No literal length and no numbered spacing rung appears in the recipes; a `weight` that is
287
+ * not a positive finite number, or a `minWidth` that is not a custom property name, throws a
288
+ * `FieldRowConfigurationError`, and a member outside `Root` throws a
289
+ * `FieldRowCompositionError`.
290
+ *
291
+ * @CallerMustEnsure — the component cannot see these and does not check them
292
+ * - Every `minWidth` token is declared in the applicable theme with a nonnegative CSS length, on
293
+ * the row or an ancestor, in any unit including font-relative ones. An undeclared token is not a
294
+ * responsive configuration: the field then falls back to its content's minimum width.
295
+ * - `Field` and `Actions` are the direct children of `Root` - through arrays, fragments and
296
+ * consumer components that render them, but never wrapped in an element of the consumer's own.
297
+ * - A `Field` holds exactly one labelled field; the field keeps its label, hint, optional marker
298
+ * and error, and FieldRow reads none of their wording.
299
+ * - The form, its submission and any filter meaning belong to the consumer; the actions are the
300
+ * consumer's `Button`s, in the consumer's words, and a filter is never a landmark by itself.
301
+ *
302
+ * @UXGuidelines
303
+ * - `gap="stack"` is a filter: fields and actions that act together. `gap="region"` separates
304
+ * fields that are not one set.
305
+ * - Weight a search field higher than a threshold or a select; give every field a minimum its
306
+ * label and placeholder can be read at, and expect the row to wrap at that width.
307
+ * - Keep action wording short: a long label widens the group and wraps the row sooner.
308
+ */
309
+ export const FieldRow = {
310
+ Root: FieldRowRoot,
311
+ Field: FieldRowField,
312
+ Actions: FieldRowActions,
313
+ } as const;
@@ -0,0 +1,6 @@
1
+ export class FieldRowCompositionError extends Error {
2
+ constructor(member: string) {
3
+ super(`FieldRow.${member} must be composed inside FieldRow.Root`);
4
+ this.name = 'FieldRowCompositionError';
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ export class FieldRowConfigurationError extends Error {
2
+ constructor(violation: string) {
3
+ super(`FieldRow configuration: ${violation}`);
4
+ this.name = 'FieldRowConfigurationError';
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ export interface IControlEdge {
2
+ /** Where the item's box starts; items sharing a top sit on one row. */
3
+ top: number;
4
+ /** How far below the item's content top its control's bottom edge sits, before any padding. */
5
+ controlEdge: number;
6
+ }
@@ -0,0 +1,35 @@
1
+ import type { IControlEdge } from './IControlEdge';
2
+
3
+ // Half a pixel: items on one flex line share a top exactly, and the tolerance only has to absorb
4
+ // the fractional positions a zoomed or sub-pixel layout reports for the same line.
5
+ const ROW_TOLERANCE = 0.5;
6
+
7
+ const isOnRow = (row: number, top: number): boolean =>
8
+ Math.abs(row - top) <= ROW_TOLERANCE;
9
+
10
+ /**
11
+ * The top padding each item needs so that, on every row, every control's bottom edge meets the
12
+ * deepest one. Rows are found from the tops alone, so the caller hands in the measurements of a
13
+ * finished line layout and gets back one padding per item, in the same order.
14
+ */
15
+ export const alignControlEdges = (
16
+ items: readonly IControlEdge[],
17
+ ): readonly number[] => {
18
+ const rows: number[] = [];
19
+ for (const { top } of items) {
20
+ if (!rows.some((row) => isOnRow(row, top))) {
21
+ rows.push(top);
22
+ }
23
+ }
24
+ const deepest = rows.map((row) =>
25
+ Math.max(
26
+ ...items
27
+ .filter((item) => isOnRow(row, item.top))
28
+ .map((item) => item.controlEdge),
29
+ ),
30
+ );
31
+ return items.map(({ top, controlEdge }) => {
32
+ const rowIndex = rows.findIndex((row) => isOnRow(row, top));
33
+ return (deepest[rowIndex] ?? controlEdge) - controlEdge;
34
+ });
35
+ };
@@ -0,0 +1,77 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ // No variants: a Box has nothing to choose. `min-w-0` lets a flex or grid holder shrink it below its
5
+ // content's min-content width, and `overflow-wrap: anywhere` (MultiSelect's idiom) breaks an unbroken
6
+ // name inside it; no overflow is hidden, so content with its own sizing contract is never clipped as
7
+ // a stand-in for wrapping. Square corners and no shadow are the absence of a utility, not a reset.
8
+ const box = cva(
9
+ [
10
+ 'min-w-0 border border-solid border-border bg-surface text-foreground',
11
+ 'p-[var(--space-box-inset)] [overflow-wrap:anywhere]',
12
+ ].join(' '),
13
+ );
14
+
15
+ export interface IBoxProps {
16
+ /** The enclosed matter - a Stack, a heading, facts. Rendered unmodified: Box imposes no anatomy. */
17
+ children?: ReactNode;
18
+ /** Exposes the enclosure as an accessible group with this name. Omit it, or pass an empty string,
19
+ * for an ordinary enclosure: an unnamed Box has no role and is inert to assistive technology. The
20
+ * name is never rendered - the consumer supplies any visible heading. */
21
+ name?: string;
22
+ testId?: string;
23
+ }
24
+
25
+ /**
26
+ * A bounded content group with a semantic surface, border and inner padding. The consumer owns its
27
+ * content, headings and internal arrangement; Stack arranges, Box encloses.
28
+ *
29
+ * @Guarantees — enforced on every render
30
+ * - It paints `surface`, `foreground` and a one-pixel `border` hairline, with square corners and no
31
+ * shadow: it borrows neither the control radius nor the floating-layer elevation.
32
+ * - Its inset is the one `--space-box-inset` role on all four sides. There is no padding or size
33
+ * prop: the theme chooses the value, and re-pointing the role moves the actual inset.
34
+ * - It fills the width its holder allocates, border and padding included, grows with its content
35
+ * and shrinks inside a narrow holder: no fixed width, height, minimum viewport or aspect. Long
36
+ * prose and unbroken text wrap inside it rather than truncate, and nothing is clipped.
37
+ * - With a non-empty `name` it is an accessible group carrying that name; without one, a plain
38
+ * `div` with no role, no label and no landmark. In neither case does it add a focus stop or
39
+ * listen for a key, so controls inside keep their ordinary keyboard behaviour.
40
+ * - An empty Box is an empty enclosure - surface, border and padding - with no invented wording.
41
+ * - `children` render unmodified, in order, and it needs no JavaScript.
42
+ *
43
+ * @CallerMustEnsure — the component cannot see these and does not check them
44
+ * - Where `name` is given it matches the visible heading the consumer places inside, since the
45
+ * component labels the group with that string and cannot reference the heading's id.
46
+ * - Content with its own intrinsic sizing or overflow contract - a table, a figure, a code block -
47
+ * handles its own overflow; the Box wraps text and never scrolls or clips on its behalf.
48
+ * - Whether an empty group should render at all is the consumer's decision: omit the Box rather
49
+ * than expecting empty-state text from it.
50
+ *
51
+ * @UXGuidelines
52
+ * - A Box groups related facts that read as one unit. Enclosing every group on a page turns the
53
+ * boundary into noise; the consumer decides which group earns one.
54
+ */
55
+ export const Box: FunctionComponent<IBoxProps> = ({
56
+ children,
57
+ name,
58
+ testId,
59
+ }) =>
60
+ // Two renders rather than a conditional aria-label: a label on a role-less div is invalid ARIA,
61
+ // and the lint rule that says so cannot see that the role arrives with the name. An empty name
62
+ // is no name (Brandmark's idiom): a group with nothing to announce is worse than no group.
63
+ name === undefined || name === '' ? (
64
+ <div className={box()} data-testid={testId}>
65
+ {children}
66
+ </div>
67
+ ) : (
68
+ // biome-ignore lint/a11y/useSemanticElements: a fieldset brings the UA's min-inline-size, which stops the box shrinking inside a narrow holder, plus legend naming and a form-disabling model a content group must not carry (MultiSelect's precedent); a div with role=group named by `name` is the whole contract
69
+ <div
70
+ className={box()}
71
+ role={'group'}
72
+ aria-label={name}
73
+ data-testid={testId}
74
+ >
75
+ {children}
76
+ </div>
77
+ );
@@ -34,7 +34,7 @@ const checklistRoot = cva('m-0 list-none p-0');
34
34
  // `--space-stack`, split above and below the hairline so it sits centred in the gap.
35
35
  const checklistItem = cva(
36
36
  [
37
- 'flex items-start gap-3 font-primary text-body leading-body text-foreground',
37
+ 'flex items-start gap-3 font-body text-body leading-body text-foreground',
38
38
  "before:mt-[0.35em] before:size-[calc(var(--tick-length)*0.7)] before:shrink-0 before:rotate-45 before:[border-top:var(--tick-thickness)_solid_var(--color-rule)] before:[border-right:var(--tick-thickness)_solid_var(--color-rule)] before:content-['']",
39
39
  '[&:not(:first-child)]:mt-[var(--space-stack)] [&:not(:first-child)]:border-border [&:not(:first-child)]:border-t [&:not(:first-child)]:border-solid [&:not(:first-child)]:pt-[var(--space-stack)]',
40
40
  ].join(' '),