@tangible/ui 0.0.1 → 0.0.2
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/Card/Card.d.ts +1 -0
- package/components/Card/Card.js +17 -20
- package/components/Checkbox/Checkbox.d.ts +9 -0
- package/components/Checkbox/Checkbox.js +92 -0
- package/components/Checkbox/index.d.ts +2 -0
- package/components/Checkbox/index.js +1 -0
- package/components/Checkbox/types.d.ts +10 -0
- package/components/Checkbox/types.js +1 -0
- package/components/Chip/Chip.d.ts +4 -1
- package/components/Chip/Chip.js +32 -7
- package/components/ChipGroup/ChipGroup.d.ts +5 -0
- package/components/ChipGroup/ChipGroup.js +68 -0
- package/components/ChipGroup/ChipGroupContext.d.ts +3 -0
- package/components/ChipGroup/ChipGroupContext.js +5 -0
- package/components/ChipGroup/index.d.ts +3 -0
- package/components/ChipGroup/index.js +2 -0
- package/components/ChipGroup/types.d.ts +36 -0
- package/components/ChipGroup/types.js +1 -0
- package/components/Chips/Chips.d.ts +2 -0
- package/components/Chips/Chips.js +1 -1
- package/components/Combobox/Combobox.d.ts +33 -0
- package/components/Combobox/Combobox.js +466 -0
- package/components/Combobox/ComboboxContext.d.ts +8 -0
- package/components/Combobox/ComboboxContext.js +36 -0
- package/components/Combobox/index.d.ts +2 -0
- package/components/Combobox/index.js +1 -0
- package/components/Combobox/types.d.ts +204 -0
- package/components/Combobox/types.js +1 -0
- package/components/Dropdown/Dropdown.js +2 -1
- package/components/Field/Field.d.ts +39 -0
- package/components/Field/Field.js +92 -0
- package/components/Field/FieldContext.d.ts +16 -0
- package/components/Field/FieldContext.js +10 -0
- package/components/Field/index.d.ts +2 -0
- package/components/Field/index.js +1 -0
- package/components/Modal/Modal.d.ts +4 -4
- package/components/Modal/Modal.js +14 -12
- package/components/MultiSelect/MultiSelect.d.ts +39 -0
- package/components/MultiSelect/MultiSelect.js +623 -0
- package/components/MultiSelect/MultiSelectContext.d.ts +20 -0
- package/components/MultiSelect/MultiSelectContext.js +56 -0
- package/components/MultiSelect/index.d.ts +2 -0
- package/components/MultiSelect/index.js +1 -0
- package/components/MultiSelect/types.d.ts +218 -0
- package/components/MultiSelect/types.js +3 -0
- package/components/Notice/Notice.d.ts +1 -1
- package/components/Notice/Notice.js +1 -1
- package/components/Progress/Progress.js +1 -1
- package/components/Progress/types.d.ts +7 -7
- package/components/Radio/Radio.d.ts +2 -0
- package/components/Radio/Radio.js +50 -0
- package/components/Radio/RadioGroup.d.ts +2 -0
- package/components/Radio/RadioGroup.js +54 -0
- package/components/Radio/RadioGroupContext.d.ts +3 -0
- package/components/Radio/RadioGroupContext.js +9 -0
- package/components/Radio/index.d.ts +8 -0
- package/components/Radio/index.js +6 -0
- package/components/Radio/types.d.ts +32 -0
- package/components/Radio/types.js +1 -0
- package/components/Rating/Rating.d.ts +5 -5
- package/components/Rating/Rating.js +2 -2
- package/components/SegmentedControl/SegmentedControl.js +20 -104
- package/components/SegmentedControl/types.d.ts +4 -8
- package/components/Select/Select.d.ts +39 -0
- package/components/Select/Select.js +497 -0
- package/components/Select/SelectContext.d.ts +20 -0
- package/components/Select/SelectContext.js +56 -0
- package/components/Select/index.d.ts +3 -0
- package/components/Select/index.js +1 -0
- package/components/Select/types.d.ts +216 -0
- package/components/Select/types.js +11 -0
- package/components/Sidebar/Sidebar.js +12 -12
- package/components/Sidebar/types.d.ts +5 -5
- package/components/StepIndicator/StepIndicator.js +1 -1
- package/components/StepList/StepList.js +2 -2
- package/components/StepList/types.d.ts +4 -4
- package/components/Switch/Switch.d.ts +9 -0
- package/components/Switch/Switch.js +91 -0
- package/components/Switch/index.d.ts +2 -0
- package/components/Switch/index.js +1 -0
- package/components/Switch/types.d.ts +11 -0
- package/components/Switch/types.js +1 -0
- package/components/TextInput/TextInput.d.ts +8 -0
- package/components/TextInput/TextInput.js +25 -0
- package/components/TextInput/index.d.ts +2 -0
- package/components/TextInput/index.js +1 -0
- package/components/TextInput/types.d.ts +32 -0
- package/components/TextInput/types.js +1 -0
- package/components/Textarea/Textarea.d.ts +6 -0
- package/components/Textarea/Textarea.js +49 -0
- package/components/Textarea/index.d.ts +2 -0
- package/components/Textarea/index.js +1 -0
- package/components/Textarea/types.d.ts +25 -0
- package/components/Textarea/types.js +1 -0
- package/components/index.d.ts +20 -0
- package/components/index.js +10 -0
- package/icons/icons.svg +1 -0
- package/icons/manifest.json +8 -0
- package/icons/registry.d.ts +2 -0
- package/icons/registry.js +1 -0
- package/icons/system/index.d.ts +2 -0
- package/icons/system/index.js +11 -0
- package/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +1187 -96
- package/styles/all.expanded.unlayered.css +1187 -96
- package/styles/all.unlayered.css +1 -1
- package/styles/components/_bundle.scss +20 -0
- package/styles/components/input/index.scss +5 -20
- package/styles/index.scss +16 -0
- package/styles/system/_control.scss +34 -0
- package/styles/system/_tokens.scss +8 -0
- package/styles/system/index.scss +2 -1
- package/styles/utilities/_index.scss +50 -0
- package/tui-manifest.json +632 -61
- package/utils/compose-events.d.ts +15 -0
- package/utils/compose-events.js +27 -0
- package/utils/hash.d.ts +15 -0
- package/utils/hash.js +32 -0
- package/utils/index.d.ts +3 -0
- package/utils/index.js +6 -0
- package/utils/is-dev.d.ts +5 -0
- package/utils/is-dev.js +7 -0
- package/utils/use-controllable-state.d.ts +19 -0
- package/utils/use-controllable-state.js +59 -0
- package/utils/use-roving-group.d.ts +33 -0
- package/utils/use-roving-group.js +123 -0
- package/utils/value-key.d.ts +16 -0
- package/utils/value-key.js +14 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, cloneElement, isValidElement, } from 'react';
|
|
3
|
+
import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useTypeahead, useRole, useClick, } from '@floating-ui/react';
|
|
4
|
+
import { cx } from '../../utils/cx.js';
|
|
5
|
+
import { toKey } from '../../utils/value-key.js';
|
|
6
|
+
import { getPortalRootFor } from '../../utils/portal.js';
|
|
7
|
+
import { Icon } from '../Icon/index.js';
|
|
8
|
+
import { SelectActionsContext, SelectStateContext, SelectContentContext, useSelectContext, useSelectContentContext, } from './SelectContext.js';
|
|
9
|
+
import { toPlacement, } from './types.js';
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Select Root
|
|
12
|
+
// =============================================================================
|
|
13
|
+
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, }) {
|
|
14
|
+
// Controlled/uncontrolled value
|
|
15
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
16
|
+
const isValueControlled = controlledValue !== undefined;
|
|
17
|
+
const value = isValueControlled ? controlledValue : uncontrolledValue;
|
|
18
|
+
// Store display text separately so it persists when dropdown is closed
|
|
19
|
+
const [displayText, setDisplayText] = useState(undefined);
|
|
20
|
+
// Option registration
|
|
21
|
+
const optionsRef = useRef(new Map());
|
|
22
|
+
const [registryVersion, setRegistryVersion] = useState(0);
|
|
23
|
+
const setValue = useCallback((newValue, textValue) => {
|
|
24
|
+
if (!isValueControlled) {
|
|
25
|
+
setUncontrolledValue(newValue);
|
|
26
|
+
setDisplayText(newValue === undefined ? undefined : textValue);
|
|
27
|
+
}
|
|
28
|
+
onValueChange?.(newValue);
|
|
29
|
+
}, [isValueControlled, onValueChange]);
|
|
30
|
+
// Sync displayText when value changes (handles controlled updates and clears)
|
|
31
|
+
const prevValue = useRef(value);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (prevValue.current !== value) {
|
|
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);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
prevValue.current = value;
|
|
44
|
+
}
|
|
45
|
+
}, [value]);
|
|
46
|
+
// Initialize displayText when options register (handles defaultValue case)
|
|
47
|
+
// This runs when registryVersion changes, catching newly registered options
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (value !== undefined && !displayText) {
|
|
50
|
+
const option = optionsRef.current.get(toKey(value));
|
|
51
|
+
if (option) {
|
|
52
|
+
setDisplayText(option.textValue);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}, [registryVersion, value, displayText]);
|
|
56
|
+
// Controlled/uncontrolled open
|
|
57
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
|
|
58
|
+
const isOpenControlled = controlledOpen !== undefined;
|
|
59
|
+
const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
|
|
60
|
+
const setOpen = useCallback((nextOpen) => {
|
|
61
|
+
if (disabled)
|
|
62
|
+
return;
|
|
63
|
+
if (!isOpenControlled) {
|
|
64
|
+
setUncontrolledOpen(nextOpen);
|
|
65
|
+
}
|
|
66
|
+
onOpenChange?.(nextOpen);
|
|
67
|
+
}, [disabled, isOpenControlled, onOpenChange]);
|
|
68
|
+
// IDs
|
|
69
|
+
const baseId = useId();
|
|
70
|
+
const triggerId = triggerIdProp ?? `${baseId}-trigger`;
|
|
71
|
+
const listboxId = `${baseId}-listbox`;
|
|
72
|
+
// Refs for list navigation
|
|
73
|
+
const listRef = useRef([]);
|
|
74
|
+
const listContentRef = useRef([]);
|
|
75
|
+
// Active index for navigation
|
|
76
|
+
const [activeIndex, setActiveIndex] = useState(null);
|
|
77
|
+
// Compute placement from props
|
|
78
|
+
const placement = toPlacement(side, align);
|
|
79
|
+
// Get ordered options
|
|
80
|
+
const getOrderedOptions = useCallback(() => {
|
|
81
|
+
const options = Array.from(optionsRef.current.values());
|
|
82
|
+
return options.sort((a, b) => {
|
|
83
|
+
if (!a.ref.current || !b.ref.current)
|
|
84
|
+
return 0;
|
|
85
|
+
const position = a.ref.current.compareDocumentPosition(b.ref.current);
|
|
86
|
+
if (position & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
87
|
+
return -1;
|
|
88
|
+
if (position & Node.DOCUMENT_POSITION_PRECEDING)
|
|
89
|
+
return 1;
|
|
90
|
+
return 0;
|
|
91
|
+
});
|
|
92
|
+
}, []);
|
|
93
|
+
// Memoized ordered options - recompute when registry changes
|
|
94
|
+
// Also update listContentRef synchronously for typeahead
|
|
95
|
+
const orderedOptions = useMemo(() => {
|
|
96
|
+
const options = getOrderedOptions();
|
|
97
|
+
// Update typeahead ref synchronously (during render is safe for refs)
|
|
98
|
+
listContentRef.current = options.map((opt) => opt.textValue);
|
|
99
|
+
return options;
|
|
100
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
+
}, [registryVersion]);
|
|
102
|
+
// Floating UI setup - in Root so Trigger and Content can share
|
|
103
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
104
|
+
placement,
|
|
105
|
+
open,
|
|
106
|
+
onOpenChange: setOpen,
|
|
107
|
+
middleware: [
|
|
108
|
+
offset(sideOffset),
|
|
109
|
+
flip({ padding: 8 }),
|
|
110
|
+
shift({ padding: 8 }),
|
|
111
|
+
sizeMiddleware({
|
|
112
|
+
apply({ availableHeight, rects, elements }) {
|
|
113
|
+
Object.assign(elements.floating.style, {
|
|
114
|
+
maxHeight: `${Math.min(availableHeight - 16, 300)}px`,
|
|
115
|
+
minWidth: `${rects.reference.width}px`,
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
padding: 8,
|
|
119
|
+
}),
|
|
120
|
+
],
|
|
121
|
+
whileElementsMounted: autoUpdate,
|
|
122
|
+
});
|
|
123
|
+
// Handle selection
|
|
124
|
+
const handleSelect = useCallback((index) => {
|
|
125
|
+
if (index === null)
|
|
126
|
+
return;
|
|
127
|
+
const option = orderedOptions[index];
|
|
128
|
+
if (option && !option.disabled) {
|
|
129
|
+
setValue(option.value, option.textValue);
|
|
130
|
+
setOpen(false);
|
|
131
|
+
}
|
|
132
|
+
}, [orderedOptions, setValue, setOpen]);
|
|
133
|
+
// Floating UI interactions
|
|
134
|
+
// Use 'click' (not 'mousedown') so button has focus when dropdown opens
|
|
135
|
+
// This ensures keyboard navigation works immediately
|
|
136
|
+
const click = useClick(context);
|
|
137
|
+
const dismiss = useDismiss(context);
|
|
138
|
+
const role = useRole(context, { role: 'listbox' });
|
|
139
|
+
// Compute disabled indices for keyboard navigation to skip
|
|
140
|
+
const disabledIndices = useMemo(() => orderedOptions.reduce((acc, opt, i) => {
|
|
141
|
+
if (opt.disabled)
|
|
142
|
+
acc.push(i);
|
|
143
|
+
return acc;
|
|
144
|
+
}, []), [orderedOptions]);
|
|
145
|
+
const listNavigation = useListNavigation(context, {
|
|
146
|
+
listRef,
|
|
147
|
+
activeIndex,
|
|
148
|
+
onNavigate: setActiveIndex,
|
|
149
|
+
loop: true,
|
|
150
|
+
virtual: true,
|
|
151
|
+
focusItemOnOpen: true,
|
|
152
|
+
selectedIndex: orderedOptions.findIndex((opt) => value !== undefined && toKey(opt.value) === toKey(value)),
|
|
153
|
+
disabledIndices,
|
|
154
|
+
});
|
|
155
|
+
const typeahead = useTypeahead(context, {
|
|
156
|
+
listRef: listContentRef,
|
|
157
|
+
activeIndex,
|
|
158
|
+
onMatch: (index) => {
|
|
159
|
+
if (open) {
|
|
160
|
+
setActiveIndex(index);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
resetMs: 500,
|
|
164
|
+
});
|
|
165
|
+
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
|
|
166
|
+
click,
|
|
167
|
+
dismiss,
|
|
168
|
+
role,
|
|
169
|
+
listNavigation,
|
|
170
|
+
typeahead,
|
|
171
|
+
]);
|
|
172
|
+
// Register option (stable callback - no value/displayText deps)
|
|
173
|
+
const registerOption = useCallback((option) => {
|
|
174
|
+
optionsRef.current.set(toKey(option.value), option);
|
|
175
|
+
setRegistryVersion((v) => v + 1);
|
|
176
|
+
}, []);
|
|
177
|
+
const unregisterOption = useCallback((optionValue) => {
|
|
178
|
+
optionsRef.current.delete(toKey(optionValue));
|
|
179
|
+
setRegistryVersion((v) => v + 1);
|
|
180
|
+
}, []);
|
|
181
|
+
// Get selected option's text value
|
|
182
|
+
const getSelectedTextValue = useCallback(() => {
|
|
183
|
+
return displayText;
|
|
184
|
+
}, [displayText]);
|
|
185
|
+
// Highlighted value for keyboard navigation
|
|
186
|
+
const highlightedValue = activeIndex !== null ? orderedOptions[activeIndex]?.value ?? null : null;
|
|
187
|
+
// Reset active index when closing
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!open) {
|
|
190
|
+
setActiveIndex(null);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// When opening, set active index to selected value or first enabled
|
|
194
|
+
const selectedIndex = orderedOptions.findIndex((opt) => value !== undefined && toKey(opt.value) === toKey(value));
|
|
195
|
+
if (selectedIndex >= 0 && !orderedOptions[selectedIndex]?.disabled) {
|
|
196
|
+
setActiveIndex(selectedIndex);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const firstEnabled = orderedOptions.findIndex((opt) => !opt.disabled);
|
|
200
|
+
setActiveIndex(firstEnabled >= 0 ? firstEnabled : null);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}, [open, orderedOptions, value]);
|
|
204
|
+
// Scroll active option into view
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (open && activeIndex !== null && listRef.current[activeIndex]) {
|
|
207
|
+
listRef.current[activeIndex]?.scrollIntoView({ block: 'nearest' });
|
|
208
|
+
}
|
|
209
|
+
}, [open, activeIndex]);
|
|
210
|
+
// ==========================================================================
|
|
211
|
+
// Split Context Values
|
|
212
|
+
// ==========================================================================
|
|
213
|
+
// Actions: stable config, IDs, refs, callbacks — rarely changes
|
|
214
|
+
// State: open, value, activeIndex, etc. — changes on interaction
|
|
215
|
+
// ==========================================================================
|
|
216
|
+
const actionsValue = useMemo(() => ({
|
|
217
|
+
// Config (from props, stable for component lifetime)
|
|
218
|
+
disabled,
|
|
219
|
+
placeholder,
|
|
220
|
+
clearable,
|
|
221
|
+
size,
|
|
222
|
+
// ARIA IDs (stable)
|
|
223
|
+
triggerId,
|
|
224
|
+
listboxId,
|
|
225
|
+
ariaLabel,
|
|
226
|
+
ariaLabelledBy,
|
|
227
|
+
ariaDescribedBy,
|
|
228
|
+
// Stable callbacks
|
|
229
|
+
setOpen,
|
|
230
|
+
setValue,
|
|
231
|
+
registerOption,
|
|
232
|
+
unregisterOption,
|
|
233
|
+
handleSelect,
|
|
234
|
+
// Refs (stable objects)
|
|
235
|
+
refs,
|
|
236
|
+
listRef,
|
|
237
|
+
// Floating UI interaction props (stable functions from useInteractions)
|
|
238
|
+
getReferenceProps,
|
|
239
|
+
getFloatingProps,
|
|
240
|
+
getItemProps,
|
|
241
|
+
}), [
|
|
242
|
+
// Config props - only change if parent rerenders with new props
|
|
243
|
+
disabled,
|
|
244
|
+
placeholder,
|
|
245
|
+
clearable,
|
|
246
|
+
size,
|
|
247
|
+
// IDs are stable (from useId)
|
|
248
|
+
triggerId,
|
|
249
|
+
listboxId,
|
|
250
|
+
ariaLabel,
|
|
251
|
+
ariaLabelledBy,
|
|
252
|
+
ariaDescribedBy,
|
|
253
|
+
// Callbacks are stable (useCallback with stable deps)
|
|
254
|
+
setOpen,
|
|
255
|
+
setValue,
|
|
256
|
+
registerOption,
|
|
257
|
+
unregisterOption,
|
|
258
|
+
handleSelect,
|
|
259
|
+
// Refs are stable objects
|
|
260
|
+
refs,
|
|
261
|
+
listRef,
|
|
262
|
+
// Interaction props from useInteractions (stable)
|
|
263
|
+
getReferenceProps,
|
|
264
|
+
getFloatingProps,
|
|
265
|
+
getItemProps,
|
|
266
|
+
]);
|
|
267
|
+
const stateValue = useMemo(() => ({
|
|
268
|
+
// Open state
|
|
269
|
+
open,
|
|
270
|
+
// Selection state
|
|
271
|
+
value,
|
|
272
|
+
// Selection helper
|
|
273
|
+
getSelectedTextValue,
|
|
274
|
+
// Navigation state
|
|
275
|
+
activeIndex,
|
|
276
|
+
highlightedValue,
|
|
277
|
+
// Derived (changes when registry changes)
|
|
278
|
+
orderedOptions,
|
|
279
|
+
// Floating UI (changes on open/position)
|
|
280
|
+
floatingStyles,
|
|
281
|
+
floatingContext: context,
|
|
282
|
+
}), [
|
|
283
|
+
open,
|
|
284
|
+
value,
|
|
285
|
+
getSelectedTextValue,
|
|
286
|
+
activeIndex,
|
|
287
|
+
highlightedValue,
|
|
288
|
+
orderedOptions,
|
|
289
|
+
floatingStyles,
|
|
290
|
+
context,
|
|
291
|
+
]);
|
|
292
|
+
return (_jsx(SelectActionsContext.Provider, { value: actionsValue, children: _jsx(SelectStateContext.Provider, { value: stateValue, children: children }) }));
|
|
293
|
+
}
|
|
294
|
+
SelectRoot.displayName = 'Select';
|
|
295
|
+
// =============================================================================
|
|
296
|
+
// Select.Trigger
|
|
297
|
+
// =============================================================================
|
|
298
|
+
function SelectTriggerComponent({ asChild = false, className, children, }) {
|
|
299
|
+
const { open, setOpen, disabled, placeholder, clearable, size, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, getSelectedTextValue, setValue, refs, getReferenceProps, activeIndex, handleSelect, } = useSelectContext();
|
|
300
|
+
const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
|
|
301
|
+
const displayValue = getSelectedTextValue();
|
|
302
|
+
const hasValue = displayValue !== undefined;
|
|
303
|
+
// Ensure trigger has focus when dropdown opens (Safari doesn't focus buttons on click)
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (open && refs.reference.current) {
|
|
306
|
+
refs.reference.current.focus();
|
|
307
|
+
}
|
|
308
|
+
}, [open, refs.reference]);
|
|
309
|
+
// Handle Enter/Space for selection when open
|
|
310
|
+
const handleKeyDown = useCallback((e) => {
|
|
311
|
+
if (open && (e.key === 'Enter' || e.key === ' ')) {
|
|
312
|
+
e.preventDefault();
|
|
313
|
+
handleSelect(activeIndex);
|
|
314
|
+
}
|
|
315
|
+
}, [open, activeIndex, handleSelect]);
|
|
316
|
+
// Close dropdown when focus leaves trigger
|
|
317
|
+
// Uses blur guard pattern: only close if focus moved outside controlled elements
|
|
318
|
+
const handleBlur = useCallback((e) => {
|
|
319
|
+
if (!open)
|
|
320
|
+
return;
|
|
321
|
+
const relatedTarget = e.relatedTarget;
|
|
322
|
+
// Focus left the document entirely (e.g., clicked outside window)
|
|
323
|
+
if (relatedTarget === null) {
|
|
324
|
+
setOpen(false);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// Check if focus moved to the floating content (clicking an option)
|
|
328
|
+
const floating = refs.floating.current;
|
|
329
|
+
if (floating && relatedTarget && floating.contains(relatedTarget)) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Focus moved somewhere outside our control — close
|
|
333
|
+
setOpen(false);
|
|
334
|
+
}, [open, setOpen, refs.floating]);
|
|
335
|
+
// Handle clear button - clears value, keeps focus on trigger
|
|
336
|
+
const handleClear = useCallback((e) => {
|
|
337
|
+
e.preventDefault(); // Keep focus in trigger
|
|
338
|
+
e.stopPropagation(); // Don't open dropdown
|
|
339
|
+
if (disabled)
|
|
340
|
+
return;
|
|
341
|
+
setValue(undefined);
|
|
342
|
+
}, [disabled, setValue]);
|
|
343
|
+
// Get Floating UI's reference props - pass our handlers so they get composed
|
|
344
|
+
const floatingProps = getReferenceProps({
|
|
345
|
+
'aria-activedescendant': activeIndex !== null ? `${listboxId}-option-${activeIndex}` : undefined,
|
|
346
|
+
onKeyDown: handleKeyDown,
|
|
347
|
+
onBlur: handleBlur,
|
|
348
|
+
});
|
|
349
|
+
const triggerProps = {
|
|
350
|
+
id: triggerId,
|
|
351
|
+
'aria-label': ariaLabel,
|
|
352
|
+
'aria-labelledby': ariaLabelledBy,
|
|
353
|
+
'aria-describedby': ariaDescribedBy,
|
|
354
|
+
'aria-disabled': disabled || undefined,
|
|
355
|
+
'data-state': open ? 'open' : 'closed',
|
|
356
|
+
'data-disabled': disabled || undefined,
|
|
357
|
+
...floatingProps,
|
|
358
|
+
};
|
|
359
|
+
if (asChild && isValidElement(children)) {
|
|
360
|
+
const childProps = children.props;
|
|
361
|
+
// Compose event handlers: child runs first, skip ours if defaultPrevented
|
|
362
|
+
const composeHandler = (ours, theirs) => {
|
|
363
|
+
if (!ours && !theirs)
|
|
364
|
+
return undefined;
|
|
365
|
+
if (!ours)
|
|
366
|
+
return theirs;
|
|
367
|
+
if (!theirs)
|
|
368
|
+
return ours;
|
|
369
|
+
return (e) => {
|
|
370
|
+
theirs(e);
|
|
371
|
+
if (!e.defaultPrevented) {
|
|
372
|
+
ours(e);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
return cloneElement(children, {
|
|
377
|
+
// Spread order: ours first, then child (child wins), then explicit overrides
|
|
378
|
+
...triggerProps,
|
|
379
|
+
...childProps,
|
|
380
|
+
// Re-assert must-have props that child cannot override
|
|
381
|
+
id: triggerId,
|
|
382
|
+
ref: refs.setReference,
|
|
383
|
+
'aria-haspopup': floatingProps['aria-haspopup'],
|
|
384
|
+
'aria-expanded': floatingProps['aria-expanded'],
|
|
385
|
+
'aria-controls': floatingProps['aria-controls'],
|
|
386
|
+
'aria-activedescendant': floatingProps['aria-activedescendant'],
|
|
387
|
+
'aria-describedby': ariaDescribedBy,
|
|
388
|
+
'aria-disabled': disabled || undefined,
|
|
389
|
+
'data-state': open ? 'open' : 'closed',
|
|
390
|
+
'data-disabled': disabled || undefined,
|
|
391
|
+
className: cx(childProps.className, 'tui-select__trigger', sizeClass, className),
|
|
392
|
+
// Composed handlers override both spreads
|
|
393
|
+
onClick: composeHandler(triggerProps.onClick, childProps.onClick),
|
|
394
|
+
onKeyDown: composeHandler(triggerProps.onKeyDown, childProps.onKeyDown),
|
|
395
|
+
onBlur: composeHandler(triggerProps.onBlur, childProps.onBlur),
|
|
396
|
+
onMouseDown: composeHandler(triggerProps.onMouseDown, childProps.onMouseDown),
|
|
397
|
+
onPointerDown: composeHandler(triggerProps.onPointerDown, childProps.onPointerDown),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
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
|
+
}
|
|
402
|
+
SelectTriggerComponent.displayName = 'Select.Trigger';
|
|
403
|
+
// =============================================================================
|
|
404
|
+
// Select.Content
|
|
405
|
+
// =============================================================================
|
|
406
|
+
function SelectContentComponent({ className, children, }) {
|
|
407
|
+
const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, handleSelect, orderedOptions, } = useSelectContext();
|
|
408
|
+
const portalRoot = getPortalRootFor(refs.reference.current);
|
|
409
|
+
// Memoized context for options
|
|
410
|
+
const contentContext = useMemo(() => ({
|
|
411
|
+
listRef,
|
|
412
|
+
activeIndex,
|
|
413
|
+
handleSelect,
|
|
414
|
+
orderedOptions,
|
|
415
|
+
}), [listRef, activeIndex, handleSelect, orderedOptions]);
|
|
416
|
+
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: {
|
|
417
|
+
...floatingStyles,
|
|
418
|
+
minWidth: refs.reference.current?.offsetWidth,
|
|
419
|
+
pointerEvents: 'auto',
|
|
420
|
+
}, ...getFloatingProps(), children: _jsx(SelectContentContext.Provider, { value: contentContext, children: children }) }) }))] }));
|
|
421
|
+
}
|
|
422
|
+
SelectContentComponent.displayName = 'Select.Content';
|
|
423
|
+
// =============================================================================
|
|
424
|
+
// Select.Option
|
|
425
|
+
// =============================================================================
|
|
426
|
+
function SelectOptionComponent({ value: optionValue, disabled = false, textValue: explicitTextValue, className, children, }) {
|
|
427
|
+
const { value: selectedValue, setValue, setOpen, listboxId, registerOption, unregisterOption, highlightedValue, getItemProps, } = useSelectContext();
|
|
428
|
+
const { listRef, orderedOptions } = useSelectContentContext();
|
|
429
|
+
const ref = useRef(null);
|
|
430
|
+
// Derive textValue from children if not explicitly provided
|
|
431
|
+
const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
|
|
432
|
+
// Warn in dev if textValue couldn't be derived
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
if (import.meta.env.DEV && !textValue) {
|
|
435
|
+
console.warn(`Select.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
|
|
436
|
+
}
|
|
437
|
+
}, [textValue, optionValue]);
|
|
438
|
+
// Register option
|
|
439
|
+
useLayoutEffect(() => {
|
|
440
|
+
registerOption({ value: optionValue, ref, disabled, textValue });
|
|
441
|
+
return () => unregisterOption(optionValue);
|
|
442
|
+
}, [optionValue, disabled, textValue, registerOption, unregisterOption]);
|
|
443
|
+
// Find this option's index in ordered list
|
|
444
|
+
const index = orderedOptions.findIndex((opt) => toKey(opt.value) === toKey(optionValue));
|
|
445
|
+
// Assign ref to listRef for navigation
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (index >= 0) {
|
|
448
|
+
listRef.current[index] = ref.current;
|
|
449
|
+
}
|
|
450
|
+
}, [index, listRef]);
|
|
451
|
+
const isSelected = selectedValue !== undefined && toKey(selectedValue) === toKey(optionValue);
|
|
452
|
+
const isHighlighted = highlightedValue !== null && toKey(highlightedValue) === toKey(optionValue);
|
|
453
|
+
const handleClick = useCallback(() => {
|
|
454
|
+
if (disabled)
|
|
455
|
+
return;
|
|
456
|
+
setValue(optionValue, textValue);
|
|
457
|
+
setOpen(false);
|
|
458
|
+
}, [disabled, optionValue, textValue, setValue, setOpen]);
|
|
459
|
+
return (_jsxs("div", { ref: ref, id: index >= 0 ? `${listboxId}-option-${index}` : undefined, role: "option", className: cx('tui-select__option', className), "aria-selected": isSelected, "aria-disabled": disabled || undefined, "data-highlighted": isHighlighted || undefined, "data-disabled": disabled || undefined, ...getItemProps({
|
|
460
|
+
onClick: handleClick,
|
|
461
|
+
}), children: [_jsx("span", { className: "tui-select__option-content", children: children }), isSelected && (_jsx(Icon, { name: "system/check", size: "sm", className: "tui-select__option-check", "aria-hidden": "true" }))] }));
|
|
462
|
+
}
|
|
463
|
+
SelectOptionComponent.displayName = 'Select.Option';
|
|
464
|
+
// =============================================================================
|
|
465
|
+
// Select.Group
|
|
466
|
+
// =============================================================================
|
|
467
|
+
function SelectGroupComponent({ className, children }) {
|
|
468
|
+
const groupId = useId();
|
|
469
|
+
return (_jsx("div", { role: "group", "aria-labelledby": `${groupId}-label`, className: cx('tui-select__group', className), children: _jsx(SelectGroupContext.Provider, { value: { groupId }, children: children }) }));
|
|
470
|
+
}
|
|
471
|
+
SelectGroupComponent.displayName = 'Select.Group';
|
|
472
|
+
const SelectGroupContext = React.createContext(null);
|
|
473
|
+
// =============================================================================
|
|
474
|
+
// Select.Label
|
|
475
|
+
// =============================================================================
|
|
476
|
+
function SelectLabelComponent({ className, children }) {
|
|
477
|
+
const groupContext = React.useContext(SelectGroupContext);
|
|
478
|
+
// No aria-hidden — aria-labelledby on Group references this element,
|
|
479
|
+
// so screen readers need access to read the label text.
|
|
480
|
+
return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-select__label', className), children: children }));
|
|
481
|
+
}
|
|
482
|
+
SelectLabelComponent.displayName = 'Select.Label';
|
|
483
|
+
export const Select = SelectRoot;
|
|
484
|
+
Select.Trigger = SelectTriggerComponent;
|
|
485
|
+
Select.Content = SelectContentComponent;
|
|
486
|
+
Select.Option = SelectOptionComponent;
|
|
487
|
+
Select.Group = SelectGroupComponent;
|
|
488
|
+
Select.Label = SelectLabelComponent;
|
|
489
|
+
// Named exports
|
|
490
|
+
export const SelectTrigger = SelectTriggerComponent;
|
|
491
|
+
export const SelectContent = SelectContentComponent;
|
|
492
|
+
export const SelectOption = SelectOptionComponent;
|
|
493
|
+
export const SelectGroup = SelectGroupComponent;
|
|
494
|
+
export const SelectLabel = SelectLabelComponent;
|
|
495
|
+
// Hook for advanced use cases
|
|
496
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
497
|
+
export { useSelectContext as useSelect } from './SelectContext.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
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>;
|
|
4
|
+
/**
|
|
5
|
+
* Access stable config, IDs, refs, and callbacks.
|
|
6
|
+
* Safe to use without causing rerenders on navigation changes.
|
|
7
|
+
*/
|
|
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;
|
|
19
|
+
export declare const SelectContentContext: import("react").Context<SelectContentContextValue | null>;
|
|
20
|
+
export declare function useSelectContentContext(): SelectContentContextValue;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// Split Contexts for Performance
|
|
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);
|
|
15
|
+
/**
|
|
16
|
+
* Access stable config, IDs, refs, and callbacks.
|
|
17
|
+
* Safe to use without causing rerenders on navigation changes.
|
|
18
|
+
*/
|
|
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);
|
|
32
|
+
if (!context) {
|
|
33
|
+
throw new Error('Select components must be used within a Select');
|
|
34
|
+
}
|
|
35
|
+
return context;
|
|
36
|
+
}
|
|
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
|
+
// =============================================================================
|
|
47
|
+
// Content Context (for Option registration)
|
|
48
|
+
// =============================================================================
|
|
49
|
+
export const SelectContentContext = createContext(null);
|
|
50
|
+
export function useSelectContentContext() {
|
|
51
|
+
const context = useContext(SelectContentContext);
|
|
52
|
+
if (!context) {
|
|
53
|
+
throw new Error('Select.Option must be used within Select.Content');
|
|
54
|
+
}
|
|
55
|
+
return context;
|
|
56
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { Select, SelectTrigger, SelectContent, SelectOption, SelectGroup, SelectLabel, useSelect, } from './Select';
|
|
2
|
+
export type { SelectProps, SelectTriggerProps, SelectContentProps, SelectOptionProps, SelectGroupProps, SelectLabelProps, } from './types';
|
|
3
|
+
export type { OptionValue } from '../../utils/value-key';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Select, SelectTrigger, SelectContent, SelectOption, SelectGroup, SelectLabel, useSelect, } from './Select.js';
|