@juwel-development/design-system 3.9.1 → 3.10.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 +1026 -582
  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 +26 -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 +23 -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 +63 -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 +45 -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,37 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { type FunctionComponent, type ReactNode } from 'react';
3
+ declare const scrollContainer: (props?: ({
4
+ axis?: "both" | "horizontal" | "vertical" | null | undefined;
5
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
+ export interface IScrollContainerProps extends VariantProps<typeof scrollContainer> {
7
+ /** The group's accessible name while it can be scrolled. Required: a tab stop with no name is a
8
+ * mystery to a screen reader. The consuming app words it, usually after the heading above. */
9
+ ariaLabel: string;
10
+ children?: ReactNode;
11
+ testId?: string;
12
+ }
13
+ /**
14
+ * Makes overflowing content reachable along the chosen axes, within the space its parent allocates.
15
+ * It owns the scrolling; the consumer owns the content and the allocation of space.
16
+ *
17
+ * @Guarantees — enforced on every render
18
+ * - Scrolls only an enabled axis and only once content overflows it; overflow on a disabled axis is
19
+ * clipped. Content wraps as it would anywhere else.
20
+ * - Keyboard-reachable - a named `group` with a tab stop - exactly while an enabled axis overflows,
21
+ * static content included; no stop and no group while everything fits. Scrollability is re-read on
22
+ * resize and on content change without moving focus.
23
+ * - Native scrolling: scrollbars, wheel, touch and the browser's own arrow/page keys on the focused
24
+ * container. No key of a control inside it is intercepted and focus is never trapped.
25
+ * - Invents no bound: no height, width or viewport unit of its own. The only space it adds is the
26
+ * focus ring's room around its content, so a focusable child flush with its edge - a Table's
27
+ * scroll region, a button - keeps a visible ring instead of having it clipped at the edge.
28
+ *
29
+ * @CallerMustEnsure
30
+ * - The parent allocates finite space on every axis the container should scroll - a sized box, or a
31
+ * flex/grid item allowed to shrink (`flex: 1 1 0; min-height: 0` in a column). Under an unbounded
32
+ * parent it simply grows with its content, as any block would.
33
+ * - Content fits a disabled axis. Clipping is not a way to hide essential content or controls.
34
+ * - `ariaLabel` is wording the viewer would recognise, typically the heading above the content.
35
+ */
36
+ export declare const ScrollContainer: FunctionComponent<IScrollContainerProps>;
37
+ export {};
@@ -1,7 +1,7 @@
1
1
  import type { VariantProps } from 'class-variance-authority';
2
2
  import type { FunctionComponent, ReactNode } from 'react';
3
3
  declare const section: (props?: ({
4
- bleed?: "full" | "inset" | null | undefined;
4
+ bleed?: "inset" | "full" | null | undefined;
5
5
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
6
  export interface ISectionProps extends VariantProps<typeof section> {
7
7
  /** Names the section as a region a screen-reader user can jump to. Omit for an ordinary section: an
@@ -41,6 +41,10 @@ export interface ISidebarContentProps {
41
41
  * - Activating the active entry or an inert one emits nothing; no render emits anything. Entries are
42
42
  * non-submitting `type="button"` buttons with native Tab/Enter/Space behaviour - no tabs/menu model.
43
43
  * - An inert entry stays visible but muted and disabled, so Tab skips it and activation is inert too.
44
+ * - A label is rendered in full inside its entry, in either arrangement: a phrase wraps at its spaces
45
+ * and a word wider than the track breaks within itself. Nothing is truncated, renamed or hidden
46
+ * behind a tooltip, no horizontal scrolling is introduced, and a translated label neither widens
47
+ * the 12rem track nor displaces the content.
44
48
  * - At and above 64rem the nav is a fixed 12rem track, sticky at the top of the scrolling area with no
45
49
  * assumed top-bar offset, capped to the screen/scrolling-area height with independent entry scrolling.
46
50
  * 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.
@@ -113,8 +114,28 @@ export type PaletteTokens = {
113
114
  * constraint stated on `success`. */
114
115
  warning: string;
115
116
  /** The error status tone. Carries the general status-tone contract and 4.5:1-against-`surface`
116
- * constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`. */
117
+ * constraint stated on `success`, plus the depletion-path constraint stated on `meterFill`. It is
118
+ * also Button's destructive fill (#119), identified like `primary` by the fill alone: at least 3:1
119
+ * against `surface` in the same theme, `errorHover` included - which the 4.5:1 text floor already
120
+ * clears - and constrained from the other side by the ink it carries, stated on `errorForeground`.
121
+ * See docs/adr/0011-status-tones-are-general-roles.md, Amendments. */
117
122
  error: string;
123
+ /** The destructive fill's hover step. Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against
124
+ * `surface` in the same theme, and at least 4.5:1 against `errorForeground`, since a hovered
125
+ * control has to stay identifiable and readable too. Not a status tone: it carries no text of its
126
+ * own and the status-tone text floor does not apply to it. Optional in the type so a palette
127
+ * object written before #119 keeps compiling: the shipped stylesheet declares `--color-error-hover`,
128
+ * and a theme that omits the role inherits that default - which pairs with the shipped `error`,
129
+ * so a theme that re-points `error` re-points this too. */
130
+ errorHover?: string;
131
+ /** Text and icons drawn on top of `error` and `errorHover`. Constraint (WCAG 2.2 SC 1.4.3): at
132
+ * least 4.5:1 against both in the same theme, hover included. Optional in the type for the same
133
+ * reason as `errorHover`, with the same obligation: the shipped default is the ink for the
134
+ * shipped `error`. The ink inverts with the theme as `primaryForeground` does, and in light it is
135
+ * pure white rather than slate-50 because the shipped `error` sits exactly on the 4.5:1 floor
136
+ * against white (4.501:1) and slate-50 measures 4.30:1 - under it. Not required against
137
+ * `disabled`, which SC 1.4.3 exempts. */
138
+ errorForeground?: string;
118
139
  /** The informational status tone. Carries the general status-tone contract and
119
140
  * 4.5:1-against-`surface` constraint stated on `success`. */
120
141
  info: string;
@@ -138,7 +159,7 @@ export type PaletteTokens = {
138
159
  * against `#0f172a`, the dark set's own `surface`, sky-600 lands at 4.36 and fails. It also buys
139
160
  * a light theme with white text on one button and black on the one beside it.
140
161
  */
141
- export declare const light: PaletteTokens;
162
+ export declare const light: Required<PaletteTokens>;
142
163
  /**
143
164
  * Dark reverses the direction the fills step: it sits higher up the ramp than light does and hovers
144
165
  * *up* into lighter still, where light sits lower and hovers down. That is what the components' old
@@ -150,8 +171,11 @@ export declare const light: PaletteTokens;
150
171
  * under its darker ones. The pair is the two ends of the one neutral ramp the rest of the palette is
151
172
  * already built from - `#f8fafc` is slate-50, `surface` slate-900, `muted` slate-500 - rather than a
152
173
  * new colour arriving for a single job. `#020617` is also the lightest slate step that still admits
153
- * violet-500 and sky-600, which is what leaves this set's `secondary` pair unmoved: slate-900 draws
154
- * 4.22 and 4.36 against them and fails. Why the ink follows the theme at all, and the two routes
155
- * rejected in getting here: see the light set.
174
+ * violet-500: slate-900 draws 4.22 against it and fails. The `secondary` pair sits one ramp step
175
+ * above where it first shipped - sky-500 at rest, sky-400 on hover, where it was sky-600 and sky-500 -
176
+ * because #119 made `secondary` an ink as well as a fill: the outlined Button draws its text and edge
177
+ * in it, and sky-600 measures 4.36:1 against this surface, under the 4.5:1 text floor (now 6.44:1
178
+ * and 8.33:1, both still clearing `secondaryForeground` at 7.28:1 and 9.42:1). Why the ink follows
179
+ * the theme at all, and the two routes rejected in getting here: see the light set.
156
180
  */
157
- export declare const dark: PaletteTokens;
181
+ export declare const dark: Required<PaletteTokens>;
@@ -1,13 +1,18 @@
1
1
  import './styles.css';
2
2
  export { Cluster } from 'Arrangement/Cluster/Cluster';
3
+ export { ColumnLayout } from 'Arrangement/ColumnLayout/ColumnLayout';
4
+ export { FieldRow } from 'Arrangement/FieldRow/FieldRow';
3
5
  export { Stack } from 'Arrangement/Stack/Stack';
6
+ export { Box } from 'Display/Box/Box';
4
7
  export { Brandmark } from 'Display/Brandmark/Brandmark';
5
8
  export { Checklist } from 'Display/Checklist/Checklist';
6
9
  export { Collection } from 'Display/Collection/Collection';
7
10
  export { DefinitionList } from 'Display/DefinitionList/DefinitionList';
8
11
  export { Figure } from 'Display/Figure/Figure';
12
+ export { Icon } from 'Display/Icon/Icon';
9
13
  export { Meter } from 'Display/Meter/Meter';
10
14
  export { Rail } from 'Display/Rail/Rail';
15
+ export type { TableColumnAllocation } from 'Display/Table/Table';
11
16
  export { Table } from 'Display/Table/Table';
12
17
  export { Eyebrow } from 'Display/Typography/Eyebrow/Eyebrow';
13
18
  export { H1 } from 'Display/Typography/H1/H1';
@@ -37,6 +42,7 @@ export { Form } from 'Layout/Form/Form';
37
42
  export { Header } from 'Layout/Header/Header';
38
43
  export { Hero } from 'Layout/Hero/Hero';
39
44
  export { PageHead } from 'Layout/PageHead/PageHead';
45
+ export { ScrollContainer } from 'Layout/ScrollContainer/ScrollContainer';
40
46
  export { Section } from 'Layout/Section/Section';
41
47
  export { Sidebar } from 'Layout/Sidebar/Sidebar';
42
48
  export type { PaletteTokens } from 'Theme/Palette';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juwel-development/design-system",
3
- "version": "3.9.1",
3
+ "version": "3.10.0",
4
4
  "type": "module",
5
5
  "description": "Shared design system: tokens and components.",
6
6
  "license": "MIT",
@@ -0,0 +1,249 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import {
4
+ Children,
5
+ type CSSProperties,
6
+ createContext,
7
+ Fragment,
8
+ type FunctionComponent,
9
+ isValidElement,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ useContext,
13
+ } from 'react';
14
+ import { ColumnLayoutCompositionError } from './ColumnLayoutCompositionError';
15
+ import { ColumnLayoutConfigurationError } from './ColumnLayoutConfigurationError';
16
+
17
+ // A wrapping flex row that aligns its tracks to the top. The gap variant also publishes the chosen
18
+ // role as `--column-layout-gap`, which the threshold below reads, so the switch and the paint take
19
+ // the role from one declaration. Nothing here measures: the arrangement needs no JavaScript.
20
+ const columnLayout = cva('flex flex-wrap items-start', {
21
+ variants: {
22
+ gap: {
23
+ stack:
24
+ 'gap-[var(--space-stack)] [--column-layout-gap:var(--space-stack)]',
25
+ region:
26
+ 'gap-[var(--space-region)] [--column-layout-gap:var(--space-region)]',
27
+ },
28
+ },
29
+ defaultVariants: { gap: 'region' },
30
+ });
31
+
32
+ // The all-or-one switch, after Heydon Pickering's "Holy Albatross": `100%` is the Root's width W
33
+ // and the threshold T sits on the Root, so W >= T clamps the basis to 0 (one line, grown by weight)
34
+ // and W < T amplifies the shortfall past 100% (a line each). The factor turns a 1/64px shortfall,
35
+ // Chrome's layout grain, into a full switch; `0px` and `100%` are the two states, not measurements.
36
+ const columnLayoutColumn = cva(
37
+ 'min-w-0 grow-[var(--column-layout-weight)] basis-[clamp(0px,(var(--column-layout-threshold)-100%)*1000000,100%)]',
38
+ );
39
+
40
+ // React's CSSProperties is closed over known properties; the two custom properties the recipes
41
+ // read are declared here so the style objects stay typed without an assertion.
42
+ type ColumnLayoutRootStyle = CSSProperties & {
43
+ '--column-layout-threshold': string;
44
+ };
45
+ type ColumnLayoutColumnStyle = CSSProperties & {
46
+ '--column-layout-weight': number;
47
+ };
48
+
49
+ /** The name of a CSS custom property, as written in a stylesheet: `--main-column-min-width`. */
50
+ type MinWidthToken = `--${string}`;
51
+
52
+ type Track = { weight: number; minWidth: MinWidthToken };
53
+
54
+ // Granted only to a Column the Root counted: the Root wraps each direct Column child in it, and a
55
+ // Column revokes it for its own descendants. Anything else is outside the composition.
56
+ const ColumnLayoutMembership = createContext<boolean>(false);
57
+
58
+ // The ident grammar CSS gives a custom property name, restricted to ASCII so a `var()`, a length,
59
+ // a space or a brace can never ride in on the name.
60
+ const TOKEN_NAME = /^--[A-Za-z0-9_-]+$/;
61
+
62
+ const toTrack = (
63
+ element: ReactElement<IColumnLayoutColumnProps>,
64
+ index: number,
65
+ ): Track => {
66
+ const { weight, minWidth } = element.props;
67
+ if (!Number.isFinite(weight) || weight <= 0) {
68
+ throw new ColumnLayoutConfigurationError(
69
+ `column ${index + 1} needs a positive finite weight (got ${weight})`,
70
+ );
71
+ }
72
+ if (!TOKEN_NAME.test(minWidth)) {
73
+ throw new ColumnLayoutConfigurationError(
74
+ `column ${index + 1} needs a custom-property name such as --main-column-min-width for minWidth (got ${JSON.stringify(minWidth)})`,
75
+ );
76
+ }
77
+ return { weight, minWidth };
78
+ };
79
+
80
+ // The documented threshold, `(n - 1) * g + max(m_i * S / w_i)`, left to the browser to resolve
81
+ // so a theme re-pointing a minimum - or the gap - moves it with no script in between.
82
+ const thresholdOf = (tracks: readonly Track[]): string => {
83
+ const total = tracks.reduce((sum, track) => sum + track.weight, 0);
84
+ const shares = tracks.map(
85
+ (track) => `var(${track.minWidth}) * ${total / track.weight}`,
86
+ );
87
+ return `calc(${tracks.length - 1} * var(--column-layout-gap) + max(${shares.join(', ')}))`;
88
+ };
89
+
90
+ const isFragment = (
91
+ node: ReactNode,
92
+ ): node is ReactElement<{ children?: ReactNode }> =>
93
+ isValidElement(node) && node.type === Fragment;
94
+
95
+ export interface IColumnLayoutRootProps
96
+ extends VariantProps<typeof columnLayout> {
97
+ /** The columns, as `ColumnLayout.Column` elements: direct children, or arrays and fragments of
98
+ * them. A conditional that renders nothing reserves nothing. */
99
+ children?: ReactNode;
100
+ testId?: string;
101
+ }
102
+
103
+ export interface IColumnLayoutColumnProps {
104
+ /** This column's share of the width left after the gaps, relative to its siblings' weights: a
105
+ * positive finite number. `2` beside `1` is a two-thirds/one-third arrangement. */
106
+ weight: number;
107
+ /** The name of the consumer's custom property holding this column's minimum readable width,
108
+ * such as `--main-column-min-width`. A token name, never a length or a `var()`: the consumer
109
+ * declares it in the theme with a nonnegative CSS length. */
110
+ minWidth: MinWidthToken;
111
+ /** The column's content. Rendered unmodified: the column imposes no anatomy. */
112
+ children?: ReactNode;
113
+ testId?: string;
114
+ }
115
+
116
+ const isColumn = (
117
+ node: ReactNode,
118
+ ): node is ReactElement<IColumnLayoutColumnProps> =>
119
+ isValidElement(node) && node.type === ColumnLayoutColumn;
120
+
121
+ const collectColumns = (
122
+ children: ReactNode,
123
+ ): ReactElement<IColumnLayoutColumnProps>[] =>
124
+ Children.toArray(children).flatMap((child) => {
125
+ if (isFragment(child)) {
126
+ return collectColumns(child.props.children);
127
+ }
128
+ return isColumn(child) ? [child] : [];
129
+ });
130
+
131
+ // Marks every counted Column and nothing else, keeping React's own keys for each position so a
132
+ // conditional column appearing later neither remounts its siblings nor moves their focus.
133
+ const markColumns = (children: ReactNode): ReactNode =>
134
+ Children.map(children, (child) => {
135
+ if (isFragment(child)) {
136
+ return <Fragment>{markColumns(child.props.children)}</Fragment>;
137
+ }
138
+ if (isColumn(child)) {
139
+ return (
140
+ <ColumnLayoutMembership.Provider value={true}>
141
+ {child}
142
+ </ColumnLayoutMembership.Provider>
143
+ );
144
+ }
145
+ return child;
146
+ });
147
+
148
+ const ColumnLayoutRoot: FunctionComponent<IColumnLayoutRootProps> = ({
149
+ gap,
150
+ children,
151
+ testId,
152
+ }) => {
153
+ const tracks = collectColumns(children).map(toTrack);
154
+ const style: ColumnLayoutRootStyle | undefined =
155
+ tracks.length === 0
156
+ ? undefined
157
+ : { '--column-layout-threshold': thresholdOf(tracks) };
158
+ return (
159
+ <div className={columnLayout({ gap })} style={style} data-testid={testId}>
160
+ {markColumns(children)}
161
+ </div>
162
+ );
163
+ };
164
+
165
+ const ColumnLayoutColumn: FunctionComponent<IColumnLayoutColumnProps> = ({
166
+ weight,
167
+ children,
168
+ testId,
169
+ }) => {
170
+ const isCounted = useContext(ColumnLayoutMembership);
171
+ if (!isCounted) {
172
+ throw new ColumnLayoutCompositionError();
173
+ }
174
+ const style: ColumnLayoutColumnStyle = { '--column-layout-weight': weight };
175
+ return (
176
+ <div className={columnLayoutColumn()} style={style} data-testid={testId}>
177
+ <ColumnLayoutMembership.Provider value={false}>
178
+ {children}
179
+ </ColumnLayoutMembership.Provider>
180
+ </div>
181
+ );
182
+ };
183
+
184
+ /**
185
+ * An arrangement of weighted columns that becomes one column when the space it is given cannot
186
+ * satisfy every column's minimum at the requested proportions. It owns the arrangement and nothing
187
+ * else - no landmark, no band, no join, no gutter, no fill - and takes no outer space, so whatever
188
+ * holds it owns the rhythm around it. `Stack`'s `split` keeps its own viewport-keyed contract; this
189
+ * one answers to the width of its holder (docs/adr/0008, Amendments).
190
+ *
191
+ * @Guarantees — enforced on every render
192
+ * - `Root` renders a `div` with no landmark role, no heading and no margin. Each `Column` is a
193
+ * `div` directly inside it, in the order written, so DOM, reading and keyboard order are the
194
+ * order of the children in both arrangements, and no column is ever remounted merely because
195
+ * the arrangement changed: resizing keeps every column's state and focus.
196
+ * - In the horizontal arrangement the gaps are taken off the Root's content width and the rest is
197
+ * divided in proportion to the weights: `2`, `1`, `1` is one half and two quarters of it. The
198
+ * columns align at the top and keep their own heights.
199
+ * - The row holds only while every proportional share is at least its own minimum. The moment one
200
+ * falls short, every column takes a line of its own and fills the Root's width - a column
201
+ * narrower than its minimum included: the minimum decides the switch and never floors a width.
202
+ * There is no in-between: no column wraps alone, no share is clamped, nothing is redistributed to
203
+ * postpone the switch. For n columns, gap g, total weight S and minimums m_i the row fits at and
204
+ * above `(n - 1) * g + max(m_i * S / w_i)`.
205
+ * - The space measured is the Root's own width, never the viewport, so two instances on one page
206
+ * switch independently, and a narrow holder on a wide screen stacks.
207
+ * - Every minimum is read from the theme as a CSS length each time layout runs, so re-pointing a
208
+ * token - in a theme class, a media query, or a scope around the Root - moves the threshold
209
+ * with it, and a font-relative length moves it as the type does.
210
+ * - `gap` selects which space role separates the columns, across the row and between the stacked
211
+ * lines alike: `region` (the default), the gap between groups of blocks, or `stack`, the gap
212
+ * between siblings within one block. Nothing else.
213
+ * - Only rendered columns count: a conditional that renders nothing reserves neither width nor
214
+ * gap, a single column fills the width, and an empty Root holds no tracks and no gaps.
215
+ * Allocations follow every change of columns, weights, gap, holder width or theme.
216
+ * - A column adds no scrolling, truncation or overflow treatment of its own and does not widen its
217
+ * track for its content: the content keeps whatever wrapping or scrolling contract it has.
218
+ * - Every value it emits is a role the token layer or the consumer's theme already names; the
219
+ * only literals are the switch's two states.
220
+ * - A non-positive or non-finite `weight`, or a `minWidth` that is not a custom-property name,
221
+ * throws {@link ColumnLayoutConfigurationError}; a `Column` that is not a direct child of a
222
+ * `Root` - loose, nested in another column, or reached through a wrapping component - throws
223
+ * {@link ColumnLayoutCompositionError}. Both are programmer errors, raised loud and early.
224
+ *
225
+ * @CallerMustEnsure — the component cannot see these and does not check them
226
+ * - Every `minWidth` token is declared, on the Root or an ancestor of it, as a valid nonnegative
227
+ * CSS length: `24rem`, `20em`, `40ch`, `320px`. A `rem` resolves against the document root, an
228
+ * `em` or `ch` against the Root's inherited type. A missing or invalid token is not a
229
+ * responsive configuration: the switch has nothing to compare and the columns size from their
230
+ * content instead.
231
+ * - The holder gives the Root a definite width, as any block does. Inside a shrink-to-fit frame
232
+ * there is no width to measure the shares against.
233
+ * - Content that cannot wrap - an unbroken string, a fixed-width control - needs its own overflow
234
+ * contract, as `Table.Root` has; the column will not widen to hold it.
235
+ * - `Root`'s children are `Column`s. Anything else placed beside them is rendered as given but is
236
+ * not a column: it takes no weight, counts for no gap or threshold, and sits in the row as
237
+ * content of its own width, which breaks the proportions. Content belongs inside a `Column`.
238
+ *
239
+ * @UXGuidelines
240
+ * - A minimum is the width below which the column's content stops being readable or operable -
241
+ * the narrowest a comparison table can be scanned, the narrowest a summary's labels keep their
242
+ * lines - not the width the designer would like. The stack is the readable fallback.
243
+ * - Weights are structural relationships: `2` and `1` say the table is the subject and the summary
244
+ * its support. A ratio chosen to hit a pixel width is a measurement, and the theme owns those.
245
+ */
246
+ export const ColumnLayout = {
247
+ Root: ColumnLayoutRoot,
248
+ Column: ColumnLayoutColumn,
249
+ } as const;
@@ -0,0 +1,8 @@
1
+ export class ColumnLayoutCompositionError extends Error {
2
+ constructor() {
3
+ super(
4
+ 'ColumnLayout.Column must be a direct child of ColumnLayout.Root (arrays and fragments are fine; a wrapping component or a nested Column is not)',
5
+ );
6
+ this.name = 'ColumnLayoutCompositionError';
7
+ }
8
+ }
@@ -0,0 +1,6 @@
1
+ export class ColumnLayoutConfigurationError extends Error {
2
+ constructor(reason: string) {
3
+ super(`ColumnLayout configuration is invalid: ${reason}`);
4
+ this.name = 'ColumnLayoutConfigurationError';
5
+ }
6
+ }