@juwel-development/design-system 3.8.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
+ };
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';