@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.
- package/components/Accordion/Accordion.js +11 -3
- package/components/Avatar/Avatar.d.ts +1 -1
- package/components/Avatar/Avatar.js +5 -4
- package/components/Avatar/AvatarGroup.js +7 -5
- package/components/Avatar/index.d.ts +2 -2
- package/components/Avatar/index.js +1 -1
- package/components/Avatar/types.d.ts +27 -0
- package/components/Avatar/types.js +8 -0
- package/components/Button/Button.js +4 -2
- package/components/Button/index.d.ts +2 -1
- package/components/Button/index.js +1 -0
- package/components/Button/types.d.ts +10 -0
- package/components/Button/types.js +3 -1
- package/components/Checkbox/Checkbox.js +46 -11
- package/components/Checkbox/types.d.ts +9 -0
- package/components/Combobox/Combobox.d.ts +1 -1
- package/components/Combobox/Combobox.js +50 -7
- package/components/Combobox/index.d.ts +2 -1
- package/components/Combobox/index.js +1 -0
- package/components/Combobox/types.d.ts +9 -0
- package/components/Combobox/types.js +3 -1
- package/components/Dropdown/Dropdown.d.ts +1 -1
- package/components/Dropdown/Dropdown.js +32 -12
- package/components/Field/Field.d.ts +4 -1
- package/components/Field/Field.js +35 -14
- package/components/Field/FieldContext.d.ts +16 -0
- package/components/Field/FieldContext.js +3 -0
- package/components/Field/index.d.ts +2 -1
- package/components/Field/index.js +1 -0
- package/components/Icon/Icon.d.ts +1 -1
- package/components/Icon/Icon.js +2 -2
- package/components/Modal/Modal.d.ts +5 -1
- package/components/Modal/Modal.js +2 -2
- package/components/MoveHandle/MoveHandle.d.ts +1 -1
- package/components/MoveHandle/MoveHandle.js +4 -4
- package/components/MoveHandle/types.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.js +58 -19
- package/components/MultiSelect/index.d.ts +2 -1
- package/components/MultiSelect/index.js +1 -0
- package/components/MultiSelect/types.d.ts +34 -0
- package/components/MultiSelect/types.js +10 -0
- package/components/Pager/Pager.d.ts +7 -1
- package/components/Pager/Pager.js +7 -5
- package/components/Pager/index.d.ts +2 -0
- package/components/Pager/index.js +1 -0
- package/components/Pager/types.d.ts +37 -0
- package/components/Pager/types.js +12 -0
- package/components/Progress/Progress.d.ts +2 -1
- package/components/Progress/Progress.js +3 -3
- package/components/Rating/Rating.d.ts +2 -32
- package/components/Rating/Rating.js +5 -3
- package/components/Rating/index.d.ts +2 -1
- package/components/Rating/index.js +1 -0
- package/components/Rating/types.d.ts +41 -0
- package/components/Rating/types.js +4 -0
- package/components/SegmentedControl/SegmentedControl.js +6 -5
- package/components/SegmentedControl/types.d.ts +17 -5
- package/components/Select/Select.d.ts +1 -0
- package/components/Select/Select.js +131 -77
- package/components/Select/SelectContext.d.ts +4 -16
- package/components/Select/SelectContext.js +5 -35
- package/components/Select/types.d.ts +19 -19
- package/components/Sidebar/Sidebar.js +25 -20
- package/components/StepIndicator/StepIndicator.d.ts +1 -1
- package/components/StepIndicator/StepIndicator.js +14 -10
- package/components/StepIndicator/index.d.ts +2 -1
- package/components/StepIndicator/index.js +1 -0
- package/components/StepIndicator/types.d.ts +18 -0
- package/components/StepIndicator/types.js +7 -1
- package/components/Table/BulkActionsBar.d.ts +4 -1
- package/components/Table/BulkActionsBar.js +5 -4
- package/components/Table/DataTable.d.ts +4 -1
- package/components/Table/DataTable.js +10 -8
- package/components/Table/index.d.ts +3 -0
- package/components/Table/index.js +2 -0
- package/components/Table/types.d.ts +20 -0
- package/components/Table/types.js +11 -0
- package/components/Tabs/Tabs.js +11 -4
- package/components/TextInput/TextInput.js +2 -1
- package/components/TextInput/types.d.ts +7 -1
- package/components/Textarea/Textarea.js +3 -2
- package/components/Textarea/types.d.ts +6 -1
- package/components/Tooltip/Tooltip.d.ts +1 -1
- package/components/Tooltip/Tooltip.js +16 -10
- package/icons/icons.svg +29 -15
- package/icons/lms/index.d.ts +8 -0
- package/icons/lms/index.js +48 -4
- package/icons/manifest.json +112 -0
- package/icons/player/index.js +9 -9
- package/icons/registry.d.ts +28 -0
- package/icons/registry.js +14 -0
- package/icons/system/index.d.ts +20 -0
- package/icons/system/index.js +112 -2
- package/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +266 -59
- package/styles/all.expanded.unlayered.css +266 -59
- package/styles/all.unlayered.css +1 -1
- package/styles/components/input/index.scss +29 -7
- package/styles/system/_constants.scss +1 -1
- package/styles/system/_tokens.scss +1 -0
- 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 {
|
|
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 (
|
|
32
|
-
const prevValue = useRef(value);
|
|
34
|
+
// Sync displayText when controlled value changes externally (parent prop update)
|
|
33
35
|
useEffect(() => {
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
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
|
-
}, [
|
|
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:
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
218
|
-
// Config
|
|
238
|
+
const contextValue = useMemo(() => ({
|
|
239
|
+
// Config
|
|
219
240
|
disabled,
|
|
220
241
|
placeholder,
|
|
221
242
|
clearable,
|
|
222
243
|
size,
|
|
223
|
-
// ARIA IDs
|
|
244
|
+
// ARIA IDs
|
|
224
245
|
triggerId,
|
|
225
246
|
listboxId,
|
|
226
247
|
ariaLabel,
|
|
227
248
|
ariaLabelledBy,
|
|
228
249
|
ariaDescribedBy,
|
|
229
|
-
//
|
|
250
|
+
// Callbacks
|
|
230
251
|
setOpen,
|
|
231
252
|
setValue,
|
|
232
253
|
registerOption,
|
|
233
254
|
unregisterOption,
|
|
234
255
|
handleSelect,
|
|
235
|
-
// Refs
|
|
256
|
+
// Refs
|
|
236
257
|
refs,
|
|
237
258
|
listRef,
|
|
238
|
-
|
|
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
|
-
|
|
274
|
-
getSelectedTextValue,
|
|
275
|
-
// Navigation state
|
|
295
|
+
displayText,
|
|
276
296
|
activeIndex,
|
|
277
297
|
highlightedValue,
|
|
278
|
-
// Derived (changes when registry changes)
|
|
279
298
|
orderedOptions,
|
|
280
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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,
|
|
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
|
-
//
|
|
449
|
-
const index =
|
|
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
|
-
|
|
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 {
|
|
2
|
-
export declare const
|
|
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
|
|
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
|
|
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
|
-
//
|
|
3
|
+
// Select Context
|
|
4
4
|
// =============================================================================
|
|
5
|
-
|
|
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
|
|
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
|
|
20
|
-
const context = useContext(
|
|
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,
|
|
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
|
|
96
|
-
* Child must be a single React element that
|
|
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
|
-
*
|
|
160
|
-
*
|
|
165
|
+
* Select context: config, IDs, refs, callbacks, and reactive state.
|
|
166
|
+
* All sub-components subscribe to one context.
|
|
161
167
|
*/
|
|
162
|
-
export type
|
|
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
|
-
|
|
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.
|