@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.
@@ -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
+ };
@@ -1,5 +1,5 @@
1
1
  import { cva } from 'class-variance-authority';
2
- import type { FunctionComponent } from 'react';
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
- /** The control's accessible name. */
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. `disabled` is the
95
- * explicit non-operable state.
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
- <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
- />
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';