@tangible/ui 0.0.1 → 0.0.3

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.
Files changed (135) hide show
  1. package/components/Card/Card.d.ts +1 -0
  2. package/components/Card/Card.js +17 -20
  3. package/components/Checkbox/Checkbox.d.ts +9 -0
  4. package/components/Checkbox/Checkbox.js +92 -0
  5. package/components/Checkbox/index.d.ts +2 -0
  6. package/components/Checkbox/index.js +1 -0
  7. package/components/Checkbox/types.d.ts +10 -0
  8. package/components/Checkbox/types.js +1 -0
  9. package/components/Chip/Chip.d.ts +4 -1
  10. package/components/Chip/Chip.js +32 -7
  11. package/components/ChipGroup/ChipGroup.d.ts +5 -0
  12. package/components/ChipGroup/ChipGroup.js +68 -0
  13. package/components/ChipGroup/ChipGroupContext.d.ts +3 -0
  14. package/components/ChipGroup/ChipGroupContext.js +5 -0
  15. package/components/ChipGroup/index.d.ts +3 -0
  16. package/components/ChipGroup/index.js +2 -0
  17. package/components/ChipGroup/types.d.ts +36 -0
  18. package/components/ChipGroup/types.js +1 -0
  19. package/components/Chips/Chips.d.ts +2 -0
  20. package/components/Chips/Chips.js +1 -1
  21. package/components/Combobox/Combobox.d.ts +33 -0
  22. package/components/Combobox/Combobox.js +466 -0
  23. package/components/Combobox/ComboboxContext.d.ts +8 -0
  24. package/components/Combobox/ComboboxContext.js +36 -0
  25. package/components/Combobox/index.d.ts +2 -0
  26. package/components/Combobox/index.js +1 -0
  27. package/components/Combobox/types.d.ts +204 -0
  28. package/components/Combobox/types.js +1 -0
  29. package/components/Dropdown/Dropdown.js +2 -1
  30. package/components/Field/Field.d.ts +39 -0
  31. package/components/Field/Field.js +92 -0
  32. package/components/Field/FieldContext.d.ts +16 -0
  33. package/components/Field/FieldContext.js +10 -0
  34. package/components/Field/index.d.ts +2 -0
  35. package/components/Field/index.js +1 -0
  36. package/components/Modal/Modal.d.ts +4 -4
  37. package/components/Modal/Modal.js +14 -12
  38. package/components/MoveHandle/MoveHandle.d.ts +2 -0
  39. package/components/MoveHandle/MoveHandle.js +84 -0
  40. package/components/MoveHandle/index.d.ts +2 -0
  41. package/components/MoveHandle/index.js +1 -0
  42. package/components/MoveHandle/types.d.ts +43 -0
  43. package/components/MoveHandle/types.js +1 -0
  44. package/components/MultiSelect/MultiSelect.d.ts +39 -0
  45. package/components/MultiSelect/MultiSelect.js +623 -0
  46. package/components/MultiSelect/MultiSelectContext.d.ts +20 -0
  47. package/components/MultiSelect/MultiSelectContext.js +56 -0
  48. package/components/MultiSelect/index.d.ts +2 -0
  49. package/components/MultiSelect/index.js +1 -0
  50. package/components/MultiSelect/types.d.ts +218 -0
  51. package/components/MultiSelect/types.js +3 -0
  52. package/components/Notice/Notice.d.ts +1 -1
  53. package/components/Notice/Notice.js +1 -1
  54. package/components/Progress/Progress.js +1 -1
  55. package/components/Progress/types.d.ts +7 -7
  56. package/components/Radio/Radio.d.ts +2 -0
  57. package/components/Radio/Radio.js +50 -0
  58. package/components/Radio/RadioGroup.d.ts +2 -0
  59. package/components/Radio/RadioGroup.js +54 -0
  60. package/components/Radio/RadioGroupContext.d.ts +3 -0
  61. package/components/Radio/RadioGroupContext.js +9 -0
  62. package/components/Radio/index.d.ts +8 -0
  63. package/components/Radio/index.js +6 -0
  64. package/components/Radio/types.d.ts +32 -0
  65. package/components/Radio/types.js +1 -0
  66. package/components/Rating/Rating.d.ts +5 -5
  67. package/components/Rating/Rating.js +2 -2
  68. package/components/SegmentedControl/SegmentedControl.js +20 -104
  69. package/components/SegmentedControl/types.d.ts +4 -8
  70. package/components/Select/Select.d.ts +39 -0
  71. package/components/Select/Select.js +497 -0
  72. package/components/Select/SelectContext.d.ts +20 -0
  73. package/components/Select/SelectContext.js +56 -0
  74. package/components/Select/index.d.ts +3 -0
  75. package/components/Select/index.js +1 -0
  76. package/components/Select/types.d.ts +216 -0
  77. package/components/Select/types.js +11 -0
  78. package/components/Sidebar/Sidebar.js +12 -12
  79. package/components/Sidebar/types.d.ts +5 -5
  80. package/components/StepIndicator/StepIndicator.js +1 -1
  81. package/components/StepList/StepList.js +2 -2
  82. package/components/StepList/types.d.ts +4 -4
  83. package/components/Switch/Switch.d.ts +9 -0
  84. package/components/Switch/Switch.js +91 -0
  85. package/components/Switch/index.d.ts +2 -0
  86. package/components/Switch/index.js +1 -0
  87. package/components/Switch/types.d.ts +11 -0
  88. package/components/Switch/types.js +1 -0
  89. package/components/TextInput/TextInput.d.ts +8 -0
  90. package/components/TextInput/TextInput.js +25 -0
  91. package/components/TextInput/index.d.ts +2 -0
  92. package/components/TextInput/index.js +1 -0
  93. package/components/TextInput/types.d.ts +32 -0
  94. package/components/TextInput/types.js +1 -0
  95. package/components/Textarea/Textarea.d.ts +6 -0
  96. package/components/Textarea/Textarea.js +49 -0
  97. package/components/Textarea/index.d.ts +2 -0
  98. package/components/Textarea/index.js +1 -0
  99. package/components/Textarea/types.d.ts +25 -0
  100. package/components/Textarea/types.js +1 -0
  101. package/components/index.d.ts +22 -0
  102. package/components/index.js +11 -0
  103. package/icons/icons.svg +2 -0
  104. package/icons/manifest.json +16 -0
  105. package/icons/registry.d.ts +4 -0
  106. package/icons/registry.js +2 -0
  107. package/icons/system/index.d.ts +4 -0
  108. package/icons/system/index.js +22 -0
  109. package/package.json +1 -1
  110. package/styles/all.css +1 -1
  111. package/styles/all.expanded.css +1838 -136
  112. package/styles/all.expanded.unlayered.css +1838 -136
  113. package/styles/all.unlayered.css +1 -1
  114. package/styles/components/_bundle.scss +22 -0
  115. package/styles/components/input/index.scss +5 -20
  116. package/styles/index.scss +21 -0
  117. package/styles/system/_control.scss +49 -0
  118. package/styles/system/_tokens.scss +124 -2
  119. package/styles/system/index.scss +2 -1
  120. package/styles/utilities/_index.scss +50 -0
  121. package/tui-manifest.json +907 -112
  122. package/utils/compose-events.d.ts +15 -0
  123. package/utils/compose-events.js +27 -0
  124. package/utils/hash.d.ts +15 -0
  125. package/utils/hash.js +32 -0
  126. package/utils/index.d.ts +3 -0
  127. package/utils/index.js +6 -0
  128. package/utils/is-dev.d.ts +5 -0
  129. package/utils/is-dev.js +7 -0
  130. package/utils/use-controllable-state.d.ts +19 -0
  131. package/utils/use-controllable-state.js +59 -0
  132. package/utils/use-roving-group.d.ts +33 -0
  133. package/utils/use-roving-group.js +123 -0
  134. package/utils/value-key.d.ts +16 -0
  135. package/utils/value-key.js +14 -0
@@ -0,0 +1,39 @@
1
+ import { type MultiSelectProps, type MultiSelectTriggerProps, type MultiSelectContentProps, type MultiSelectOptionProps, type MultiSelectGroupProps, type MultiSelectLabelProps } from './types';
2
+ declare function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, display, maxChips, max, onMaxReached, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }: MultiSelectProps): import("react/jsx-runtime").JSX.Element;
3
+ declare namespace MultiSelectRoot {
4
+ var displayName: string;
5
+ }
6
+ declare function MultiSelectTriggerComponent({ asChild, className, children, }: MultiSelectTriggerProps): import("react/jsx-runtime").JSX.Element;
7
+ declare namespace MultiSelectTriggerComponent {
8
+ var displayName: string;
9
+ }
10
+ declare function MultiSelectContentComponent({ className, children }: MultiSelectContentProps): import("react/jsx-runtime").JSX.Element;
11
+ declare namespace MultiSelectContentComponent {
12
+ var displayName: string;
13
+ }
14
+ declare function MultiSelectOptionComponent({ value: optionValue, disabled, textValue: explicitTextValue, className, children, }: MultiSelectOptionProps): import("react/jsx-runtime").JSX.Element;
15
+ declare namespace MultiSelectOptionComponent {
16
+ var displayName: string;
17
+ }
18
+ declare function MultiSelectGroupComponent({ className, children }: MultiSelectGroupProps): import("react/jsx-runtime").JSX.Element;
19
+ declare namespace MultiSelectGroupComponent {
20
+ var displayName: string;
21
+ }
22
+ declare function MultiSelectLabelComponent({ className, children }: MultiSelectLabelProps): import("react/jsx-runtime").JSX.Element;
23
+ declare namespace MultiSelectLabelComponent {
24
+ var displayName: string;
25
+ }
26
+ type MultiSelectCompound = typeof MultiSelectRoot & {
27
+ Trigger: typeof MultiSelectTriggerComponent;
28
+ Content: typeof MultiSelectContentComponent;
29
+ Option: typeof MultiSelectOptionComponent;
30
+ Group: typeof MultiSelectGroupComponent;
31
+ Label: typeof MultiSelectLabelComponent;
32
+ };
33
+ export declare const MultiSelect: MultiSelectCompound;
34
+ export declare const MultiSelectTrigger: typeof MultiSelectTriggerComponent;
35
+ export declare const MultiSelectContent: typeof MultiSelectContentComponent;
36
+ export declare const MultiSelectOption: typeof MultiSelectOptionComponent;
37
+ export declare const MultiSelectGroup: typeof MultiSelectGroupComponent;
38
+ export declare const MultiSelectLabel: typeof MultiSelectLabelComponent;
39
+ export { useMultiSelectContext as useMultiSelect } from './MultiSelectContext';
@@ -0,0 +1,623 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, cloneElement, isValidElement, } from 'react';
3
+ import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useTypeahead, useRole, useClick, } from '@floating-ui/react';
4
+ import { cx } from '../../utils/cx.js';
5
+ import { getPortalRootFor } from '../../utils/portal.js';
6
+ import { Icon } from '../Icon/index.js';
7
+ import { MultiSelectActionsContext, MultiSelectStateContext, MultiSelectContentContext, useMultiSelectContext, useMultiSelectContentContext, } from './MultiSelectContext.js';
8
+ import { toKey, } from './types.js';
9
+ // =============================================================================
10
+ // MultiSelect Root
11
+ // =============================================================================
12
+ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', display = 'count', maxChips = 3, max, onMaxReached, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }) {
13
+ // Controlled/uncontrolled value
14
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? []);
15
+ const isValueControlled = controlledValue !== undefined;
16
+ const value = isValueControlled ? controlledValue : uncontrolledValue;
17
+ // Track selected text values for display when closed
18
+ const selectedTextMapRef = useRef(new Map());
19
+ // Option registration
20
+ const optionsRef = useRef(new Map());
21
+ const [registryVersion, setRegistryVersion] = useState(0);
22
+ // Is selected helper
23
+ const isSelected = useCallback((optionValue) => {
24
+ const key = toKey(optionValue);
25
+ return value.some((v) => toKey(v) === key);
26
+ }, [value]);
27
+ // Check if max reached
28
+ const maxReached = max !== undefined && value.length >= max;
29
+ // Track if we just hit max (for callback)
30
+ const prevMaxReached = useRef(maxReached);
31
+ useEffect(() => {
32
+ if (maxReached && !prevMaxReached.current) {
33
+ onMaxReached?.();
34
+ }
35
+ prevMaxReached.current = maxReached;
36
+ }, [maxReached, onMaxReached]);
37
+ // Toggle option selection
38
+ const toggleOption = useCallback((optionValue, textValue) => {
39
+ const key = toKey(optionValue);
40
+ const currentlySelected = value.some((v) => toKey(v) === key);
41
+ let newValue;
42
+ if (currentlySelected) {
43
+ // Remove from selection
44
+ newValue = value.filter((v) => toKey(v) !== key);
45
+ selectedTextMapRef.current.delete(key);
46
+ }
47
+ else {
48
+ // Add to selection (unless max reached)
49
+ if (max !== undefined && value.length >= max) {
50
+ return; // Don't add more
51
+ }
52
+ newValue = [...value, optionValue];
53
+ selectedTextMapRef.current.set(key, textValue);
54
+ }
55
+ if (!isValueControlled) {
56
+ setUncontrolledValue(newValue);
57
+ }
58
+ onValueChange?.(newValue);
59
+ }, [value, max, isValueControlled, onValueChange]);
60
+ // Clear all selections
61
+ const clearAll = useCallback(() => {
62
+ selectedTextMapRef.current.clear();
63
+ if (!isValueControlled) {
64
+ setUncontrolledValue([]);
65
+ }
66
+ onValueChange?.([]);
67
+ }, [isValueControlled, onValueChange]);
68
+ // Sync selectedTextMap when controlled value changes
69
+ useEffect(() => {
70
+ if (isValueControlled && controlledValue) {
71
+ // Remove keys that are no longer selected
72
+ const currentKeys = new Set(controlledValue.map(toKey));
73
+ for (const key of selectedTextMapRef.current.keys()) {
74
+ if (!currentKeys.has(key)) {
75
+ selectedTextMapRef.current.delete(key);
76
+ }
77
+ }
78
+ // Add any missing keys from registered options
79
+ for (const v of controlledValue) {
80
+ const key = toKey(v);
81
+ if (!selectedTextMapRef.current.has(key)) {
82
+ const option = optionsRef.current.get(key);
83
+ if (option) {
84
+ selectedTextMapRef.current.set(key, option.textValue);
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }, [controlledValue, isValueControlled]);
90
+ // Initialize selectedTextMap when options register (handles defaultValue)
91
+ useEffect(() => {
92
+ for (const v of value) {
93
+ const key = toKey(v);
94
+ if (!selectedTextMapRef.current.has(key)) {
95
+ const option = optionsRef.current.get(key);
96
+ if (option) {
97
+ selectedTextMapRef.current.set(key, option.textValue);
98
+ }
99
+ }
100
+ }
101
+ }, [registryVersion, value]);
102
+ // Controlled/uncontrolled open
103
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
104
+ const isOpenControlled = controlledOpen !== undefined;
105
+ const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
106
+ const setOpen = useCallback((nextOpen) => {
107
+ if (disabled)
108
+ return;
109
+ if (!isOpenControlled) {
110
+ setUncontrolledOpen(nextOpen);
111
+ }
112
+ onOpenChange?.(nextOpen);
113
+ }, [disabled, isOpenControlled, onOpenChange]);
114
+ // IDs
115
+ const baseId = useId();
116
+ const triggerId = triggerIdProp ?? `${baseId}-trigger`;
117
+ const listboxId = `${baseId}-listbox`;
118
+ // Refs for list navigation
119
+ const listRef = useRef([]);
120
+ const listContentRef = useRef([]);
121
+ // Active index for navigation
122
+ const [activeIndex, setActiveIndex] = useState(null);
123
+ // Get ordered options
124
+ const getOrderedOptions = useCallback(() => {
125
+ const options = Array.from(optionsRef.current.values());
126
+ return options.sort((a, b) => {
127
+ if (!a.ref.current || !b.ref.current)
128
+ return 0;
129
+ const position = a.ref.current.compareDocumentPosition(b.ref.current);
130
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING)
131
+ return -1;
132
+ if (position & Node.DOCUMENT_POSITION_PRECEDING)
133
+ return 1;
134
+ return 0;
135
+ });
136
+ }, []);
137
+ // Memoized ordered options - recompute when registry changes
138
+ // getOrderedOptions is intentionally omitted - we only want to recompute when registryVersion changes
139
+ const orderedOptions = useMemo(() => {
140
+ const options = getOrderedOptions();
141
+ // Update typeahead ref synchronously
142
+ listContentRef.current = options.map((opt) => opt.textValue);
143
+ return options;
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [registryVersion]);
146
+ // Compute disabled indices for keyboard navigation
147
+ // Skip disabled options AND unselected options when max reached
148
+ const disabledIndices = useMemo(() => orderedOptions.reduce((acc, opt, i) => {
149
+ const selected = isSelected(opt.value);
150
+ const isSelectable = !opt.disabled && (!maxReached || selected);
151
+ if (!isSelectable)
152
+ acc.push(i);
153
+ return acc;
154
+ }, []), [orderedOptions, maxReached, isSelected]);
155
+ // Floating UI setup
156
+ const { refs, floatingStyles, context } = useFloating({
157
+ placement: 'bottom-start',
158
+ open,
159
+ onOpenChange: setOpen,
160
+ middleware: [
161
+ offset(4),
162
+ flip({ padding: 8 }),
163
+ shift({ padding: 8 }),
164
+ sizeMiddleware({
165
+ apply({ availableHeight, rects, elements }) {
166
+ Object.assign(elements.floating.style, {
167
+ maxHeight: `${Math.min(availableHeight - 16, 300)}px`,
168
+ minWidth: `${rects.reference.width}px`,
169
+ });
170
+ },
171
+ padding: 8,
172
+ }),
173
+ ],
174
+ whileElementsMounted: autoUpdate,
175
+ });
176
+ // Floating UI interactions
177
+ // Use 'click' (not 'mousedown') so button has focus when dropdown opens
178
+ // This ensures keyboard navigation works immediately
179
+ const click = useClick(context);
180
+ const dismiss = useDismiss(context);
181
+ const role = useRole(context, { role: 'listbox' });
182
+ const listNavigation = useListNavigation(context, {
183
+ listRef,
184
+ activeIndex,
185
+ onNavigate: setActiveIndex,
186
+ loop: true,
187
+ virtual: true,
188
+ focusItemOnOpen: true,
189
+ disabledIndices,
190
+ });
191
+ const typeahead = useTypeahead(context, {
192
+ listRef: listContentRef,
193
+ activeIndex,
194
+ onMatch: (index) => {
195
+ // Only match if the option is selectable
196
+ if (open && !disabledIndices.includes(index)) {
197
+ setActiveIndex(index);
198
+ }
199
+ },
200
+ resetMs: 500,
201
+ });
202
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
203
+ click,
204
+ dismiss,
205
+ role,
206
+ listNavigation,
207
+ typeahead,
208
+ ]);
209
+ // Register option
210
+ const registerOption = useCallback((option) => {
211
+ optionsRef.current.set(toKey(option.value), option);
212
+ setRegistryVersion((v) => v + 1);
213
+ }, []);
214
+ const unregisterOption = useCallback((optionValue) => {
215
+ optionsRef.current.delete(toKey(optionValue));
216
+ setRegistryVersion((v) => v + 1);
217
+ }, []);
218
+ // Get selected options for trigger display
219
+ const getSelectedOptions = useCallback(() => {
220
+ return value
221
+ .map((v) => {
222
+ const key = toKey(v);
223
+ const option = optionsRef.current.get(key);
224
+ if (option)
225
+ return option;
226
+ // Fallback for options not yet registered
227
+ const textValue = selectedTextMapRef.current.get(key) || String(v);
228
+ return { value: v, ref: { current: null }, disabled: false, textValue };
229
+ })
230
+ .filter(Boolean);
231
+ }, [value]);
232
+ // Highlighted value for keyboard navigation
233
+ const highlightedValue = activeIndex !== null ? orderedOptions[activeIndex]?.value ?? null : null;
234
+ // Track previous open state to detect open/close transitions
235
+ const prevOpenRef = useRef(open);
236
+ // Reset active index when closing, set initial when opening
237
+ useEffect(() => {
238
+ const wasOpen = prevOpenRef.current;
239
+ prevOpenRef.current = open;
240
+ if (!open) {
241
+ // Closing: reset active index
242
+ setActiveIndex(null);
243
+ }
244
+ else if (!wasOpen) {
245
+ // Just opened (transition from closed to open): set initial active index
246
+ const firstEnabled = orderedOptions.findIndex((opt) => {
247
+ const selected = value.some((v) => toKey(v) === toKey(opt.value));
248
+ const atMax = max !== undefined && value.length >= max;
249
+ return !opt.disabled && (!atMax || selected);
250
+ });
251
+ setActiveIndex(firstEnabled >= 0 ? firstEnabled : null);
252
+ }
253
+ // Note: Don't reset activeIndex while open - that would jump focus on toggle
254
+ }, [open, orderedOptions, value, max]);
255
+ // Scroll active option into view
256
+ useEffect(() => {
257
+ if (open && activeIndex !== null && listRef.current[activeIndex]) {
258
+ listRef.current[activeIndex]?.scrollIntoView({ block: 'nearest' });
259
+ }
260
+ }, [open, activeIndex]);
261
+ // ==========================================================================
262
+ // Split Context Values
263
+ // ==========================================================================
264
+ // Actions: stable config, IDs, refs, callbacks — rarely changes
265
+ // State: open, value, activeIndex, etc. — changes on interaction
266
+ // ==========================================================================
267
+ const actionsValue = useMemo(() => ({
268
+ // Config (from props, stable for component lifetime)
269
+ disabled,
270
+ placeholder,
271
+ display,
272
+ maxChips,
273
+ max,
274
+ size,
275
+ // ARIA IDs (stable)
276
+ triggerId,
277
+ listboxId,
278
+ ariaLabel,
279
+ ariaLabelledBy,
280
+ ariaDescribedBy,
281
+ // Stable callbacks
282
+ setOpen,
283
+ toggleOption,
284
+ clearAll,
285
+ registerOption,
286
+ unregisterOption,
287
+ // Refs (stable objects)
288
+ refs,
289
+ listRef,
290
+ // Floating UI interaction props (stable functions from useInteractions)
291
+ getReferenceProps,
292
+ getFloatingProps,
293
+ getItemProps,
294
+ }), [
295
+ // Config props - only change if parent rerenders with new props
296
+ disabled,
297
+ placeholder,
298
+ display,
299
+ maxChips,
300
+ max,
301
+ size,
302
+ // IDs are stable (from useId)
303
+ triggerId,
304
+ listboxId,
305
+ ariaLabel,
306
+ ariaLabelledBy,
307
+ ariaDescribedBy,
308
+ // Callbacks are stable (useCallback with stable deps)
309
+ setOpen,
310
+ toggleOption,
311
+ clearAll,
312
+ registerOption,
313
+ unregisterOption,
314
+ // Refs are stable objects
315
+ refs,
316
+ listRef,
317
+ // Interaction props from useInteractions (stable)
318
+ getReferenceProps,
319
+ getFloatingProps,
320
+ getItemProps,
321
+ ]);
322
+ const stateValue = useMemo(() => ({
323
+ // Open state
324
+ open,
325
+ // Selection state
326
+ value,
327
+ maxReached,
328
+ // Selection helpers (semantically tied to value)
329
+ isSelected,
330
+ getSelectedOptions,
331
+ // Navigation state
332
+ activeIndex,
333
+ highlightedValue,
334
+ // Derived (changes when registry/value changes)
335
+ orderedOptions,
336
+ // Floating UI (changes on open/position)
337
+ floatingStyles,
338
+ floatingContext: context,
339
+ }), [
340
+ open,
341
+ value,
342
+ maxReached,
343
+ isSelected,
344
+ getSelectedOptions,
345
+ activeIndex,
346
+ highlightedValue,
347
+ orderedOptions,
348
+ floatingStyles,
349
+ context,
350
+ ]);
351
+ return (_jsx(MultiSelectActionsContext.Provider, { value: actionsValue, children: _jsx(MultiSelectStateContext.Provider, { value: stateValue, children: children }) }));
352
+ }
353
+ MultiSelectRoot.displayName = 'MultiSelect';
354
+ // =============================================================================
355
+ // MultiSelect.Trigger
356
+ // =============================================================================
357
+ function MultiSelectTriggerComponent({ asChild = false, className, children, }) {
358
+ const { open, setOpen, disabled, placeholder, size, display, maxChips, max, maxReached, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, getSelectedOptions, clearAll, refs, getReferenceProps, activeIndex, orderedOptions, toggleOption, isSelected, } = useMultiSelectContext();
359
+ const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
360
+ const selectedOptions = getSelectedOptions();
361
+ const hasSelection = selectedOptions.length > 0;
362
+ // Ensure trigger has focus when dropdown opens (Safari doesn't focus buttons on click)
363
+ useEffect(() => {
364
+ if (open && refs.reference.current) {
365
+ refs.reference.current.focus();
366
+ }
367
+ }, [open, refs.reference]);
368
+ // Handle keyboard interactions (Enter/Space for toggle when open)
369
+ // Note: Backspace/Delete handled directly on button element to avoid typeahead interference
370
+ const handleKeyDown = useCallback((e) => {
371
+ // Toggle option with Enter/Space when open
372
+ if (open && (e.key === 'Enter' || e.key === ' ')) {
373
+ e.preventDefault();
374
+ if (activeIndex !== null) {
375
+ const option = orderedOptions[activeIndex];
376
+ if (option) {
377
+ const selected = isSelected(option.value);
378
+ const canToggle = !option.disabled && (!maxReached || selected);
379
+ if (canToggle) {
380
+ toggleOption(option.value, option.textValue);
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }, [open, activeIndex, orderedOptions, isSelected, maxReached, toggleOption]);
386
+ // Close dropdown when focus leaves trigger
387
+ // Uses blur guard pattern: only close if focus moved outside controlled elements
388
+ const handleBlur = useCallback((e) => {
389
+ if (!open)
390
+ return;
391
+ const relatedTarget = e.relatedTarget;
392
+ // Focus left the document entirely (e.g., clicked outside window)
393
+ if (relatedTarget === null) {
394
+ setOpen(false);
395
+ return;
396
+ }
397
+ // Check if focus moved to the floating content (clicking an option)
398
+ const floating = refs.floating.current;
399
+ if (floating && relatedTarget && floating.contains(relatedTarget)) {
400
+ return;
401
+ }
402
+ // Focus moved somewhere outside our control — close
403
+ setOpen(false);
404
+ }, [open, setOpen, refs.floating]);
405
+ // Handle clear button click
406
+ const handleClearClick = useCallback((e) => {
407
+ e.stopPropagation(); // Prevent triggering the dropdown
408
+ if (disabled)
409
+ return;
410
+ clearAll();
411
+ }, [disabled, clearAll]);
412
+ // Get Floating UI's reference props
413
+ const floatingProps = getReferenceProps({
414
+ 'aria-activedescendant': activeIndex !== null ? `${listboxId}-option-${activeIndex}` : undefined,
415
+ onKeyDown: handleKeyDown,
416
+ onBlur: handleBlur,
417
+ });
418
+ // Render trigger content based on display mode
419
+ const renderTriggerContent = () => {
420
+ if (!hasSelection) {
421
+ return _jsx("span", { className: "tui-multiselect__placeholder", children: placeholder });
422
+ }
423
+ if (display === 'count') {
424
+ return (_jsxs("span", { className: "tui-multiselect__count", children: [selectedOptions.length, " selected"] }));
425
+ }
426
+ // chips mode
427
+ const visibleChips = selectedOptions.slice(0, maxChips);
428
+ const overflow = selectedOptions.length - maxChips;
429
+ return (_jsxs("span", { className: "tui-multiselect__chips", children: [visibleChips.map((opt) => (_jsx("span", { className: "tui-multiselect__chip", children: opt.textValue }, toKey(opt.value)))), overflow > 0 && (_jsxs("span", { className: "tui-multiselect__more", children: ["+", overflow, " more"] }))] }));
430
+ };
431
+ // Default trigger content (when not using asChild or custom children)
432
+ const defaultTriggerContent = (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-multiselect__value", children: renderTriggerContent() }), hasSelection && (_jsx("span", { className: "tui-multiselect__clear", onClick: handleClearClick, "aria-hidden": "true", children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx(Icon, { name: "system/chevron-down", size: "sm", className: "tui-multiselect__icon", "aria-hidden": "true" })] }));
433
+ // Generate status message for screen readers
434
+ const statusMessage = hasSelection
435
+ ? `${selectedOptions.length} item${selectedOptions.length === 1 ? '' : 's'} selected${maxReached && max ? `. Maximum of ${max} reached` : ''}`
436
+ : '';
437
+ // Live region (rendered outside button, sibling to trigger)
438
+ const liveRegion = (_jsx("span", { className: "tui-visually-hidden", role: "status", "aria-live": "polite", "aria-atomic": "true", children: statusMessage }));
439
+ // Base trigger props
440
+ const triggerProps = {
441
+ id: triggerId,
442
+ 'aria-label': ariaLabel,
443
+ 'aria-labelledby': ariaLabelledBy,
444
+ 'aria-describedby': ariaDescribedBy,
445
+ 'aria-haspopup': 'listbox',
446
+ 'aria-expanded': open,
447
+ 'aria-controls': listboxId,
448
+ 'aria-disabled': disabled || undefined,
449
+ 'data-state': open ? 'open' : 'closed',
450
+ 'data-disabled': disabled || undefined,
451
+ ...floatingProps,
452
+ };
453
+ // asChild: merge props onto child element
454
+ if (asChild && isValidElement(children)) {
455
+ const childProps = children.props;
456
+ // Compose event handlers: child runs first, skip ours if defaultPrevented
457
+ const composeHandler = (ours, theirs) => {
458
+ if (!ours && !theirs)
459
+ return undefined;
460
+ if (!ours)
461
+ return theirs;
462
+ if (!theirs)
463
+ return ours;
464
+ return (e) => {
465
+ theirs(e);
466
+ if (!e.defaultPrevented) {
467
+ ours(e);
468
+ }
469
+ };
470
+ };
471
+ return (_jsxs(_Fragment, { children: [cloneElement(children, {
472
+ // Spread order: ours first, then child (child wins), then explicit overrides
473
+ ...triggerProps,
474
+ ...childProps,
475
+ // Re-assert must-have props that child cannot override
476
+ id: triggerId,
477
+ ref: refs.setReference,
478
+ 'aria-haspopup': 'listbox',
479
+ 'aria-expanded': open,
480
+ 'aria-controls': listboxId,
481
+ 'aria-activedescendant': floatingProps['aria-activedescendant'],
482
+ 'aria-describedby': ariaDescribedBy,
483
+ 'aria-disabled': disabled || undefined,
484
+ 'data-state': open ? 'open' : 'closed',
485
+ 'data-disabled': disabled || undefined,
486
+ className: cx(childProps.className, 'tui-multiselect__trigger', sizeClass, className),
487
+ // Composed handlers override both spreads
488
+ onClick: composeHandler(triggerProps.onClick, childProps.onClick),
489
+ // Handle Backspace/Delete before composed handlers (typeahead may intercept)
490
+ onKeyDown: (e) => {
491
+ if (!open && hasSelection && (e.key === 'Backspace' || e.key === 'Delete')) {
492
+ e.preventDefault();
493
+ clearAll();
494
+ return;
495
+ }
496
+ // Then run composed handlers
497
+ const composedHandler = composeHandler(triggerProps.onKeyDown, childProps.onKeyDown);
498
+ composedHandler?.(e);
499
+ },
500
+ onBlur: composeHandler(triggerProps.onBlur, childProps.onBlur),
501
+ onMouseDown: composeHandler(triggerProps.onMouseDown, childProps.onMouseDown),
502
+ onPointerDown: composeHandler(triggerProps.onPointerDown, childProps.onPointerDown),
503
+ }), liveRegion] }));
504
+ }
505
+ // Default: render button with optional custom content
506
+ const triggerContent = children ?? defaultTriggerContent;
507
+ return (_jsxs(_Fragment, { children: [_jsx("button", { ref: refs.setReference, type: "button", id: triggerId, className: cx('tui-multiselect__trigger', sizeClass, className), disabled: disabled, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": listboxId, "aria-disabled": disabled || undefined, "data-state": open ? 'open' : 'closed', "data-disabled": disabled || undefined, ...floatingProps,
508
+ // Handle Backspace/Delete AFTER floatingProps to ensure we catch it
509
+ // (typeahead may intercept these for its buffer)
510
+ onKeyDown: (e) => {
511
+ // Clear all with Backspace/Delete when closed and has selection
512
+ if (!open && hasSelection && (e.key === 'Backspace' || e.key === 'Delete')) {
513
+ e.preventDefault();
514
+ clearAll();
515
+ return;
516
+ }
517
+ // Call the composed handler from floatingProps for everything else
518
+ floatingProps.onKeyDown?.(e);
519
+ }, children: triggerContent }), liveRegion] }));
520
+ }
521
+ MultiSelectTriggerComponent.displayName = 'MultiSelect.Trigger';
522
+ // =============================================================================
523
+ // MultiSelect.Content
524
+ // =============================================================================
525
+ function MultiSelectContentComponent({ className, children }) {
526
+ const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, orderedOptions, } = useMultiSelectContext();
527
+ const portalRoot = getPortalRootFor(refs.reference.current);
528
+ // Memoized context for options
529
+ const contentContext = useMemo(() => ({
530
+ listRef,
531
+ activeIndex,
532
+ orderedOptions,
533
+ }), [listRef, activeIndex, orderedOptions]);
534
+ return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { style: { display: 'none' }, "aria-hidden": "true", children: _jsx(MultiSelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, "aria-multiselectable": "true", className: cx('tui-multiselect__content', className), style: {
535
+ ...floatingStyles,
536
+ minWidth: refs.reference.current?.offsetWidth,
537
+ pointerEvents: 'auto',
538
+ }, ...getFloatingProps(), children: _jsx(MultiSelectContentContext.Provider, { value: contentContext, children: children }) }) }))] }));
539
+ }
540
+ MultiSelectContentComponent.displayName = 'MultiSelect.Content';
541
+ // =============================================================================
542
+ // MultiSelect.Option
543
+ // =============================================================================
544
+ function MultiSelectOptionComponent({ value: optionValue, disabled = false, textValue: explicitTextValue, className, children, }) {
545
+ const { isSelected, toggleOption, listboxId, registerOption, unregisterOption, highlightedValue, getItemProps, maxReached, } = useMultiSelectContext();
546
+ const { listRef, orderedOptions } = useMultiSelectContentContext();
547
+ const ref = useRef(null);
548
+ // Derive textValue from children if not explicitly provided
549
+ const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
550
+ // Warn in dev if textValue couldn't be derived
551
+ useEffect(() => {
552
+ if (import.meta.env.DEV && !textValue) {
553
+ console.warn(`MultiSelect.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
554
+ }
555
+ }, [textValue, optionValue]);
556
+ // Register option
557
+ useLayoutEffect(() => {
558
+ registerOption({ value: optionValue, ref, disabled, textValue });
559
+ return () => unregisterOption(optionValue);
560
+ }, [optionValue, disabled, textValue, registerOption, unregisterOption]);
561
+ // Find this option's index in ordered list
562
+ const index = orderedOptions.findIndex((opt) => toKey(opt.value) === toKey(optionValue));
563
+ // Assign ref to listRef for navigation
564
+ useEffect(() => {
565
+ if (index >= 0) {
566
+ listRef.current[index] = ref.current;
567
+ }
568
+ }, [index, listRef]);
569
+ const selected = isSelected(optionValue);
570
+ const isHighlighted = highlightedValue !== null && toKey(highlightedValue) === toKey(optionValue);
571
+ // Blocked: max reached and not selected (blocked by constraint, not intrinsically disabled)
572
+ const isBlocked = !disabled && maxReached && !selected;
573
+ // Selectable: not disabled AND (not max reached OR already selected)
574
+ const isSelectable = !disabled && (!maxReached || selected);
575
+ const handlePointerDown = useCallback((e) => {
576
+ // Prevent focus shift from trigger
577
+ e.preventDefault();
578
+ }, []);
579
+ const handleClick = useCallback(() => {
580
+ if (!isSelectable)
581
+ return;
582
+ toggleOption(optionValue, textValue);
583
+ }, [isSelectable, optionValue, textValue, toggleOption]);
584
+ return (_jsxs("div", { ref: ref, id: index >= 0 ? `${listboxId}-option-${index}` : undefined, role: "option", className: cx('tui-multiselect__option', className), "aria-selected": selected, "aria-disabled": !isSelectable || undefined, "data-highlighted": isHighlighted || undefined, "data-disabled": disabled || undefined, "data-blocked": isBlocked || undefined, ...getItemProps({
585
+ onClick: isSelectable ? handleClick : undefined,
586
+ onPointerDown: handlePointerDown,
587
+ }), children: [_jsx("span", { className: cx('tui-multiselect__checkbox', selected && 'is-checked'), "aria-hidden": "true", children: selected && _jsx(Icon, { name: "system/check", size: "xs" }) }), _jsx("span", { className: "tui-multiselect__option-content", children: children })] }));
588
+ }
589
+ MultiSelectOptionComponent.displayName = 'MultiSelect.Option';
590
+ // =============================================================================
591
+ // MultiSelect.Group
592
+ // =============================================================================
593
+ function MultiSelectGroupComponent({ className, children }) {
594
+ const groupId = useId();
595
+ return (_jsx("div", { role: "group", "aria-labelledby": `${groupId}-label`, className: cx('tui-multiselect__group', className), children: _jsx(MultiSelectGroupContext.Provider, { value: { groupId }, children: children }) }));
596
+ }
597
+ MultiSelectGroupComponent.displayName = 'MultiSelect.Group';
598
+ const MultiSelectGroupContext = React.createContext(null);
599
+ // =============================================================================
600
+ // MultiSelect.Label
601
+ // =============================================================================
602
+ function MultiSelectLabelComponent({ className, children }) {
603
+ const groupContext = React.useContext(MultiSelectGroupContext);
604
+ // No aria-hidden or role="presentation" — aria-labelledby on Group references this element,
605
+ // so screen readers need access to read the label text.
606
+ return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-multiselect__label', className), children: children }));
607
+ }
608
+ MultiSelectLabelComponent.displayName = 'MultiSelect.Label';
609
+ export const MultiSelect = MultiSelectRoot;
610
+ MultiSelect.Trigger = MultiSelectTriggerComponent;
611
+ MultiSelect.Content = MultiSelectContentComponent;
612
+ MultiSelect.Option = MultiSelectOptionComponent;
613
+ MultiSelect.Group = MultiSelectGroupComponent;
614
+ MultiSelect.Label = MultiSelectLabelComponent;
615
+ // Named exports
616
+ export const MultiSelectTrigger = MultiSelectTriggerComponent;
617
+ export const MultiSelectContent = MultiSelectContentComponent;
618
+ export const MultiSelectOption = MultiSelectOptionComponent;
619
+ export const MultiSelectGroup = MultiSelectGroupComponent;
620
+ export const MultiSelectLabel = MultiSelectLabelComponent;
621
+ // Hook for advanced use cases
622
+ // eslint-disable-next-line react-refresh/only-export-components
623
+ export { useMultiSelectContext as useMultiSelect } from './MultiSelectContext.js';