@juwel-development/design-system 3.11.0 → 4.1.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 (70) hide show
  1. package/README.md +16 -3
  2. package/dist/design-system.js +1094 -546
  3. package/dist/styles.css +2 -0
  4. package/dist/styles.dark.css +2 -0
  5. package/dist/styles.light.css +2 -0
  6. package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +38 -18
  7. package/dist/types/Display/Callout/Callout.d.ts +31 -0
  8. package/dist/types/Display/Icon/Icon.d.ts +20 -24
  9. package/dist/types/Display/Meter/Meter.d.ts +5 -6
  10. package/dist/types/Display/Rule/Rule.d.ts +22 -0
  11. package/dist/types/Display/Table/Table.d.ts +57 -7
  12. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
  13. package/dist/types/Display/Typography/H1/H1.d.ts +1 -1
  14. package/dist/types/Display/Typography/H2/H2.d.ts +1 -1
  15. package/dist/types/Display/Typography/H3/H3.d.ts +1 -1
  16. package/dist/types/Display/Typography/H4/H4.d.ts +1 -1
  17. package/dist/types/Display/Typography/H5/H5.d.ts +1 -1
  18. package/dist/types/Display/Typography/H6/H6.d.ts +1 -1
  19. package/dist/types/Display/Typography/Note/Note.d.ts +16 -3
  20. package/dist/types/Display/Typography/P/P.d.ts +15 -2
  21. package/dist/types/Display/Typography/Prose/Prose.d.ts +14 -6
  22. package/dist/types/Interaction/Button/Button.d.ts +10 -4
  23. package/dist/types/Interaction/Slider/Slider.d.ts +3 -0
  24. package/dist/types/Interaction/Stepper/Stepper.d.ts +59 -0
  25. package/dist/types/Interaction/Stepper/StepperCompositionError.d.ts +3 -0
  26. package/dist/types/Interaction/Switch/Switch.d.ts +20 -0
  27. package/dist/types/Interaction/Tabs/Tabs.d.ts +24 -3
  28. package/dist/types/Layout/Dialog/Dialog.d.ts +11 -3
  29. package/dist/types/Layout/Form/FormConfigurationError.d.ts +3 -0
  30. package/dist/types/Layout/Pane/Pane.d.ts +57 -0
  31. package/dist/types/Layout/Screen/Screen.d.ts +31 -0
  32. package/dist/types/Layout/Screen/ScreenBodyAllocation.d.ts +1 -0
  33. package/dist/types/Layout/Shell/Shell.d.ts +39 -0
  34. package/dist/types/Layout/Sidebar/Sidebar.d.ts +16 -2
  35. package/dist/types/Theme/Palette.d.ts +11 -4
  36. package/dist/types/index.d.ts +22 -1
  37. package/package.json +18 -8
  38. package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +86 -28
  39. package/src/Display/Callout/Callout.tsx +64 -0
  40. package/src/Display/Icon/Icon.tsx +53 -13
  41. package/src/Display/Meter/Meter.tsx +12 -13
  42. package/src/Display/Rule/Rule.tsx +31 -0
  43. package/src/Display/Table/Table.tsx +189 -24
  44. package/src/Display/Typography/Note/Note.tsx +27 -11
  45. package/src/Display/Typography/P/P.tsx +29 -4
  46. package/src/Display/Typography/Prose/Prose.tsx +44 -19
  47. package/src/Interaction/Button/Button.tsx +11 -4
  48. package/src/Interaction/Slider/Slider.tsx +3 -5
  49. package/src/Interaction/Stepper/Stepper.tsx +307 -0
  50. package/src/Interaction/Stepper/StepperCompositionError.ts +6 -0
  51. package/src/Interaction/Switch/Switch.tsx +64 -0
  52. package/src/Interaction/Tabs/Tabs.tsx +44 -10
  53. package/src/Layout/Dialog/Dialog.tsx +19 -6
  54. package/src/Layout/Form/Form.tsx +2 -1
  55. package/src/Layout/Form/FormConfigurationError.ts +6 -0
  56. package/src/Layout/Pane/Pane.tsx +272 -0
  57. package/src/Layout/Screen/Screen.tsx +61 -0
  58. package/src/Layout/Screen/ScreenBodyAllocation.ts +5 -0
  59. package/src/Layout/Shell/Shell.tsx +124 -0
  60. package/src/Layout/Sidebar/Sidebar.tsx +31 -7
  61. package/src/Theme/Palette.ts +17 -6
  62. package/src/Theme/renderTokens.ts +61 -12
  63. package/src/index.ts +22 -2
  64. package/src/styles.css +5 -6
  65. package/src/styles.dark.css +5 -6
  66. package/src/styles.light.css +5 -6
  67. package/src/tokens.css +54 -9
  68. package/src/tokens.dark.css +51 -8
  69. package/src/tokens.light.css +49 -6
  70. package/dist/index.css +0 -2
@@ -1,15 +1,26 @@
1
1
  import type { VariantProps } from 'class-variance-authority';
2
2
  import { cva } from 'class-variance-authority';
3
- import type { FunctionComponent, ReactNode } from 'react';
3
+ import {
4
+ createContext,
5
+ type FunctionComponent,
6
+ type ReactNode,
7
+ useContext,
8
+ } from 'react';
4
9
 
5
- // The reading column. It caps every child at --measure and sets no font-size, so the ch measure
6
- // keeps resolving against inherited body type - the same rule Form follows when it takes the measure.
7
- // The sibling gap is the type-scale --space-stack, never a one-idea-per-screen gap: the air the block
8
- // wants is at its edges (the 66ch measure) and inside the type, not between the blocks. It sets no
9
- // margin, so the page - not the component - owns the vertical rhythm around the column.
10
- const proseRoot = cva(
11
- 'flex max-w-[var(--measure)] flex-col gap-[var(--space-stack)]',
12
- );
10
+ // Root sets letter family and size to resolve its ch measure, but leaves leading inherited so
11
+ // Tail keeps its existing line height. Letter leading belongs only to Body (ADR 0004).
12
+ const proseRoot = cva('flex flex-col gap-[var(--space-stack)]', {
13
+ variants: {
14
+ variant: {
15
+ default: 'max-w-[var(--measure)]',
16
+ letter:
17
+ 'min-w-0 max-w-[min(100%,var(--measure-letter))] font-letter text-letter',
18
+ },
19
+ },
20
+ defaultVariants: { variant: 'default' },
21
+ });
22
+
23
+ const LetterContext = createContext(false);
13
24
 
14
25
  // The opening paragraph, at the new lede role: larger than body and led tighter (docs/adr/0004), but
15
26
  // still running-text leading, not a heading's. Colour is the foreground token, re-pointed by `.dark`.
@@ -18,8 +29,12 @@ const proseLede = cva('font-body text-lede leading-lede text-foreground');
18
29
  // The body role inside the column - the same utilities P emits, restated because the no-cross-import
19
30
  // rule forbids reusing P's recipe. A test pins the two equal so they cannot drift. Colour is a
20
31
  // semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
21
- const proseBody = cva('font-body text-body leading-body', {
32
+ const proseBody = cva('', {
22
33
  variants: {
34
+ letter: {
35
+ false: 'font-body text-body leading-body',
36
+ true: 'font-letter text-letter leading-letter',
37
+ },
23
38
  color: {
24
39
  foreground: 'text-foreground',
25
40
  muted: 'text-muted',
@@ -29,13 +44,13 @@ const proseBody = cva('font-body text-body leading-body', {
29
44
  info: 'text-info',
30
45
  },
31
46
  },
32
- defaultVariants: { color: 'foreground' },
47
+ defaultVariants: { letter: false, color: 'foreground' },
33
48
  });
34
49
 
35
50
  // The stepped-down closing note: the small role, always muted. small carries the enforced 15px floor.
36
51
  const proseTail = cva('font-body text-small text-muted');
37
52
 
38
- interface IProseRootProps {
53
+ export interface IProseRootProps extends VariantProps<typeof proseRoot> {
39
54
  children?: ReactNode;
40
55
  testId?: string;
41
56
  }
@@ -44,7 +59,8 @@ interface IProseLedeProps {
44
59
  children?: ReactNode;
45
60
  }
46
61
 
47
- interface IProseBodyProps extends VariantProps<typeof proseBody> {
62
+ interface IProseBodyProps
63
+ extends Pick<VariantProps<typeof proseBody>, 'color'> {
48
64
  children?: ReactNode;
49
65
  }
50
66
 
@@ -55,10 +71,13 @@ interface IProseTailProps {
55
71
  const ProseRoot: FunctionComponent<IProseRootProps> = ({
56
72
  children,
57
73
  testId,
74
+ variant,
58
75
  }) => (
59
- <div className={proseRoot()} data-testid={testId}>
60
- {children}
61
- </div>
76
+ <LetterContext value={variant === 'letter'}>
77
+ <div className={proseRoot({ variant })} data-testid={testId}>
78
+ {children}
79
+ </div>
80
+ </LetterContext>
62
81
  );
63
82
 
64
83
  const ProseLede: FunctionComponent<IProseLedeProps> = ({ children }) => (
@@ -66,7 +85,9 @@ const ProseLede: FunctionComponent<IProseLedeProps> = ({ children }) => (
66
85
  );
67
86
 
68
87
  const ProseBody: FunctionComponent<IProseBodyProps> = ({ color, children }) => (
69
- <p className={proseBody({ color })}>{children}</p>
88
+ <p className={proseBody({ color, letter: useContext(LetterContext) })}>
89
+ {children}
90
+ </p>
70
91
  );
71
92
 
72
93
  const ProseTail: FunctionComponent<IProseTailProps> = ({ children }) => (
@@ -79,15 +100,19 @@ const ProseTail: FunctionComponent<IProseTailProps> = ({ children }) => (
79
100
  * bounding the column. Composed from the four members; the consumer supplies the copy.
80
101
  *
81
102
  * @Guarantees — enforced on every render
82
- * - `Root` renders a `div` (never a `section`: it owns no landmark and no heading), capped at
103
+ * - Ordinary `Root` renders a `div` (never a `section`: it owns no landmark and no heading), capped at
83
104
  * `--measure`, setting no font-size so the `ch` measure resolves against inherited body type.
105
+ * - `Root variant="letter"` selects --measure-letter resolved against its own letter family and
106
+ * size, capped by available width. Body uses --text-letter and --leading-letter, and --font-letter
107
+ * falling back to --font-primary at the element. Lede and Tail keep their explicit roles.
84
108
  * - `Root` stacks its children on `--space-stack` and takes no outer margin: the page owns the
85
109
  * rhythm around the block, and the air the block wants is at its edges and inside the type.
86
- * - `Lede` renders a fixed-foreground `p` at the lede role; `Body` at the body role with a
110
+ * - `Lede` renders a fixed-foreground `p` at the lede role; `Body` at the selected root's role with a
87
111
  * `foreground`, `muted`, `success`, `warning`, `error` or `info` `color`; `Tail` at the small
88
112
  * role, always muted. A status tone changes colour only and adds no announcement semantics.
89
113
  *
90
114
  * @CallerMustEnsure — the component cannot see these and does not check them
115
+ * - Use Body throughout a letter, including the consumer-worded salutation and signature.
91
116
  * - Use `Prose.Body` for a paragraph inside this reading column; for a paragraph with no reading
92
117
  * column around it - in a form, a card, a table cell - use `P` instead.
93
118
  * - Status-toned content communicates its status without relying on colour. The caller owns any
@@ -6,7 +6,7 @@ import type { Subject } from 'rxjs';
6
6
  // No `dark:` classes here by design: every colour below is a semantic token whose value is
7
7
  // re-pointed by the `.dark` class in tokens.css, so one set of classes serves both themes.
8
8
  // Every variant shares colour motion and the focus ring. Faced controls share their typography,
9
- // radius and label arrangement; plain inherits its surrounding text. Long words wrap within the
9
+ // radius and label arrangement; plain and link inherit their surrounding text. Long words wrap within the
10
10
  // available width without clipping, and faced minimum widths yield to a narrower holder (#119).
11
11
  const face =
12
12
  'font-control text-body text-balance wrap-anywhere rounded-[var(--radius-control)] select-none inline-flex flex-row items-center justify-center gap-2';
@@ -30,6 +30,7 @@ const button = cva(
30
30
  // Disabled follows Input: edge and ink go to the disabled tones and no fill appears.
31
31
  outlined: `${face} px-[calc(1rem_-_1px)] sm:px-[calc(1.5rem_-_1px)] py-[calc(0.5rem_-_1px)] min-w-[min(var(--control-min-width),100%)] border border-solid border-secondary bg-transparent text-secondary hover:bg-backing disabled:border-disabled disabled:text-muted disabled:hover:bg-transparent`,
32
32
  destructive: `${face} px-4 sm:px-6 py-2 min-w-[min(var(--control-min-width),100%)] bg-error text-error-foreground hover:bg-error-hover disabled:bg-disabled disabled:hover:bg-disabled-hover forced-colors:border`,
33
+ link: 'inline p-0 min-w-0 max-w-full whitespace-normal wrap-anywhere bg-transparent [font:inherit] [letter-spacing:inherit] [text-align:inherit] text-link underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] enabled:hover:decoration-[length:var(--underline-thickness-hover)] disabled:text-disabled',
33
34
  plain:
34
35
  'inline p-0 min-w-0 max-w-full whitespace-normal wrap-anywhere bg-transparent [font:inherit] [letter-spacing:inherit] [color:inherit] [text-align:inherit] disabled:text-disabled',
35
36
  },
@@ -40,7 +41,7 @@ const button = cva(
40
41
  },
41
42
  );
42
43
 
43
- interface IButtonProps extends VariantProps<typeof button> {
44
+ export interface IButtonProps extends VariantProps<typeof button> {
44
45
  /** Optional: an icon-only button renders none, and names itself with `ariaLabel` instead. */
45
46
  children?: ReactNode;
46
47
  onClick$?: Subject<void>;
@@ -82,17 +83,23 @@ interface IButtonProps extends VariantProps<typeof button> {
82
83
  * text around it, with no face, padding, corner or hover underline of its own. Reach for it where
83
84
  * the action belongs to a line of content - a sortable column header composed with `Icon` - and
84
85
  * for `ghost` where a quiet but still button-shaped action is wanted
86
+ * - `link` is an inline action with the prose Link treatment: persistent underline and link ink,
87
+ * inheriting the surrounding face, size and line height, with no inset, corner or minimum width.
88
+ * Use `Link` when there is an address; use `Button variant="link"` with `onClick$` when there is
89
+ * none. The consumer owns the action and navigation. Supply text, not nested interactive content.
85
90
  * - Maintain consistent button styling throughout the application
86
91
  * - Provide visual feedback on hover/active states
87
- * - Ensure sufficient touch target size (minimum 44x44px) for mobile users
92
+ * - Inline actions take their target size from the surrounding text; use faced actions for standalone controls
88
93
  * - Position primary actions on the right for multi-button layouts
89
94
  * - A submit button's busy state is a label swap ("Send" to "Sending…"), never a spinner: it costs
90
95
  * nothing to render server-side and keeps a Form's `sending` state driver-agnostic
91
96
  *
92
97
  * @Accessibility
93
98
  * - Ensure adequate color contrast (4.5:1 minimum ratio)
94
- * - Provide focus styles for keyboard navigation: every variant, `plain` included, draws the shared
99
+ * - Provide focus styles for keyboard navigation: every variant, including `plain` and `link`, draws the shared
95
100
  * focus ring and nothing else changes on focus
101
+ * - A disabled `link` retains its underline, uses the disabled ink and does not thicken on hover.
102
+ * Native disabled semantics prevent activation and remove it from the tab sequence.
96
103
  * - A disabled `plain` button is told apart by the `disabled` text tone and keeps native disabled semantics
97
104
  * - Use appropriate ARIA attributes when needed
98
105
  * - Status colour never stands alone: a destructive action is named by its words or symbol
@@ -83,11 +83,6 @@ const assertOperatingRange = (
83
83
  `\`step\` (${range.step}) must be positive`,
84
84
  );
85
85
  }
86
- if (range.value < range.min || range.value > range.max) {
87
- throw new SliderConfigurationError(
88
- `\`value\` (${range.value}) must lie within [${range.min}, ${range.max}]`,
89
- );
90
- }
91
86
  };
92
87
 
93
88
  /**
@@ -98,6 +93,9 @@ const assertOperatingRange = (
98
93
  * `Input` too: visible, associated, and the only text the component renders (#112). It is still
99
94
  * a live control and not a form field, so none of Input's annotations - hint, error, optional
100
95
  * marker - come with it. `disabled` is the explicit non-operable state.
96
+ *
97
+ * An off-range value renders at the nearest bound through the native control. The consumer still
98
+ * owns the value: the clamped position is not emitted on `onInput$` until the user moves the thumb.
101
99
  */
102
100
  export const Slider: FunctionComponent<ISliderProps> = ({
103
101
  min,
@@ -0,0 +1,307 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import {
3
+ Children,
4
+ createContext,
5
+ type FocusEvent,
6
+ Fragment,
7
+ type FunctionComponent,
8
+ isValidElement,
9
+ type KeyboardEvent,
10
+ type ReactNode,
11
+ useContext,
12
+ useEffect,
13
+ useId,
14
+ useState,
15
+ } from 'react';
16
+ import type { Observable, Subject } from 'rxjs';
17
+ import { StepperCompositionError } from './StepperCompositionError';
18
+
19
+ const stepperRoot = cva(
20
+ 'flex h-full min-h-0 min-w-0 flex-col gap-[var(--space-stack)]',
21
+ );
22
+
23
+ const stepperList = cva(
24
+ [
25
+ 'flex flex-row shrink-0 min-w-0 overflow-x-auto',
26
+ 'p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
27
+ 'scroll-p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))]',
28
+ ].join(' '),
29
+ );
30
+
31
+ const stepperStep = cva([
32
+ 'relative isolate flex flex-1 flex-col items-center gap-[var(--space-stack)] text-center',
33
+ 'font-secondary text-label text-foreground cursor-pointer select-none',
34
+ 'px-[var(--step-inset-inline)] py-[var(--step-inset-block)]',
35
+ 'border-b-[length:var(--step-selection-thickness)] border-solid border-transparent aria-selected:border-foreground',
36
+ 'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
37
+ "after:content-[''] after:absolute after:-z-10 after:top-[calc(var(--step-inset-block)+var(--step-marker-size)/2)] after:start-1/2 after:w-full after:border-t-[length:var(--step-connector-thickness)] after:border-border last:after:hidden",
38
+ '[&>[data-marker]]:flex [&>[data-marker]]:shrink-0 [&>[data-marker]]:items-center [&>[data-marker]]:justify-center',
39
+ '[&>[data-marker]]:size-[var(--step-marker-size)] [&>[data-marker]]:border [&>[data-marker]]:border-foreground [&>[data-marker]]:bg-surface [&>[data-marker]]:rounded-[var(--radius-control)]',
40
+ '[&>[data-value-text]]:text-small [&>[data-status-content]]:text-small',
41
+ 'forced-colors:after:border-[CanvasText] forced-colors:aria-selected:border-[Highlight]',
42
+ 'forced-colors:border-b-0 forced-colors:aria-selected:border-b-[length:var(--step-selection-thickness)]',
43
+ 'forced-colors:[&>[data-marker]]:bg-[Canvas] forced-colors:[&>[data-marker]]:text-[CanvasText] forced-colors:[&>[data-marker]]:border-[CanvasText]',
44
+ ]);
45
+
46
+ const stepperPanel = cva(
47
+ 'min-h-0 min-w-0 flex-auto outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
48
+ );
49
+
50
+ type StepperContract = {
51
+ active: string | undefined;
52
+ onSelect$: Subject<string>;
53
+ label: string;
54
+ baseId: string;
55
+ };
56
+
57
+ const PositionContext = createContext<number | undefined>(undefined);
58
+
59
+ const flattenSteps = (children: ReactNode): ReactNode[] =>
60
+ Children.toArray(children).flatMap((child) =>
61
+ isValidElement<{ children: ReactNode }>(child) && child.type === Fragment
62
+ ? flattenSteps(child.props.children)
63
+ : [child],
64
+ );
65
+
66
+ const StepperContext = createContext<StepperContract | undefined>(undefined);
67
+
68
+ const useStepperContract = (member: string): StepperContract => {
69
+ const contract = useContext(StepperContext);
70
+ if (contract === undefined) {
71
+ throw new StepperCompositionError(member);
72
+ }
73
+ return contract;
74
+ };
75
+
76
+ const encodeValue = (value: string): string =>
77
+ value
78
+ .split('')
79
+ .map((unit) => unit.charCodeAt(0).toString(16).padStart(4, '0'))
80
+ .join('');
81
+
82
+ const keepFocusedTabVisible = (event: FocusEvent<HTMLButtonElement>): void => {
83
+ event.currentTarget.scrollIntoView({ block: 'nearest', inline: 'nearest' });
84
+ };
85
+
86
+ const tabId = (baseId: string, value: string): string =>
87
+ `${baseId}tab-${encodeValue(value)}`;
88
+ const panelId = (baseId: string, value: string): string =>
89
+ `${baseId}panel-${encodeValue(value)}`;
90
+
91
+ const neighbourTab = (
92
+ current: HTMLButtonElement,
93
+ step: 1 | -1,
94
+ ): HTMLButtonElement | undefined => {
95
+ const row = current.closest('[role="tablist"]');
96
+ if (row === null) {
97
+ return undefined;
98
+ }
99
+ const stepButtons = Array.from(
100
+ row.querySelectorAll<HTMLButtonElement>(':scope > [role="tab"]'),
101
+ );
102
+ const index = stepButtons.indexOf(current);
103
+ return stepButtons[(index + step + stepButtons.length) % stepButtons.length];
104
+ };
105
+
106
+ export interface IStepperRootProps {
107
+ /** Consumer-owned selection. No panel is active before this source first emits. */
108
+ active$: Observable<string>;
109
+ /** Requests selection, including blocked steps, without changing the input. */
110
+ onSelect$: Subject<string>;
111
+ label: string;
112
+ children: ReactNode;
113
+ testId?: string;
114
+ }
115
+
116
+ export interface IStepperListProps {
117
+ children: ReactNode;
118
+ testId?: string;
119
+ }
120
+
121
+ export interface IStepperStepProps {
122
+ /** Stable identity, unique within Root, paired with exactly one Panel. */
123
+ value: string;
124
+ label: string;
125
+ status: 'done' | 'open' | 'blocked';
126
+ /** Noninteractive consumer content, rendered whole and included in the accessible name. */
127
+ valueText: ReactNode;
128
+ /** Optional visible wording, associated as the control's accessible description. */
129
+ statusContent?: ReactNode;
130
+ testId?: string;
131
+ }
132
+
133
+ export interface IStepperPanelProps {
134
+ /** The tab this panel belongs to - exactly one panel per tab value within a Root. */
135
+ value: string;
136
+ /** Mounted only while active; departure unmounts it, return mounts it fresh. */
137
+ children: ReactNode;
138
+ testId?: string;
139
+ }
140
+
141
+ const StepperRoot: FunctionComponent<IStepperRootProps> = ({
142
+ active$,
143
+ onSelect$,
144
+ label,
145
+ children,
146
+ testId,
147
+ }) => {
148
+ const baseId = useId();
149
+ const [active, setActive] = useState<string>();
150
+ useEffect(() => {
151
+ setActive(undefined);
152
+ const subscription = active$.subscribe(setActive);
153
+ return () => subscription.unsubscribe();
154
+ }, [active$]);
155
+ return (
156
+ <StepperContext.Provider value={{ active, onSelect$, label, baseId }}>
157
+ <div className={stepperRoot()} data-testid={testId}>
158
+ {children}
159
+ </div>
160
+ </StepperContext.Provider>
161
+ );
162
+ };
163
+
164
+ const StepperList: FunctionComponent<IStepperListProps> = ({
165
+ children,
166
+ testId,
167
+ }) => {
168
+ const { label } = useStepperContract('List');
169
+ return (
170
+ <div
171
+ role="tablist"
172
+ aria-label={label}
173
+ className={stepperList()}
174
+ data-testid={testId}
175
+ >
176
+ {flattenSteps(children).map((child, index) => (
177
+ <PositionContext.Provider
178
+ key={
179
+ isValidElement<{ value: string }>(child) ? child.props.value : index
180
+ }
181
+ value={index + 1}
182
+ >
183
+ {child}
184
+ </PositionContext.Provider>
185
+ ))}
186
+ </div>
187
+ );
188
+ };
189
+
190
+ const StepperStep: FunctionComponent<IStepperStepProps> = ({
191
+ value,
192
+ label,
193
+ status,
194
+ valueText,
195
+ statusContent,
196
+ testId,
197
+ }) => {
198
+ const { active, onSelect$, baseId } = useStepperContract('Step');
199
+ const position = useContext(PositionContext);
200
+ if (position === undefined)
201
+ throw new StepperCompositionError('Step (inside List)');
202
+ const isActive = active === value;
203
+
204
+ const requestNeighbour = (event: KeyboardEvent<HTMLButtonElement>): void => {
205
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
206
+ return;
207
+ }
208
+ event.preventDefault();
209
+ const neighbour = neighbourTab(
210
+ event.currentTarget,
211
+ event.key === 'ArrowRight' ? 1 : -1,
212
+ );
213
+ if (neighbour === undefined) {
214
+ return;
215
+ }
216
+ neighbour.focus();
217
+ const neighbourValue = neighbour.dataset.value;
218
+ if (neighbourValue !== undefined) {
219
+ onSelect$.next(neighbourValue);
220
+ }
221
+ };
222
+
223
+ return (
224
+ <button
225
+ type="button"
226
+ role="tab"
227
+ id={tabId(baseId, value)}
228
+ aria-selected={isActive}
229
+ aria-controls={panelId(baseId, value)}
230
+ aria-labelledby={`${tabId(baseId, value)}-label ${tabId(baseId, value)}-value`}
231
+ aria-describedby={
232
+ statusContent !== undefined
233
+ ? `${tabId(baseId, value)}-status`
234
+ : undefined
235
+ }
236
+ tabIndex={isActive ? 0 : -1}
237
+ data-value={value}
238
+ data-testid={testId}
239
+ className={stepperStep()}
240
+ onClick={() => onSelect$.next(value)}
241
+ onKeyDown={requestNeighbour}
242
+ onFocus={keepFocusedTabVisible}
243
+ >
244
+ <span data-marker aria-hidden="true">
245
+ {status === 'done' ? '✓' : status === 'blocked' ? '⊘' : position}
246
+ </span>
247
+ <span id={`${tabId(baseId, value)}-label`}>{label}</span>
248
+ <span data-value-text id={`${tabId(baseId, value)}-value`}>
249
+ {valueText}
250
+ </span>
251
+ {statusContent !== undefined ? (
252
+ <span data-status-content id={`${tabId(baseId, value)}-status`}>
253
+ {statusContent}
254
+ </span>
255
+ ) : undefined}
256
+ </button>
257
+ );
258
+ };
259
+
260
+ const StepperPanel: FunctionComponent<IStepperPanelProps> = ({
261
+ value,
262
+ children,
263
+ testId,
264
+ }) => {
265
+ const { active, baseId } = useStepperContract('Panel');
266
+ const isActive = active === value;
267
+ return (
268
+ <div
269
+ role="tabpanel"
270
+ id={panelId(baseId, value)}
271
+ aria-labelledby={tabId(baseId, value)}
272
+ hidden={!isActive}
273
+ tabIndex={isActive ? 0 : undefined}
274
+ className={stepperPanel()}
275
+ data-testid={testId}
276
+ >
277
+ {isActive ? children : undefined}
278
+ </div>
279
+ );
280
+ };
281
+
282
+ /**
283
+ * Connected form steps with independent status and consumer-controlled selection.
284
+ * @Guarantees
285
+ * - Root observes active$ and emits identities through onSelect$ without changing selection.
286
+ * - Source replacement clears selection until the new source emits; subscriptions tear down.
287
+ * - Done and blocked steps remain selectable. Left/Right wraps focus and requests selection.
288
+ * - Label and value text name each tab; optional visible status content describes it.
289
+ * - Only the active panel mounts content; returning mounts fresh with no state cache.
290
+ * - The row wraps text before scrolling horizontally and reveals focused steps.
291
+ * - Root takes its parent's height; Panel passes the remainder without owning scrolling.
292
+ * An unsized parent retains natural content height.
293
+ * @CallerMustEnsure
294
+ * - Supply a valid selected identity, unique stable Step values and matching Panels.
295
+ * - Compose one List of direct Steps (arrays/fragments supported), no wrapper components.
296
+ * - For bounded content, List and Panels are direct DOM children of Root and the Panel's
297
+ * direct child accepts height (for example Pane). Fixed content must leave usable body space.
298
+ * - Value and status slots contain no interactive descendants. Supply localized status wording
299
+ * when spoken status is needed; the library supplies shapes but no status translations.
300
+ * - Persist form state outside Stepper and restore it into controls when they mount.
301
+ */
302
+ export const Stepper = {
303
+ Root: StepperRoot,
304
+ List: StepperList,
305
+ Step: StepperStep,
306
+ Panel: StepperPanel,
307
+ } as const;
@@ -0,0 +1,6 @@
1
+ export class StepperCompositionError extends Error {
2
+ constructor(member: string) {
3
+ super(`Stepper.${member} must be composed inside Stepper.Root`);
4
+ this.name = 'StepperCompositionError';
5
+ }
6
+ }
@@ -0,0 +1,64 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import { type FunctionComponent, useEffect, useState } from 'react';
3
+ import type { Observable, Subject } from 'rxjs';
4
+
5
+ const switchControl = cva([
6
+ 'inline-flex max-w-full items-center gap-[var(--space-stack)] rounded-[var(--radius-control)] bg-transparent p-0 text-start cursor-pointer',
7
+ 'font-secondary font-medium text-body text-foreground disabled:text-muted disabled:cursor-not-allowed',
8
+ 'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
9
+ '[&>[data-track]]:inline-flex [&>[data-track]]:shrink-0 [&>[data-track]]:box-content [&>[data-track]]:w-[calc(2*var(--switch-thumb-size))] [&>[data-track]]:p-[var(--switch-track-inset)]',
10
+ '[&>[data-track]]:rounded-[var(--radius-control)] [&>[data-track]]:border [&>[data-track]]:border-solid [&>[data-track]]:border-control-border [&>[data-track]]:bg-surface',
11
+ "[&>[data-track]::before]:content-[''] [&>[data-track]::before]:block [&>[data-track]::before]:size-[var(--switch-thumb-size)] [&>[data-track]::before]:rounded-[var(--radius-control)] [&>[data-track]::before]:bg-foreground",
12
+ 'aria-checked:[&>[data-track]]:justify-end',
13
+ 'disabled:[&>[data-track]]:border-disabled disabled:[&>[data-track]::before]:bg-disabled',
14
+ 'forced-colors:[&>[data-track]]:[forced-color-adjust:none] forced-colors:[&>[data-track]]:border-[ButtonText] forced-colors:[&>[data-track]]:bg-[Canvas] forced-colors:[&>[data-track]::before]:bg-[ButtonText]',
15
+ 'forced-colors:disabled:text-[GrayText] forced-colors:disabled:[&>[data-track]]:border-[GrayText] forced-colors:disabled:[&>[data-track]::before]:bg-[GrayText]',
16
+ '[&>[data-label]]:min-w-0 [&>[data-label]]:wrap-anywhere',
17
+ ]);
18
+
19
+ export interface ISwitchProps {
20
+ /** A visible, stable name for the setting, not a changing on/off caption. */
21
+ label: string;
22
+ /** Consumer-owned state. Off until the current source first emits, including on replacement. */
23
+ checked$: Observable<boolean>;
24
+ /** Requests the opposite of the displayed state; only an input emission changes the display. */
25
+ onChange$: Subject<boolean>;
26
+ /** Native disabled conduct; the current state remains visible. */
27
+ disabled?: boolean;
28
+ testId?: string;
29
+ }
30
+
31
+ /**
32
+ * One immediate binary setting, named by its visible label. A native button supplies a single
33
+ * tab stop and Space/Enter activation without submitting a surrounding form. Thumb position and
34
+ * aria-checked expose the input state; neither activation nor focus changes it independently.
35
+ * Use Choices for described alternatives, or a checkbox in a Form for a submitted selection.
36
+ */
37
+ export const Switch: FunctionComponent<ISwitchProps> = ({
38
+ label,
39
+ checked$,
40
+ onChange$,
41
+ disabled,
42
+ testId,
43
+ }) => {
44
+ const [isChecked, setChecked] = useState(false);
45
+ useEffect(() => {
46
+ setChecked(false);
47
+ const subscription = checked$.subscribe(setChecked);
48
+ return () => subscription.unsubscribe();
49
+ }, [checked$]);
50
+ return (
51
+ <button
52
+ type="button"
53
+ role="switch"
54
+ aria-checked={isChecked}
55
+ disabled={disabled}
56
+ data-testid={testId}
57
+ className={switchControl()}
58
+ onClick={() => onChange$.next(!isChecked)}
59
+ >
60
+ <span data-track aria-hidden="true" />
61
+ <span data-label>{label}</span>
62
+ </button>
63
+ );
64
+ };