@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.
- package/README.md +171 -0
- package/dist/design-system.js +644 -258
- 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/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/index.ts +2 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import {
|
|
3
|
+
Children,
|
|
4
|
+
createContext,
|
|
5
|
+
type FocusEvent,
|
|
6
|
+
Fragment,
|
|
7
|
+
type FunctionComponent,
|
|
8
|
+
isValidElement,
|
|
9
|
+
type KeyboardEvent,
|
|
10
|
+
type MouseEvent,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
type RefCallback,
|
|
13
|
+
type RefObject,
|
|
14
|
+
useContext,
|
|
15
|
+
useEffect,
|
|
16
|
+
useId,
|
|
17
|
+
useLayoutEffect,
|
|
18
|
+
useReducer,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
} from 'react';
|
|
22
|
+
import type { Observable, Subject } from 'rxjs';
|
|
23
|
+
import { fitChips } from './fitChips';
|
|
24
|
+
import { MultiSelectCompositionError } from './MultiSelectCompositionError';
|
|
25
|
+
|
|
26
|
+
const FOCUS_RING =
|
|
27
|
+
'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]';
|
|
28
|
+
|
|
29
|
+
const COUNT = `relative inline-flex shrink-0 cursor-pointer items-center rounded-[var(--radius-control)] bg-backing px-2 py-0.5 font-secondary text-small text-foreground disabled:cursor-not-allowed disabled:text-muted pointer-events-auto ${FOCUS_RING}`;
|
|
30
|
+
|
|
31
|
+
// Root's one recipe paints every part of the closed control and the floating surface, keyed by
|
|
32
|
+
// `part`. Field styling follows Select and ADRs 0001-0004. The clip window's padding and negative
|
|
33
|
+
// margin reserve the focus ring's width and offset, so a chip removal's ring is not cut by the
|
|
34
|
+
// overflow that hides the chips.
|
|
35
|
+
const multiSelectRoot = cva('', {
|
|
36
|
+
variants: {
|
|
37
|
+
part: {
|
|
38
|
+
root: [
|
|
39
|
+
'relative flex flex-col gap-[var(--space-stack)]',
|
|
40
|
+
'[&>label]:font-secondary [&>label]:font-medium [&>label]:text-body [&>label]:text-foreground',
|
|
41
|
+
'[&>p]:font-secondary [&>p]:text-small [&>p]:text-muted',
|
|
42
|
+
].join(' '),
|
|
43
|
+
field: [
|
|
44
|
+
'group/field relative flex items-center gap-1',
|
|
45
|
+
'rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2',
|
|
46
|
+
'font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)]',
|
|
47
|
+
'data-[disabled]:border-disabled data-[disabled]:text-muted',
|
|
48
|
+
].join(' '),
|
|
49
|
+
trigger: `absolute inset-0 flex cursor-pointer items-center justify-end rounded-[var(--radius-control)] bg-transparent px-3 text-muted disabled:cursor-not-allowed ${FOCUS_RING}`,
|
|
50
|
+
clip: 'relative min-w-0 flex-1 overflow-hidden p-[calc(var(--focus-ring-width)+var(--focus-ring-offset))] -m-[calc(var(--focus-ring-width)+var(--focus-ring-offset))] pointer-events-none',
|
|
51
|
+
track:
|
|
52
|
+
'relative flex min-h-[calc(var(--text-body)*var(--leading-body))] items-center gap-1',
|
|
53
|
+
chip: [
|
|
54
|
+
'relative inline-flex max-w-[12rem] shrink-0 items-center gap-1 pointer-events-auto',
|
|
55
|
+
'rounded-[var(--radius-control)] bg-backing py-0.5 pr-0.5 pl-2 font-secondary text-small text-foreground',
|
|
56
|
+
'group-data-[disabled]/field:text-muted data-[overflow]:invisible data-[overflow]:absolute',
|
|
57
|
+
].join(' '),
|
|
58
|
+
count: COUNT,
|
|
59
|
+
countMeasure: `${COUNT} invisible absolute`,
|
|
60
|
+
iconButton: `relative inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-[var(--radius-control)] text-muted transition-colors duration-[var(--motion-duration-color)] hover:text-foreground disabled:cursor-not-allowed disabled:hover:text-muted ${FOCUS_RING}`,
|
|
61
|
+
surface: [
|
|
62
|
+
'absolute left-0 right-0 z-10 flex flex-col gap-1 overflow-y-auto p-1',
|
|
63
|
+
'rounded-[var(--radius-control)] border border-solid border-border bg-surface shadow-[var(--elevation-floating)]',
|
|
64
|
+
'max-h-[var(--multiselect-surface-max-height,50vh)]',
|
|
65
|
+
'data-[placement=below]:top-full data-[placement=below]:mt-1 data-[placement=above]:bottom-full data-[placement=above]:mb-1',
|
|
66
|
+
].join(' '),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
defaultVariants: { part: 'root' },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The checked row is told apart by the tick and a boundary flip from `controlBorder` to
|
|
73
|
+
// `foreground` (the Choices treatment), keyed on aria-checked so the attribute the device reads
|
|
74
|
+
// is the one the paint follows. The marker is square where the Choices marker is round: the two
|
|
75
|
+
// conventions are how a viewer tells independent toggles from an exclusive choice.
|
|
76
|
+
const multiSelectOption = cva(
|
|
77
|
+
[
|
|
78
|
+
'group/option flex w-full flex-row items-center gap-3 text-left',
|
|
79
|
+
'rounded-[var(--radius-control)] bg-transparent px-3 py-2 hover:bg-backing',
|
|
80
|
+
'cursor-pointer disabled:cursor-not-allowed',
|
|
81
|
+
'transition-colors duration-[var(--motion-duration-color)]',
|
|
82
|
+
'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
|
|
83
|
+
].join(' '),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Keeps the floating shadow, and a focused option's ring, clear of the viewport edge.
|
|
87
|
+
const SURFACE_MARGIN = 8;
|
|
88
|
+
|
|
89
|
+
type Placement = 'below' | 'above';
|
|
90
|
+
|
|
91
|
+
type Option = { value: string; label: string };
|
|
92
|
+
|
|
93
|
+
type FocusRequest =
|
|
94
|
+
| { kind: 'firstOption' }
|
|
95
|
+
| { kind: 'afterRemoval'; index: number; control: HTMLElement }
|
|
96
|
+
| { kind: 'afterClear'; control: HTMLElement };
|
|
97
|
+
|
|
98
|
+
type MultiSelectContract = {
|
|
99
|
+
selected: ReadonlySet<string>;
|
|
100
|
+
disabled: boolean;
|
|
101
|
+
toggle: (value: string) => void;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const MultiSelectContext = createContext<MultiSelectContract | undefined>(
|
|
105
|
+
undefined,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const useMultiSelectContract = (member: string): MultiSelectContract => {
|
|
109
|
+
const contract = useContext(MultiSelectContext);
|
|
110
|
+
if (contract === undefined) {
|
|
111
|
+
throw new MultiSelectCompositionError(member);
|
|
112
|
+
}
|
|
113
|
+
return contract;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export interface IMultiSelectRootProps {
|
|
117
|
+
/** Always rendered and associated with the dropdown trigger; also names the option group. */
|
|
118
|
+
label: string;
|
|
119
|
+
/** The current set of selected identities. Rendered from the latest emission only: empty
|
|
120
|
+
* before the first one, and empty again while a replacement source has not yet emitted. */
|
|
121
|
+
selected$: Observable<readonly string[]>;
|
|
122
|
+
/** Emits a fresh full proposed selection - option order, no duplicates, only supplied
|
|
123
|
+
* identities - once per toggle, chip removal or clear-all. Nothing else emits. */
|
|
124
|
+
onChange$: Subject<readonly string[]>;
|
|
125
|
+
/** Caller-localized wording shown in the closed control while nothing is selected. */
|
|
126
|
+
emptyLabel: string;
|
|
127
|
+
/** Accessible-name wording for a chip's removal control; `{label}` stands for the option's
|
|
128
|
+
* label, e.g. `Remove {label}`. */
|
|
129
|
+
removeLabel: string;
|
|
130
|
+
/** Accessible name of the clear-all control. */
|
|
131
|
+
clearLabel: string;
|
|
132
|
+
/** Wording for the hidden-selection count; `{count}` stands for the number, e.g. `+{count}`. */
|
|
133
|
+
overflowLabel: string;
|
|
134
|
+
hint?: string;
|
|
135
|
+
/** Keeps the selection visible, closes an open dropdown and permits no user change. */
|
|
136
|
+
disabled?: boolean;
|
|
137
|
+
testId?: string;
|
|
138
|
+
/** Compose MultiSelect.Option children; arrays and fragments are supported. */
|
|
139
|
+
children?: ReactNode;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface IMultiSelectOptionProps {
|
|
143
|
+
/** The unique, stable identity `selected$` names and proposals carry. Not React's `key`. */
|
|
144
|
+
value: string;
|
|
145
|
+
/** Caller-localized text; the chip and the row both read it. */
|
|
146
|
+
children: string;
|
|
147
|
+
testId?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const attach =
|
|
151
|
+
<T extends HTMLElement>(ref: RefObject<T | undefined>): RefCallback<T> =>
|
|
152
|
+
(node) => {
|
|
153
|
+
ref.current = node ?? undefined;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const widthOf = (element: Element): number =>
|
|
157
|
+
element.getBoundingClientRect().width;
|
|
158
|
+
|
|
159
|
+
// Root reads the options it can see - direct children, arrays and fragments - for the chips'
|
|
160
|
+
// order and labels; the rows themselves render through the context, as in Choices.
|
|
161
|
+
const collectOptions = (children: ReactNode): Option[] =>
|
|
162
|
+
Children.toArray(children).flatMap((child) => {
|
|
163
|
+
if (!isValidElement(child)) {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
if (child.type === Fragment) {
|
|
167
|
+
return collectOptions((child.props as { children?: ReactNode }).children);
|
|
168
|
+
}
|
|
169
|
+
if (child.type === MultiSelectOption) {
|
|
170
|
+
const { value, children: label } = child.props as IMultiSelectOptionProps;
|
|
171
|
+
return [{ value, label }];
|
|
172
|
+
}
|
|
173
|
+
return [];
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const visibleRemovals = (root: HTMLElement): HTMLButtonElement[] =>
|
|
177
|
+
Array.from(
|
|
178
|
+
root.querySelectorAll<HTMLButtonElement>(
|
|
179
|
+
'[data-multiselect-chip]:not([data-overflow]) button',
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Focus is lost when the render removed the focused control, or hid it behind the count: a
|
|
184
|
+
// browser's own fixup for a control turned inert is silent and uneven, so the rule is applied here.
|
|
185
|
+
const focusIsLost = (root: HTMLElement): boolean => {
|
|
186
|
+
const active = document.activeElement;
|
|
187
|
+
return (
|
|
188
|
+
!root.contains(active) ||
|
|
189
|
+
(active instanceof Element && active.closest('[data-overflow]') !== null)
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const firstOption = (surface: HTMLElement): HTMLElement | undefined =>
|
|
194
|
+
surface.querySelector<HTMLElement>(
|
|
195
|
+
'[role="checkbox"][aria-checked="true"]',
|
|
196
|
+
) ??
|
|
197
|
+
surface.querySelector<HTMLElement>('[role="checkbox"]') ??
|
|
198
|
+
undefined;
|
|
199
|
+
|
|
200
|
+
// The proposal an edit emits: supplied option order, each identity once, nothing unsupplied.
|
|
201
|
+
const proposeSelection = (
|
|
202
|
+
options: readonly Option[],
|
|
203
|
+
next: ReadonlySet<string>,
|
|
204
|
+
): readonly string[] => [
|
|
205
|
+
...new Set(
|
|
206
|
+
options.map(({ value }) => value).filter((value) => next.has(value)),
|
|
207
|
+
),
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
// A keyboard open is answered on the next render; a removal or clear-all waits until the
|
|
211
|
+
// consumer's answer has taken the activated control off the page.
|
|
212
|
+
const isSettled = (request: FocusRequest): boolean =>
|
|
213
|
+
request.kind === 'firstOption' || !request.control.isConnected;
|
|
214
|
+
|
|
215
|
+
// Where a settled request lands: the first selected option, the next visible chip removal,
|
|
216
|
+
// then the preceding one, then the trigger; clear-all always the trigger.
|
|
217
|
+
const focusFor = (
|
|
218
|
+
request: FocusRequest,
|
|
219
|
+
root: HTMLElement,
|
|
220
|
+
trigger: HTMLElement,
|
|
221
|
+
surface: HTMLElement | undefined,
|
|
222
|
+
): HTMLElement | undefined => {
|
|
223
|
+
switch (request.kind) {
|
|
224
|
+
case 'firstOption':
|
|
225
|
+
return surface === undefined ? undefined : firstOption(surface);
|
|
226
|
+
case 'afterRemoval': {
|
|
227
|
+
const removals = visibleRemovals(root);
|
|
228
|
+
return removals[request.index] ?? removals[request.index - 1] ?? trigger;
|
|
229
|
+
}
|
|
230
|
+
case 'afterClear':
|
|
231
|
+
return trigger;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const CloseGlyph: FunctionComponent = () => (
|
|
236
|
+
<svg
|
|
237
|
+
aria-hidden={true}
|
|
238
|
+
focusable={false}
|
|
239
|
+
viewBox={'0 0 16 16'}
|
|
240
|
+
className={'size-3'}
|
|
241
|
+
>
|
|
242
|
+
<path
|
|
243
|
+
d={'M4 4l8 8M12 4l-8 8'}
|
|
244
|
+
fill={'none'}
|
|
245
|
+
stroke={'currentColor'}
|
|
246
|
+
strokeWidth={1.5}
|
|
247
|
+
strokeLinecap={'round'}
|
|
248
|
+
/>
|
|
249
|
+
</svg>
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const MultiSelectRoot: FunctionComponent<IMultiSelectRootProps> = ({
|
|
253
|
+
label,
|
|
254
|
+
selected$,
|
|
255
|
+
onChange$,
|
|
256
|
+
emptyLabel,
|
|
257
|
+
removeLabel,
|
|
258
|
+
clearLabel,
|
|
259
|
+
overflowLabel,
|
|
260
|
+
hint,
|
|
261
|
+
disabled,
|
|
262
|
+
testId,
|
|
263
|
+
children,
|
|
264
|
+
}) => {
|
|
265
|
+
const id = useId();
|
|
266
|
+
const labelId = `${id}-label`;
|
|
267
|
+
const triggerId = `${id}-trigger`;
|
|
268
|
+
const optionsId = `${id}-options`;
|
|
269
|
+
const hintId = `${id}-hint`;
|
|
270
|
+
const emptyId = `${id}-empty`;
|
|
271
|
+
const isDisabled = disabled === true;
|
|
272
|
+
|
|
273
|
+
const [selected, setSelected] = useState<readonly string[]>([]);
|
|
274
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
275
|
+
const [visibleCount, setVisibleCount] = useState<number | undefined>(
|
|
276
|
+
undefined,
|
|
277
|
+
);
|
|
278
|
+
const [placement, setPlacement] = useState<Placement>('below');
|
|
279
|
+
const [, relayout] = useReducer((tick: number) => tick + 1, 0);
|
|
280
|
+
|
|
281
|
+
const rootRef = useRef<HTMLDivElement | undefined>(undefined);
|
|
282
|
+
const fieldRef = useRef<HTMLDivElement | undefined>(undefined);
|
|
283
|
+
const triggerRef = useRef<HTMLButtonElement | undefined>(undefined);
|
|
284
|
+
const trackRef = useRef<HTMLDivElement | undefined>(undefined);
|
|
285
|
+
const surfaceRef = useRef<HTMLDivElement | undefined>(undefined);
|
|
286
|
+
const focusRequestRef = useRef<FocusRequest | undefined>(undefined);
|
|
287
|
+
const focusWithinRef = useRef(false);
|
|
288
|
+
|
|
289
|
+
const open = isOpen && !isDisabled;
|
|
290
|
+
const options = collectOptions(children);
|
|
291
|
+
const selectedSet: ReadonlySet<string> = new Set(selected);
|
|
292
|
+
const selectedOptions = options.filter((option) =>
|
|
293
|
+
selectedSet.has(option.value),
|
|
294
|
+
);
|
|
295
|
+
const shown =
|
|
296
|
+
visibleCount === undefined
|
|
297
|
+
? selectedOptions.length
|
|
298
|
+
: Math.min(visibleCount, selectedOptions.length);
|
|
299
|
+
const hiddenCount = selectedOptions.length - shown;
|
|
300
|
+
|
|
301
|
+
// Observe the current source instance only (docs/adr/0013): a replacement discards what the
|
|
302
|
+
// old source said and waits, and teardown is the component's.
|
|
303
|
+
useLayoutEffect(() => {
|
|
304
|
+
setSelected([]);
|
|
305
|
+
const subscription = selected$.subscribe((next) => setSelected(next));
|
|
306
|
+
return () => subscription.unsubscribe();
|
|
307
|
+
}, [selected$]);
|
|
308
|
+
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (isDisabled) {
|
|
311
|
+
setIsOpen(false);
|
|
312
|
+
}
|
|
313
|
+
}, [isDisabled]);
|
|
314
|
+
|
|
315
|
+
useLayoutEffect(() => {
|
|
316
|
+
const track = trackRef.current;
|
|
317
|
+
if (track === undefined || typeof ResizeObserver === 'undefined') {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const observer = new ResizeObserver(() => relayout());
|
|
321
|
+
observer.observe(track);
|
|
322
|
+
return () => observer.disconnect();
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
// Every chip stays measurable - an overflowing one is invisible and out of flow, not
|
|
326
|
+
// display:none - so one pass after any render reads live widths and settles in one step.
|
|
327
|
+
useLayoutEffect(() => {
|
|
328
|
+
const track = trackRef.current;
|
|
329
|
+
if (track === undefined) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const chips = Array.from(
|
|
333
|
+
track.querySelectorAll<HTMLElement>('[data-multiselect-chip]'),
|
|
334
|
+
);
|
|
335
|
+
const count = track.querySelector<HTMLElement>('[data-multiselect-count]');
|
|
336
|
+
const gap = Number.parseFloat(getComputedStyle(track).columnGap) || 0;
|
|
337
|
+
const fit = fitChips(
|
|
338
|
+
chips.map(widthOf),
|
|
339
|
+
count === null ? 0 : widthOf(count),
|
|
340
|
+
widthOf(track),
|
|
341
|
+
gap,
|
|
342
|
+
);
|
|
343
|
+
setVisibleCount((current) => (current === fit ? current : fit));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
useLayoutEffect(() => {
|
|
347
|
+
const field = fieldRef.current;
|
|
348
|
+
const surface = surfaceRef.current;
|
|
349
|
+
if (!open || field === undefined || surface === undefined) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const box = field.getBoundingClientRect();
|
|
353
|
+
const below = window.innerHeight - box.bottom - SURFACE_MARGIN;
|
|
354
|
+
const above = box.top - SURFACE_MARGIN;
|
|
355
|
+
const opensAbove = surface.scrollHeight > below && above > below;
|
|
356
|
+
surface.style.setProperty(
|
|
357
|
+
'--multiselect-surface-max-height',
|
|
358
|
+
`${Math.max(opensAbove ? above : below, 0)}px`,
|
|
359
|
+
);
|
|
360
|
+
setPlacement(opensAbove ? 'above' : 'below');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// An outside press is a departure: the focused option it unmounts is not restored to the
|
|
364
|
+
// trigger, so the pointer's own destination keeps focus.
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
if (!open) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const closeFromOutside = (event: Event): void => {
|
|
370
|
+
const root = rootRef.current;
|
|
371
|
+
if (
|
|
372
|
+
root !== undefined &&
|
|
373
|
+
event.target instanceof Node &&
|
|
374
|
+
!root.contains(event.target)
|
|
375
|
+
) {
|
|
376
|
+
focusWithinRef.current = false;
|
|
377
|
+
focusRequestRef.current = undefined;
|
|
378
|
+
setIsOpen(false);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
document.addEventListener('pointerdown', closeFromOutside);
|
|
382
|
+
return () => document.removeEventListener('pointerdown', closeFromOutside);
|
|
383
|
+
}, [open]);
|
|
384
|
+
|
|
385
|
+
// Focus never falls to the body through this component's own DOM changes: a settled request
|
|
386
|
+
// is answered once, and anything else the render took away returns to the trigger.
|
|
387
|
+
useLayoutEffect(() => {
|
|
388
|
+
const root = rootRef.current;
|
|
389
|
+
const trigger = triggerRef.current;
|
|
390
|
+
if (root === undefined || trigger === undefined) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const request = focusRequestRef.current;
|
|
394
|
+
if (request !== undefined && isSettled(request)) {
|
|
395
|
+
focusRequestRef.current = undefined;
|
|
396
|
+
focusFor(
|
|
397
|
+
request,
|
|
398
|
+
root,
|
|
399
|
+
trigger,
|
|
400
|
+
open ? surfaceRef.current : undefined,
|
|
401
|
+
)?.focus();
|
|
402
|
+
} else if (
|
|
403
|
+
request === undefined &&
|
|
404
|
+
focusWithinRef.current &&
|
|
405
|
+
focusIsLost(root)
|
|
406
|
+
) {
|
|
407
|
+
trigger.focus();
|
|
408
|
+
}
|
|
409
|
+
focusWithinRef.current = root.contains(document.activeElement);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const propose = (next: ReadonlySet<string>): void => {
|
|
413
|
+
onChange$.next(proposeSelection(options, next));
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const toggle = (value: string): void => {
|
|
417
|
+
if (isDisabled) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const next = new Set(selectedSet);
|
|
421
|
+
if (!next.delete(value)) {
|
|
422
|
+
next.add(value);
|
|
423
|
+
}
|
|
424
|
+
propose(next);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const removeChip =
|
|
428
|
+
(value: string, index: number) =>
|
|
429
|
+
(event: MouseEvent<HTMLButtonElement>): void => {
|
|
430
|
+
if (isDisabled) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (document.activeElement === event.currentTarget) {
|
|
434
|
+
focusRequestRef.current = {
|
|
435
|
+
kind: 'afterRemoval',
|
|
436
|
+
index,
|
|
437
|
+
control: event.currentTarget,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const next = new Set(selectedSet);
|
|
441
|
+
next.delete(value);
|
|
442
|
+
propose(next);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const clearAll = (event: MouseEvent<HTMLButtonElement>): void => {
|
|
446
|
+
if (isDisabled) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (document.activeElement === event.currentTarget) {
|
|
450
|
+
focusRequestRef.current = {
|
|
451
|
+
kind: 'afterClear',
|
|
452
|
+
control: event.currentTarget,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
onChange$.next([]);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// A keyboard activation reaches a button as a click with `detail` 0; only that kind moves
|
|
459
|
+
// focus into the options, so a pointer open leaves focus where the pointer put it.
|
|
460
|
+
const openFromControl = (event: MouseEvent<HTMLButtonElement>): void => {
|
|
461
|
+
if (isDisabled) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (event.detail === 0) {
|
|
465
|
+
focusRequestRef.current = { kind: 'firstOption' };
|
|
466
|
+
}
|
|
467
|
+
setIsOpen(true);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const toggleOpen = (event: MouseEvent<HTMLButtonElement>): void => {
|
|
471
|
+
if (open) {
|
|
472
|
+
setIsOpen(false);
|
|
473
|
+
} else {
|
|
474
|
+
openFromControl(event);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const openFromArrow = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
|
479
|
+
if (event.key !== 'ArrowDown' || isDisabled) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
event.preventDefault();
|
|
483
|
+
const surface = surfaceRef.current;
|
|
484
|
+
if (open && surface !== undefined) {
|
|
485
|
+
firstOption(surface)?.focus();
|
|
486
|
+
} else {
|
|
487
|
+
focusRequestRef.current = { kind: 'firstOption' };
|
|
488
|
+
setIsOpen(true);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const closeFromEscape = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
493
|
+
if (event.key !== 'Escape' || !open) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
event.preventDefault();
|
|
497
|
+
event.stopPropagation();
|
|
498
|
+
setIsOpen(false);
|
|
499
|
+
triggerRef.current?.focus();
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// Focus moving on from an activated removal or clear-all withdraws its unanswered request.
|
|
503
|
+
const noteFocus = (event: FocusEvent<HTMLDivElement>): void => {
|
|
504
|
+
focusWithinRef.current = true;
|
|
505
|
+
const request = focusRequestRef.current;
|
|
506
|
+
if (
|
|
507
|
+
request !== undefined &&
|
|
508
|
+
request.kind !== 'firstOption' &&
|
|
509
|
+
event.target !== request.control
|
|
510
|
+
) {
|
|
511
|
+
focusRequestRef.current = undefined;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Focus leaving the whole control closes it. A control removed while focused fires no
|
|
516
|
+
// departure the component should act on; the focus effect above restores that focus.
|
|
517
|
+
const closeFromDeparture = (event: FocusEvent<HTMLDivElement>): void => {
|
|
518
|
+
const root = rootRef.current;
|
|
519
|
+
const destination = event.relatedTarget;
|
|
520
|
+
if (
|
|
521
|
+
root !== undefined &&
|
|
522
|
+
destination instanceof Node &&
|
|
523
|
+
root.contains(destination)
|
|
524
|
+
) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!event.target.isConnected) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
focusWithinRef.current = false;
|
|
531
|
+
focusRequestRef.current = undefined;
|
|
532
|
+
setIsOpen(false);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// A press on the surface's own padding or scrollbar must not blur the focused option, which
|
|
536
|
+
// would read as a departure and close the dropdown under the pointer.
|
|
537
|
+
const keepFocusOnSurface = (event: MouseEvent<HTMLDivElement>): void => {
|
|
538
|
+
if (
|
|
539
|
+
!(event.target instanceof Element) ||
|
|
540
|
+
event.target.closest('button') === null
|
|
541
|
+
) {
|
|
542
|
+
event.preventDefault();
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const describedBy =
|
|
547
|
+
[
|
|
548
|
+
selectedOptions.length === 0 ? emptyId : undefined,
|
|
549
|
+
hint ? hintId : undefined,
|
|
550
|
+
]
|
|
551
|
+
.filter(Boolean)
|
|
552
|
+
.join(' ') || undefined;
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: the wrapper only observes focus and Escape bubbling from the controls it composes; every operable element inside is a real button
|
|
556
|
+
<div
|
|
557
|
+
ref={attach(rootRef)}
|
|
558
|
+
className={multiSelectRoot()}
|
|
559
|
+
onKeyDown={closeFromEscape}
|
|
560
|
+
onFocus={noteFocus}
|
|
561
|
+
onBlur={closeFromDeparture}
|
|
562
|
+
>
|
|
563
|
+
<label id={labelId} htmlFor={triggerId}>
|
|
564
|
+
{label}
|
|
565
|
+
</label>
|
|
566
|
+
<div
|
|
567
|
+
ref={attach(fieldRef)}
|
|
568
|
+
data-multiselect-field
|
|
569
|
+
data-disabled={isDisabled || undefined}
|
|
570
|
+
className={multiSelectRoot({ part: 'field' })}
|
|
571
|
+
>
|
|
572
|
+
<button
|
|
573
|
+
ref={attach(triggerRef)}
|
|
574
|
+
id={triggerId}
|
|
575
|
+
type={'button'}
|
|
576
|
+
aria-labelledby={labelId}
|
|
577
|
+
aria-expanded={open}
|
|
578
|
+
aria-controls={open ? optionsId : undefined}
|
|
579
|
+
aria-describedby={describedBy}
|
|
580
|
+
disabled={isDisabled}
|
|
581
|
+
data-testid={testId}
|
|
582
|
+
className={multiSelectRoot({ part: 'trigger' })}
|
|
583
|
+
onClick={toggleOpen}
|
|
584
|
+
onKeyDown={openFromArrow}
|
|
585
|
+
>
|
|
586
|
+
<svg
|
|
587
|
+
aria-hidden={true}
|
|
588
|
+
focusable={false}
|
|
589
|
+
viewBox={'0 0 16 16'}
|
|
590
|
+
className={'size-4'}
|
|
591
|
+
>
|
|
592
|
+
<path
|
|
593
|
+
d={'M3 6l5 5 5-5'}
|
|
594
|
+
fill={'none'}
|
|
595
|
+
stroke={'currentColor'}
|
|
596
|
+
strokeWidth={1.5}
|
|
597
|
+
strokeLinecap={'round'}
|
|
598
|
+
strokeLinejoin={'round'}
|
|
599
|
+
/>
|
|
600
|
+
</svg>
|
|
601
|
+
</button>
|
|
602
|
+
<div className={multiSelectRoot({ part: 'clip' })}>
|
|
603
|
+
<div
|
|
604
|
+
ref={attach(trackRef)}
|
|
605
|
+
data-multiselect-track
|
|
606
|
+
className={multiSelectRoot({ part: 'track' })}
|
|
607
|
+
>
|
|
608
|
+
{selectedOptions.length === 0 && (
|
|
609
|
+
<span id={emptyId} className={'truncate text-muted'}>
|
|
610
|
+
{emptyLabel}
|
|
611
|
+
</span>
|
|
612
|
+
)}
|
|
613
|
+
{selectedOptions.map(({ value, label: optionLabel }, index) => {
|
|
614
|
+
const overflows = index >= shown;
|
|
615
|
+
return (
|
|
616
|
+
<span
|
|
617
|
+
key={value}
|
|
618
|
+
data-multiselect-chip
|
|
619
|
+
data-overflow={overflows || undefined}
|
|
620
|
+
aria-hidden={overflows || undefined}
|
|
621
|
+
inert={overflows || undefined}
|
|
622
|
+
className={multiSelectRoot({ part: 'chip' })}
|
|
623
|
+
>
|
|
624
|
+
<span className={'truncate'}>{optionLabel}</span>
|
|
625
|
+
<button
|
|
626
|
+
type={'button'}
|
|
627
|
+
aria-label={removeLabel.replaceAll('{label}', optionLabel)}
|
|
628
|
+
disabled={isDisabled}
|
|
629
|
+
tabIndex={overflows ? -1 : undefined}
|
|
630
|
+
className={multiSelectRoot({ part: 'iconButton' })}
|
|
631
|
+
onClick={removeChip(value, index)}
|
|
632
|
+
>
|
|
633
|
+
<CloseGlyph />
|
|
634
|
+
</button>
|
|
635
|
+
</span>
|
|
636
|
+
);
|
|
637
|
+
})}
|
|
638
|
+
{hiddenCount > 0 && (
|
|
639
|
+
<button
|
|
640
|
+
type={'button'}
|
|
641
|
+
disabled={isDisabled}
|
|
642
|
+
className={multiSelectRoot({ part: 'count' })}
|
|
643
|
+
onClick={openFromControl}
|
|
644
|
+
>
|
|
645
|
+
{overflowLabel.replaceAll('{count}', String(hiddenCount))}
|
|
646
|
+
</button>
|
|
647
|
+
)}
|
|
648
|
+
{selectedOptions.length > 0 && (
|
|
649
|
+
<span
|
|
650
|
+
aria-hidden={true}
|
|
651
|
+
data-multiselect-count
|
|
652
|
+
className={multiSelectRoot({ part: 'countMeasure' })}
|
|
653
|
+
>
|
|
654
|
+
{overflowLabel.replaceAll(
|
|
655
|
+
'{count}',
|
|
656
|
+
String(selectedOptions.length),
|
|
657
|
+
)}
|
|
658
|
+
</span>
|
|
659
|
+
)}
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
{selectedOptions.length > 0 ? (
|
|
663
|
+
<button
|
|
664
|
+
type={'button'}
|
|
665
|
+
aria-label={clearLabel}
|
|
666
|
+
disabled={isDisabled}
|
|
667
|
+
className={multiSelectRoot({ part: 'iconButton' })}
|
|
668
|
+
onClick={clearAll}
|
|
669
|
+
>
|
|
670
|
+
<CloseGlyph />
|
|
671
|
+
</button>
|
|
672
|
+
) : (
|
|
673
|
+
<span aria-hidden={true} className={'size-6 shrink-0'} />
|
|
674
|
+
)}
|
|
675
|
+
<span aria-hidden={true} className={'size-6 shrink-0'} />
|
|
676
|
+
{open && (
|
|
677
|
+
// biome-ignore lint/a11y/useSemanticElements: a fieldset brings the UA's min-inline-size, legend naming and a disabling model the floating surface must not carry; a div with role=group named by the visible label is the disclosure content, and its mousedown only keeps the focused option focused
|
|
678
|
+
<div
|
|
679
|
+
ref={attach(surfaceRef)}
|
|
680
|
+
id={optionsId}
|
|
681
|
+
role={'group'}
|
|
682
|
+
aria-labelledby={labelId}
|
|
683
|
+
data-placement={placement}
|
|
684
|
+
className={multiSelectRoot({ part: 'surface' })}
|
|
685
|
+
onMouseDown={keepFocusOnSurface}
|
|
686
|
+
>
|
|
687
|
+
<MultiSelectContext
|
|
688
|
+
value={{ selected: selectedSet, disabled: isDisabled, toggle }}
|
|
689
|
+
>
|
|
690
|
+
{children}
|
|
691
|
+
</MultiSelectContext>
|
|
692
|
+
</div>
|
|
693
|
+
)}
|
|
694
|
+
</div>
|
|
695
|
+
{hint && <p id={hintId}>{hint}</p>}
|
|
696
|
+
</div>
|
|
697
|
+
);
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const MultiSelectOption: FunctionComponent<IMultiSelectOptionProps> = ({
|
|
701
|
+
value,
|
|
702
|
+
children,
|
|
703
|
+
testId,
|
|
704
|
+
}) => {
|
|
705
|
+
const { selected, disabled, toggle } = useMultiSelectContract('Option');
|
|
706
|
+
const isChecked = selected.has(value);
|
|
707
|
+
return (
|
|
708
|
+
// biome-ignore lint/a11y/useSemanticElements: a native checkbox checks itself on activation, which the controlled contract forbids - the row only requests; a button carrying role="checkbox" keeps aria-checked the render's alone, the Choices precedent
|
|
709
|
+
<button
|
|
710
|
+
type={'button'}
|
|
711
|
+
role={'checkbox'}
|
|
712
|
+
aria-checked={isChecked}
|
|
713
|
+
disabled={disabled}
|
|
714
|
+
data-testid={testId}
|
|
715
|
+
className={multiSelectOption()}
|
|
716
|
+
onClick={() => toggle(value)}
|
|
717
|
+
>
|
|
718
|
+
<span
|
|
719
|
+
className={
|
|
720
|
+
'flex size-[var(--choice-marker-size)] shrink-0 items-center justify-center border border-solid border-control-border group-aria-checked/option:border-foreground'
|
|
721
|
+
}
|
|
722
|
+
>
|
|
723
|
+
{isChecked && (
|
|
724
|
+
<svg
|
|
725
|
+
aria-hidden={true}
|
|
726
|
+
focusable={false}
|
|
727
|
+
viewBox={'0 0 16 16'}
|
|
728
|
+
className={'size-3 text-foreground'}
|
|
729
|
+
>
|
|
730
|
+
<path
|
|
731
|
+
d={'M3 8.5l3 3 7-7'}
|
|
732
|
+
fill={'none'}
|
|
733
|
+
stroke={'currentColor'}
|
|
734
|
+
strokeWidth={2}
|
|
735
|
+
strokeLinecap={'round'}
|
|
736
|
+
strokeLinejoin={'round'}
|
|
737
|
+
/>
|
|
738
|
+
</svg>
|
|
739
|
+
)}
|
|
740
|
+
</span>
|
|
741
|
+
{/* min-w-0 and anywhere-wrapping let a long translated label wrap inside the row. */}
|
|
742
|
+
<span
|
|
743
|
+
className={
|
|
744
|
+
'min-w-0 flex-1 font-secondary text-body text-foreground [overflow-wrap:anywhere]'
|
|
745
|
+
}
|
|
746
|
+
>
|
|
747
|
+
{children}
|
|
748
|
+
</span>
|
|
749
|
+
</button>
|
|
750
|
+
);
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* A dropdown for selecting zero, one or several options independently from a finite set. The
|
|
755
|
+
* closed control is one labelled line: the selected options as individually removable chips in
|
|
756
|
+
* option order, a `+N` count for the ones that do not fit, a clear-all control and the dropdown
|
|
757
|
+
* arrow. Open, it presents a single column of checkable options anchored to the control. The
|
|
758
|
+
* consumer owns the options, the selection, every word and the meaning of the selected set;
|
|
759
|
+
* MultiSelect owns the control, the selection semantics, focus and the themed presentation.
|
|
760
|
+
* Composed from `Root`, which carries the contract, and `Option`, one checkable row.
|
|
761
|
+
*
|
|
762
|
+
* @Guarantees — enforced on every render
|
|
763
|
+
* - The visible `label` names the trigger and the option group. The trigger exposes
|
|
764
|
+
* `aria-expanded`, and while open `aria-controls` names the group; each option is a
|
|
765
|
+
* `role="checkbox"` button whose visible text is its name and whose `aria-checked` is the
|
|
766
|
+
* render's alone. Selection has a visible tick independent of colour.
|
|
767
|
+
* - Selection is the latest `selected$` emission and nothing else: empty before the first
|
|
768
|
+
* emission, empty again while a replacement source has not emitted, chips and checks updated
|
|
769
|
+
* silently on every emission - open, closed or disabled. Chips follow option order and dedupe
|
|
770
|
+
* identities; an identity with no supplied option renders no chip.
|
|
771
|
+
* - Every toggle, chip removal and clear-all emits one fresh full proposal on `onChange$` -
|
|
772
|
+
* option order, no duplicates, only supplied identities, never a handed-in array - and
|
|
773
|
+
* nothing else emits: not rendering, opening, closing, option updates, source replacement
|
|
774
|
+
* or a `selected$` emission. The dropdown stays open while toggling. An unanswered request
|
|
775
|
+
* leaves the selection as it was.
|
|
776
|
+
* - The control stays on one line: the leading chips that fit are shown, the rest are counted
|
|
777
|
+
* by `overflowLabel`, down to a count-only display. Overflow is recalculated on width, label
|
|
778
|
+
* and selection changes without touching the selection, and the count's activation opens the
|
|
779
|
+
* dropdown, so every selection stays reachable. A long chip label truncates visually while
|
|
780
|
+
* its removal control keeps the full name.
|
|
781
|
+
* - The dropdown floats over the page (the shared `--elevation-floating` role), opens above the
|
|
782
|
+
* control when the viewport below cannot hold it, is capped to the room it has and scrolls
|
|
783
|
+
* its options.
|
|
784
|
+
* - Enter, Space or ArrowDown on the closed trigger opens and focuses the first selected option
|
|
785
|
+
* in option order, or the first option; with no options focus stays on the trigger. Tab
|
|
786
|
+
* traverses chips, clear-all and options normally with no trap; Space toggles a focused
|
|
787
|
+
* option. Escape closes and returns focus to the trigger. Focus leaving the whole control, an
|
|
788
|
+
* outside pointer interaction and the trigger itself close it, none of them moving focus or
|
|
789
|
+
* emitting.
|
|
790
|
+
* - Chip removals and clear-all are named, non-submitting buttons outside the trigger. When a
|
|
791
|
+
* removal takes its own focused control away, focus moves to the next visible removal, then
|
|
792
|
+
* the preceding one, then the trigger; clear-all returns focus to the trigger; a chip hidden by
|
|
793
|
+
* overflow while focused hands focus to the trigger. Focus never falls to the body.
|
|
794
|
+
* - `disabled` keeps the selection visible, closes an open dropdown, disables every control and
|
|
795
|
+
* emits nothing; `selected$` still updates the rendering.
|
|
796
|
+
*
|
|
797
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
798
|
+
* - Options are direct children of `Root` (arrays and fragments supported), each `value` unique
|
|
799
|
+
* and stable across reordering and translation, with stable React keys when mapping.
|
|
800
|
+
* - `selected$` names supplied identities only. An update that removes options removes their
|
|
801
|
+
* identities from the selection in the same logical update; MultiSelect prunes nothing and
|
|
802
|
+
* invents nothing.
|
|
803
|
+
* - Answering `onChange$` by emitting the accepted selection on `selected$` is what changes the
|
|
804
|
+
* selection - immediately, for ordinary interaction. A replaying source (a `BehaviorSubject`
|
|
805
|
+
* or `ReplaySubject(1)`) lets a remounted control show the current state at once.
|
|
806
|
+
* - The consumer owns both streams' lifetime, completion and error; MultiSelect owns only the
|
|
807
|
+
* teardown of its `selected$` subscription.
|
|
808
|
+
*
|
|
809
|
+
* @UXGuidelines
|
|
810
|
+
* - Every word is the caller's: `emptyLabel` for the empty control, `removeLabel` with `{label}`
|
|
811
|
+
* for a chip's removal name, `clearLabel` for clear-all, `overflowLabel` with `{count}` for the
|
|
812
|
+
* hidden count, optional `hint`, and each option's text. The library ships no English.
|
|
813
|
+
* - Keep `overflowLabel` short (`+{count}`, `{count} weitere`): it shares the one line.
|
|
814
|
+
* - This is a consumer-controlled selection control, not a form field: no `name`, native
|
|
815
|
+
* submission, reset or validation.
|
|
816
|
+
*/
|
|
817
|
+
export const MultiSelect = {
|
|
818
|
+
Root: MultiSelectRoot,
|
|
819
|
+
Option: MultiSelectOption,
|
|
820
|
+
} as const;
|