@juwel-development/design-system 3.2.0 → 3.4.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 +32 -0
- package/dist/design-system.js +390 -138
- package/dist/index.css +1 -1
- package/dist/types/Arrangement/Stack/Stack.d.ts +19 -6
- package/dist/types/Display/Collection/Collection.d.ts +30 -0
- package/dist/types/Display/Meter/Meter.d.ts +59 -0
- package/dist/types/Display/Meter/MeterConfigurationError.d.ts +3 -0
- package/dist/types/Interaction/Slider/Slider.d.ts +41 -0
- package/dist/types/Interaction/Slider/SliderConfigurationError.d.ts +3 -0
- package/dist/types/Interaction/Tabs/Tabs.d.ts +79 -0
- package/dist/types/Interaction/Tabs/TabsCompositionError.d.ts +3 -0
- package/dist/types/Layout/Sidebar/Sidebar.d.ts +69 -0
- package/dist/types/Theme/Palette.d.ts +13 -0
- package/dist/types/index.d.ts +5 -0
- package/package.json +1 -1
- package/src/Arrangement/Stack/Stack.tsx +30 -10
- package/src/Display/Collection/Collection.tsx +58 -0
- package/src/Display/Meter/Meter.tsx +136 -0
- package/src/Display/Meter/MeterConfigurationError.ts +6 -0
- package/src/Interaction/Slider/Slider.tsx +125 -0
- package/src/Interaction/Slider/SliderConfigurationError.ts +6 -0
- package/src/Interaction/Tabs/Tabs.tsx +284 -0
- package/src/Interaction/Tabs/TabsCompositionError.ts +6 -0
- package/src/Layout/Cover/Cover.tsx +3 -3
- package/src/Layout/Sidebar/Sidebar.tsx +224 -0
- package/src/Theme/Palette.ts +20 -0
- package/src/Theme/renderTokens.ts +54 -0
- package/src/index.ts +5 -0
- package/src/tokens.css +31 -0
- package/src/tokens.dark.css +29 -0
- package/src/tokens.light.css +29 -0
|
@@ -4,8 +4,8 @@ import type { FunctionComponent, ReactNode } from 'react';
|
|
|
4
4
|
|
|
5
5
|
// One recipe on a plain <div>. Six components hand-write this same utility set and their specs pin
|
|
6
6
|
// it as a set, not an order (architecture standard, the import test). `band` is vertical padding,
|
|
7
|
-
// never a gap;
|
|
8
|
-
// (#97),
|
|
7
|
+
// never a gap; `split` turns at `lg`, the Rail/DefinitionList threshold. The bound holds the two
|
|
8
|
+
// container roles docs/adr/0008's Amendments attest (#97), sized as the guarantees below argue (#99).
|
|
9
9
|
const stack = cva('flex', {
|
|
10
10
|
variants: {
|
|
11
11
|
gap: {
|
|
@@ -15,7 +15,11 @@ const stack = cva('flex', {
|
|
|
15
15
|
measure: {
|
|
16
16
|
true: 'max-w-[var(--measure)]',
|
|
17
17
|
false: '',
|
|
18
|
-
action: 'w-
|
|
18
|
+
action: 'w-[var(--measure-action)] max-w-full mx-auto',
|
|
19
|
+
},
|
|
20
|
+
align: {
|
|
21
|
+
start: 'text-start',
|
|
22
|
+
center: 'text-center',
|
|
19
23
|
},
|
|
20
24
|
direction: {
|
|
21
25
|
column: 'flex-col',
|
|
@@ -33,12 +37,14 @@ export interface IStackProps extends VariantProps<typeof stack> {
|
|
|
33
37
|
|
|
34
38
|
/**
|
|
35
39
|
* A vertical arrangement: children in a column, separated by one named space role and optionally
|
|
36
|
-
* bounded by the reading measure or the action column
|
|
37
|
-
*
|
|
38
|
-
*
|
|
40
|
+
* bounded by the reading measure or the action column, with optional inherited text alignment.
|
|
41
|
+
* It owns no landmark, heading, band, join, gutter or fill and takes no outer space, so whatever
|
|
42
|
+
* holds it owns the rhythm around it.
|
|
39
43
|
*
|
|
40
44
|
* @Guarantees — enforced on every render
|
|
41
|
-
* - It renders a `div` with no landmark role, no heading and no margin of its own
|
|
45
|
+
* - It renders a `div` with no landmark role, no heading and no margin of its own - the one
|
|
46
|
+
* exception the action column's auto inline margins, which name no space role and take no space:
|
|
47
|
+
* they only centre the bound column inside a holder wider than the bound.
|
|
42
48
|
* - `gap` selects which space role separates the children: `stack` (the default), the gap between
|
|
43
49
|
* siblings within one block, or `region`, the gap between groups of blocks. Nothing else — a
|
|
44
50
|
* spacing neither role expresses is a request for a measurement (docs/adr/0003, docs/adr/0004).
|
|
@@ -46,14 +52,24 @@ export interface IStackProps extends VariantProps<typeof stack> {
|
|
|
46
52
|
* unbounded, which is right wherever the children are not running text. It sets no font-size, so
|
|
47
53
|
* a `ch`-counted measure keeps resolving against inherited body type.
|
|
48
54
|
* - `measure="action"` bounds the element to `--measure-action`, the action column - the width a
|
|
49
|
-
* stack of full-width controls fills, so they read as one unit and their labels align
|
|
50
|
-
*
|
|
55
|
+
* stack of full-width controls fills, so they read as one unit and their labels align. The column
|
|
56
|
+
* asks for that bound as its own definite width, capped at its holder's, so it reaches the bound
|
|
57
|
+
* even inside a shrink-to-fit frame that sizes from its content - `Cover`'s slot - independently
|
|
58
|
+
* of what its siblings measure, and its auto inline margins keep it centred in a holder that
|
|
59
|
+
* stays wider. (#99, correcting #97's reasoning: `w-full` fills a containing slot but cannot
|
|
60
|
+
* raise a shrink-to-fit ancestor's intrinsic width, so it never guaranteed the bound.) The two
|
|
51
61
|
* options answer different questions about what the column holds - text or controls - never how
|
|
52
62
|
* wide it should be (docs/adr/0008, Amendments).
|
|
53
63
|
* - `direction="column"` (the default) never changes axis. `direction="split"` is the same column
|
|
54
64
|
* turning into a row at and above 64rem, its children aligned to their start edge rather than
|
|
55
65
|
* stretched. It is the only viewport-dependent behaviour here, and it is named for the job: the
|
|
56
66
|
* breakpoint is an implementation detail and is not part of the vocabulary.
|
|
67
|
+
* - `align="center"` centres inline text within each receiving text block; `align="start"`
|
|
68
|
+
* resets it to the logical start edge in LTR or RTL. Omission sets no alignment, so nested
|
|
69
|
+
* Stacks inherit. Descendants that declare their own alignment retain it.
|
|
70
|
+
* - Alignment never moves or resizes child boxes or changes gap, measure, direction, wrapping or
|
|
71
|
+
* overflow. A narrower text block centres within itself, not on its holder's axis; an unbroken
|
|
72
|
+
* word wider than its block is outside the centring guarantee, including in split arrangements.
|
|
57
73
|
* - No literal length and no numbered spacing rung appears in the recipe: every value it emits is a
|
|
58
74
|
* role the token layer already names, so a second brand re-points all of them.
|
|
59
75
|
* - `children` render unmodified, and it needs no JavaScript.
|
|
@@ -72,10 +88,14 @@ export const Stack: FunctionComponent<IStackProps> = ({
|
|
|
72
88
|
gap,
|
|
73
89
|
measure,
|
|
74
90
|
direction,
|
|
91
|
+
align,
|
|
75
92
|
children,
|
|
76
93
|
testId,
|
|
77
94
|
}) => (
|
|
78
|
-
<div
|
|
95
|
+
<div
|
|
96
|
+
className={stack({ gap, measure, direction, align })}
|
|
97
|
+
data-testid={testId}
|
|
98
|
+
>
|
|
79
99
|
{children}
|
|
80
100
|
</div>
|
|
81
101
|
);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
const collectionRoot = cva('m-0 list-none p-0');
|
|
5
|
+
const collectionItem = cva(
|
|
6
|
+
'py-[var(--space-collection-item)] [&+li]:border-t [&+li]:border-solid [&+li]:border-border',
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export interface ICollectionRootProps {
|
|
10
|
+
/** Collection.Item children, including mapped items and conditional omissions. */
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
testId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ICollectionItemProps {
|
|
16
|
+
/** Freely composed content; its typography, arrangement and interaction remain its own. */
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
testId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CollectionRoot: FunctionComponent<ICollectionRootProps> = ({
|
|
22
|
+
children,
|
|
23
|
+
testId,
|
|
24
|
+
}) => (
|
|
25
|
+
// biome-ignore lint/a11y/noRedundantRoles: list-style:none removes list semantics in Safari/VoiceOver; Checklist establishes this explicit-role precedent.
|
|
26
|
+
<ul className={collectionRoot()} role={'list'} data-testid={testId}>
|
|
27
|
+
{children}
|
|
28
|
+
</ul>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const CollectionItem: FunctionComponent<ICollectionItemProps> = ({
|
|
32
|
+
children,
|
|
33
|
+
testId,
|
|
34
|
+
}) => (
|
|
35
|
+
<li className={collectionItem()} data-testid={testId}>
|
|
36
|
+
{children}
|
|
37
|
+
</li>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A vertical group of freely composed items with internal hairlines and open outer edges.
|
|
42
|
+
* The library owns spacing and separation; the consumer owns content, arrangement and interaction.
|
|
43
|
+
*
|
|
44
|
+
* @Guarantees
|
|
45
|
+
* - Root is a semantic list and Item a list item, with no visual markers or horizontal indent.
|
|
46
|
+
* - Each item takes vertical padding from --space-collection-item; adjacent items share one
|
|
47
|
+
* hairline in the border colour role. Empty and single-item roots have no rules or placeholder.
|
|
48
|
+
* - Children render in supplied order, retaining their own typography and arrangement.
|
|
49
|
+
* - Neither member adds focus stops, interaction, or responsive content rearrangement.
|
|
50
|
+
*
|
|
51
|
+
* @CallerMustEnsure
|
|
52
|
+
* - Supply Collection.Item as Root's rendered children, directly or through fragments and maps.
|
|
53
|
+
* - Compose each item's content with typography, arrangements and controls for the jobs it contains.
|
|
54
|
+
*/
|
|
55
|
+
export const Collection = {
|
|
56
|
+
Root: CollectionRoot,
|
|
57
|
+
Item: CollectionItem,
|
|
58
|
+
} as const;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
import type { CSSProperties, FunctionComponent } from 'react';
|
|
3
|
+
import { MeterConfigurationError } from './MeterConfigurationError';
|
|
4
|
+
|
|
5
|
+
// The track is the whole capacity: square corners, no radius token - Meter is not a control
|
|
6
|
+
// (docs/adr/0003). The one-pixel `rule` line identifies the capacity against the surface;
|
|
7
|
+
// `meterTrack` behind it makes the scale visible without boxing (docs/adr/0010). Deliberately no
|
|
8
|
+
// transition class: the library's one motion belongs to state changes on controls, not a display.
|
|
9
|
+
const meterTrack = cva(
|
|
10
|
+
'w-full h-[var(--meter-track-thickness)] bg-meter-track border border-solid border-rule',
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
// The fill is a block box sized by --meter-level, the normalized share the component computes;
|
|
14
|
+
// block layout places it at inline-start in both LTR and RTL, no physical offset. Depleting reads
|
|
15
|
+
// the same share into color-mix: `meterFill` at max, `error` at min, linearly through OKLab - a
|
|
16
|
+
// perceptual space, so no threshold (docs/adr/0010). Both endpoints stay tokens a brand re-points.
|
|
17
|
+
const meterFill = cva('h-full w-[calc(var(--meter-level)*100%)]', {
|
|
18
|
+
variants: {
|
|
19
|
+
treatment: {
|
|
20
|
+
neutral: 'bg-meter-fill',
|
|
21
|
+
depleting:
|
|
22
|
+
'bg-[color-mix(in_oklab,var(--color-meter-fill)_calc(var(--meter-level)*100%),var(--color-error))]',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
treatment: 'neutral',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// React's CSSProperties is closed over known properties; the one custom property the recipes read
|
|
31
|
+
// is declared here so the style object stays typed without an assertion.
|
|
32
|
+
type MeterLevelStyle = CSSProperties & { '--meter-level': number };
|
|
33
|
+
|
|
34
|
+
export interface IMeterProps extends VariantProps<typeof meterFill> {
|
|
35
|
+
/** The current level. Must lie inside `[min, max]`; an outside value throws
|
|
36
|
+
* {@link MeterConfigurationError} rather than clamping - a wrong number must not render as a
|
|
37
|
+
* plausible level. */
|
|
38
|
+
value: number;
|
|
39
|
+
/** The bottom of the scale. Defaults to `0`. */
|
|
40
|
+
min?: number;
|
|
41
|
+
/** The top of the scale. Must exceed `min`. */
|
|
42
|
+
max: number;
|
|
43
|
+
/** The meter's accessible name - what the level measures. Required, and never rendered visibly:
|
|
44
|
+
* the component shows a filled track and nothing else. Its wording is the consumer's. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** Optional `aria-valuetext`, so a screen reader hears the consumer's wording ("strained")
|
|
47
|
+
* instead of a bare number. Omitted, the numeric value is announced. */
|
|
48
|
+
valueText?: string;
|
|
49
|
+
testId?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A read-only display of one numeric level within a bounded scale, shown as a filled share of a
|
|
54
|
+
* whole: a short track, a fill from inline-start, no text. It states an amount - not an operation's
|
|
55
|
+
* completion, and unlike a Slider it cannot be operated.
|
|
56
|
+
*
|
|
57
|
+
* @Guarantees — enforced on every render
|
|
58
|
+
* - The root carries `role="meter"`, `aria-valuenow`/`aria-valuemin`/`aria-valuemax`, the `label`
|
|
59
|
+
* as its accessible name, and `aria-valuetext` exactly when `valueText` is supplied. It is not
|
|
60
|
+
* focusable, and the visual fill is hidden from the accessibility tree.
|
|
61
|
+
* - The filled share is `(value - min) / (max - min)` of the container and begins at logical
|
|
62
|
+
* inline-start under both LTR and RTL direction.
|
|
63
|
+
* - Neutral paints the fill with `meterFill`; `depleting` maps the normalized level linearly
|
|
64
|
+
* through OKLab from `meterFill` at the maximum to `error` at the minimum - no threshold, no
|
|
65
|
+
* wording, no icon (docs/adr/0010-meter-depletion-is-a-treatment.md).
|
|
66
|
+
* - A value change lands instantly: no width or colour animation.
|
|
67
|
+
* - The track fills its container, reads `--meter-track-thickness`, keeps square corners (no
|
|
68
|
+
* radius token - Meter is not a control), and draws a persistent one-pixel `rule` line that
|
|
69
|
+
* identifies the whole capacity against the surface.
|
|
70
|
+
* - Invalid numeric configuration throws {@link MeterConfigurationError}: non-finite `value`,
|
|
71
|
+
* `min` or `max`, `min >= max`, or a value outside the inclusive range. Nothing is clamped.
|
|
72
|
+
* - No visible wording of its own, no numeric readout, no caption slot, no `className`/`style`
|
|
73
|
+
* passthrough.
|
|
74
|
+
*
|
|
75
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
76
|
+
* - `label` carries meaningful wording; the component renders it to assistive technology only and
|
|
77
|
+
* does not inspect it.
|
|
78
|
+
* - When `depleting` is selected, surrounding visible content explains why nearing the minimum
|
|
79
|
+
* matters - the colour walk reinforces a stated consequence and must not be its only carrier.
|
|
80
|
+
*
|
|
81
|
+
* @UXGuidelines
|
|
82
|
+
* - Place any visible caption or readout yourself with typography; the Meter deliberately carries
|
|
83
|
+
* none, so what the level measures stays the consumer's wording.
|
|
84
|
+
* - Reach for `depleting` only where a low value is genuinely more erroneous - some quantities
|
|
85
|
+
* become safer toward their minimum, which is why the treatment is opt-in.
|
|
86
|
+
*/
|
|
87
|
+
export const Meter: FunctionComponent<IMeterProps> = ({
|
|
88
|
+
value,
|
|
89
|
+
min = 0,
|
|
90
|
+
max,
|
|
91
|
+
treatment,
|
|
92
|
+
label,
|
|
93
|
+
valueText,
|
|
94
|
+
testId,
|
|
95
|
+
}) => {
|
|
96
|
+
if (
|
|
97
|
+
!Number.isFinite(value) ||
|
|
98
|
+
!Number.isFinite(min) ||
|
|
99
|
+
!Number.isFinite(max)
|
|
100
|
+
) {
|
|
101
|
+
throw new MeterConfigurationError(
|
|
102
|
+
`value, min and max must be finite numbers (got value=${value}, min=${min}, max=${max})`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (min >= max) {
|
|
106
|
+
throw new MeterConfigurationError(
|
|
107
|
+
`min must be less than max (got min=${min}, max=${max})`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (value < min || value > max) {
|
|
111
|
+
throw new MeterConfigurationError(
|
|
112
|
+
`value must lie within [min, max] (got value=${value}, min=${min}, max=${max})`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const style: MeterLevelStyle = {
|
|
117
|
+
'--meter-level': (value - min) / (max - min),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
// biome-ignore lint/a11y/useSemanticElements: the native <meter> paints its own optimum/low/high bands the token contract cannot reach, and its fill is unstylable cross-browser; the ARIA meter pattern on a div carries the same semantics with the library's paint (#105).
|
|
122
|
+
<div
|
|
123
|
+
role={'meter'}
|
|
124
|
+
aria-valuenow={value}
|
|
125
|
+
aria-valuemin={min}
|
|
126
|
+
aria-valuemax={max}
|
|
127
|
+
aria-label={label}
|
|
128
|
+
aria-valuetext={valueText}
|
|
129
|
+
data-testid={testId}
|
|
130
|
+
className={meterTrack()}
|
|
131
|
+
style={style}
|
|
132
|
+
>
|
|
133
|
+
<div aria-hidden={'true'} className={meterFill({ treatment })} />
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import type { FunctionComponent } from 'react';
|
|
3
|
+
import type { Subject } from 'rxjs';
|
|
4
|
+
import { SliderConfigurationError } from './SliderConfigurationError';
|
|
5
|
+
|
|
6
|
+
// The track and thumb are vendor pseudo-elements, so every dimension on them needs a token name -
|
|
7
|
+
// no consumer selector reaches them (ADR 0004's test; the tick precedent). The track takes
|
|
8
|
+
// `controlBorder`: the one thing separating an unfilled control from the surface, >=3:1 against it.
|
|
9
|
+
// The thumb takes `foreground`, a solid mark drawn on the surface like the tab marker; disabled
|
|
10
|
+
// paints both in the disabled role, the non-operable statement every control makes. WebKit does
|
|
11
|
+
// not centre the thumb on the track, hence the computed negative margin; Firefox does, but draws a
|
|
12
|
+
// default thumb border WebKit does not, hence border-none on its thumb alone.
|
|
13
|
+
const slider = cva(
|
|
14
|
+
[
|
|
15
|
+
'block h-[var(--slider-thumb-size)] w-full cursor-pointer appearance-none bg-transparent',
|
|
16
|
+
'disabled:cursor-not-allowed',
|
|
17
|
+
'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
18
|
+
'[&::-webkit-slider-runnable-track]:h-[var(--slider-track-thickness)] [&::-webkit-slider-runnable-track]:rounded-[var(--radius-control)] [&::-webkit-slider-runnable-track]:bg-control-border',
|
|
19
|
+
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-[var(--slider-thumb-size)] [&::-webkit-slider-thumb]:rounded-[var(--radius-control)] [&::-webkit-slider-thumb]:bg-foreground',
|
|
20
|
+
'[&::-webkit-slider-thumb]:mt-[calc((var(--slider-track-thickness)-var(--slider-thumb-size))/2)]',
|
|
21
|
+
'[&:disabled::-webkit-slider-runnable-track]:bg-disabled [&:disabled::-webkit-slider-thumb]:bg-disabled',
|
|
22
|
+
'[&::-moz-range-track]:h-[var(--slider-track-thickness)] [&::-moz-range-track]:rounded-[var(--radius-control)] [&::-moz-range-track]:bg-control-border',
|
|
23
|
+
'[&::-moz-range-thumb]:size-[var(--slider-thumb-size)] [&::-moz-range-thumb]:rounded-[var(--radius-control)] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:bg-foreground',
|
|
24
|
+
'[&:disabled::-moz-range-track]:bg-disabled [&:disabled::-moz-range-thumb]:bg-disabled',
|
|
25
|
+
].join(' '),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export interface ISliderProps {
|
|
29
|
+
/** The operating range's lower bound - interaction geometry, not a content rule (ADR 0009). */
|
|
30
|
+
min: number;
|
|
31
|
+
/** The operating range's upper bound. */
|
|
32
|
+
max: number;
|
|
33
|
+
/**
|
|
34
|
+
* The increment a movement produces. Defaults to 1: whole-step movement. Keeping `value` and
|
|
35
|
+
* `max` on the step grid is the caller's obligation - Slider neither rejects nor repairs an
|
|
36
|
+
* off-grid value, and the native control's own conduct stays observable: inside a surrounding
|
|
37
|
+
* form, misalignment counts as a `stepMismatch` against that form's validity.
|
|
38
|
+
*/
|
|
39
|
+
step?: number;
|
|
40
|
+
/** The current value. Controlled - the consumer holds it and passes it back in. */
|
|
41
|
+
value: number;
|
|
42
|
+
/**
|
|
43
|
+
* Emits the new value on every native input event. Required, not optional: the control is
|
|
44
|
+
* controlled and takes no part in form submission, so a consumer that did not listen here
|
|
45
|
+
* could never read a value at all.
|
|
46
|
+
*/
|
|
47
|
+
onInput$: Subject<number>;
|
|
48
|
+
/** The control's accessible name. */
|
|
49
|
+
label: string;
|
|
50
|
+
/**
|
|
51
|
+
* How the value is announced, in the consumer's wording ("$60 a week"). Left out, assistive
|
|
52
|
+
* technology reads the bare number: the library ships no wording of its own.
|
|
53
|
+
*/
|
|
54
|
+
valueText?: string;
|
|
55
|
+
/** The explicit non-operable state: native disabled conduct, no emissions, disabled paint. */
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
testId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Structural misconfiguration is a programmer error, so it fails fast - no clamping, no
|
|
61
|
+
// normalization, no substitute emission. Step-grid alignment is deliberately not checked: that is
|
|
62
|
+
// the caller's obligation, documented on `step`, and native constraint behaviour stays observable.
|
|
63
|
+
const assertOperatingRange = (
|
|
64
|
+
range: Pick<Required<ISliderProps>, 'min' | 'max' | 'step' | 'value'>,
|
|
65
|
+
): void => {
|
|
66
|
+
for (const [name, given] of Object.entries(range)) {
|
|
67
|
+
if (!Number.isFinite(given)) {
|
|
68
|
+
throw new SliderConfigurationError(
|
|
69
|
+
`\`${name}\` must be a finite number, got ${given}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (range.min >= range.max) {
|
|
74
|
+
throw new SliderConfigurationError(
|
|
75
|
+
`\`min\` (${range.min}) must be below \`max\` (${range.max})`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (range.step <= 0) {
|
|
79
|
+
throw new SliderConfigurationError(
|
|
80
|
+
`\`step\` (${range.step}) must be positive`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (range.value < range.min || range.value > range.max) {
|
|
84
|
+
throw new SliderConfigurationError(
|
|
85
|
+
`\`value\` (${range.value}) must lie within [${range.min}, ${range.max}]`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A control that sets one numeric value by moving one thumb along a fixed, visible operating
|
|
92
|
+
* range. Controlled: the consumer holds the value and passes it back in, and a value it does not
|
|
93
|
+
* pass back is never adopted. It renders the value nowhere - the consumer sets any figures beside
|
|
94
|
+
* it with typography - and it fills its container's width the way `Input` does. `disabled` is the
|
|
95
|
+
* explicit non-operable state.
|
|
96
|
+
*/
|
|
97
|
+
export const Slider: FunctionComponent<ISliderProps> = ({
|
|
98
|
+
min,
|
|
99
|
+
max,
|
|
100
|
+
step = 1,
|
|
101
|
+
value,
|
|
102
|
+
onInput$,
|
|
103
|
+
label,
|
|
104
|
+
valueText,
|
|
105
|
+
disabled,
|
|
106
|
+
testId,
|
|
107
|
+
}) => {
|
|
108
|
+
assertOperatingRange({ min, max, step, value });
|
|
109
|
+
return (
|
|
110
|
+
<input
|
|
111
|
+
type={'range'}
|
|
112
|
+
className={slider()}
|
|
113
|
+
min={min}
|
|
114
|
+
max={max}
|
|
115
|
+
step={step}
|
|
116
|
+
value={value}
|
|
117
|
+
disabled={disabled}
|
|
118
|
+
aria-label={label}
|
|
119
|
+
aria-valuetext={valueText}
|
|
120
|
+
data-testid={testId}
|
|
121
|
+
// React's onChange rides the native input event, so every movement emits through here.
|
|
122
|
+
onChange={(event) => onInput$.next(event.currentTarget.valueAsNumber)}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
};
|