@juwel-development/design-system 3.11.0 → 4.0.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.
- package/README.md +16 -3
- package/dist/design-system.js +1029 -540
- package/dist/styles.css +2 -0
- package/dist/styles.dark.css +2 -0
- package/dist/styles.light.css +2 -0
- package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +38 -18
- package/dist/types/Display/Callout/Callout.d.ts +31 -0
- package/dist/types/Display/Icon/Icon.d.ts +20 -24
- package/dist/types/Display/Meter/Meter.d.ts +5 -6
- package/dist/types/Display/Rule/Rule.d.ts +22 -0
- package/dist/types/Display/Table/Table.d.ts +57 -7
- package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
- package/dist/types/Display/Typography/H1/H1.d.ts +1 -1
- package/dist/types/Display/Typography/H2/H2.d.ts +1 -1
- package/dist/types/Display/Typography/H3/H3.d.ts +1 -1
- package/dist/types/Display/Typography/H4/H4.d.ts +1 -1
- package/dist/types/Display/Typography/H5/H5.d.ts +1 -1
- package/dist/types/Display/Typography/H6/H6.d.ts +1 -1
- package/dist/types/Display/Typography/Note/Note.d.ts +16 -3
- package/dist/types/Display/Typography/P/P.d.ts +15 -2
- package/dist/types/Display/Typography/Prose/Prose.d.ts +14 -6
- package/dist/types/Interaction/Button/Button.d.ts +10 -4
- package/dist/types/Interaction/Slider/Slider.d.ts +3 -0
- package/dist/types/Interaction/Stepper/Stepper.d.ts +59 -0
- package/dist/types/Interaction/Stepper/StepperCompositionError.d.ts +3 -0
- package/dist/types/Interaction/Switch/Switch.d.ts +20 -0
- package/dist/types/Interaction/Tabs/Tabs.d.ts +24 -3
- package/dist/types/Layout/Dialog/Dialog.d.ts +11 -3
- package/dist/types/Layout/Form/FormConfigurationError.d.ts +3 -0
- package/dist/types/Layout/Pane/Pane.d.ts +55 -0
- package/dist/types/Layout/Shell/Shell.d.ts +39 -0
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +16 -2
- package/dist/types/Theme/Palette.d.ts +11 -4
- package/dist/types/index.d.ts +21 -1
- package/package.json +18 -8
- package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +86 -28
- package/src/Display/Callout/Callout.tsx +64 -0
- package/src/Display/Icon/Icon.tsx +53 -13
- package/src/Display/Meter/Meter.tsx +12 -13
- package/src/Display/Rule/Rule.tsx +31 -0
- package/src/Display/Table/Table.tsx +189 -24
- package/src/Display/Typography/Note/Note.tsx +27 -11
- package/src/Display/Typography/P/P.tsx +29 -4
- package/src/Display/Typography/Prose/Prose.tsx +44 -19
- package/src/Interaction/Button/Button.tsx +11 -4
- package/src/Interaction/Slider/Slider.tsx +3 -5
- package/src/Interaction/Stepper/Stepper.tsx +307 -0
- package/src/Interaction/Stepper/StepperCompositionError.ts +6 -0
- package/src/Interaction/Switch/Switch.tsx +64 -0
- package/src/Interaction/Tabs/Tabs.tsx +44 -10
- package/src/Layout/Dialog/Dialog.tsx +19 -6
- package/src/Layout/Form/Form.tsx +2 -1
- package/src/Layout/Form/FormConfigurationError.ts +6 -0
- package/src/Layout/Pane/Pane.tsx +159 -0
- package/src/Layout/Shell/Shell.tsx +124 -0
- package/src/Layout/Sidebar/Sidebar.tsx +31 -7
- package/src/Theme/Palette.ts +17 -6
- package/src/Theme/renderTokens.ts +61 -12
- package/src/index.ts +21 -2
- package/src/styles.css +5 -6
- package/src/styles.dark.css +5 -6
- package/src/styles.light.css +5 -6
- package/src/tokens.css +54 -9
- package/src/tokens.dark.css +51 -8
- package/src/tokens.light.css +49 -6
- 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
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
type FunctionComponent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useContext,
|
|
8
|
+
} from 'react';
|
|
4
9
|
|
|
5
|
-
//
|
|
6
|
-
// keeps
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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('
|
|
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
|
|
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
|
-
<
|
|
60
|
-
{
|
|
61
|
-
|
|
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 })}>
|
|
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
|
|
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
|
|
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
|
-
* -
|
|
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`
|
|
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,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
|
+
};
|