@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,466 @@
|
|
|
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, } from 'react';
|
|
3
|
+
import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useRole, } 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 { hashForId } from '../../utils/hash.js';
|
|
8
|
+
import { composeEventHandlers } from '../../utils/compose-events.js';
|
|
9
|
+
import { Icon } from '../Icon/index.js';
|
|
10
|
+
import { ComboboxActionsContext, ComboboxStateContext, ComboboxContentContext, useComboboxContext, useComboboxContentContext, } from './ComboboxContext.js';
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Combobox Root
|
|
13
|
+
// =============================================================================
|
|
14
|
+
function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', openOnFocus = true, filterMode = 'always', onQueryChange, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, children, }) {
|
|
15
|
+
// Controlled/uncontrolled value (initialize from defaultValue)
|
|
16
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
17
|
+
const isValueControlled = controlledValue !== undefined;
|
|
18
|
+
const value = isValueControlled ? controlledValue : uncontrolledValue;
|
|
19
|
+
// Controlled/uncontrolled inputValue
|
|
20
|
+
const [uncontrolledInputValue, setUncontrolledInputValue] = useState('');
|
|
21
|
+
const isInputControlled = controlledInputValue !== undefined;
|
|
22
|
+
const inputValue = isInputControlled ? controlledInputValue : uncontrolledInputValue;
|
|
23
|
+
// Controlled/uncontrolled open
|
|
24
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
|
|
25
|
+
const isOpenControlled = controlledOpen !== undefined;
|
|
26
|
+
const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
|
|
27
|
+
// Internal searching state (private - not exposed)
|
|
28
|
+
// True only when user is actively typing, false after selection/clear/escape
|
|
29
|
+
const [searching, setSearching] = useState(false);
|
|
30
|
+
// Compute effective query for filtering
|
|
31
|
+
const query = filterMode === 'when-searching' && !searching ? '' : inputValue;
|
|
32
|
+
// Track previous query to fire onQueryChange
|
|
33
|
+
const prevQueryRef = useRef(query);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (prevQueryRef.current !== query) {
|
|
36
|
+
prevQueryRef.current = query;
|
|
37
|
+
onQueryChange?.(query);
|
|
38
|
+
}
|
|
39
|
+
}, [query, onQueryChange]);
|
|
40
|
+
// Option registration
|
|
41
|
+
const optionsRef = useRef(new Map());
|
|
42
|
+
const [registryVersion, setRegistryVersion] = useState(0);
|
|
43
|
+
// IDs
|
|
44
|
+
const baseId = useId();
|
|
45
|
+
const inputId = `${baseId}-input`;
|
|
46
|
+
const listboxId = `${baseId}-listbox`;
|
|
47
|
+
// Refs
|
|
48
|
+
const inputRef = useRef(null);
|
|
49
|
+
const listRef = useRef([]);
|
|
50
|
+
// Active index for navigation (-1 means no active option)
|
|
51
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
52
|
+
// Track previous highlighted value to maintain selection across filter changes
|
|
53
|
+
const prevHighlightedValueRef = useRef(null);
|
|
54
|
+
// Set open state
|
|
55
|
+
const setOpen = useCallback((nextOpen) => {
|
|
56
|
+
if (disabled)
|
|
57
|
+
return;
|
|
58
|
+
if (!isOpenControlled) {
|
|
59
|
+
setUncontrolledOpen(nextOpen);
|
|
60
|
+
}
|
|
61
|
+
onOpenChange?.(nextOpen);
|
|
62
|
+
}, [disabled, isOpenControlled, onOpenChange]);
|
|
63
|
+
// Set input value
|
|
64
|
+
const setInputValue = useCallback((nextValue) => {
|
|
65
|
+
if (!isInputControlled) {
|
|
66
|
+
setUncontrolledInputValue(nextValue);
|
|
67
|
+
}
|
|
68
|
+
onInputChange?.(nextValue);
|
|
69
|
+
}, [isInputControlled, onInputChange]);
|
|
70
|
+
// Select an option
|
|
71
|
+
const selectOption = useCallback((optionValue) => {
|
|
72
|
+
if (!isValueControlled) {
|
|
73
|
+
setUncontrolledValue(optionValue);
|
|
74
|
+
}
|
|
75
|
+
onValueChange?.(optionValue);
|
|
76
|
+
setSearching(false); // End searching mode on selection
|
|
77
|
+
setOpen(false);
|
|
78
|
+
}, [isValueControlled, onValueChange, setOpen]);
|
|
79
|
+
// Clear the selected value
|
|
80
|
+
// Always clear uncontrolled state - we can't predict what state we'll be in
|
|
81
|
+
// after the callback runs (controlled value might become undefined)
|
|
82
|
+
const clearValue = useCallback(() => {
|
|
83
|
+
setUncontrolledValue(undefined);
|
|
84
|
+
onValueChange?.(undefined);
|
|
85
|
+
setSearching(false); // End searching mode on clear
|
|
86
|
+
}, [onValueChange]);
|
|
87
|
+
// Get ordered options
|
|
88
|
+
const getOrderedOptions = useCallback(() => {
|
|
89
|
+
const options = Array.from(optionsRef.current.values());
|
|
90
|
+
return options.sort((a, b) => {
|
|
91
|
+
if (!a.ref.current || !b.ref.current)
|
|
92
|
+
return 0;
|
|
93
|
+
const position = a.ref.current.compareDocumentPosition(b.ref.current);
|
|
94
|
+
if (position & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
95
|
+
return -1;
|
|
96
|
+
if (position & Node.DOCUMENT_POSITION_PRECEDING)
|
|
97
|
+
return 1;
|
|
98
|
+
return 0;
|
|
99
|
+
});
|
|
100
|
+
}, []);
|
|
101
|
+
// Memoized ordered options
|
|
102
|
+
const orderedOptions = useMemo(() => {
|
|
103
|
+
return getOrderedOptions();
|
|
104
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
+
}, [registryVersion]);
|
|
106
|
+
// Compute disabled indices for keyboard navigation
|
|
107
|
+
const disabledIndices = useMemo(() => orderedOptions.reduce((acc, opt, i) => {
|
|
108
|
+
if (opt.disabled)
|
|
109
|
+
acc.push(i);
|
|
110
|
+
return acc;
|
|
111
|
+
}, []), [orderedOptions]);
|
|
112
|
+
// Floating UI setup
|
|
113
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
114
|
+
placement: 'bottom-start',
|
|
115
|
+
open,
|
|
116
|
+
onOpenChange: setOpen,
|
|
117
|
+
middleware: [
|
|
118
|
+
offset(4),
|
|
119
|
+
flip({ padding: 8 }),
|
|
120
|
+
shift({ padding: 8 }),
|
|
121
|
+
sizeMiddleware({
|
|
122
|
+
apply({ availableHeight, rects, elements }) {
|
|
123
|
+
Object.assign(elements.floating.style, {
|
|
124
|
+
maxHeight: `${Math.min(availableHeight - 16, 300)}px`,
|
|
125
|
+
minWidth: `${rects.reference.width}px`,
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
padding: 8,
|
|
129
|
+
}),
|
|
130
|
+
],
|
|
131
|
+
whileElementsMounted: autoUpdate,
|
|
132
|
+
});
|
|
133
|
+
// Floating UI interactions
|
|
134
|
+
const dismiss = useDismiss(context);
|
|
135
|
+
const role = useRole(context, { role: 'listbox' });
|
|
136
|
+
const listNavigation = useListNavigation(context, {
|
|
137
|
+
listRef,
|
|
138
|
+
activeIndex: activeIndex >= 0 ? activeIndex : null,
|
|
139
|
+
onNavigate: (index) => setActiveIndex(index ?? -1),
|
|
140
|
+
loop: true,
|
|
141
|
+
virtual: true,
|
|
142
|
+
disabledIndices,
|
|
143
|
+
});
|
|
144
|
+
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
|
|
145
|
+
dismiss,
|
|
146
|
+
role,
|
|
147
|
+
listNavigation,
|
|
148
|
+
]);
|
|
149
|
+
// Register option
|
|
150
|
+
const registerOption = useCallback((option) => {
|
|
151
|
+
optionsRef.current.set(toKey(option.value), option);
|
|
152
|
+
setRegistryVersion((v) => v + 1);
|
|
153
|
+
}, []);
|
|
154
|
+
const unregisterOption = useCallback((optionValue) => {
|
|
155
|
+
optionsRef.current.delete(toKey(optionValue));
|
|
156
|
+
setRegistryVersion((v) => v + 1);
|
|
157
|
+
}, []);
|
|
158
|
+
// Active option ID for aria-activedescendant (hash-based for stability during filtering)
|
|
159
|
+
const activeOptionId = activeIndex >= 0 && orderedOptions[activeIndex]
|
|
160
|
+
? `${listboxId}-opt-${hashForId(toKey(orderedOptions[activeIndex].value))}`
|
|
161
|
+
: undefined;
|
|
162
|
+
// Update activeIndex when options change (filtering)
|
|
163
|
+
// Stable logic: keep highlighted if still present, else first enabled, else -1
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!open)
|
|
166
|
+
return;
|
|
167
|
+
const prevValue = prevHighlightedValueRef.current;
|
|
168
|
+
const enabledOptions = orderedOptions.filter((opt) => !opt.disabled);
|
|
169
|
+
if (enabledOptions.length === 0) {
|
|
170
|
+
setActiveIndex(-1);
|
|
171
|
+
prevHighlightedValueRef.current = null;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// If previously highlighted value still exists and is enabled, keep it
|
|
175
|
+
if (prevValue !== null) {
|
|
176
|
+
const prevIndex = orderedOptions.findIndex((opt) => toKey(opt.value) === toKey(prevValue) && !opt.disabled);
|
|
177
|
+
if (prevIndex >= 0) {
|
|
178
|
+
setActiveIndex(prevIndex);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Otherwise, set to first enabled option
|
|
183
|
+
const firstEnabledIndex = orderedOptions.findIndex((opt) => !opt.disabled);
|
|
184
|
+
setActiveIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : -1);
|
|
185
|
+
if (firstEnabledIndex >= 0) {
|
|
186
|
+
prevHighlightedValueRef.current = orderedOptions[firstEnabledIndex].value;
|
|
187
|
+
}
|
|
188
|
+
}, [open, orderedOptions]);
|
|
189
|
+
// Track highlighted value when activeIndex changes
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (activeIndex >= 0 && orderedOptions[activeIndex]) {
|
|
192
|
+
prevHighlightedValueRef.current = orderedOptions[activeIndex].value;
|
|
193
|
+
}
|
|
194
|
+
}, [activeIndex, orderedOptions]);
|
|
195
|
+
// Reset activeIndex when closing
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!open) {
|
|
198
|
+
setActiveIndex(-1);
|
|
199
|
+
prevHighlightedValueRef.current = null;
|
|
200
|
+
}
|
|
201
|
+
}, [open]);
|
|
202
|
+
// Scroll active option into view
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (open && activeIndex >= 0 && listRef.current[activeIndex]) {
|
|
205
|
+
listRef.current[activeIndex]?.scrollIntoView({ block: 'nearest' });
|
|
206
|
+
}
|
|
207
|
+
}, [open, activeIndex]);
|
|
208
|
+
// Handle input change (user typing)
|
|
209
|
+
const handleInputChange = useCallback((e) => {
|
|
210
|
+
const newValue = e.target.value;
|
|
211
|
+
setInputValue(newValue);
|
|
212
|
+
// Clear selection when input is emptied (clearValue sets searching=false)
|
|
213
|
+
if (newValue === '' && value !== undefined) {
|
|
214
|
+
clearValue();
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
// User is actively typing - enter searching mode
|
|
218
|
+
setSearching(true);
|
|
219
|
+
}
|
|
220
|
+
if (!open) {
|
|
221
|
+
setOpen(true);
|
|
222
|
+
}
|
|
223
|
+
}, [setInputValue, open, setOpen, value, clearValue]);
|
|
224
|
+
// Handle input focus
|
|
225
|
+
const handleInputFocus = useCallback(() => {
|
|
226
|
+
if (openOnFocus && !disabled && !open) {
|
|
227
|
+
setOpen(true);
|
|
228
|
+
}
|
|
229
|
+
}, [openOnFocus, disabled, open, setOpen]);
|
|
230
|
+
// Handle input keydown
|
|
231
|
+
const handleInputKeyDown = useCallback((e) => {
|
|
232
|
+
if (disabled)
|
|
233
|
+
return;
|
|
234
|
+
switch (e.key) {
|
|
235
|
+
case 'ArrowDown':
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
if (!open) {
|
|
238
|
+
setOpen(true);
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
case 'ArrowUp':
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
if (!open) {
|
|
244
|
+
setOpen(true);
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
case 'Enter':
|
|
248
|
+
if (open && activeIndex >= 0) {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
const option = orderedOptions[activeIndex];
|
|
251
|
+
if (option && !option.disabled) {
|
|
252
|
+
selectOption(option.value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
case 'Escape':
|
|
257
|
+
if (open) {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
setSearching(false); // End searching mode on escape
|
|
260
|
+
setOpen(false);
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
case 'Tab':
|
|
264
|
+
if (open) {
|
|
265
|
+
setSearching(false); // End searching mode on tab away
|
|
266
|
+
setOpen(false);
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}, [disabled, open, setOpen, activeIndex, orderedOptions, selectOption]);
|
|
271
|
+
// Handle input blur with blur guard
|
|
272
|
+
const handleInputBlur = useCallback((e) => {
|
|
273
|
+
if (!open)
|
|
274
|
+
return;
|
|
275
|
+
const relatedTarget = e.relatedTarget;
|
|
276
|
+
// Focus left document
|
|
277
|
+
if (relatedTarget === null) {
|
|
278
|
+
setSearching(false); // End searching mode on blur
|
|
279
|
+
setOpen(false);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Check if focus moved to floating content
|
|
283
|
+
const floating = refs.floating.current;
|
|
284
|
+
if (floating && relatedTarget && floating.contains(relatedTarget)) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
setSearching(false); // End searching mode on blur
|
|
288
|
+
setOpen(false);
|
|
289
|
+
}, [open, setOpen, refs.floating]);
|
|
290
|
+
// Handle clear button - clears both input text and selected value
|
|
291
|
+
const handleClear = useCallback((e) => {
|
|
292
|
+
e.preventDefault(); // Keep focus in input
|
|
293
|
+
setInputValue('');
|
|
294
|
+
clearValue();
|
|
295
|
+
inputRef.current?.focus();
|
|
296
|
+
}, [setInputValue, clearValue]);
|
|
297
|
+
// ==========================================================================
|
|
298
|
+
// Split Context Values
|
|
299
|
+
// ==========================================================================
|
|
300
|
+
const actionsValue = useMemo(() => ({
|
|
301
|
+
disabled,
|
|
302
|
+
placeholder,
|
|
303
|
+
openOnFocus,
|
|
304
|
+
inputId,
|
|
305
|
+
listboxId,
|
|
306
|
+
ariaLabel,
|
|
307
|
+
ariaLabelledBy,
|
|
308
|
+
setOpen,
|
|
309
|
+
setInputValue,
|
|
310
|
+
selectOption,
|
|
311
|
+
registerOption,
|
|
312
|
+
unregisterOption,
|
|
313
|
+
refs,
|
|
314
|
+
inputRef,
|
|
315
|
+
listRef,
|
|
316
|
+
getReferenceProps,
|
|
317
|
+
getFloatingProps,
|
|
318
|
+
getItemProps,
|
|
319
|
+
}), [
|
|
320
|
+
disabled,
|
|
321
|
+
placeholder,
|
|
322
|
+
openOnFocus,
|
|
323
|
+
inputId,
|
|
324
|
+
listboxId,
|
|
325
|
+
ariaLabel,
|
|
326
|
+
ariaLabelledBy,
|
|
327
|
+
setOpen,
|
|
328
|
+
setInputValue,
|
|
329
|
+
selectOption,
|
|
330
|
+
registerOption,
|
|
331
|
+
unregisterOption,
|
|
332
|
+
refs,
|
|
333
|
+
getReferenceProps,
|
|
334
|
+
getFloatingProps,
|
|
335
|
+
getItemProps,
|
|
336
|
+
]);
|
|
337
|
+
const stateValue = useMemo(() => ({
|
|
338
|
+
open,
|
|
339
|
+
value,
|
|
340
|
+
inputValue,
|
|
341
|
+
query,
|
|
342
|
+
activeIndex,
|
|
343
|
+
activeOptionId,
|
|
344
|
+
orderedOptions,
|
|
345
|
+
floatingStyles,
|
|
346
|
+
floatingContext: context,
|
|
347
|
+
}), [
|
|
348
|
+
open,
|
|
349
|
+
value,
|
|
350
|
+
inputValue,
|
|
351
|
+
query,
|
|
352
|
+
activeIndex,
|
|
353
|
+
activeOptionId,
|
|
354
|
+
orderedOptions,
|
|
355
|
+
floatingStyles,
|
|
356
|
+
context,
|
|
357
|
+
]);
|
|
358
|
+
// Get reference props for input (onChange handled separately to ensure it runs)
|
|
359
|
+
const referenceProps = getReferenceProps({
|
|
360
|
+
onFocus: handleInputFocus,
|
|
361
|
+
onBlur: handleInputBlur,
|
|
362
|
+
onKeyDown: handleInputKeyDown,
|
|
363
|
+
});
|
|
364
|
+
return (_jsx(ComboboxActionsContext.Provider, { value: actionsValue, children: _jsx(ComboboxStateContext.Provider, { value: stateValue, children: _jsxs("div", { className: "tui-combobox", children: [_jsxs("div", { className: "tui-combobox__input-wrapper", children: [_jsx("input", { ref: (node) => {
|
|
365
|
+
inputRef.current = node;
|
|
366
|
+
refs.setReference(node);
|
|
367
|
+
}, type: "text", id: inputId, className: cx('tui-combobox__input', size !== 'md' && `is-size-${size}`, inputClassName), role: "combobox", "aria-expanded": open, "aria-controls": listboxId, "aria-autocomplete": "list", "aria-activedescendant": activeOptionId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, disabled: disabled, placeholder: placeholder, value: inputValue, autoComplete: "off", ...referenceProps, onChange: handleInputChange }), inputValue && !disabled && (_jsx("span", { className: "tui-combobox__clear", onPointerDown: handleClear, "aria-hidden": "true", children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx("span", { className: "tui-combobox__icon", "aria-hidden": "true", children: _jsx(Icon, { name: "system/chevron-down", size: "sm" }) })] }), children] }) }) }));
|
|
368
|
+
}
|
|
369
|
+
ComboboxRoot.displayName = 'Combobox';
|
|
370
|
+
// =============================================================================
|
|
371
|
+
// Combobox.Content
|
|
372
|
+
// =============================================================================
|
|
373
|
+
function ComboboxContentComponent({ className, children }) {
|
|
374
|
+
const { open, listboxId, inputId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, orderedOptions, } = useComboboxContext();
|
|
375
|
+
const portalRoot = getPortalRootFor(refs.reference.current);
|
|
376
|
+
const contentContext = useMemo(() => ({
|
|
377
|
+
listRef,
|
|
378
|
+
activeIndex,
|
|
379
|
+
orderedOptions,
|
|
380
|
+
}), [listRef, activeIndex, orderedOptions]);
|
|
381
|
+
// Always render for option registration
|
|
382
|
+
return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { style: { display: 'none' }, "aria-hidden": "true", children: _jsx(ComboboxContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": inputId, className: cx('tui-combobox__content', className), style: {
|
|
383
|
+
...floatingStyles,
|
|
384
|
+
minWidth: refs.reference.current?.offsetWidth,
|
|
385
|
+
pointerEvents: 'auto',
|
|
386
|
+
}, tabIndex: -1, ...getFloatingProps(), children: _jsx(ComboboxContentContext.Provider, { value: contentContext, children: children }) }) }))] }));
|
|
387
|
+
}
|
|
388
|
+
ComboboxContentComponent.displayName = 'Combobox.Content';
|
|
389
|
+
// =============================================================================
|
|
390
|
+
// Combobox.Option
|
|
391
|
+
// =============================================================================
|
|
392
|
+
function ComboboxOptionComponent({ value: optionValue, disabled = false, textValue: explicitTextValue, className, children, }) {
|
|
393
|
+
const { value: selectedValue, selectOption, listboxId, registerOption, unregisterOption, getItemProps, } = useComboboxContext();
|
|
394
|
+
const { listRef, activeIndex, orderedOptions } = useComboboxContentContext();
|
|
395
|
+
const ref = useRef(null);
|
|
396
|
+
const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
|
|
397
|
+
// Warn in dev if textValue couldn't be derived
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (import.meta.env.DEV && !textValue) {
|
|
400
|
+
console.warn(`Combobox.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
|
|
401
|
+
}
|
|
402
|
+
}, [textValue, optionValue]);
|
|
403
|
+
// Register option
|
|
404
|
+
useLayoutEffect(() => {
|
|
405
|
+
registerOption({ value: optionValue, ref, disabled, textValue });
|
|
406
|
+
return () => unregisterOption(optionValue);
|
|
407
|
+
}, [optionValue, disabled, textValue, registerOption, unregisterOption]);
|
|
408
|
+
// Find this option's index
|
|
409
|
+
const index = orderedOptions.findIndex((opt) => toKey(opt.value) === toKey(optionValue));
|
|
410
|
+
// Assign ref to listRef for navigation
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (index >= 0) {
|
|
413
|
+
listRef.current[index] = ref.current;
|
|
414
|
+
}
|
|
415
|
+
}, [index, listRef]);
|
|
416
|
+
const isSelected = selectedValue !== undefined && toKey(selectedValue) === toKey(optionValue);
|
|
417
|
+
const isHighlighted = activeIndex === index;
|
|
418
|
+
const handleClick = useCallback(() => {
|
|
419
|
+
if (disabled)
|
|
420
|
+
return;
|
|
421
|
+
selectOption(optionValue);
|
|
422
|
+
}, [disabled, optionValue, selectOption]);
|
|
423
|
+
// Prevent blur on input when clicking option (must run before any Floating UI handler)
|
|
424
|
+
const handlePointerDown = useCallback((e) => {
|
|
425
|
+
e.preventDefault();
|
|
426
|
+
}, []);
|
|
427
|
+
// Hash-based ID for stability during filtering (aria-activedescendant references this)
|
|
428
|
+
// Use toKey() before hashing to differentiate number 1 from string "1"
|
|
429
|
+
const optionId = `${listboxId}-opt-${hashForId(toKey(optionValue))}`;
|
|
430
|
+
// Get item props and compose our pointerdown handler with any from Floating UI
|
|
431
|
+
const itemProps = getItemProps({
|
|
432
|
+
onClick: handleClick,
|
|
433
|
+
});
|
|
434
|
+
return (_jsxs("div", { ref: ref, id: optionId, role: "option", className: cx('tui-combobox__option', className), "aria-selected": isSelected, "aria-disabled": disabled || undefined, "data-highlighted": isHighlighted || undefined, "data-disabled": disabled || undefined, ...itemProps, onPointerDown: composeEventHandlers(handlePointerDown, itemProps.onPointerDown), children: [_jsx("span", { className: "tui-combobox__option-content", children: children }), isSelected && (_jsx(Icon, { name: "system/check", size: "sm", className: "tui-combobox__option-check", "aria-hidden": "true" }))] }));
|
|
435
|
+
}
|
|
436
|
+
ComboboxOptionComponent.displayName = 'Combobox.Option';
|
|
437
|
+
// =============================================================================
|
|
438
|
+
// Combobox.Group
|
|
439
|
+
// =============================================================================
|
|
440
|
+
function ComboboxGroupComponent({ className, children }) {
|
|
441
|
+
const groupId = useId();
|
|
442
|
+
return (_jsx("div", { role: "group", "aria-labelledby": `${groupId}-label`, className: cx('tui-combobox__group', className), children: _jsx(ComboboxGroupContext.Provider, { value: { groupId }, children: children }) }));
|
|
443
|
+
}
|
|
444
|
+
ComboboxGroupComponent.displayName = 'Combobox.Group';
|
|
445
|
+
const ComboboxGroupContext = React.createContext(null);
|
|
446
|
+
// =============================================================================
|
|
447
|
+
// Combobox.Label
|
|
448
|
+
// =============================================================================
|
|
449
|
+
function ComboboxLabelComponent({ className, children }) {
|
|
450
|
+
const groupContext = React.useContext(ComboboxGroupContext);
|
|
451
|
+
return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-combobox__label', className), children: children }));
|
|
452
|
+
}
|
|
453
|
+
ComboboxLabelComponent.displayName = 'Combobox.Label';
|
|
454
|
+
export const Combobox = ComboboxRoot;
|
|
455
|
+
Combobox.Content = ComboboxContentComponent;
|
|
456
|
+
Combobox.Option = ComboboxOptionComponent;
|
|
457
|
+
Combobox.Group = ComboboxGroupComponent;
|
|
458
|
+
Combobox.Label = ComboboxLabelComponent;
|
|
459
|
+
// Named exports
|
|
460
|
+
export const ComboboxContent = ComboboxContentComponent;
|
|
461
|
+
export const ComboboxOption = ComboboxOptionComponent;
|
|
462
|
+
export const ComboboxGroup = ComboboxGroupComponent;
|
|
463
|
+
export const ComboboxLabel = ComboboxLabelComponent;
|
|
464
|
+
// Hook for advanced use cases
|
|
465
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
466
|
+
export { useComboboxContext as useCombobox } from './ComboboxContext.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ComboboxActionsContextValue, ComboboxStateContextValue, ComboboxContentContextValue } from './types';
|
|
2
|
+
export declare const ComboboxActionsContext: import("react").Context<ComboboxActionsContextValue | null>;
|
|
3
|
+
export declare const ComboboxStateContext: import("react").Context<ComboboxStateContextValue | null>;
|
|
4
|
+
export declare function useComboboxActions(): ComboboxActionsContextValue;
|
|
5
|
+
export declare function useComboboxState(): ComboboxStateContextValue;
|
|
6
|
+
export declare function useComboboxContext(): ComboboxActionsContextValue & ComboboxStateContextValue;
|
|
7
|
+
export declare const ComboboxContentContext: import("react").Context<ComboboxContentContextValue | null>;
|
|
8
|
+
export declare function useComboboxContentContext(): ComboboxContentContextValue;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// Split Contexts for Performance
|
|
4
|
+
// =============================================================================
|
|
5
|
+
export const ComboboxActionsContext = createContext(null);
|
|
6
|
+
export const ComboboxStateContext = createContext(null);
|
|
7
|
+
export function useComboboxActions() {
|
|
8
|
+
const context = useContext(ComboboxActionsContext);
|
|
9
|
+
if (!context) {
|
|
10
|
+
throw new Error('Combobox components must be used within a Combobox');
|
|
11
|
+
}
|
|
12
|
+
return context;
|
|
13
|
+
}
|
|
14
|
+
export function useComboboxState() {
|
|
15
|
+
const context = useContext(ComboboxStateContext);
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error('Combobox components must be used within a Combobox');
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
21
|
+
export function useComboboxContext() {
|
|
22
|
+
const actions = useComboboxActions();
|
|
23
|
+
const state = useComboboxState();
|
|
24
|
+
return { ...actions, ...state };
|
|
25
|
+
}
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Content Context (for Option registration)
|
|
28
|
+
// =============================================================================
|
|
29
|
+
export const ComboboxContentContext = createContext(null);
|
|
30
|
+
export function useComboboxContentContext() {
|
|
31
|
+
const context = useContext(ComboboxContentContext);
|
|
32
|
+
if (!context) {
|
|
33
|
+
throw new Error('Combobox.Option must be used within Combobox.Content');
|
|
34
|
+
}
|
|
35
|
+
return context;
|
|
36
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { Combobox, ComboboxContent, ComboboxOption, ComboboxGroup, ComboboxLabel, useCombobox, } from './Combobox';
|
|
2
|
+
export type { ComboboxProps, ComboboxContentProps, ComboboxOptionProps, ComboboxGroupProps, ComboboxLabelProps, FilterMode as ComboboxFilterMode, RegisteredOption as ComboboxRegisteredOption, } from './types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Combobox, ComboboxContent, ComboboxOption, ComboboxGroup, ComboboxLabel, useCombobox, } from './Combobox.js';
|