@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,8 +2,14 @@ import type { VariantProps } from 'class-variance-authority';
2
2
  import type { FunctionComponent, ReactNode } from 'react';
3
3
  declare const header: (props?: ({
4
4
  edge?: "none" | "rule" | null | undefined;
5
+ mode?: "navigation" | "statusAction" | null | undefined;
5
6
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
- export interface IHeaderProps extends VariantProps<typeof header> {
7
+ interface IHeaderShellProps extends Omit<VariantProps<typeof header>, 'mode'> {
8
+ testId?: string;
9
+ }
10
+ /** The navigation bar: the mode every existing caller is in, and the default. A standing link and a
11
+ * `<nav>`, and never a status or an action slot - the other shape is `IHeaderStatusActionProps`. */
12
+ export interface IHeaderNavigationProps extends IHeaderShellProps {
7
13
  /** The standing link: a place name, a mark, or a home link. The consumer supplies the whole anchor -
8
14
  * Header renders no link of its own. A place name uses `<Link treatment="quiet" href="/">…</Link>`; a
9
15
  * mark uses `<Link treatment="graphic" href="/"><Brandmark …/></Link>`, whose `graphic` treatment
@@ -13,15 +19,45 @@ export interface IHeaderProps extends VariantProps<typeof header> {
13
19
  navName?: string;
14
20
  /** The nav links. */
15
21
  children?: ReactNode;
16
- testId?: string;
22
+ status?: never;
23
+ action?: never;
17
24
  }
25
+ /** The status/action bar: a bar that reports and acts rather than navigates, for a product whose
26
+ * shell carries a readout and a control and no links. It has no standing link and no `<nav>` - a
27
+ * button is not navigation - and the two shapes cannot be mixed: the compiler rejects a call that
28
+ * hands this bar a standing link, a nav name or nav children. */
29
+ export interface IHeaderStatusActionProps extends IHeaderShellProps {
30
+ /** The readout - a date, a balance, a short note, or a `Cluster` of them - at the start edge, first
31
+ * in reading order. A plain `div` with no role, no name and no live region: what it holds carries its
32
+ * own semantics, and whether a change is announced is the consumer's decision, made by wrapping its
33
+ * own live region. Long and unbroken wording wraps inside the slot. Omitted, nothing renders. */
34
+ status?: ReactNode;
35
+ /** The control - a `Button`, or a `Cluster` of them - at the end edge, last in reading and keyboard
36
+ * order, on whichever line it lands when the bar breaks. A plain `div` like `status`. Header never
37
+ * decides whether the control is available: pass it disabled, or withhold it (`{ready && <Button/>}`
38
+ * renders no box). Omitted, nothing renders. */
39
+ action?: ReactNode;
40
+ standing?: never;
41
+ navName?: never;
42
+ children?: never;
43
+ }
44
+ export type IHeaderProps = IHeaderNavigationProps | IHeaderStatusActionProps;
18
45
  /**
19
- * The shell's top edge: a standing link and a nav, at the label type role. It renders the banner
20
- * landmark and a single `<nav>`, arranging nothing beyond the two slots, so it works with no hydration.
46
+ * The shell's top edge, at the label type role, in one of two mutually exclusive modes. The
47
+ * **navigation bar** - the default, and what every existing caller renders - is a standing link and a
48
+ * single `<nav>`. The **status/action bar** is a readout at the start edge and a control at the end
49
+ * edge, both plain boxes with no landmark of their own and no `<nav>` anywhere. The props type is a
50
+ * union of the two shapes, so a call that mixes them does not compile. Either bar arranges nothing
51
+ * beyond its slots and works with no hydration.
21
52
  *
22
53
  * @Guarantees — enforced on every render
23
54
  * - The header is set at the label role and carries no size of its own: `font-secondary text-label
24
55
  * leading-label tracking-label text-muted`, so it never grows past that role whatever the page font size.
56
+ * - The shell's air is one value above, below and between: `--space-region` vertically, between the
57
+ * nav's items and between the status/action bar's slots along a line, `--gutter` across, so the bar
58
+ * aligns with every inset `Section`. It is never sticky and needs no JavaScript.
59
+ *
60
+ * The navigation bar, unchanged by #125:
25
61
  * - The nav's line box is the library's own rather than the consuming document's: the header leads
26
62
  * itself at the label role, so the height it declares for its slot is the height it renders.
27
63
  * - The standing slot is floored, never fixed: its minimum height is that same line box - the label
@@ -31,10 +67,24 @@ export interface IHeaderProps extends VariantProps<typeof header> {
31
67
  * grows the slot, on one line, and is never clamped or wrapped.
32
68
  * - A nav item marked `aria-current="page"` renders at `foreground` whatever supplied it - the treatment
33
69
  * keys on the attribute, not on `Link`, so it holds against a bare anchor or any component.
34
- * - The shell's air is one value above, below and between: `--space-region` vertically and between nav
35
- * items, `--gutter` across, so the bar aligns with every inset `Section`.
36
- * - It is never sticky and needs no JavaScript: there is no `sticky` variant and nothing to hydrate.
37
- * - Omitting `navName` emits no `aria-label` at all, not an empty one.
70
+ * - The standing slot and the `<nav>` are the bar's two children whatever the caller passes, the nav
71
+ * flush with the end content edge and absorbing narrowing by wrapping its links. Omitting `navName`
72
+ * emits no `aria-label` at all, not an empty one.
73
+ *
74
+ * The status/action bar:
75
+ * - It renders no `<nav>`, no standing slot, no role, no name and no live region of its own. Each
76
+ * filled slot is one `div` directly inside the banner; an omitted or withheld slot (`false`, `null`)
77
+ * renders no box and reserves no space, so a status-only bar starts with its readout and an action-only
78
+ * bar is the control alone at the end edge.
79
+ * - Reading and keyboard order is status, then action, and the DOM order is the same at every width.
80
+ * - With both fitting, the readout sits at the start content edge and the control at the end content
81
+ * edge on one line. When they cannot share a line, the control drops below the readout and stays
82
+ * flush with the end edge; a readout then takes the whole line and wraps inside it, unbroken wording
83
+ * included, so the bar never widens the page and never clips.
84
+ * - Breaking is CSS alone: a width change re-flows the same elements, so descendant state and focus
85
+ * survive it.
86
+ * - What the consumer passes keeps its semantics: a heading stays a heading, a live region stays one, a
87
+ * disabled control stays disabled. Header adds, removes and announces nothing.
38
88
  *
39
89
  * @CallerMustEnsure — the component cannot see these and does not check them
40
90
  * - A mark that should *fill* the standing slot is given a **definite width** by whoever placed it -
@@ -47,6 +97,10 @@ export interface IHeaderProps extends VariantProps<typeof header> {
47
97
  * width; `flex: 1 1 0; min-width: 0` only collapses the mark when the bar is already out of room, so
48
98
  * it is not the rule to reach for. The library cannot apply either rule for you: the same width on a
49
99
  * place name would clamp the name and wrap it.
100
+ * - Set the type of what fills a status/action slot: the bar sets the label role, so a bare string in
101
+ * `status` reads as a label, while a `P` or a `Note` wears its own role and a `Button` its own.
102
+ * - Whether a changing readout interrupts is yours to decide: wrap your own live region inside `status`
103
+ * where a change must be announced, and leave it out where it must not. Header takes neither side.
50
104
  *
51
105
  * @UXGuidelines
52
106
  * - Name the nav with `navName` once the page has more than one navigation landmark - a footer nav will
@@ -55,6 +109,11 @@ export interface IHeaderProps extends VariantProps<typeof header> {
55
109
  * `aria-current`, so set `current` on the `Link` and style nothing yourself.
56
110
  * - The nav does not collapse into a menu: a disclosure needs JavaScript, so on a narrow viewport the
57
111
  * items wrap. A mobile menu is out of scope, not a follow-up.
112
+ * - A bar that reports and acts is a status/action bar, never a navigation bar with a button among its
113
+ * links: a Balance line or a Continue button inside a `<nav>` is announced as navigation. A product
114
+ * whose shell needs links as well as a control has two bars, not one.
115
+ * - Several readouts or several actions go in a `Cluster` inside the slot: `gap="stack"` keeps them one
116
+ * group, and the Cluster wraps inside the slot before the bar runs out of room.
58
117
  */
59
118
  export declare const Header: FunctionComponent<IHeaderProps>;
60
119
  export {};
@@ -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.11.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
+ }