@tangible/ui 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/components/Card/Card.d.ts +1 -0
  2. package/components/Card/Card.js +17 -20
  3. package/components/Checkbox/Checkbox.d.ts +9 -0
  4. package/components/Checkbox/Checkbox.js +92 -0
  5. package/components/Checkbox/index.d.ts +2 -0
  6. package/components/Checkbox/index.js +1 -0
  7. package/components/Checkbox/types.d.ts +10 -0
  8. package/components/Checkbox/types.js +1 -0
  9. package/components/Chip/Chip.d.ts +4 -1
  10. package/components/Chip/Chip.js +32 -7
  11. package/components/ChipGroup/ChipGroup.d.ts +5 -0
  12. package/components/ChipGroup/ChipGroup.js +68 -0
  13. package/components/ChipGroup/ChipGroupContext.d.ts +3 -0
  14. package/components/ChipGroup/ChipGroupContext.js +5 -0
  15. package/components/ChipGroup/index.d.ts +3 -0
  16. package/components/ChipGroup/index.js +2 -0
  17. package/components/ChipGroup/types.d.ts +36 -0
  18. package/components/ChipGroup/types.js +1 -0
  19. package/components/Chips/Chips.d.ts +2 -0
  20. package/components/Chips/Chips.js +1 -1
  21. package/components/Combobox/Combobox.d.ts +33 -0
  22. package/components/Combobox/Combobox.js +466 -0
  23. package/components/Combobox/ComboboxContext.d.ts +8 -0
  24. package/components/Combobox/ComboboxContext.js +36 -0
  25. package/components/Combobox/index.d.ts +2 -0
  26. package/components/Combobox/index.js +1 -0
  27. package/components/Combobox/types.d.ts +204 -0
  28. package/components/Combobox/types.js +1 -0
  29. package/components/Dropdown/Dropdown.js +2 -1
  30. package/components/Field/Field.d.ts +39 -0
  31. package/components/Field/Field.js +92 -0
  32. package/components/Field/FieldContext.d.ts +16 -0
  33. package/components/Field/FieldContext.js +10 -0
  34. package/components/Field/index.d.ts +2 -0
  35. package/components/Field/index.js +1 -0
  36. package/components/Modal/Modal.d.ts +4 -4
  37. package/components/Modal/Modal.js +14 -12
  38. package/components/MoveHandle/MoveHandle.d.ts +2 -0
  39. package/components/MoveHandle/MoveHandle.js +84 -0
  40. package/components/MoveHandle/index.d.ts +2 -0
  41. package/components/MoveHandle/index.js +1 -0
  42. package/components/MoveHandle/types.d.ts +43 -0
  43. package/components/MoveHandle/types.js +1 -0
  44. package/components/MultiSelect/MultiSelect.d.ts +39 -0
  45. package/components/MultiSelect/MultiSelect.js +623 -0
  46. package/components/MultiSelect/MultiSelectContext.d.ts +20 -0
  47. package/components/MultiSelect/MultiSelectContext.js +56 -0
  48. package/components/MultiSelect/index.d.ts +2 -0
  49. package/components/MultiSelect/index.js +1 -0
  50. package/components/MultiSelect/types.d.ts +218 -0
  51. package/components/MultiSelect/types.js +3 -0
  52. package/components/Notice/Notice.d.ts +1 -1
  53. package/components/Notice/Notice.js +1 -1
  54. package/components/Progress/Progress.js +1 -1
  55. package/components/Progress/types.d.ts +7 -7
  56. package/components/Radio/Radio.d.ts +2 -0
  57. package/components/Radio/Radio.js +50 -0
  58. package/components/Radio/RadioGroup.d.ts +2 -0
  59. package/components/Radio/RadioGroup.js +54 -0
  60. package/components/Radio/RadioGroupContext.d.ts +3 -0
  61. package/components/Radio/RadioGroupContext.js +9 -0
  62. package/components/Radio/index.d.ts +8 -0
  63. package/components/Radio/index.js +6 -0
  64. package/components/Radio/types.d.ts +32 -0
  65. package/components/Radio/types.js +1 -0
  66. package/components/Rating/Rating.d.ts +5 -5
  67. package/components/Rating/Rating.js +2 -2
  68. package/components/SegmentedControl/SegmentedControl.js +20 -104
  69. package/components/SegmentedControl/types.d.ts +4 -8
  70. package/components/Select/Select.d.ts +39 -0
  71. package/components/Select/Select.js +497 -0
  72. package/components/Select/SelectContext.d.ts +20 -0
  73. package/components/Select/SelectContext.js +56 -0
  74. package/components/Select/index.d.ts +3 -0
  75. package/components/Select/index.js +1 -0
  76. package/components/Select/types.d.ts +216 -0
  77. package/components/Select/types.js +11 -0
  78. package/components/Sidebar/Sidebar.js +12 -12
  79. package/components/Sidebar/types.d.ts +5 -5
  80. package/components/StepIndicator/StepIndicator.js +1 -1
  81. package/components/StepList/StepList.js +2 -2
  82. package/components/StepList/types.d.ts +4 -4
  83. package/components/Switch/Switch.d.ts +9 -0
  84. package/components/Switch/Switch.js +91 -0
  85. package/components/Switch/index.d.ts +2 -0
  86. package/components/Switch/index.js +1 -0
  87. package/components/Switch/types.d.ts +11 -0
  88. package/components/Switch/types.js +1 -0
  89. package/components/TextInput/TextInput.d.ts +8 -0
  90. package/components/TextInput/TextInput.js +25 -0
  91. package/components/TextInput/index.d.ts +2 -0
  92. package/components/TextInput/index.js +1 -0
  93. package/components/TextInput/types.d.ts +32 -0
  94. package/components/TextInput/types.js +1 -0
  95. package/components/Textarea/Textarea.d.ts +6 -0
  96. package/components/Textarea/Textarea.js +49 -0
  97. package/components/Textarea/index.d.ts +2 -0
  98. package/components/Textarea/index.js +1 -0
  99. package/components/Textarea/types.d.ts +25 -0
  100. package/components/Textarea/types.js +1 -0
  101. package/components/index.d.ts +22 -0
  102. package/components/index.js +11 -0
  103. package/icons/icons.svg +2 -0
  104. package/icons/manifest.json +16 -0
  105. package/icons/registry.d.ts +4 -0
  106. package/icons/registry.js +2 -0
  107. package/icons/system/index.d.ts +4 -0
  108. package/icons/system/index.js +22 -0
  109. package/package.json +1 -1
  110. package/styles/all.css +1 -1
  111. package/styles/all.expanded.css +1838 -136
  112. package/styles/all.expanded.unlayered.css +1838 -136
  113. package/styles/all.unlayered.css +1 -1
  114. package/styles/components/_bundle.scss +22 -0
  115. package/styles/components/input/index.scss +5 -20
  116. package/styles/index.scss +21 -0
  117. package/styles/system/_control.scss +49 -0
  118. package/styles/system/_tokens.scss +124 -2
  119. package/styles/system/index.scss +2 -1
  120. package/styles/utilities/_index.scss +50 -0
  121. package/tui-manifest.json +907 -112
  122. package/utils/compose-events.d.ts +15 -0
  123. package/utils/compose-events.js +27 -0
  124. package/utils/hash.d.ts +15 -0
  125. package/utils/hash.js +32 -0
  126. package/utils/index.d.ts +3 -0
  127. package/utils/index.js +6 -0
  128. package/utils/is-dev.d.ts +5 -0
  129. package/utils/is-dev.js +7 -0
  130. package/utils/use-controllable-state.d.ts +19 -0
  131. package/utils/use-controllable-state.js +59 -0
  132. package/utils/use-roving-group.d.ts +33 -0
  133. package/utils/use-roving-group.js +123 -0
  134. package/utils/value-key.d.ts +16 -0
  135. 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';