@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,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;