@tangible/ui 0.0.6 → 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.
- package/components/Accordion/Accordion.js +11 -3
- package/components/Avatar/Avatar.js +4 -3
- 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 +28 -7
- package/components/Combobox/index.d.ts +2 -1
- package/components/Combobox/index.js +1 -0
- package/components/Combobox/types.d.ts +14 -0
- package/components/Combobox/types.js +3 -1
- package/components/Dropdown/Dropdown.js +16 -4
- package/components/Field/Field.d.ts +4 -1
- package/components/Field/Field.js +38 -7
- package/components/Field/FieldContext.d.ts +18 -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/MoveHandle/MoveHandle.js +1 -1
- package/components/MoveHandle/types.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.js +37 -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/Radio/Radio.d.ts +4 -0
- package/components/Radio/Radio.js +15 -5
- package/components/Radio/RadioGroup.d.ts +1 -1
- package/components/Radio/RadioGroup.js +2 -2
- package/components/Radio/types.d.ts +10 -0
- 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 +109 -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.js +11 -8
- 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/Switch/Switch.js +28 -14
- 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/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 +426 -136
- package/styles/all.expanded.unlayered.css +426 -136
- 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/styles/utilities/_index.scss +14 -4
- package/tui-manifest.json +102 -46
- package/utils/use-roving-group.js +9 -6
|
@@ -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 {
|
|
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 (
|
|
32
|
-
const prevValue = useRef(value);
|
|
32
|
+
// Sync displayText when controlled value changes externally (parent prop update)
|
|
33
33
|
useEffect(() => {
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
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
|
-
}, [
|
|
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:
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
218
|
-
// Config
|
|
222
|
+
const contextValue = useMemo(() => ({
|
|
223
|
+
// Config
|
|
219
224
|
disabled,
|
|
220
225
|
placeholder,
|
|
221
226
|
clearable,
|
|
222
227
|
size,
|
|
223
|
-
// ARIA IDs
|
|
228
|
+
// ARIA IDs
|
|
224
229
|
triggerId,
|
|
225
230
|
listboxId,
|
|
226
231
|
ariaLabel,
|
|
227
232
|
ariaLabelledBy,
|
|
228
233
|
ariaDescribedBy,
|
|
229
|
-
//
|
|
234
|
+
// Callbacks
|
|
230
235
|
setOpen,
|
|
231
236
|
setValue,
|
|
232
237
|
registerOption,
|
|
233
238
|
unregisterOption,
|
|
234
239
|
handleSelect,
|
|
235
|
-
// Refs
|
|
240
|
+
// Refs
|
|
236
241
|
refs,
|
|
237
242
|
listRef,
|
|
238
|
-
|
|
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
|
-
|
|
279
|
+
displayText,
|
|
287
280
|
activeIndex,
|
|
288
281
|
highlightedValue,
|
|
289
282
|
orderedOptions,
|
|
283
|
+
optionIndexMap,
|
|
290
284
|
floatingStyles,
|
|
291
|
-
context,
|
|
292
285
|
]);
|
|
293
|
-
return (_jsx(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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,
|
|
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
|
-
//
|
|
449
|
-
const index =
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
@@ -25,45 +25,50 @@ function SidebarRoot(props) {
|
|
|
25
25
|
}, [open]);
|
|
26
26
|
const isClosing = drawer && !open && visible;
|
|
27
27
|
const shouldRender = drawer && (open || visible);
|
|
28
|
+
// Dismiss: set visible false and restore focus to the element that opened
|
|
29
|
+
// the drawer. Called from both the reduced-motion layout effect and the
|
|
30
|
+
// animation-end handler — these are the actual unmount points, so focus
|
|
31
|
+
// must be restored here (not in a useEffect, which may not fire before
|
|
32
|
+
// the synchronous re-render unmounts the component).
|
|
33
|
+
const dismiss = useCallback(() => {
|
|
34
|
+
setVisible(false);
|
|
35
|
+
const el = restoreRef.current;
|
|
36
|
+
if (el && typeof el.focus === 'function' && document.contains(el)) {
|
|
37
|
+
el.focus();
|
|
38
|
+
}
|
|
39
|
+
restoreRef.current = null;
|
|
40
|
+
}, []);
|
|
28
41
|
// When closing, check if animation will actually play (reduced motion).
|
|
29
|
-
// If not,
|
|
42
|
+
// If not, dismiss immediately to avoid getting stuck.
|
|
30
43
|
useLayoutEffect(() => {
|
|
31
44
|
if (!isClosing)
|
|
32
45
|
return;
|
|
33
46
|
const panel = panelRef.current;
|
|
34
47
|
if (!panel) {
|
|
35
|
-
|
|
48
|
+
dismiss();
|
|
36
49
|
return;
|
|
37
50
|
}
|
|
38
51
|
const style = getComputedStyle(panel);
|
|
39
52
|
if (!style.animationName || style.animationName === 'none') {
|
|
40
|
-
|
|
53
|
+
dismiss();
|
|
41
54
|
}
|
|
42
|
-
}, [isClosing]);
|
|
55
|
+
}, [isClosing, dismiss]);
|
|
43
56
|
const handleAnimationEnd = useCallback((e) => {
|
|
44
57
|
if (e.target === panelRef.current) {
|
|
45
|
-
|
|
58
|
+
dismiss();
|
|
46
59
|
}
|
|
47
|
-
}, []);
|
|
60
|
+
}, [dismiss]);
|
|
48
61
|
const rootClassName = ['tui-sidebar', className].filter(Boolean).join(' ');
|
|
49
62
|
// ---------------------------------------------------------------------------
|
|
50
63
|
// Drawer mode: capture trigger for focus restoration
|
|
51
64
|
// ---------------------------------------------------------------------------
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
// Capture the element that had focus when the drawer opens.
|
|
66
|
+
// Must be a layout effect so it runs BEFORE the initial focus layout effect
|
|
67
|
+
// moves focus into the drawer. Focus restoration is handled by `dismiss()`.
|
|
68
|
+
useLayoutEffect(() => {
|
|
69
|
+
if (!drawer || !isBrowser || !open)
|
|
54
70
|
return;
|
|
55
|
-
|
|
56
|
-
// Capture focus target when opening
|
|
57
|
-
restoreRef.current = document.activeElement;
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
// Restore focus when closing (with DOM containment guard)
|
|
61
|
-
const el = restoreRef.current;
|
|
62
|
-
if (el && typeof el.focus === 'function' && document.contains(el)) {
|
|
63
|
-
el.focus();
|
|
64
|
-
}
|
|
65
|
-
restoreRef.current = null;
|
|
66
|
-
}
|
|
71
|
+
restoreRef.current = document.activeElement;
|
|
67
72
|
}, [drawer, open]);
|
|
68
73
|
// ---------------------------------------------------------------------------
|
|
69
74
|
// Drawer mode: body scroll lock
|