@juwel-development/design-system 3.8.0 → 3.9.1
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 +171 -0
- package/dist/design-system.js +669 -273
- package/dist/index.css +1 -1
- package/dist/types/Interaction/MultiSelect/MultiSelect.d.ts +102 -0
- package/dist/types/Interaction/MultiSelect/MultiSelectCompositionError.d.ts +3 -0
- package/dist/types/Interaction/MultiSelect/fitChips.d.ts +6 -0
- package/dist/types/Interaction/NumberInput/NumberInput.d.ts +34 -0
- package/dist/types/Interaction/Slider/Slider.d.ts +9 -4
- package/dist/types/index.d.ts +2 -0
- package/package.json +1 -1
- package/src/Interaction/MultiSelect/MultiSelect.tsx +820 -0
- package/src/Interaction/MultiSelect/MultiSelectCompositionError.ts +6 -0
- package/src/Interaction/MultiSelect/fitChips.ts +27 -0
- package/src/Interaction/NumberInput/NumberInput.tsx +125 -0
- package/src/Interaction/Slider/Slider.tsx +34 -18
- package/src/index.ts +2 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many leading chips fit on one line beside the hidden-selection count. Every chip fits
|
|
3
|
+
* when their widths and gaps do; otherwise the count is reserved and the longest prefix that
|
|
4
|
+
* leaves room for it - and the gap before it - is shown, down to a count-only display.
|
|
5
|
+
*/
|
|
6
|
+
export const fitChips = (
|
|
7
|
+
chipWidths: readonly number[],
|
|
8
|
+
countWidth: number,
|
|
9
|
+
availableWidth: number,
|
|
10
|
+
gap: number,
|
|
11
|
+
): number => {
|
|
12
|
+
const gaps = Math.max(chipWidths.length - 1, 0) * gap;
|
|
13
|
+
const total = chipWidths.reduce((sum, width) => sum + width, 0) + gaps;
|
|
14
|
+
if (total <= availableWidth) {
|
|
15
|
+
return chipWidths.length;
|
|
16
|
+
}
|
|
17
|
+
let visible = 0;
|
|
18
|
+
let used = countWidth;
|
|
19
|
+
for (const width of chipWidths) {
|
|
20
|
+
if (used + width + gap > availableWidth) {
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
used += width + gap;
|
|
24
|
+
visible += 1;
|
|
25
|
+
}
|
|
26
|
+
return visible;
|
|
27
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import { type FunctionComponent, useEffect, useId, useRef } from 'react';
|
|
3
|
+
import type { Subject } from 'rxjs';
|
|
4
|
+
|
|
5
|
+
// One recipe of its own, not Input's (design-system-components.md §4, issue #111): a text control
|
|
6
|
+
// so `-`, `1.` and `1,5` survive - the Number state sanitises them to `''` (WHATWG input.html
|
|
7
|
+
// #number-state) - with `inputmode=decimal` as a keyboard hint only. The classes are Input's, for
|
|
8
|
+
// Input's reasons: tokens re-pointed by `.dark`, the ring of docs/adr/0002, the faces of 0004.
|
|
9
|
+
const numberInput = cva(
|
|
10
|
+
'block w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export interface INumberInputProps {
|
|
14
|
+
/** Always rendered and associated with the control; never replaced by the placeholder. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** How the surrounding form reads the entered text on submit. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Presence only: the form rejects an empty field, never a non-numeric one. */
|
|
19
|
+
required?: boolean;
|
|
20
|
+
/** How a non-required field says so, in the consuming app's language; left out, no marker renders. */
|
|
21
|
+
optionalLabel?: string;
|
|
22
|
+
hint?: string;
|
|
23
|
+
/** The consumer's verdict on the text; the control renders it and keeps accepting edits. */
|
|
24
|
+
invalid?: boolean;
|
|
25
|
+
errorMessage?: string;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Initialises the uncontrolled field, on mount only: a later change never replaces the current
|
|
30
|
+
* edit. To restore saved text, remount the field with it; native form reset returns to it.
|
|
31
|
+
*/
|
|
32
|
+
defaultValue?: string;
|
|
33
|
+
/** Emits the current text on every user edit - typing, pasting, clearing - and on nothing else. */
|
|
34
|
+
onInput$?: Subject<string>;
|
|
35
|
+
/** Emit to empty the control in place, keeping the same node so focus survives; `onInput$` stays silent. */
|
|
36
|
+
reset$?: Subject<void>;
|
|
37
|
+
testId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A labelled field for typing numeric amounts and thresholds. It is a text control with a decimal
|
|
42
|
+
* keyboard hint, so the entered text - blank, `0`, `-`, `1.`, `1,5` or pasted content - is kept
|
|
43
|
+
* exactly as typed and read by the form by `name`. Parsing, locale and validity are the consumer's.
|
|
44
|
+
*/
|
|
45
|
+
export const NumberInput: FunctionComponent<INumberInputProps> = ({
|
|
46
|
+
label,
|
|
47
|
+
name,
|
|
48
|
+
required,
|
|
49
|
+
optionalLabel,
|
|
50
|
+
hint,
|
|
51
|
+
invalid,
|
|
52
|
+
errorMessage,
|
|
53
|
+
disabled,
|
|
54
|
+
placeholder,
|
|
55
|
+
defaultValue,
|
|
56
|
+
onInput$,
|
|
57
|
+
reset$,
|
|
58
|
+
testId,
|
|
59
|
+
}) => {
|
|
60
|
+
const id = useId();
|
|
61
|
+
const controlId = `${id}-control`;
|
|
62
|
+
const hintId = `${id}-hint`;
|
|
63
|
+
const errorId = `${id}-error`;
|
|
64
|
+
const describedBy =
|
|
65
|
+
[hint ? hintId : undefined, invalid ? errorId : undefined]
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join(' ') || undefined;
|
|
68
|
+
|
|
69
|
+
// reset$ is the one inbound command, so the component subscribes here (coding.md#asynchrony) and
|
|
70
|
+
// owns the teardown; onInput$ it only emits on. Emptying the live node keeps focus and any
|
|
71
|
+
// in-flight IME composition, which a `key` remount would discard (issue #64).
|
|
72
|
+
const controlRef = useRef<HTMLInputElement>(null);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const subscription = reset$?.subscribe(() => {
|
|
75
|
+
if (controlRef.current) {
|
|
76
|
+
controlRef.current.value = '';
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return () => subscription?.unsubscribe();
|
|
80
|
+
}, [reset$]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className={'flex flex-col gap-[var(--space-stack)]'}>
|
|
84
|
+
<label
|
|
85
|
+
htmlFor={controlId}
|
|
86
|
+
className={'font-secondary font-medium text-body text-foreground'}
|
|
87
|
+
>
|
|
88
|
+
{label}
|
|
89
|
+
</label>
|
|
90
|
+
<input
|
|
91
|
+
ref={controlRef}
|
|
92
|
+
id={controlId}
|
|
93
|
+
name={name}
|
|
94
|
+
type={'text'}
|
|
95
|
+
inputMode={'decimal'}
|
|
96
|
+
className={numberInput()}
|
|
97
|
+
required={required}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
defaultValue={defaultValue}
|
|
100
|
+
placeholder={placeholder}
|
|
101
|
+
aria-invalid={invalid || undefined}
|
|
102
|
+
aria-describedby={describedBy}
|
|
103
|
+
data-testid={testId}
|
|
104
|
+
onInput={(event) =>
|
|
105
|
+
!disabled && onInput$?.next(event.currentTarget.value)
|
|
106
|
+
}
|
|
107
|
+
/>
|
|
108
|
+
{!required && optionalLabel && (
|
|
109
|
+
<span className={'font-secondary text-muted text-small'}>
|
|
110
|
+
{optionalLabel}
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
{hint && (
|
|
114
|
+
<p id={hintId} className={'font-secondary text-muted text-small'}>
|
|
115
|
+
{hint}
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
{invalid && (
|
|
119
|
+
<p id={errorId} className={'font-secondary text-error text-small'}>
|
|
120
|
+
{errorMessage}
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cva } from 'class-variance-authority';
|
|
2
|
-
import type
|
|
2
|
+
import { type FunctionComponent, useId } from 'react';
|
|
3
3
|
import type { Subject } from 'rxjs';
|
|
4
4
|
import { SliderConfigurationError } from './SliderConfigurationError';
|
|
5
5
|
|
|
@@ -45,7 +45,10 @@ export interface ISliderProps {
|
|
|
45
45
|
* could never read a value at all.
|
|
46
46
|
*/
|
|
47
47
|
onInput$: Subject<number>;
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Always rendered as a visible label associated with the control, the way `Input`'s is (#112);
|
|
50
|
+
* it doubles as the accessible name.
|
|
51
|
+
*/
|
|
49
52
|
label: string;
|
|
50
53
|
/**
|
|
51
54
|
* How the value is announced, in the consumer's wording ("$60 a week"). Left out, assistive
|
|
@@ -91,8 +94,10 @@ const assertOperatingRange = (
|
|
|
91
94
|
* A control that sets one numeric value by moving one thumb along a fixed, visible operating
|
|
92
95
|
* range. Controlled: the consumer holds the value and passes it back in, and a value it does not
|
|
93
96
|
* 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.
|
|
95
|
-
*
|
|
97
|
+
* it with typography - and it fills its container's width the way `Input` does. Its label follows
|
|
98
|
+
* `Input` too: visible, associated, and the only text the component renders (#112). It is still
|
|
99
|
+
* a live control and not a form field, so none of Input's annotations - hint, error, optional
|
|
100
|
+
* marker - come with it. `disabled` is the explicit non-operable state.
|
|
96
101
|
*/
|
|
97
102
|
export const Slider: FunctionComponent<ISliderProps> = ({
|
|
98
103
|
min,
|
|
@@ -105,21 +110,32 @@ export const Slider: FunctionComponent<ISliderProps> = ({
|
|
|
105
110
|
disabled,
|
|
106
111
|
testId,
|
|
107
112
|
}) => {
|
|
113
|
+
const controlId = `${useId()}-control`;
|
|
108
114
|
assertOperatingRange({ min, max, step, value });
|
|
109
115
|
return (
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
<div className={'flex flex-col gap-[var(--space-stack)]'}>
|
|
117
|
+
{/* The label declares its face and size on itself, never on the wrapper, which would hand
|
|
118
|
+
them to the control too; `body` is the control's own role (docs/adr/0004, #90, #92). */}
|
|
119
|
+
<label
|
|
120
|
+
htmlFor={controlId}
|
|
121
|
+
className={'font-secondary font-medium text-body text-foreground'}
|
|
122
|
+
>
|
|
123
|
+
{label}
|
|
124
|
+
</label>
|
|
125
|
+
<input
|
|
126
|
+
id={controlId}
|
|
127
|
+
type={'range'}
|
|
128
|
+
className={slider()}
|
|
129
|
+
min={min}
|
|
130
|
+
max={max}
|
|
131
|
+
step={step}
|
|
132
|
+
value={value}
|
|
133
|
+
disabled={disabled}
|
|
134
|
+
aria-valuetext={valueText}
|
|
135
|
+
data-testid={testId}
|
|
136
|
+
// React's onChange rides the native input event, so every movement emits through here.
|
|
137
|
+
onChange={(event) => onInput$.next(event.currentTarget.valueAsNumber)}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
124
140
|
);
|
|
125
141
|
};
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ export { Button } from 'Interaction/Button/Button';
|
|
|
24
24
|
export { Choices } from 'Interaction/Choices/Choices';
|
|
25
25
|
export { Input } from 'Interaction/Input/Input';
|
|
26
26
|
export { Link } from 'Interaction/Link/Link';
|
|
27
|
+
export { MultiSelect } from 'Interaction/MultiSelect/MultiSelect';
|
|
28
|
+
export { NumberInput } from 'Interaction/NumberInput/NumberInput';
|
|
27
29
|
export { Select } from 'Interaction/Select/Select';
|
|
28
30
|
export { Slider } from 'Interaction/Slider/Slider';
|
|
29
31
|
export { Tabs } from 'Interaction/Tabs/Tabs';
|