@tangible/ui 0.0.7 → 0.0.8

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 (91) hide show
  1. package/components/Accordion/Accordion.js +11 -3
  2. package/components/Avatar/Avatar.js +4 -3
  3. package/components/Avatar/AvatarGroup.js +7 -5
  4. package/components/Avatar/index.d.ts +2 -2
  5. package/components/Avatar/index.js +1 -1
  6. package/components/Avatar/types.d.ts +27 -0
  7. package/components/Avatar/types.js +8 -0
  8. package/components/Button/Button.js +4 -2
  9. package/components/Button/index.d.ts +2 -1
  10. package/components/Button/index.js +1 -0
  11. package/components/Button/types.d.ts +10 -0
  12. package/components/Button/types.js +3 -1
  13. package/components/Checkbox/Checkbox.js +46 -11
  14. package/components/Checkbox/types.d.ts +9 -0
  15. package/components/Combobox/Combobox.d.ts +1 -1
  16. package/components/Combobox/Combobox.js +28 -7
  17. package/components/Combobox/index.d.ts +2 -1
  18. package/components/Combobox/index.js +1 -0
  19. package/components/Combobox/types.d.ts +9 -0
  20. package/components/Combobox/types.js +3 -1
  21. package/components/Dropdown/Dropdown.js +16 -4
  22. package/components/Field/Field.d.ts +4 -1
  23. package/components/Field/Field.js +35 -14
  24. package/components/Field/FieldContext.d.ts +16 -0
  25. package/components/Field/FieldContext.js +3 -0
  26. package/components/Field/index.d.ts +2 -1
  27. package/components/Field/index.js +1 -0
  28. package/components/MoveHandle/MoveHandle.js +1 -1
  29. package/components/MoveHandle/types.d.ts +1 -1
  30. package/components/MultiSelect/MultiSelect.d.ts +1 -1
  31. package/components/MultiSelect/MultiSelect.js +37 -19
  32. package/components/MultiSelect/index.d.ts +2 -1
  33. package/components/MultiSelect/index.js +1 -0
  34. package/components/MultiSelect/types.d.ts +34 -0
  35. package/components/MultiSelect/types.js +10 -0
  36. package/components/Pager/Pager.d.ts +7 -1
  37. package/components/Pager/Pager.js +7 -5
  38. package/components/Pager/index.d.ts +2 -0
  39. package/components/Pager/index.js +1 -0
  40. package/components/Pager/types.d.ts +37 -0
  41. package/components/Pager/types.js +12 -0
  42. package/components/Rating/Rating.d.ts +2 -32
  43. package/components/Rating/Rating.js +5 -3
  44. package/components/Rating/index.d.ts +2 -1
  45. package/components/Rating/index.js +1 -0
  46. package/components/Rating/types.d.ts +41 -0
  47. package/components/Rating/types.js +4 -0
  48. package/components/SegmentedControl/SegmentedControl.js +6 -5
  49. package/components/SegmentedControl/types.d.ts +17 -5
  50. package/components/Select/Select.d.ts +1 -0
  51. package/components/Select/Select.js +109 -77
  52. package/components/Select/SelectContext.d.ts +4 -16
  53. package/components/Select/SelectContext.js +5 -35
  54. package/components/Select/types.d.ts +19 -19
  55. package/components/Sidebar/Sidebar.js +25 -20
  56. package/components/StepIndicator/StepIndicator.js +11 -8
  57. package/components/StepIndicator/index.d.ts +2 -1
  58. package/components/StepIndicator/index.js +1 -0
  59. package/components/StepIndicator/types.d.ts +18 -0
  60. package/components/StepIndicator/types.js +7 -1
  61. package/components/Table/BulkActionsBar.d.ts +4 -1
  62. package/components/Table/BulkActionsBar.js +5 -4
  63. package/components/Table/DataTable.d.ts +4 -1
  64. package/components/Table/DataTable.js +10 -8
  65. package/components/Table/index.d.ts +3 -0
  66. package/components/Table/index.js +2 -0
  67. package/components/Table/types.d.ts +20 -0
  68. package/components/Table/types.js +11 -0
  69. package/components/Tabs/Tabs.js +11 -4
  70. package/components/TextInput/TextInput.js +2 -1
  71. package/components/TextInput/types.d.ts +7 -1
  72. package/components/Textarea/Textarea.js +3 -2
  73. package/components/Textarea/types.d.ts +6 -1
  74. package/icons/icons.svg +29 -15
  75. package/icons/lms/index.d.ts +8 -0
  76. package/icons/lms/index.js +48 -4
  77. package/icons/manifest.json +112 -0
  78. package/icons/player/index.js +9 -9
  79. package/icons/registry.d.ts +28 -0
  80. package/icons/registry.js +14 -0
  81. package/icons/system/index.d.ts +20 -0
  82. package/icons/system/index.js +112 -2
  83. package/package.json +1 -1
  84. package/styles/all.css +1 -1
  85. package/styles/all.expanded.css +266 -59
  86. package/styles/all.expanded.unlayered.css +266 -59
  87. package/styles/all.unlayered.css +1 -1
  88. package/styles/components/input/index.scss +29 -7
  89. package/styles/system/_constants.scss +1 -1
  90. package/styles/system/_tokens.scss +1 -0
  91. package/tui-manifest.json +73 -44
@@ -0,0 +1,41 @@
1
+ import type { SizeStandard, Theme as ThemeFull } from '../../types';
2
+ export type Size = SizeStandard;
3
+ export type Theme = ThemeFull;
4
+ export type RatingLabels = {
5
+ /** Group label for the radiogroup/img. Receives current value and max. */
6
+ rating?: (value: number, max: number) => string;
7
+ /** Visually-hidden label for each star. Receives star number and max. */
8
+ value?: (n: number, max: number) => string;
9
+ };
10
+ export declare const defaultRatingLabels: Required<RatingLabels>;
11
+ export type RatingProps = {
12
+ /** Controlled value (1..max). Use with onValueChange */
13
+ value?: number;
14
+ /** Uncontrolled initial value */
15
+ defaultValue?: number;
16
+ /** Maximum icons shown */
17
+ max?: number;
18
+ /** Disable interaction (keeps semantics) */
19
+ disabled?: boolean;
20
+ /** Presentational readOnly (no form semantics) */
21
+ readOnly?: boolean;
22
+ /** Name for the radio group (if you care about form posts) */
23
+ name?: string;
24
+ /** Size maps to icon + spacing */
25
+ size?: Size;
26
+ /** Theme feeds foreground color tokens */
27
+ theme?: Theme;
28
+ /** Called when the value changes */
29
+ onValueChange?: (value: number) => void;
30
+ /** Allow clicking the current selection to clear back to 0 */
31
+ allowClear?: boolean;
32
+ className?: string;
33
+ /** Gap override (e.g. '0.25rem') – otherwise uses density utilities */
34
+ gap?: string;
35
+ /** Accessible label for the rating group. Defaults to "Rating: X of Y" */
36
+ 'aria-label'?: string;
37
+ /**
38
+ * Override default English strings for i18n.
39
+ */
40
+ labels?: RatingLabels;
41
+ };
@@ -0,0 +1,4 @@
1
+ export const defaultRatingLabels = {
2
+ rating: (value, max) => `Rating: ${value} of ${max}`,
3
+ value: (n, max) => `${n} of ${max}`,
4
+ };
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { isDev } from '../../utils/is-dev.js';
5
5
  import { toKey } from '../../utils/value-key.js';
@@ -8,9 +8,10 @@ import { SegmentedControlContext, useSegmentedControlContext } from './Segmented
8
8
  // =============================================================================
9
9
  // SegmentedControl Root
10
10
  // =============================================================================
11
- function SegmentedControlRoot({ value: controlledValue, defaultValue, onValueChange, variant = 'pill', size = 'md', orientation = 'horizontal', loop = true, disabled = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }) {
11
+ function SegmentedControlRoot({ value: controlledValue, defaultValue, onValueChange, variant = 'pill', size = 'md', orientation = 'horizontal', loop = true, wrap = false, disabled = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }) {
12
12
  const [internalValue, setInternalValue] = useState(defaultValue);
13
- const isControlled = controlledValue !== undefined;
13
+ // Lock controlled/uncontrolled decision at mount to prevent mode switching
14
+ const isControlled = useRef(controlledValue !== undefined).current;
14
15
  const selectedValue = isControlled ? controlledValue : internalValue;
15
16
  // Selection handler
16
17
  const onSelect = useCallback((newValue) => {
@@ -28,7 +29,7 @@ function SegmentedControlRoot({ value: controlledValue, defaultValue, onValueCha
28
29
  disabled,
29
30
  loop,
30
31
  orientation,
31
- orientationKeyboard: true,
32
+ orientationKeyboard: false,
32
33
  });
33
34
  // Dev-only: Warn if missing accessible name
34
35
  useEffect(() => {
@@ -60,7 +61,7 @@ function SegmentedControlRoot({ value: controlledValue, defaultValue, onValueCha
60
61
  unregisterItem,
61
62
  onSelect,
62
63
  ]);
63
- return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: _jsx("div", { role: "radiogroup", className: cx('tui-segmented', `is-variant-${variant}`, `is-size-${size}`, orientation === 'vertical' && 'is-vertical', className), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-orientation": orientation, onKeyDown: handleKeyDown, children: children }) }));
64
+ return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: _jsx("div", { role: "radiogroup", className: cx('tui-segmented', `is-variant-${variant}`, `is-size-${size}`, orientation === 'vertical' && 'is-vertical', wrap && 'is-wrap', className), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-orientation": orientation, onKeyDown: handleKeyDown, children: children }) }));
64
65
  }
65
66
  // =============================================================================
66
67
  // SegmentedControl.Item
@@ -1,8 +1,9 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { RovingItemRecord } from '../../utils/use-roving-group';
3
+ import type { SizeStandard } from '../../types/sizes';
3
4
  export type SegmentedControlValue = string | number;
4
5
  export type SegmentedControlVariant = 'pill' | 'outline' | 'underline';
5
- export type SegmentedControlSize = 'sm' | 'md';
6
+ export type SegmentedControlSize = SizeStandard;
6
7
  export type SegmentedControlOrientation = 'horizontal' | 'vertical';
7
8
  export type SegmentedControlProps = {
8
9
  /** Controlled selected value */
@@ -19,11 +20,19 @@ export type SegmentedControlProps = {
19
20
  orientation?: SegmentedControlOrientation;
20
21
  /** Whether arrow keys wrap around */
21
22
  loop?: boolean;
23
+ /** Allow items to wrap to multiple lines (default: false) */
24
+ wrap?: boolean;
22
25
  /** Disable all items */
23
26
  disabled?: boolean;
24
- /** Accessible label */
27
+ /**
28
+ * Accessible label for the radiogroup.
29
+ * At least one of `aria-label` or `aria-labelledby` is required.
30
+ */
25
31
  'aria-label'?: string;
26
- /** ID of element that labels this control */
32
+ /**
33
+ * ID of element that labels this control.
34
+ * At least one of `aria-label` or `aria-labelledby` is required.
35
+ */
27
36
  'aria-labelledby'?: string;
28
37
  /** Additional classes */
29
38
  className?: string;
@@ -34,9 +43,12 @@ export type SegmentedControlItemProps = {
34
43
  value: SegmentedControlValue;
35
44
  /** Disable this item */
36
45
  disabled?: boolean;
37
- /** Icon element */
46
+ /**
47
+ * Icon element rendered before the label.
48
+ * @remarks Icon-only items (no `children`) must provide `aria-label`.
49
+ */
38
50
  icon?: ReactNode;
39
- /** Accessible label for icon-only items */
51
+ /** Accessible label — required for icon-only items */
40
52
  'aria-label'?: string;
41
53
  /** Additional classes */
42
54
  className?: string;
@@ -37,3 +37,4 @@ export declare const SelectOption: typeof SelectOptionComponent;
37
37
  export declare const SelectGroup: typeof SelectGroupComponent;
38
38
  export declare const SelectLabel: typeof SelectLabelComponent;
39
39
  export { useSelectContext as useSelect } from './SelectContext';
40
+ export type { SelectContextValue } from './types';
@@ -6,15 +6,16 @@ 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);
@@ -28,20 +29,16 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
28
29
  }
29
30
  onValueChange?.(newValue);
30
31
  }, [isValueControlled, onValueChange]);
31
- // Sync displayText when value changes (handles controlled updates and clears)
32
- const prevValue = useRef(value);
32
+ // Sync displayText when controlled value changes externally (parent prop update)
33
33
  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
- }
34
+ if (value === undefined) {
35
+ setDisplayText(undefined);
36
+ }
37
+ else {
38
+ const option = optionsRef.current.get(toKey(value));
39
+ if (option) {
40
+ setDisplayText(option.textValue);
43
41
  }
44
- prevValue.current = value;
45
42
  }
46
43
  }, [value]);
47
44
  // Initialize displayText when options register (handles defaultValue case)
@@ -58,9 +55,15 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
58
55
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
59
56
  const isOpenControlled = controlledOpen !== undefined;
60
57
  const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
58
+ // Guard flag: when Backspace/Delete clears a closed Select, Floating UI's
59
+ // composed handlers (useTypeahead/useListNavigation) also fire and call
60
+ // setOpen(true). This ref rejects that open request within the same microtask.
61
+ const justClearedRef = useRef(false);
61
62
  const setOpen = useCallback((nextOpen) => {
62
63
  if (disabled)
63
64
  return;
65
+ if (nextOpen && justClearedRef.current)
66
+ return;
64
67
  if (!isOpenControlled) {
65
68
  setUncontrolledOpen(nextOpen);
66
69
  }
@@ -100,6 +103,15 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
100
103
  return options;
101
104
  // eslint-disable-next-line react-hooks/exhaustive-deps
102
105
  }, [registryVersion]);
106
+ // O(1) index lookup map — rebuilt when orderedOptions changes
107
+ const optionIndexMap = useMemo(() => {
108
+ const map = new Map();
109
+ orderedOptions.forEach((opt, i) => map.set(toKey(opt.value), i));
110
+ return map;
111
+ }, [orderedOptions]);
112
+ // Ref for stable handleSelect — avoids recreating on registry changes
113
+ const orderedOptionsRef = useRef(orderedOptions);
114
+ orderedOptionsRef.current = orderedOptions;
103
115
  // Floating UI setup - in Root so Trigger and Content can share
104
116
  const { refs, floatingStyles, context } = useFloating({
105
117
  placement,
@@ -121,16 +133,16 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
121
133
  ],
122
134
  whileElementsMounted: autoUpdate,
123
135
  });
124
- // Handle selection
136
+ // Handle selection — reads orderedOptionsRef to avoid recreating on registry changes
125
137
  const handleSelect = useCallback((index) => {
126
138
  if (index === null)
127
139
  return;
128
- const option = orderedOptions[index];
140
+ const option = orderedOptionsRef.current[index];
129
141
  if (option && !option.disabled) {
130
142
  setValue(option.value, option.textValue);
131
143
  setOpen(false);
132
144
  }
133
- }, [orderedOptions, setValue, setOpen]);
145
+ }, [setValue, setOpen]);
134
146
  // Floating UI interactions
135
147
  // Use 'click' (not 'mousedown') so button has focus when dropdown opens
136
148
  // This ensures keyboard navigation works immediately
@@ -150,7 +162,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
150
162
  loop: true,
151
163
  virtual: true,
152
164
  focusItemOnOpen: true,
153
- selectedIndex: orderedOptions.findIndex((opt) => value !== undefined && toKey(opt.value) === toKey(value)),
165
+ selectedIndex: value !== undefined ? (optionIndexMap.get(toKey(value)) ?? -1) : -1,
154
166
  disabledIndices,
155
167
  });
156
168
  const typeahead = useTypeahead(context, {
@@ -179,10 +191,6 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
179
191
  optionsRef.current.delete(toKey(optionValue));
180
192
  setRegistryVersion((v) => v + 1);
181
193
  }, []);
182
- // Get selected option's text value
183
- const getSelectedTextValue = useCallback(() => {
184
- return displayText;
185
- }, [displayText]);
186
194
  // Highlighted value for keyboard navigation
187
195
  const highlightedValue = activeIndex !== null ? orderedOptions[activeIndex]?.value ?? null : null;
188
196
  // Reset active index when closing
@@ -192,7 +200,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
192
200
  }
193
201
  else {
194
202
  // When opening, set active index to selected value or first enabled
195
- const selectedIndex = orderedOptions.findIndex((opt) => value !== undefined && toKey(opt.value) === toKey(value));
203
+ const selectedIndex = value !== undefined ? (optionIndexMap.get(toKey(value)) ?? -1) : -1;
196
204
  if (selectedIndex >= 0 && !orderedOptions[selectedIndex]?.disabled) {
197
205
  setActiveIndex(selectedIndex);
198
206
  }
@@ -201,7 +209,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
201
209
  setActiveIndex(firstEnabled >= 0 ? firstEnabled : null);
202
210
  }
203
211
  }
204
- }, [open, orderedOptions, value]);
212
+ }, [open, orderedOptions, optionIndexMap, value]);
205
213
  // Scroll active option into view
206
214
  useEffect(() => {
207
215
  if (open && activeIndex !== null && listRef.current[activeIndex]) {
@@ -209,98 +217,82 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
209
217
  }
210
218
  }, [open, activeIndex]);
211
219
  // ==========================================================================
212
- // Split Context Values
213
- // ==========================================================================
214
- // Actions: stable config, IDs, refs, callbacks — rarely changes
215
- // State: open, value, activeIndex, etc. — changes on interaction
220
+ // Context Value
216
221
  // ==========================================================================
217
- const actionsValue = useMemo(() => ({
218
- // Config (from props, stable for component lifetime)
222
+ const contextValue = useMemo(() => ({
223
+ // Config
219
224
  disabled,
220
225
  placeholder,
221
226
  clearable,
222
227
  size,
223
- // ARIA IDs (stable)
228
+ // ARIA IDs
224
229
  triggerId,
225
230
  listboxId,
226
231
  ariaLabel,
227
232
  ariaLabelledBy,
228
233
  ariaDescribedBy,
229
- // Stable callbacks
234
+ // Callbacks
230
235
  setOpen,
231
236
  setValue,
232
237
  registerOption,
233
238
  unregisterOption,
234
239
  handleSelect,
235
- // Refs (stable objects)
240
+ // Refs
236
241
  refs,
237
242
  listRef,
238
- // Floating UI interaction props (stable functions from useInteractions)
243
+ justClearedRef,
244
+ // Floating UI interaction props
239
245
  getReferenceProps,
240
246
  getFloatingProps,
241
247
  getItemProps,
248
+ // State
249
+ open,
250
+ value,
251
+ displayText,
252
+ activeIndex,
253
+ highlightedValue,
254
+ orderedOptions,
255
+ optionIndexMap,
256
+ floatingStyles,
242
257
  }), [
243
- // Config props - only change if parent rerenders with new props
244
258
  disabled,
245
259
  placeholder,
246
260
  clearable,
247
261
  size,
248
- // IDs are stable (from useId)
249
262
  triggerId,
250
263
  listboxId,
251
264
  ariaLabel,
252
265
  ariaLabelledBy,
253
266
  ariaDescribedBy,
254
- // Callbacks are stable (useCallback with stable deps)
255
267
  setOpen,
256
268
  setValue,
257
269
  registerOption,
258
270
  unregisterOption,
259
271
  handleSelect,
260
- // Refs are stable objects
261
272
  refs,
262
273
  listRef,
263
- // Interaction props from useInteractions (stable)
264
274
  getReferenceProps,
265
275
  getFloatingProps,
266
276
  getItemProps,
267
- ]);
268
- const stateValue = useMemo(() => ({
269
- // Open state
270
- open,
271
- // Selection state
272
- value,
273
- // Selection helper
274
- getSelectedTextValue,
275
- // Navigation state
276
- activeIndex,
277
- highlightedValue,
278
- // Derived (changes when registry changes)
279
- orderedOptions,
280
- // Floating UI (changes on open/position)
281
- floatingStyles,
282
- floatingContext: context,
283
- }), [
284
277
  open,
285
278
  value,
286
- getSelectedTextValue,
279
+ displayText,
287
280
  activeIndex,
288
281
  highlightedValue,
289
282
  orderedOptions,
283
+ optionIndexMap,
290
284
  floatingStyles,
291
- context,
292
285
  ]);
293
- return (_jsx(SelectActionsContext.Provider, { value: actionsValue, children: _jsx(SelectStateContext.Provider, { value: stateValue, children: children }) }));
286
+ return (_jsx(SelectContext.Provider, { value: contextValue, children: children }));
294
287
  }
295
288
  SelectRoot.displayName = 'Select';
296
289
  // =============================================================================
297
290
  // Select.Trigger
298
291
  // =============================================================================
299
292
  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();
293
+ const { open, setOpen, disabled, placeholder, clearable, size, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, displayText, setValue, refs, getReferenceProps, activeIndex, handleSelect, justClearedRef, } = useSelectContext();
301
294
  const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
302
- const displayValue = getSelectedTextValue();
303
- const hasValue = displayValue !== undefined;
295
+ const hasValue = displayText !== undefined;
304
296
  // Ensure trigger has focus when dropdown opens (Safari doesn't focus buttons on click)
305
297
  useEffect(() => {
306
298
  if (open && refs.reference.current) {
@@ -315,9 +307,11 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
315
307
  }
316
308
  if (!open && clearable && hasValue && (e.key === 'Delete' || e.key === 'Backspace')) {
317
309
  e.preventDefault();
310
+ justClearedRef.current = true;
311
+ queueMicrotask(() => { justClearedRef.current = false; });
318
312
  setValue(undefined);
319
313
  }
320
- }, [open, activeIndex, handleSelect, clearable, hasValue, setValue]);
314
+ }, [open, activeIndex, handleSelect, clearable, hasValue, setValue, justClearedRef]);
321
315
  // Close dropdown when focus leaves trigger
322
316
  // Uses blur guard pattern: only close if focus moved outside controlled elements
323
317
  const handleBlur = useCallback((e) => {
@@ -344,7 +338,9 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
344
338
  if (disabled)
345
339
  return;
346
340
  setValue(undefined);
347
- }, [disabled, setValue]);
341
+ // Return focus to trigger after clearing
342
+ refs.reference.current?.focus();
343
+ }, [disabled, setValue, refs.reference]);
348
344
  // Get Floating UI's reference props - pass our handlers so they get composed
349
345
  const floatingProps = getReferenceProps({
350
346
  'aria-activedescendant': activeIndex !== null ? `${listboxId}-option-${activeIndex}` : undefined,
@@ -356,9 +352,8 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
356
352
  'aria-label': ariaLabel,
357
353
  'aria-labelledby': ariaLabelledBy,
358
354
  'aria-describedby': ariaDescribedBy,
359
- 'aria-disabled': disabled || undefined,
355
+ 'aria-keyshortcuts': clearable && hasValue ? 'Delete' : undefined,
360
356
  'data-state': open ? 'open' : 'closed',
361
- 'data-disabled': disabled || undefined,
362
357
  ...floatingProps,
363
358
  };
364
359
  if (asChild && isValidElement(children)) {
@@ -390,6 +385,7 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
390
385
  'aria-controls': floatingProps['aria-controls'],
391
386
  'aria-activedescendant': floatingProps['aria-activedescendant'],
392
387
  'aria-describedby': ariaDescribedBy,
388
+ // asChild: use aria-disabled + data-disabled since element may not support native disabled
393
389
  'aria-disabled': disabled || undefined,
394
390
  'data-state': open ? 'open' : 'closed',
395
391
  'data-disabled': disabled || undefined,
@@ -402,14 +398,18 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
402
398
  onPointerDown: composeHandler(triggerProps.onPointerDown, childProps.onPointerDown),
403
399
  });
404
400
  }
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" })] }));
401
+ const showClear = clearable && hasValue && !disabled;
402
+ // Wrap in a relative container so the clear button can be a sibling of the
403
+ // trigger <button> — nesting <button> inside <button> is invalid HTML and
404
+ // causes browsers to eject the inner button via the adoption agency algorithm.
405
+ 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
406
  }
407
407
  SelectTriggerComponent.displayName = 'Select.Trigger';
408
408
  // =============================================================================
409
409
  // Select.Content
410
410
  // =============================================================================
411
411
  function SelectContentComponent({ className, children, }) {
412
- const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, handleSelect, orderedOptions, } = useSelectContext();
412
+ const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, handleSelect, orderedOptions, optionIndexMap, } = useSelectContext();
413
413
  const portalRoot = getPortalRootFor(refs.reference.current);
414
414
  // Memoized context for options
415
415
  const contentContext = useMemo(() => ({
@@ -417,8 +417,9 @@ function SelectContentComponent({ className, children, }) {
417
417
  activeIndex,
418
418
  handleSelect,
419
419
  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: {
420
+ optionIndexMap,
421
+ }), [listRef, activeIndex, handleSelect, orderedOptions, optionIndexMap]);
422
+ return (_jsxs(_Fragment, { children: [!open && (_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
423
  ...floatingStyles,
423
424
  minWidth: refs.reference.current?.offsetWidth,
424
425
  pointerEvents: 'auto',
@@ -430,13 +431,15 @@ SelectContentComponent.displayName = 'Select.Content';
430
431
  // =============================================================================
431
432
  function SelectOptionComponent({ value: optionValue, disabled = false, textValue: explicitTextValue, className, children, }) {
432
433
  const { value: selectedValue, setValue, setOpen, listboxId, registerOption, unregisterOption, highlightedValue, getItemProps, } = useSelectContext();
433
- const { listRef, orderedOptions } = useSelectContentContext();
434
+ const { listRef, optionIndexMap } = useSelectContentContext();
434
435
  const ref = useRef(null);
435
436
  // Derive textValue from children if not explicitly provided
436
437
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
437
- // Warn in dev if textValue couldn't be derived
438
+ // Warn in dev if textValue couldn't be derived (once per mount)
439
+ const warnedTextValueRef = useRef(false);
438
440
  useEffect(() => {
439
- if (isDev() && !textValue) {
441
+ if (isDev() && !textValue && !warnedTextValueRef.current) {
442
+ warnedTextValueRef.current = true;
440
443
  console.warn(`Select.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
441
444
  }
442
445
  }, [textValue, optionValue]);
@@ -445,8 +448,8 @@ function SelectOptionComponent({ value: optionValue, disabled = false, textValue
445
448
  registerOption({ value: optionValue, ref, disabled, textValue });
446
449
  return () => unregisterOption(optionValue);
447
450
  }, [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));
451
+ // O(1) index lookup via Map
452
+ const index = optionIndexMap.get(toKey(optionValue)) ?? -1;
450
453
  // Assign ref to listRef for navigation
451
454
  useEffect(() => {
452
455
  if (index >= 0) {
@@ -471,7 +474,27 @@ SelectOptionComponent.displayName = 'Select.Option';
471
474
  // =============================================================================
472
475
  function SelectGroupComponent({ className, children }) {
473
476
  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 }) }));
477
+ const groupRef = useRef(null);
478
+ // Guard aria-labelledby: only set when a Label child actually exists in the DOM.
479
+ // Without this, aria-labelledby points to a non-existent ID, which per AccName 1.2
480
+ // step 2B overrides all other name sources and resolves to empty string.
481
+ // useLayoutEffect runs before paint, so AT never sees the dangling reference.
482
+ // groupId is stable (from useId) and label presence is stable after mount —
483
+ // only run once to avoid a DOM query on every render.
484
+ useLayoutEffect(() => {
485
+ const groupEl = groupRef.current;
486
+ if (!groupEl)
487
+ return;
488
+ const labelId = `${groupId}-label`;
489
+ const labelEl = groupEl.querySelector(`#${CSS.escape(labelId)}`);
490
+ if (labelEl) {
491
+ groupEl.setAttribute('aria-labelledby', labelId);
492
+ }
493
+ else {
494
+ groupEl.removeAttribute('aria-labelledby');
495
+ }
496
+ }, [groupId]);
497
+ return (_jsx("div", { ref: groupRef, role: "group", className: cx('tui-select__group', className), children: _jsx(SelectGroupContext.Provider, { value: { groupId }, children: children }) }));
475
498
  }
476
499
  SelectGroupComponent.displayName = 'Select.Group';
477
500
  const SelectGroupContext = React.createContext(null);
@@ -480,6 +503,15 @@ const SelectGroupContext = React.createContext(null);
480
503
  // =============================================================================
481
504
  function SelectLabelComponent({ className, children }) {
482
505
  const groupContext = React.useContext(SelectGroupContext);
506
+ // DEV: warn if Label is used outside a Group — it has no semantic effect there
507
+ const warnedRef = useRef(false);
508
+ useEffect(() => {
509
+ if (isDev() && !groupContext && !warnedRef.current) {
510
+ warnedRef.current = true;
511
+ console.warn('Select.Label rendered outside Select.Group has no effect. ' +
512
+ 'Wrap in Select.Group so aria-labelledby is wired correctly.');
513
+ }
514
+ }, [groupContext]);
483
515
  // No aria-hidden — aria-labelledby on Group references this element,
484
516
  // so screen readers need access to read the label text.
485
517
  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
  // =============================================================================