@juwel-development/design-system 3.7.0 → 3.9.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.
@@ -0,0 +1,6 @@
1
+ export class MultiSelectCompositionError extends Error {
2
+ constructor(member: string) {
3
+ super(`MultiSelect.${member} must be composed inside MultiSelect.Root`);
4
+ this.name = 'MultiSelectCompositionError';
5
+ }
6
+ }
@@ -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
+ };
@@ -0,0 +1,140 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import { type FunctionComponent, type ReactNode, useId } from 'react';
3
+ import type { Subject } from 'rxjs';
4
+
5
+ // Field styling follows Input and ADRs 0001–0004; native appearance retains the platform picker.
6
+ const selectRoot = cva(
7
+ [
8
+ 'flex flex-col gap-[var(--space-stack)]',
9
+ '[&>label]:font-secondary [&>label]:font-medium [&>label]:text-body [&>label]:text-foreground',
10
+ '[&>span]:font-secondary [&>span]:text-muted [&>span]:text-small',
11
+ '[&>p]:font-secondary [&>p]:text-small [&>p]:text-muted [&>p[data-error]]:text-error',
12
+ '[&>select]:block [&>select]:w-full [&>select]:rounded-[var(--radius-control)] [&>select]:border [&>select]:border-solid [&>select]:border-control-border [&>select]:bg-transparent [&>select]:px-3 [&>select]:py-2',
13
+ '[&>select]:font-primary [&>select]:text-body [&>select]:text-foreground [&>select]:transition-colors [&>select]:duration-[var(--motion-duration-color)]',
14
+ '[&>select]:outline-focus-ring [&>select]:outline-offset-[var(--focus-ring-offset)] [&>select]:focus-visible:outline [&>select]:focus-visible:outline-[length:var(--focus-ring-width)]',
15
+ '[&>select:user-invalid]:border-error [&>select]:aria-[invalid=true]:border-error [&>select]:disabled:cursor-not-allowed [&>select]:disabled:border-disabled [&>select]:disabled:text-muted',
16
+ ].join(' '),
17
+ );
18
+
19
+ export interface ISelectRootProps {
20
+ /** Always rendered and associated with the control. */
21
+ label: string;
22
+ /** How the surrounding form reads the value on submit. */
23
+ name: string;
24
+ required?: boolean;
25
+ /** Caller-localized marker, rendered only when the field is optional. */
26
+ optionalLabel?: string;
27
+ disabled?: boolean;
28
+ invalid?: boolean;
29
+ hint?: string;
30
+ errorMessage?: string;
31
+ testId?: string;
32
+ /** Initial selection only; omitted or unmatched values start empty. */
33
+ defaultValue?: string;
34
+ /** Emits the selected value only on user changes; never on render or native form reset. */
35
+ onChange$?: Subject<string>;
36
+ /** Caller-localized wording for the first, empty option, which remains selectable. */
37
+ placeholder: string;
38
+ /** Compose Select.Option children; arrays, fragments and consumer components are supported. */
39
+ children?: ReactNode;
40
+ }
41
+
42
+ /**
43
+ * An uncontrolled native single-select field. The first option is always empty and selectable;
44
+ * `required` makes that empty value invalid. Option updates preserve a surviving selected value
45
+ * and fall back to empty when it disappears, without emitting. The form reads the value by name.
46
+ */
47
+ const SelectRoot: FunctionComponent<ISelectRootProps> = ({
48
+ label,
49
+ name,
50
+ required,
51
+ optionalLabel,
52
+ disabled,
53
+ invalid,
54
+ hint,
55
+ errorMessage,
56
+ testId,
57
+ defaultValue,
58
+ onChange$,
59
+ placeholder,
60
+ children,
61
+ }) => {
62
+ const id = useId();
63
+ const controlId = `${id}-control`;
64
+ const hintId = `${id}-hint`;
65
+ const errorId = `${id}-error`;
66
+ const describedBy =
67
+ [hint ? hintId : undefined, invalid && errorMessage ? errorId : undefined]
68
+ .filter(Boolean)
69
+ .join(' ') || undefined;
70
+
71
+ return (
72
+ <div className={selectRoot()}>
73
+ <label htmlFor={controlId}>{label}</label>
74
+ <select
75
+ id={controlId}
76
+ name={name}
77
+ required={required}
78
+ disabled={disabled}
79
+ defaultValue={defaultValue ?? ''}
80
+ aria-invalid={invalid || undefined}
81
+ aria-describedby={describedBy}
82
+ data-testid={testId}
83
+ onChange={(event) =>
84
+ !disabled && onChange$?.next(event.currentTarget.value)
85
+ }
86
+ >
87
+ <option value={''}>{placeholder}</option>
88
+ {children}
89
+ </select>
90
+ {!required && optionalLabel && <span>{optionalLabel}</span>}
91
+ {hint && <p id={hintId}>{hint}</p>}
92
+ {invalid && errorMessage && (
93
+ <p id={errorId} data-error>
94
+ {errorMessage}
95
+ </p>
96
+ )}
97
+ </div>
98
+ );
99
+ };
100
+
101
+ export interface ISelectOptionProps {
102
+ /** Unique, stable, nonempty identity; the empty string belongs to Root's placeholder. */
103
+ value: string;
104
+ /** Caller-localized text; native options do not accept rich content. */
105
+ children: string;
106
+ testId?: string;
107
+ }
108
+
109
+ const SelectOption: FunctionComponent<ISelectOptionProps> = ({
110
+ value,
111
+ children,
112
+ testId,
113
+ }) => (
114
+ <option value={value} data-testid={testId}>
115
+ {children}
116
+ </option>
117
+ );
118
+
119
+ /**
120
+ * An uncontrolled native single-select field composed from Root and Option.
121
+ * Root owns the label, selectable empty placeholder and field messages; Option renders text.
122
+ *
123
+ * @Guarantees
124
+ * - Required rejects empty; forms read the current stable value by name.
125
+ * - onChange$ emits user changes (including clearing), never rendering, option updates or reset.
126
+ * - Omitted/unmatched defaults start empty; later defaultValue changes do not select a value.
127
+ * - Removing the selected option falls back to empty without emitting.
128
+ *
129
+ * @CallerMustEnsure
130
+ * - Compose Option members under Root; arrays, fragments and consumer components are supported.
131
+ * - Option values are unique, stable and nonempty, with stable React keys when mapping children.
132
+ * - The consumer owns all wording, stream lifetime and domain reconciliation on updates/reset.
133
+ * - Native reset restores the original default while its option stays mounted, otherwise empty.
134
+ * A newly mounted option does not inherit a removed option's reset default; remount Root to
135
+ * initialize another record. Translation/reordering with stable keys preserves native state.
136
+ */
137
+ export const Select = {
138
+ Root: SelectRoot,
139
+ Option: SelectOption,
140
+ } as const;
package/src/index.ts CHANGED
@@ -24,6 +24,9 @@ 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';
29
+ export { Select } from 'Interaction/Select/Select';
27
30
  export { Slider } from 'Interaction/Slider/Slider';
28
31
  export { Tabs } from 'Interaction/Tabs/Tabs';
29
32
  export { TextArea } from 'Interaction/TextArea/TextArea';