@tangible/ui 0.0.7 → 0.0.9

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 (103) hide show
  1. package/components/Accordion/Accordion.js +11 -3
  2. package/components/Avatar/Avatar.d.ts +1 -1
  3. package/components/Avatar/Avatar.js +5 -4
  4. package/components/Avatar/AvatarGroup.js +7 -5
  5. package/components/Avatar/index.d.ts +2 -2
  6. package/components/Avatar/index.js +1 -1
  7. package/components/Avatar/types.d.ts +27 -0
  8. package/components/Avatar/types.js +8 -0
  9. package/components/Button/Button.js +4 -2
  10. package/components/Button/index.d.ts +2 -1
  11. package/components/Button/index.js +1 -0
  12. package/components/Button/types.d.ts +10 -0
  13. package/components/Button/types.js +3 -1
  14. package/components/Checkbox/Checkbox.js +46 -11
  15. package/components/Checkbox/types.d.ts +9 -0
  16. package/components/Combobox/Combobox.d.ts +1 -1
  17. package/components/Combobox/Combobox.js +50 -7
  18. package/components/Combobox/index.d.ts +2 -1
  19. package/components/Combobox/index.js +1 -0
  20. package/components/Combobox/types.d.ts +9 -0
  21. package/components/Combobox/types.js +3 -1
  22. package/components/Dropdown/Dropdown.d.ts +1 -1
  23. package/components/Dropdown/Dropdown.js +32 -12
  24. package/components/Field/Field.d.ts +4 -1
  25. package/components/Field/Field.js +35 -14
  26. package/components/Field/FieldContext.d.ts +16 -0
  27. package/components/Field/FieldContext.js +3 -0
  28. package/components/Field/index.d.ts +2 -1
  29. package/components/Field/index.js +1 -0
  30. package/components/Icon/Icon.d.ts +1 -1
  31. package/components/Icon/Icon.js +2 -2
  32. package/components/Modal/Modal.d.ts +5 -1
  33. package/components/Modal/Modal.js +2 -2
  34. package/components/MoveHandle/MoveHandle.d.ts +1 -1
  35. package/components/MoveHandle/MoveHandle.js +4 -4
  36. package/components/MoveHandle/types.d.ts +1 -1
  37. package/components/MultiSelect/MultiSelect.d.ts +1 -1
  38. package/components/MultiSelect/MultiSelect.js +58 -19
  39. package/components/MultiSelect/index.d.ts +2 -1
  40. package/components/MultiSelect/index.js +1 -0
  41. package/components/MultiSelect/types.d.ts +34 -0
  42. package/components/MultiSelect/types.js +10 -0
  43. package/components/Pager/Pager.d.ts +7 -1
  44. package/components/Pager/Pager.js +7 -5
  45. package/components/Pager/index.d.ts +2 -0
  46. package/components/Pager/index.js +1 -0
  47. package/components/Pager/types.d.ts +37 -0
  48. package/components/Pager/types.js +12 -0
  49. package/components/Progress/Progress.d.ts +2 -1
  50. package/components/Progress/Progress.js +3 -3
  51. package/components/Rating/Rating.d.ts +2 -32
  52. package/components/Rating/Rating.js +5 -3
  53. package/components/Rating/index.d.ts +2 -1
  54. package/components/Rating/index.js +1 -0
  55. package/components/Rating/types.d.ts +41 -0
  56. package/components/Rating/types.js +4 -0
  57. package/components/SegmentedControl/SegmentedControl.js +6 -5
  58. package/components/SegmentedControl/types.d.ts +17 -5
  59. package/components/Select/Select.d.ts +1 -0
  60. package/components/Select/Select.js +131 -77
  61. package/components/Select/SelectContext.d.ts +4 -16
  62. package/components/Select/SelectContext.js +5 -35
  63. package/components/Select/types.d.ts +19 -19
  64. package/components/Sidebar/Sidebar.js +25 -20
  65. package/components/StepIndicator/StepIndicator.d.ts +1 -1
  66. package/components/StepIndicator/StepIndicator.js +14 -10
  67. package/components/StepIndicator/index.d.ts +2 -1
  68. package/components/StepIndicator/index.js +1 -0
  69. package/components/StepIndicator/types.d.ts +18 -0
  70. package/components/StepIndicator/types.js +7 -1
  71. package/components/Table/BulkActionsBar.d.ts +4 -1
  72. package/components/Table/BulkActionsBar.js +5 -4
  73. package/components/Table/DataTable.d.ts +4 -1
  74. package/components/Table/DataTable.js +10 -8
  75. package/components/Table/index.d.ts +3 -0
  76. package/components/Table/index.js +2 -0
  77. package/components/Table/types.d.ts +20 -0
  78. package/components/Table/types.js +11 -0
  79. package/components/Tabs/Tabs.js +11 -4
  80. package/components/TextInput/TextInput.js +2 -1
  81. package/components/TextInput/types.d.ts +7 -1
  82. package/components/Textarea/Textarea.js +3 -2
  83. package/components/Textarea/types.d.ts +6 -1
  84. package/components/Tooltip/Tooltip.d.ts +1 -1
  85. package/components/Tooltip/Tooltip.js +16 -10
  86. package/icons/icons.svg +29 -15
  87. package/icons/lms/index.d.ts +8 -0
  88. package/icons/lms/index.js +48 -4
  89. package/icons/manifest.json +112 -0
  90. package/icons/player/index.js +9 -9
  91. package/icons/registry.d.ts +28 -0
  92. package/icons/registry.js +14 -0
  93. package/icons/system/index.d.ts +20 -0
  94. package/icons/system/index.js +112 -2
  95. package/package.json +1 -1
  96. package/styles/all.css +1 -1
  97. package/styles/all.expanded.css +266 -59
  98. package/styles/all.expanded.unlayered.css +266 -59
  99. package/styles/all.unlayered.css +1 -1
  100. package/styles/components/input/index.scss +29 -7
  101. package/styles/system/_constants.scss +1 -1
  102. package/styles/system/_tokens.scss +1 -0
  103. package/tui-manifest.json +78 -52
@@ -6,21 +6,24 @@ import { cx } from '../../utils/cx.js';
6
6
  import { toKey } from '../../utils/value-key.js';
7
7
  import { getPortalRootFor } from '../../utils/portal.js';
8
8
  import { Icon } from '../Icon/index.js';
9
- import { SelectActionsContext, SelectStateContext, SelectContentContext, useSelectContext, useSelectContentContext, } from './SelectContext.js';
9
+ import { SelectContext, SelectContentContext, useSelectContext, useSelectContentContext, } from './SelectContext.js';
10
10
  import { toPlacement, } from './types.js';
11
11
  // =============================================================================
12
12
  // Select Root
13
13
  // =============================================================================
14
14
  function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', side = 'bottom', align = 'start', sideOffset = 4, clearable = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }) {
15
- // Controlled/uncontrolled value
15
+ // Controlled/uncontrolled value — lock decision at mount time to prevent
16
+ // switching modes when controlled value is cleared to undefined
16
17
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
17
- const isValueControlled = controlledValue !== undefined;
18
+ const isValueControlled = useRef(controlledValue !== undefined).current;
18
19
  const value = isValueControlled ? controlledValue : uncontrolledValue;
19
20
  // Store display text separately so it persists when dropdown is closed
20
21
  const [displayText, setDisplayText] = useState(undefined);
21
22
  // Option registration
22
23
  const optionsRef = useRef(new Map());
23
24
  const [registryVersion, setRegistryVersion] = useState(0);
25
+ // Track open state via ref so unregisterOption can check synchronously
26
+ const openRef = useRef(false);
24
27
  const setValue = useCallback((newValue, textValue) => {
25
28
  if (!isValueControlled) {
26
29
  setUncontrolledValue(newValue);
@@ -28,20 +31,16 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
28
31
  }
29
32
  onValueChange?.(newValue);
30
33
  }, [isValueControlled, onValueChange]);
31
- // Sync displayText when value changes (handles controlled updates and clears)
32
- const prevValue = useRef(value);
34
+ // Sync displayText when controlled value changes externally (parent prop update)
33
35
  useEffect(() => {
34
- if (prevValue.current !== value) {
35
- if (value === undefined) {
36
- setDisplayText(undefined);
37
- }
38
- else {
39
- const option = optionsRef.current.get(toKey(value));
40
- if (option) {
41
- setDisplayText(option.textValue);
42
- }
36
+ if (value === undefined) {
37
+ setDisplayText(undefined);
38
+ }
39
+ else {
40
+ const option = optionsRef.current.get(toKey(value));
41
+ if (option) {
42
+ setDisplayText(option.textValue);
43
43
  }
44
- prevValue.current = value;
45
44
  }
46
45
  }, [value]);
47
46
  // Initialize displayText when options register (handles defaultValue case)
@@ -58,9 +57,16 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
58
57
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
59
58
  const isOpenControlled = controlledOpen !== undefined;
60
59
  const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
60
+ openRef.current = open;
61
+ // Guard flag: when Backspace/Delete clears a closed Select, Floating UI's
62
+ // composed handlers (useTypeahead/useListNavigation) also fire and call
63
+ // setOpen(true). This ref rejects that open request within the same microtask.
64
+ const justClearedRef = useRef(false);
61
65
  const setOpen = useCallback((nextOpen) => {
62
66
  if (disabled)
63
67
  return;
68
+ if (nextOpen && justClearedRef.current)
69
+ return;
64
70
  if (!isOpenControlled) {
65
71
  setUncontrolledOpen(nextOpen);
66
72
  }
@@ -100,6 +106,15 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
100
106
  return options;
101
107
  // eslint-disable-next-line react-hooks/exhaustive-deps
102
108
  }, [registryVersion]);
109
+ // O(1) index lookup map — rebuilt when orderedOptions changes
110
+ const optionIndexMap = useMemo(() => {
111
+ const map = new Map();
112
+ orderedOptions.forEach((opt, i) => map.set(toKey(opt.value), i));
113
+ return map;
114
+ }, [orderedOptions]);
115
+ // Ref for stable handleSelect — avoids recreating on registry changes
116
+ const orderedOptionsRef = useRef(orderedOptions);
117
+ orderedOptionsRef.current = orderedOptions;
103
118
  // Floating UI setup - in Root so Trigger and Content can share
104
119
  const { refs, floatingStyles, context } = useFloating({
105
120
  placement,
@@ -121,16 +136,16 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
121
136
  ],
122
137
  whileElementsMounted: autoUpdate,
123
138
  });
124
- // Handle selection
139
+ // Handle selection — reads orderedOptionsRef to avoid recreating on registry changes
125
140
  const handleSelect = useCallback((index) => {
126
141
  if (index === null)
127
142
  return;
128
- const option = orderedOptions[index];
143
+ const option = orderedOptionsRef.current[index];
129
144
  if (option && !option.disabled) {
130
145
  setValue(option.value, option.textValue);
131
146
  setOpen(false);
132
147
  }
133
- }, [orderedOptions, setValue, setOpen]);
148
+ }, [setValue, setOpen]);
134
149
  // Floating UI interactions
135
150
  // Use 'click' (not 'mousedown') so button has focus when dropdown opens
136
151
  // This ensures keyboard navigation works immediately
@@ -150,7 +165,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
150
165
  loop: true,
151
166
  virtual: true,
152
167
  focusItemOnOpen: true,
153
- selectedIndex: orderedOptions.findIndex((opt) => value !== undefined && toKey(opt.value) === toKey(value)),
168
+ selectedIndex: value !== undefined ? (optionIndexMap.get(toKey(value)) ?? -1) : -1,
154
169
  disabledIndices,
155
170
  });
156
171
  const typeahead = useTypeahead(context, {
@@ -176,15 +191,24 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
176
191
  setRegistryVersion((v) => v + 1);
177
192
  }, []);
178
193
  const unregisterOption = useCallback((optionValue) => {
194
+ // When the dropdown closes, options unmount and call unregister in cleanup.
195
+ // Skip the delete to preserve the registry — displayText and typeahead
196
+ // depend on it while closed. Options re-register on next open (same keys).
197
+ if (!openRef.current)
198
+ return;
179
199
  optionsRef.current.delete(toKey(optionValue));
180
200
  setRegistryVersion((v) => v + 1);
181
201
  }, []);
182
- // Get selected option's text value
183
- const getSelectedTextValue = useCallback(() => {
184
- return displayText;
185
- }, [displayText]);
186
202
  // Highlighted value for keyboard navigation
187
203
  const highlightedValue = activeIndex !== null ? orderedOptions[activeIndex]?.value ?? null : null;
204
+ // Flush stale registry on open. Options that were registered before close
205
+ // may no longer exist (parent changed children while closed). Clearing
206
+ // before the new options mount ensures no orphaned entries accumulate.
207
+ useLayoutEffect(() => {
208
+ if (open) {
209
+ optionsRef.current.clear();
210
+ }
211
+ }, [open]);
188
212
  // Reset active index when closing
189
213
  useEffect(() => {
190
214
  if (!open) {
@@ -192,7 +216,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
192
216
  }
193
217
  else {
194
218
  // When opening, set active index to selected value or first enabled
195
- const selectedIndex = orderedOptions.findIndex((opt) => value !== undefined && toKey(opt.value) === toKey(value));
219
+ const selectedIndex = value !== undefined ? (optionIndexMap.get(toKey(value)) ?? -1) : -1;
196
220
  if (selectedIndex >= 0 && !orderedOptions[selectedIndex]?.disabled) {
197
221
  setActiveIndex(selectedIndex);
198
222
  }
@@ -201,7 +225,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
201
225
  setActiveIndex(firstEnabled >= 0 ? firstEnabled : null);
202
226
  }
203
227
  }
204
- }, [open, orderedOptions, value]);
228
+ }, [open, orderedOptions, optionIndexMap, value]);
205
229
  // Scroll active option into view
206
230
  useEffect(() => {
207
231
  if (open && activeIndex !== null && listRef.current[activeIndex]) {
@@ -209,98 +233,82 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
209
233
  }
210
234
  }, [open, activeIndex]);
211
235
  // ==========================================================================
212
- // Split Context Values
213
- // ==========================================================================
214
- // Actions: stable config, IDs, refs, callbacks — rarely changes
215
- // State: open, value, activeIndex, etc. — changes on interaction
236
+ // Context Value
216
237
  // ==========================================================================
217
- const actionsValue = useMemo(() => ({
218
- // Config (from props, stable for component lifetime)
238
+ const contextValue = useMemo(() => ({
239
+ // Config
219
240
  disabled,
220
241
  placeholder,
221
242
  clearable,
222
243
  size,
223
- // ARIA IDs (stable)
244
+ // ARIA IDs
224
245
  triggerId,
225
246
  listboxId,
226
247
  ariaLabel,
227
248
  ariaLabelledBy,
228
249
  ariaDescribedBy,
229
- // Stable callbacks
250
+ // Callbacks
230
251
  setOpen,
231
252
  setValue,
232
253
  registerOption,
233
254
  unregisterOption,
234
255
  handleSelect,
235
- // Refs (stable objects)
256
+ // Refs
236
257
  refs,
237
258
  listRef,
238
- // Floating UI interaction props (stable functions from useInteractions)
259
+ justClearedRef,
260
+ // Floating UI interaction props
239
261
  getReferenceProps,
240
262
  getFloatingProps,
241
263
  getItemProps,
264
+ // State
265
+ open,
266
+ value,
267
+ displayText,
268
+ activeIndex,
269
+ highlightedValue,
270
+ orderedOptions,
271
+ optionIndexMap,
272
+ floatingStyles,
242
273
  }), [
243
- // Config props - only change if parent rerenders with new props
244
274
  disabled,
245
275
  placeholder,
246
276
  clearable,
247
277
  size,
248
- // IDs are stable (from useId)
249
278
  triggerId,
250
279
  listboxId,
251
280
  ariaLabel,
252
281
  ariaLabelledBy,
253
282
  ariaDescribedBy,
254
- // Callbacks are stable (useCallback with stable deps)
255
283
  setOpen,
256
284
  setValue,
257
285
  registerOption,
258
286
  unregisterOption,
259
287
  handleSelect,
260
- // Refs are stable objects
261
288
  refs,
262
289
  listRef,
263
- // Interaction props from useInteractions (stable)
264
290
  getReferenceProps,
265
291
  getFloatingProps,
266
292
  getItemProps,
267
- ]);
268
- const stateValue = useMemo(() => ({
269
- // Open state
270
293
  open,
271
- // Selection state
272
294
  value,
273
- // Selection helper
274
- getSelectedTextValue,
275
- // Navigation state
295
+ displayText,
276
296
  activeIndex,
277
297
  highlightedValue,
278
- // Derived (changes when registry changes)
279
298
  orderedOptions,
280
- // Floating UI (changes on open/position)
299
+ optionIndexMap,
281
300
  floatingStyles,
282
- floatingContext: context,
283
- }), [
284
- open,
285
- value,
286
- getSelectedTextValue,
287
- activeIndex,
288
- highlightedValue,
289
- orderedOptions,
290
- floatingStyles,
291
- context,
292
301
  ]);
293
- return (_jsx(SelectActionsContext.Provider, { value: actionsValue, children: _jsx(SelectStateContext.Provider, { value: stateValue, children: children }) }));
302
+ return (_jsx(SelectContext.Provider, { value: contextValue, children: children }));
294
303
  }
295
304
  SelectRoot.displayName = 'Select';
296
305
  // =============================================================================
297
306
  // Select.Trigger
298
307
  // =============================================================================
299
308
  function SelectTriggerComponent({ asChild = false, className, children, }) {
300
- const { open, setOpen, disabled, placeholder, clearable, size, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, getSelectedTextValue, setValue, refs, getReferenceProps, activeIndex, handleSelect, } = useSelectContext();
309
+ const { open, setOpen, disabled, placeholder, clearable, size, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, displayText, setValue, refs, getReferenceProps, activeIndex, handleSelect, justClearedRef, } = useSelectContext();
301
310
  const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
302
- const displayValue = getSelectedTextValue();
303
- const hasValue = displayValue !== undefined;
311
+ const hasValue = displayText !== undefined;
304
312
  // Ensure trigger has focus when dropdown opens (Safari doesn't focus buttons on click)
305
313
  useEffect(() => {
306
314
  if (open && refs.reference.current) {
@@ -315,9 +323,11 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
315
323
  }
316
324
  if (!open && clearable && hasValue && (e.key === 'Delete' || e.key === 'Backspace')) {
317
325
  e.preventDefault();
326
+ justClearedRef.current = true;
327
+ queueMicrotask(() => { justClearedRef.current = false; });
318
328
  setValue(undefined);
319
329
  }
320
- }, [open, activeIndex, handleSelect, clearable, hasValue, setValue]);
330
+ }, [open, activeIndex, handleSelect, clearable, hasValue, setValue, justClearedRef]);
321
331
  // Close dropdown when focus leaves trigger
322
332
  // Uses blur guard pattern: only close if focus moved outside controlled elements
323
333
  const handleBlur = useCallback((e) => {
@@ -344,7 +354,9 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
344
354
  if (disabled)
345
355
  return;
346
356
  setValue(undefined);
347
- }, [disabled, setValue]);
357
+ // Return focus to trigger after clearing
358
+ refs.reference.current?.focus();
359
+ }, [disabled, setValue, refs.reference]);
348
360
  // Get Floating UI's reference props - pass our handlers so they get composed
349
361
  const floatingProps = getReferenceProps({
350
362
  'aria-activedescendant': activeIndex !== null ? `${listboxId}-option-${activeIndex}` : undefined,
@@ -356,9 +368,8 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
356
368
  'aria-label': ariaLabel,
357
369
  'aria-labelledby': ariaLabelledBy,
358
370
  'aria-describedby': ariaDescribedBy,
359
- 'aria-disabled': disabled || undefined,
371
+ 'aria-keyshortcuts': clearable && hasValue ? 'Delete' : undefined,
360
372
  'data-state': open ? 'open' : 'closed',
361
- 'data-disabled': disabled || undefined,
362
373
  ...floatingProps,
363
374
  };
364
375
  if (asChild && isValidElement(children)) {
@@ -390,6 +401,7 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
390
401
  'aria-controls': floatingProps['aria-controls'],
391
402
  'aria-activedescendant': floatingProps['aria-activedescendant'],
392
403
  'aria-describedby': ariaDescribedBy,
404
+ // asChild: use aria-disabled + data-disabled since element may not support native disabled
393
405
  'aria-disabled': disabled || undefined,
394
406
  'data-state': open ? 'open' : 'closed',
395
407
  'data-disabled': disabled || undefined,
@@ -402,23 +414,34 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
402
414
  onPointerDown: composeHandler(triggerProps.onPointerDown, childProps.onPointerDown),
403
415
  });
404
416
  }
405
- return (_jsxs("button", { ref: refs.setReference, type: "button", className: cx('tui-select__trigger', sizeClass, className), disabled: disabled, ...triggerProps, children: [_jsx("span", { className: "tui-select__value", children: displayValue || _jsx("span", { className: "tui-select__placeholder", children: placeholder }) }), clearable && hasValue && !disabled && (_jsx("span", { className: "tui-select__clear", onPointerDown: handleClear, "aria-hidden": "true", children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx(Icon, { name: "system/chevron-down", size: "sm", className: "tui-select__icon", "aria-hidden": "true" })] }));
417
+ const showClear = clearable && hasValue && !disabled;
418
+ // Wrap in a relative container so the clear button can be a sibling of the
419
+ // trigger <button> — nesting <button> inside <button> is invalid HTML and
420
+ // causes browsers to eject the inner button via the adoption agency algorithm.
421
+ return (_jsxs("div", { className: cx('tui-select__trigger-wrap', showClear && 'has-clear'), children: [_jsxs("button", { ref: refs.setReference, type: "button", className: cx('tui-select__trigger', sizeClass, className), disabled: disabled, ...triggerProps, children: [_jsx("span", { className: "tui-select__value", children: displayText ?? _jsx("span", { className: "tui-select__placeholder", children: placeholder }) }), _jsx(Icon, { name: "system/chevron-down", size: "sm", className: "tui-select__icon", "aria-hidden": "true" })] }), showClear && (_jsx("button", { type: "button", className: "tui-select__clear", onClick: handleClear, "aria-label": "Clear selection", tabIndex: -1, children: _jsx(Icon, { name: "system/close", size: "sm" }) }))] }));
406
422
  }
407
423
  SelectTriggerComponent.displayName = 'Select.Trigger';
408
424
  // =============================================================================
409
425
  // Select.Content
410
426
  // =============================================================================
411
427
  function SelectContentComponent({ className, children, }) {
412
- const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, handleSelect, orderedOptions, } = useSelectContext();
428
+ const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, handleSelect, orderedOptions, optionIndexMap, } = useSelectContext();
413
429
  const portalRoot = getPortalRootFor(refs.reference.current);
430
+ // Track whether dropdown has ever been opened. Before first open, mount
431
+ // children in a hidden div for option registration (defaultValue resolution).
432
+ // After first open, only mount children when open (in portal).
433
+ const hasEverOpened = useRef(false);
434
+ if (open)
435
+ hasEverOpened.current = true;
414
436
  // Memoized context for options
415
437
  const contentContext = useMemo(() => ({
416
438
  listRef,
417
439
  activeIndex,
418
440
  handleSelect,
419
441
  orderedOptions,
420
- }), [listRef, activeIndex, handleSelect, orderedOptions]);
421
- return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { style: { display: 'none' }, "aria-hidden": "true", children: _jsx(SelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, className: cx('tui-select__content', className), style: {
442
+ optionIndexMap,
443
+ }), [listRef, activeIndex, handleSelect, orderedOptions, optionIndexMap]);
444
+ return (_jsxs(_Fragment, { children: [!open && !hasEverOpened.current && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(SelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, className: cx('tui-select__content', className), style: {
422
445
  ...floatingStyles,
423
446
  minWidth: refs.reference.current?.offsetWidth,
424
447
  pointerEvents: 'auto',
@@ -430,13 +453,15 @@ SelectContentComponent.displayName = 'Select.Content';
430
453
  // =============================================================================
431
454
  function SelectOptionComponent({ value: optionValue, disabled = false, textValue: explicitTextValue, className, children, }) {
432
455
  const { value: selectedValue, setValue, setOpen, listboxId, registerOption, unregisterOption, highlightedValue, getItemProps, } = useSelectContext();
433
- const { listRef, orderedOptions } = useSelectContentContext();
456
+ const { listRef, optionIndexMap } = useSelectContentContext();
434
457
  const ref = useRef(null);
435
458
  // Derive textValue from children if not explicitly provided
436
459
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
437
- // Warn in dev if textValue couldn't be derived
460
+ // Warn in dev if textValue couldn't be derived (once per mount)
461
+ const warnedTextValueRef = useRef(false);
438
462
  useEffect(() => {
439
- if (isDev() && !textValue) {
463
+ if (isDev() && !textValue && !warnedTextValueRef.current) {
464
+ warnedTextValueRef.current = true;
440
465
  console.warn(`Select.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
441
466
  }
442
467
  }, [textValue, optionValue]);
@@ -445,8 +470,8 @@ function SelectOptionComponent({ value: optionValue, disabled = false, textValue
445
470
  registerOption({ value: optionValue, ref, disabled, textValue });
446
471
  return () => unregisterOption(optionValue);
447
472
  }, [optionValue, disabled, textValue, registerOption, unregisterOption]);
448
- // Find this option's index in ordered list
449
- const index = orderedOptions.findIndex((opt) => toKey(opt.value) === toKey(optionValue));
473
+ // O(1) index lookup via Map
474
+ const index = optionIndexMap.get(toKey(optionValue)) ?? -1;
450
475
  // Assign ref to listRef for navigation
451
476
  useEffect(() => {
452
477
  if (index >= 0) {
@@ -471,7 +496,27 @@ SelectOptionComponent.displayName = 'Select.Option';
471
496
  // =============================================================================
472
497
  function SelectGroupComponent({ className, children }) {
473
498
  const groupId = useId();
474
- return (_jsx("div", { role: "group", "aria-labelledby": `${groupId}-label`, className: cx('tui-select__group', className), children: _jsx(SelectGroupContext.Provider, { value: { groupId }, children: children }) }));
499
+ const groupRef = useRef(null);
500
+ // Guard aria-labelledby: only set when a Label child actually exists in the DOM.
501
+ // Without this, aria-labelledby points to a non-existent ID, which per AccName 1.2
502
+ // step 2B overrides all other name sources and resolves to empty string.
503
+ // useLayoutEffect runs before paint, so AT never sees the dangling reference.
504
+ // groupId is stable (from useId) and label presence is stable after mount —
505
+ // only run once to avoid a DOM query on every render.
506
+ useLayoutEffect(() => {
507
+ const groupEl = groupRef.current;
508
+ if (!groupEl)
509
+ return;
510
+ const labelId = `${groupId}-label`;
511
+ const labelEl = groupEl.querySelector(`#${CSS.escape(labelId)}`);
512
+ if (labelEl) {
513
+ groupEl.setAttribute('aria-labelledby', labelId);
514
+ }
515
+ else {
516
+ groupEl.removeAttribute('aria-labelledby');
517
+ }
518
+ }, [groupId]);
519
+ return (_jsx("div", { ref: groupRef, role: "group", className: cx('tui-select__group', className), children: _jsx(SelectGroupContext.Provider, { value: { groupId }, children: children }) }));
475
520
  }
476
521
  SelectGroupComponent.displayName = 'Select.Group';
477
522
  const SelectGroupContext = React.createContext(null);
@@ -480,6 +525,15 @@ const SelectGroupContext = React.createContext(null);
480
525
  // =============================================================================
481
526
  function SelectLabelComponent({ className, children }) {
482
527
  const groupContext = React.useContext(SelectGroupContext);
528
+ // DEV: warn if Label is used outside a Group — it has no semantic effect there
529
+ const warnedRef = useRef(false);
530
+ useEffect(() => {
531
+ if (isDev() && !groupContext && !warnedRef.current) {
532
+ warnedRef.current = true;
533
+ console.warn('Select.Label rendered outside Select.Group has no effect. ' +
534
+ 'Wrap in Select.Group so aria-labelledby is wired correctly.');
535
+ }
536
+ }, [groupContext]);
483
537
  // No aria-hidden — aria-labelledby on Group references this element,
484
538
  // so screen readers need access to read the label text.
485
539
  return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-select__label', className), children: children }));
@@ -1,20 +1,8 @@
1
- import type { SelectActionsContextValue, SelectStateContextValue, SelectContentContextValue } from './types';
2
- export declare const SelectActionsContext: import("react").Context<SelectActionsContextValue | null>;
3
- export declare const SelectStateContext: import("react").Context<SelectStateContextValue | null>;
1
+ import type { SelectContextValue, SelectContentContextValue } from './types';
2
+ export declare const SelectContext: import("react").Context<SelectContextValue | null>;
4
3
  /**
5
- * Access stable config, IDs, refs, and callbacks.
6
- * Safe to use without causing rerenders on navigation changes.
4
+ * Access Select context: config, IDs, refs, callbacks, and reactive state.
7
5
  */
8
- export declare function useSelectActions(): SelectActionsContextValue;
9
- /**
10
- * Access reactive state: open, value, activeIndex, orderedOptions.
11
- * Subscribing causes rerender when these values change.
12
- */
13
- export declare function useSelectState(): SelectStateContextValue;
14
- /**
15
- * Combined hook for components that need both.
16
- * Convenience for Trigger/Content that need everything.
17
- */
18
- export declare function useSelectContext(): SelectActionsContextValue & SelectStateContextValue;
6
+ export declare function useSelectContext(): SelectContextValue;
19
7
  export declare const SelectContentContext: import("react").Context<SelectContentContextValue | null>;
20
8
  export declare function useSelectContentContext(): SelectContentContextValue;
@@ -1,48 +1,18 @@
1
1
  import { createContext, useContext } from 'react';
2
2
  // =============================================================================
3
- // Split Contexts for Performance
3
+ // Select Context
4
4
  // =============================================================================
5
- // Actions context: stable config, IDs, refs, callbacks — rarely changes
6
- // State context: open, value, activeIndex, etc. — changes on interaction
7
- //
8
- // Components subscribe to what they need:
9
- // - Options: actions (register) + minimal state (isSelected check)
10
- // - Trigger: both (needs state for display, actions for handlers)
11
- // - Content: both (needs floating styles, refs)
12
- // =============================================================================
13
- export const SelectActionsContext = createContext(null);
14
- export const SelectStateContext = createContext(null);
5
+ export const SelectContext = createContext(null);
15
6
  /**
16
- * Access stable config, IDs, refs, and callbacks.
17
- * Safe to use without causing rerenders on navigation changes.
7
+ * Access Select context: config, IDs, refs, callbacks, and reactive state.
18
8
  */
19
- export function useSelectActions() {
20
- const context = useContext(SelectActionsContext);
21
- if (!context) {
22
- throw new Error('Select components must be used within a Select');
23
- }
24
- return context;
25
- }
26
- /**
27
- * Access reactive state: open, value, activeIndex, orderedOptions.
28
- * Subscribing causes rerender when these values change.
29
- */
30
- export function useSelectState() {
31
- const context = useContext(SelectStateContext);
9
+ export function useSelectContext() {
10
+ const context = useContext(SelectContext);
32
11
  if (!context) {
33
12
  throw new Error('Select components must be used within a Select');
34
13
  }
35
14
  return context;
36
15
  }
37
- /**
38
- * Combined hook for components that need both.
39
- * Convenience for Trigger/Content that need everything.
40
- */
41
- export function useSelectContext() {
42
- const actions = useSelectActions();
43
- const state = useSelectState();
44
- return { ...actions, ...state };
45
- }
46
16
  // =============================================================================
47
17
  // Content Context (for Option registration)
48
18
  // =============================================================================
@@ -1,4 +1,4 @@
1
- import type { Placement, FloatingContext, ReferenceType } from '@floating-ui/react';
1
+ import type { Placement, ReferenceType } from '@floating-ui/react';
2
2
  import type { RefObject, CSSProperties, MutableRefObject } from 'react';
3
3
  import type { SizeStandard } from '../../types/sizes';
4
4
  import type { OptionValue } from '../../utils/value-key';
@@ -92,8 +92,14 @@ export type SelectProps = {
92
92
  };
93
93
  export type SelectTriggerProps = {
94
94
  /**
95
- * When true, merges props onto the child element instead of wrapping.
96
- * Child must be a single React element that accepts ref and event handlers.
95
+ * When true, merges props onto the child element instead of rendering
96
+ * the default trigger button. Child must be a single React element that
97
+ * accepts ref, ARIA attributes, and event handlers (onClick, onKeyDown,
98
+ * onBlur, onMouseDown, onPointerDown).
99
+ *
100
+ * Note: `aria-label` and `aria-labelledby` from Select Root are spread
101
+ * onto the child but may be overridden by the child's own props.
102
+ * Ensure the child does not silently replace these if the Root provides them.
97
103
  * @default false
98
104
  */
99
105
  asChild?: boolean;
@@ -156,10 +162,10 @@ export type RegisteredOption = {
156
162
  textValue: string;
157
163
  };
158
164
  /**
159
- * Stable context: config, IDs, refs, and stable callbacks.
160
- * Rarely changes safe to subscribe without rerender concerns.
165
+ * Select context: config, IDs, refs, callbacks, and reactive state.
166
+ * All sub-components subscribe to one context.
161
167
  */
162
- export type SelectActionsContextValue = {
168
+ export type SelectContextValue = {
163
169
  disabled: boolean;
164
170
  placeholder: string;
165
171
  clearable: boolean;
@@ -181,34 +187,28 @@ export type SelectActionsContextValue = {
181
187
  setFloating: (node: HTMLElement | null) => void;
182
188
  };
183
189
  listRef: MutableRefObject<(HTMLElement | null)[]>;
190
+ /** Guard flag — prevents Floating UI from opening dropdown during a clear */
191
+ justClearedRef: MutableRefObject<boolean>;
184
192
  getReferenceProps: (userProps?: React.HTMLProps<Element>) => Record<string, unknown>;
185
193
  getFloatingProps: (userProps?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
186
194
  getItemProps: (userProps?: React.HTMLProps<HTMLElement>) => Record<string, unknown>;
187
- };
188
- /**
189
- * State context: values that change during interaction.
190
- * Subscribe only when you need reactive updates.
191
- */
192
- export type SelectStateContextValue = {
193
195
  open: boolean;
194
196
  value: OptionValue | undefined;
195
- getSelectedTextValue: () => string | undefined;
197
+ displayText: string | undefined;
196
198
  activeIndex: number | null;
197
199
  highlightedValue: OptionValue | null;
198
200
  orderedOptions: RegisteredOption[];
201
+ /** O(1) index lookup: toKey(value) → index in orderedOptions */
202
+ optionIndexMap: Map<string, number>;
199
203
  floatingStyles: CSSProperties;
200
- floatingContext: FloatingContext;
201
204
  };
202
- /**
203
- * Combined context value (for backwards compat and convenience hooks).
204
- * @deprecated Prefer using useSelectActions + useSelectState separately.
205
- */
206
- export type SelectContextValue = SelectActionsContextValue & SelectStateContextValue;
207
205
  export type SelectContentContextValue = {
208
206
  listRef: MutableRefObject<(HTMLElement | null)[]>;
209
207
  activeIndex: number | null;
210
208
  handleSelect: (index: number | null) => void;
211
209
  orderedOptions: RegisteredOption[];
210
+ /** O(1) index lookup: toKey(value) → index in orderedOptions */
211
+ optionIndexMap: Map<string, number>;
212
212
  };
213
213
  /**
214
214
  * Convert side + align to Floating UI placement.