@tangible/ui 0.0.7 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Accordion/Accordion.js +11 -3
- package/components/Avatar/Avatar.js +4 -3
- package/components/Avatar/AvatarGroup.js +7 -5
- package/components/Avatar/index.d.ts +2 -2
- package/components/Avatar/index.js +1 -1
- package/components/Avatar/types.d.ts +27 -0
- package/components/Avatar/types.js +8 -0
- package/components/Button/Button.js +4 -2
- package/components/Button/index.d.ts +2 -1
- package/components/Button/index.js +1 -0
- package/components/Button/types.d.ts +10 -0
- package/components/Button/types.js +3 -1
- package/components/Checkbox/Checkbox.js +46 -11
- package/components/Checkbox/types.d.ts +9 -0
- package/components/Combobox/Combobox.d.ts +1 -1
- package/components/Combobox/Combobox.js +28 -7
- package/components/Combobox/index.d.ts +2 -1
- package/components/Combobox/index.js +1 -0
- package/components/Combobox/types.d.ts +9 -0
- package/components/Combobox/types.js +3 -1
- package/components/Dropdown/Dropdown.js +16 -4
- package/components/Field/Field.d.ts +4 -1
- package/components/Field/Field.js +35 -14
- package/components/Field/FieldContext.d.ts +16 -0
- package/components/Field/FieldContext.js +3 -0
- package/components/Field/index.d.ts +2 -1
- package/components/Field/index.js +1 -0
- package/components/MoveHandle/MoveHandle.js +1 -1
- package/components/MoveHandle/types.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.js +37 -19
- package/components/MultiSelect/index.d.ts +2 -1
- package/components/MultiSelect/index.js +1 -0
- package/components/MultiSelect/types.d.ts +34 -0
- package/components/MultiSelect/types.js +10 -0
- package/components/Pager/Pager.d.ts +7 -1
- package/components/Pager/Pager.js +7 -5
- package/components/Pager/index.d.ts +2 -0
- package/components/Pager/index.js +1 -0
- package/components/Pager/types.d.ts +37 -0
- package/components/Pager/types.js +12 -0
- package/components/Rating/Rating.d.ts +2 -32
- package/components/Rating/Rating.js +5 -3
- package/components/Rating/index.d.ts +2 -1
- package/components/Rating/index.js +1 -0
- package/components/Rating/types.d.ts +41 -0
- package/components/Rating/types.js +4 -0
- package/components/SegmentedControl/SegmentedControl.js +6 -5
- package/components/SegmentedControl/types.d.ts +17 -5
- package/components/Select/Select.d.ts +1 -0
- package/components/Select/Select.js +109 -77
- package/components/Select/SelectContext.d.ts +4 -16
- package/components/Select/SelectContext.js +5 -35
- package/components/Select/types.d.ts +19 -19
- package/components/Sidebar/Sidebar.js +25 -20
- package/components/StepIndicator/StepIndicator.js +11 -8
- package/components/StepIndicator/index.d.ts +2 -1
- package/components/StepIndicator/index.js +1 -0
- package/components/StepIndicator/types.d.ts +18 -0
- package/components/StepIndicator/types.js +7 -1
- package/components/Table/BulkActionsBar.d.ts +4 -1
- package/components/Table/BulkActionsBar.js +5 -4
- package/components/Table/DataTable.d.ts +4 -1
- package/components/Table/DataTable.js +10 -8
- package/components/Table/index.d.ts +3 -0
- package/components/Table/index.js +2 -0
- package/components/Table/types.d.ts +20 -0
- package/components/Table/types.js +11 -0
- package/components/Tabs/Tabs.js +11 -4
- package/components/TextInput/TextInput.js +2 -1
- package/components/TextInput/types.d.ts +7 -1
- package/components/Textarea/Textarea.js +3 -2
- package/components/Textarea/types.d.ts +6 -1
- package/icons/icons.svg +29 -15
- package/icons/lms/index.d.ts +8 -0
- package/icons/lms/index.js +48 -4
- package/icons/manifest.json +112 -0
- package/icons/player/index.js +9 -9
- package/icons/registry.d.ts +28 -0
- package/icons/registry.js +14 -0
- package/icons/system/index.d.ts +20 -0
- package/icons/system/index.js +112 -2
- package/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +266 -59
- package/styles/all.expanded.unlayered.css +266 -59
- package/styles/all.unlayered.css +1 -1
- package/styles/components/input/index.scss +29 -7
- package/styles/system/_constants.scss +1 -1
- package/styles/system/_tokens.scss +1 -0
- package/tui-manifest.json +73 -44
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SizeStandard, Theme as ThemeFull } from '../../types';
|
|
2
|
+
export type Size = SizeStandard;
|
|
3
|
+
export type Theme = ThemeFull;
|
|
4
|
+
export type RatingLabels = {
|
|
5
|
+
/** Group label for the radiogroup/img. Receives current value and max. */
|
|
6
|
+
rating?: (value: number, max: number) => string;
|
|
7
|
+
/** Visually-hidden label for each star. Receives star number and max. */
|
|
8
|
+
value?: (n: number, max: number) => string;
|
|
9
|
+
};
|
|
10
|
+
export declare const defaultRatingLabels: Required<RatingLabels>;
|
|
11
|
+
export type RatingProps = {
|
|
12
|
+
/** Controlled value (1..max). Use with onValueChange */
|
|
13
|
+
value?: number;
|
|
14
|
+
/** Uncontrolled initial value */
|
|
15
|
+
defaultValue?: number;
|
|
16
|
+
/** Maximum icons shown */
|
|
17
|
+
max?: number;
|
|
18
|
+
/** Disable interaction (keeps semantics) */
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Presentational readOnly (no form semantics) */
|
|
21
|
+
readOnly?: boolean;
|
|
22
|
+
/** Name for the radio group (if you care about form posts) */
|
|
23
|
+
name?: string;
|
|
24
|
+
/** Size maps to icon + spacing */
|
|
25
|
+
size?: Size;
|
|
26
|
+
/** Theme feeds foreground color tokens */
|
|
27
|
+
theme?: Theme;
|
|
28
|
+
/** Called when the value changes */
|
|
29
|
+
onValueChange?: (value: number) => void;
|
|
30
|
+
/** Allow clicking the current selection to clear back to 0 */
|
|
31
|
+
allowClear?: boolean;
|
|
32
|
+
className?: string;
|
|
33
|
+
/** Gap override (e.g. '0.25rem') – otherwise uses density utilities */
|
|
34
|
+
gap?: string;
|
|
35
|
+
/** Accessible label for the rating group. Defaults to "Rating: X of Y" */
|
|
36
|
+
'aria-label'?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Override default English strings for i18n.
|
|
39
|
+
*/
|
|
40
|
+
labels?: RatingLabels;
|
|
41
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { isDev } from '../../utils/is-dev.js';
|
|
5
5
|
import { toKey } from '../../utils/value-key.js';
|
|
@@ -8,9 +8,10 @@ import { SegmentedControlContext, useSegmentedControlContext } from './Segmented
|
|
|
8
8
|
// =============================================================================
|
|
9
9
|
// SegmentedControl Root
|
|
10
10
|
// =============================================================================
|
|
11
|
-
function SegmentedControlRoot({ value: controlledValue, defaultValue, onValueChange, variant = 'pill', size = 'md', orientation = 'horizontal', loop = true, disabled = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }) {
|
|
11
|
+
function SegmentedControlRoot({ value: controlledValue, defaultValue, onValueChange, variant = 'pill', size = 'md', orientation = 'horizontal', loop = true, wrap = false, disabled = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }) {
|
|
12
12
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
13
|
-
|
|
13
|
+
// Lock controlled/uncontrolled decision at mount to prevent mode switching
|
|
14
|
+
const isControlled = useRef(controlledValue !== undefined).current;
|
|
14
15
|
const selectedValue = isControlled ? controlledValue : internalValue;
|
|
15
16
|
// Selection handler
|
|
16
17
|
const onSelect = useCallback((newValue) => {
|
|
@@ -28,7 +29,7 @@ function SegmentedControlRoot({ value: controlledValue, defaultValue, onValueCha
|
|
|
28
29
|
disabled,
|
|
29
30
|
loop,
|
|
30
31
|
orientation,
|
|
31
|
-
orientationKeyboard:
|
|
32
|
+
orientationKeyboard: false,
|
|
32
33
|
});
|
|
33
34
|
// Dev-only: Warn if missing accessible name
|
|
34
35
|
useEffect(() => {
|
|
@@ -60,7 +61,7 @@ function SegmentedControlRoot({ value: controlledValue, defaultValue, onValueCha
|
|
|
60
61
|
unregisterItem,
|
|
61
62
|
onSelect,
|
|
62
63
|
]);
|
|
63
|
-
return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: _jsx("div", { role: "radiogroup", className: cx('tui-segmented', `is-variant-${variant}`, `is-size-${size}`, orientation === 'vertical' && 'is-vertical', className), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-orientation": orientation, onKeyDown: handleKeyDown, children: children }) }));
|
|
64
|
+
return (_jsx(SegmentedControlContext.Provider, { value: contextValue, children: _jsx("div", { role: "radiogroup", className: cx('tui-segmented', `is-variant-${variant}`, `is-size-${size}`, orientation === 'vertical' && 'is-vertical', wrap && 'is-wrap', className), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-orientation": orientation, onKeyDown: handleKeyDown, children: children }) }));
|
|
64
65
|
}
|
|
65
66
|
// =============================================================================
|
|
66
67
|
// SegmentedControl.Item
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { RovingItemRecord } from '../../utils/use-roving-group';
|
|
3
|
+
import type { SizeStandard } from '../../types/sizes';
|
|
3
4
|
export type SegmentedControlValue = string | number;
|
|
4
5
|
export type SegmentedControlVariant = 'pill' | 'outline' | 'underline';
|
|
5
|
-
export type SegmentedControlSize =
|
|
6
|
+
export type SegmentedControlSize = SizeStandard;
|
|
6
7
|
export type SegmentedControlOrientation = 'horizontal' | 'vertical';
|
|
7
8
|
export type SegmentedControlProps = {
|
|
8
9
|
/** Controlled selected value */
|
|
@@ -19,11 +20,19 @@ export type SegmentedControlProps = {
|
|
|
19
20
|
orientation?: SegmentedControlOrientation;
|
|
20
21
|
/** Whether arrow keys wrap around */
|
|
21
22
|
loop?: boolean;
|
|
23
|
+
/** Allow items to wrap to multiple lines (default: false) */
|
|
24
|
+
wrap?: boolean;
|
|
22
25
|
/** Disable all items */
|
|
23
26
|
disabled?: boolean;
|
|
24
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Accessible label for the radiogroup.
|
|
29
|
+
* At least one of `aria-label` or `aria-labelledby` is required.
|
|
30
|
+
*/
|
|
25
31
|
'aria-label'?: string;
|
|
26
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* ID of element that labels this control.
|
|
34
|
+
* At least one of `aria-label` or `aria-labelledby` is required.
|
|
35
|
+
*/
|
|
27
36
|
'aria-labelledby'?: string;
|
|
28
37
|
/** Additional classes */
|
|
29
38
|
className?: string;
|
|
@@ -34,9 +43,12 @@ export type SegmentedControlItemProps = {
|
|
|
34
43
|
value: SegmentedControlValue;
|
|
35
44
|
/** Disable this item */
|
|
36
45
|
disabled?: boolean;
|
|
37
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Icon element rendered before the label.
|
|
48
|
+
* @remarks Icon-only items (no `children`) must provide `aria-label`.
|
|
49
|
+
*/
|
|
38
50
|
icon?: ReactNode;
|
|
39
|
-
/** Accessible label for icon-only items */
|
|
51
|
+
/** Accessible label — required for icon-only items */
|
|
40
52
|
'aria-label'?: string;
|
|
41
53
|
/** Additional classes */
|
|
42
54
|
className?: string;
|
|
@@ -37,3 +37,4 @@ export declare const SelectOption: typeof SelectOptionComponent;
|
|
|
37
37
|
export declare const SelectGroup: typeof SelectGroupComponent;
|
|
38
38
|
export declare const SelectLabel: typeof SelectLabelComponent;
|
|
39
39
|
export { useSelectContext as useSelect } from './SelectContext';
|
|
40
|
+
export type { SelectContextValue } from './types';
|
|
@@ -6,15 +6,16 @@ import { cx } from '../../utils/cx.js';
|
|
|
6
6
|
import { toKey } from '../../utils/value-key.js';
|
|
7
7
|
import { getPortalRootFor } from '../../utils/portal.js';
|
|
8
8
|
import { Icon } from '../Icon/index.js';
|
|
9
|
-
import {
|
|
9
|
+
import { SelectContext, SelectContentContext, useSelectContext, useSelectContentContext, } from './SelectContext.js';
|
|
10
10
|
import { toPlacement, } from './types.js';
|
|
11
11
|
// =============================================================================
|
|
12
12
|
// Select Root
|
|
13
13
|
// =============================================================================
|
|
14
14
|
function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', side = 'bottom', align = 'start', sideOffset = 4, clearable = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }) {
|
|
15
|
-
// Controlled/uncontrolled value
|
|
15
|
+
// Controlled/uncontrolled value — lock decision at mount time to prevent
|
|
16
|
+
// switching modes when controlled value is cleared to undefined
|
|
16
17
|
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
17
|
-
const isValueControlled = controlledValue !== undefined;
|
|
18
|
+
const isValueControlled = useRef(controlledValue !== undefined).current;
|
|
18
19
|
const value = isValueControlled ? controlledValue : uncontrolledValue;
|
|
19
20
|
// Store display text separately so it persists when dropdown is closed
|
|
20
21
|
const [displayText, setDisplayText] = useState(undefined);
|
|
@@ -28,20 +29,16 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
28
29
|
}
|
|
29
30
|
onValueChange?.(newValue);
|
|
30
31
|
}, [isValueControlled, onValueChange]);
|
|
31
|
-
// Sync displayText when value changes (
|
|
32
|
-
const prevValue = useRef(value);
|
|
32
|
+
// Sync displayText when controlled value changes externally (parent prop update)
|
|
33
33
|
useEffect(() => {
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
setDisplayText(option.textValue);
|
|
42
|
-
}
|
|
34
|
+
if (value === undefined) {
|
|
35
|
+
setDisplayText(undefined);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const option = optionsRef.current.get(toKey(value));
|
|
39
|
+
if (option) {
|
|
40
|
+
setDisplayText(option.textValue);
|
|
43
41
|
}
|
|
44
|
-
prevValue.current = value;
|
|
45
42
|
}
|
|
46
43
|
}, [value]);
|
|
47
44
|
// Initialize displayText when options register (handles defaultValue case)
|
|
@@ -58,9 +55,15 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
58
55
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
|
|
59
56
|
const isOpenControlled = controlledOpen !== undefined;
|
|
60
57
|
const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
|
|
58
|
+
// Guard flag: when Backspace/Delete clears a closed Select, Floating UI's
|
|
59
|
+
// composed handlers (useTypeahead/useListNavigation) also fire and call
|
|
60
|
+
// setOpen(true). This ref rejects that open request within the same microtask.
|
|
61
|
+
const justClearedRef = useRef(false);
|
|
61
62
|
const setOpen = useCallback((nextOpen) => {
|
|
62
63
|
if (disabled)
|
|
63
64
|
return;
|
|
65
|
+
if (nextOpen && justClearedRef.current)
|
|
66
|
+
return;
|
|
64
67
|
if (!isOpenControlled) {
|
|
65
68
|
setUncontrolledOpen(nextOpen);
|
|
66
69
|
}
|
|
@@ -100,6 +103,15 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
100
103
|
return options;
|
|
101
104
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
105
|
}, [registryVersion]);
|
|
106
|
+
// O(1) index lookup map — rebuilt when orderedOptions changes
|
|
107
|
+
const optionIndexMap = useMemo(() => {
|
|
108
|
+
const map = new Map();
|
|
109
|
+
orderedOptions.forEach((opt, i) => map.set(toKey(opt.value), i));
|
|
110
|
+
return map;
|
|
111
|
+
}, [orderedOptions]);
|
|
112
|
+
// Ref for stable handleSelect — avoids recreating on registry changes
|
|
113
|
+
const orderedOptionsRef = useRef(orderedOptions);
|
|
114
|
+
orderedOptionsRef.current = orderedOptions;
|
|
103
115
|
// Floating UI setup - in Root so Trigger and Content can share
|
|
104
116
|
const { refs, floatingStyles, context } = useFloating({
|
|
105
117
|
placement,
|
|
@@ -121,16 +133,16 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
121
133
|
],
|
|
122
134
|
whileElementsMounted: autoUpdate,
|
|
123
135
|
});
|
|
124
|
-
// Handle selection
|
|
136
|
+
// Handle selection — reads orderedOptionsRef to avoid recreating on registry changes
|
|
125
137
|
const handleSelect = useCallback((index) => {
|
|
126
138
|
if (index === null)
|
|
127
139
|
return;
|
|
128
|
-
const option =
|
|
140
|
+
const option = orderedOptionsRef.current[index];
|
|
129
141
|
if (option && !option.disabled) {
|
|
130
142
|
setValue(option.value, option.textValue);
|
|
131
143
|
setOpen(false);
|
|
132
144
|
}
|
|
133
|
-
}, [
|
|
145
|
+
}, [setValue, setOpen]);
|
|
134
146
|
// Floating UI interactions
|
|
135
147
|
// Use 'click' (not 'mousedown') so button has focus when dropdown opens
|
|
136
148
|
// This ensures keyboard navigation works immediately
|
|
@@ -150,7 +162,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
150
162
|
loop: true,
|
|
151
163
|
virtual: true,
|
|
152
164
|
focusItemOnOpen: true,
|
|
153
|
-
selectedIndex:
|
|
165
|
+
selectedIndex: value !== undefined ? (optionIndexMap.get(toKey(value)) ?? -1) : -1,
|
|
154
166
|
disabledIndices,
|
|
155
167
|
});
|
|
156
168
|
const typeahead = useTypeahead(context, {
|
|
@@ -179,10 +191,6 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
179
191
|
optionsRef.current.delete(toKey(optionValue));
|
|
180
192
|
setRegistryVersion((v) => v + 1);
|
|
181
193
|
}, []);
|
|
182
|
-
// Get selected option's text value
|
|
183
|
-
const getSelectedTextValue = useCallback(() => {
|
|
184
|
-
return displayText;
|
|
185
|
-
}, [displayText]);
|
|
186
194
|
// Highlighted value for keyboard navigation
|
|
187
195
|
const highlightedValue = activeIndex !== null ? orderedOptions[activeIndex]?.value ?? null : null;
|
|
188
196
|
// Reset active index when closing
|
|
@@ -192,7 +200,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
192
200
|
}
|
|
193
201
|
else {
|
|
194
202
|
// When opening, set active index to selected value or first enabled
|
|
195
|
-
const selectedIndex =
|
|
203
|
+
const selectedIndex = value !== undefined ? (optionIndexMap.get(toKey(value)) ?? -1) : -1;
|
|
196
204
|
if (selectedIndex >= 0 && !orderedOptions[selectedIndex]?.disabled) {
|
|
197
205
|
setActiveIndex(selectedIndex);
|
|
198
206
|
}
|
|
@@ -201,7 +209,7 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
201
209
|
setActiveIndex(firstEnabled >= 0 ? firstEnabled : null);
|
|
202
210
|
}
|
|
203
211
|
}
|
|
204
|
-
}, [open, orderedOptions, value]);
|
|
212
|
+
}, [open, orderedOptions, optionIndexMap, value]);
|
|
205
213
|
// Scroll active option into view
|
|
206
214
|
useEffect(() => {
|
|
207
215
|
if (open && activeIndex !== null && listRef.current[activeIndex]) {
|
|
@@ -209,98 +217,82 @@ function SelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, o
|
|
|
209
217
|
}
|
|
210
218
|
}, [open, activeIndex]);
|
|
211
219
|
// ==========================================================================
|
|
212
|
-
//
|
|
213
|
-
// ==========================================================================
|
|
214
|
-
// Actions: stable config, IDs, refs, callbacks — rarely changes
|
|
215
|
-
// State: open, value, activeIndex, etc. — changes on interaction
|
|
220
|
+
// Context Value
|
|
216
221
|
// ==========================================================================
|
|
217
|
-
const
|
|
218
|
-
// Config
|
|
222
|
+
const contextValue = useMemo(() => ({
|
|
223
|
+
// Config
|
|
219
224
|
disabled,
|
|
220
225
|
placeholder,
|
|
221
226
|
clearable,
|
|
222
227
|
size,
|
|
223
|
-
// ARIA IDs
|
|
228
|
+
// ARIA IDs
|
|
224
229
|
triggerId,
|
|
225
230
|
listboxId,
|
|
226
231
|
ariaLabel,
|
|
227
232
|
ariaLabelledBy,
|
|
228
233
|
ariaDescribedBy,
|
|
229
|
-
//
|
|
234
|
+
// Callbacks
|
|
230
235
|
setOpen,
|
|
231
236
|
setValue,
|
|
232
237
|
registerOption,
|
|
233
238
|
unregisterOption,
|
|
234
239
|
handleSelect,
|
|
235
|
-
// Refs
|
|
240
|
+
// Refs
|
|
236
241
|
refs,
|
|
237
242
|
listRef,
|
|
238
|
-
|
|
243
|
+
justClearedRef,
|
|
244
|
+
// Floating UI interaction props
|
|
239
245
|
getReferenceProps,
|
|
240
246
|
getFloatingProps,
|
|
241
247
|
getItemProps,
|
|
248
|
+
// State
|
|
249
|
+
open,
|
|
250
|
+
value,
|
|
251
|
+
displayText,
|
|
252
|
+
activeIndex,
|
|
253
|
+
highlightedValue,
|
|
254
|
+
orderedOptions,
|
|
255
|
+
optionIndexMap,
|
|
256
|
+
floatingStyles,
|
|
242
257
|
}), [
|
|
243
|
-
// Config props - only change if parent rerenders with new props
|
|
244
258
|
disabled,
|
|
245
259
|
placeholder,
|
|
246
260
|
clearable,
|
|
247
261
|
size,
|
|
248
|
-
// IDs are stable (from useId)
|
|
249
262
|
triggerId,
|
|
250
263
|
listboxId,
|
|
251
264
|
ariaLabel,
|
|
252
265
|
ariaLabelledBy,
|
|
253
266
|
ariaDescribedBy,
|
|
254
|
-
// Callbacks are stable (useCallback with stable deps)
|
|
255
267
|
setOpen,
|
|
256
268
|
setValue,
|
|
257
269
|
registerOption,
|
|
258
270
|
unregisterOption,
|
|
259
271
|
handleSelect,
|
|
260
|
-
// Refs are stable objects
|
|
261
272
|
refs,
|
|
262
273
|
listRef,
|
|
263
|
-
// Interaction props from useInteractions (stable)
|
|
264
274
|
getReferenceProps,
|
|
265
275
|
getFloatingProps,
|
|
266
276
|
getItemProps,
|
|
267
|
-
]);
|
|
268
|
-
const stateValue = useMemo(() => ({
|
|
269
|
-
// Open state
|
|
270
|
-
open,
|
|
271
|
-
// Selection state
|
|
272
|
-
value,
|
|
273
|
-
// Selection helper
|
|
274
|
-
getSelectedTextValue,
|
|
275
|
-
// Navigation state
|
|
276
|
-
activeIndex,
|
|
277
|
-
highlightedValue,
|
|
278
|
-
// Derived (changes when registry changes)
|
|
279
|
-
orderedOptions,
|
|
280
|
-
// Floating UI (changes on open/position)
|
|
281
|
-
floatingStyles,
|
|
282
|
-
floatingContext: context,
|
|
283
|
-
}), [
|
|
284
277
|
open,
|
|
285
278
|
value,
|
|
286
|
-
|
|
279
|
+
displayText,
|
|
287
280
|
activeIndex,
|
|
288
281
|
highlightedValue,
|
|
289
282
|
orderedOptions,
|
|
283
|
+
optionIndexMap,
|
|
290
284
|
floatingStyles,
|
|
291
|
-
context,
|
|
292
285
|
]);
|
|
293
|
-
return (_jsx(
|
|
286
|
+
return (_jsx(SelectContext.Provider, { value: contextValue, children: children }));
|
|
294
287
|
}
|
|
295
288
|
SelectRoot.displayName = 'Select';
|
|
296
289
|
// =============================================================================
|
|
297
290
|
// Select.Trigger
|
|
298
291
|
// =============================================================================
|
|
299
292
|
function SelectTriggerComponent({ asChild = false, className, children, }) {
|
|
300
|
-
const { open, setOpen, disabled, placeholder, clearable, size, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy,
|
|
293
|
+
const { open, setOpen, disabled, placeholder, clearable, size, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, displayText, setValue, refs, getReferenceProps, activeIndex, handleSelect, justClearedRef, } = useSelectContext();
|
|
301
294
|
const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
|
|
302
|
-
const
|
|
303
|
-
const hasValue = displayValue !== undefined;
|
|
295
|
+
const hasValue = displayText !== undefined;
|
|
304
296
|
// Ensure trigger has focus when dropdown opens (Safari doesn't focus buttons on click)
|
|
305
297
|
useEffect(() => {
|
|
306
298
|
if (open && refs.reference.current) {
|
|
@@ -315,9 +307,11 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
|
|
|
315
307
|
}
|
|
316
308
|
if (!open && clearable && hasValue && (e.key === 'Delete' || e.key === 'Backspace')) {
|
|
317
309
|
e.preventDefault();
|
|
310
|
+
justClearedRef.current = true;
|
|
311
|
+
queueMicrotask(() => { justClearedRef.current = false; });
|
|
318
312
|
setValue(undefined);
|
|
319
313
|
}
|
|
320
|
-
}, [open, activeIndex, handleSelect, clearable, hasValue, setValue]);
|
|
314
|
+
}, [open, activeIndex, handleSelect, clearable, hasValue, setValue, justClearedRef]);
|
|
321
315
|
// Close dropdown when focus leaves trigger
|
|
322
316
|
// Uses blur guard pattern: only close if focus moved outside controlled elements
|
|
323
317
|
const handleBlur = useCallback((e) => {
|
|
@@ -344,7 +338,9 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
|
|
|
344
338
|
if (disabled)
|
|
345
339
|
return;
|
|
346
340
|
setValue(undefined);
|
|
347
|
-
|
|
341
|
+
// Return focus to trigger after clearing
|
|
342
|
+
refs.reference.current?.focus();
|
|
343
|
+
}, [disabled, setValue, refs.reference]);
|
|
348
344
|
// Get Floating UI's reference props - pass our handlers so they get composed
|
|
349
345
|
const floatingProps = getReferenceProps({
|
|
350
346
|
'aria-activedescendant': activeIndex !== null ? `${listboxId}-option-${activeIndex}` : undefined,
|
|
@@ -356,9 +352,8 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
|
|
|
356
352
|
'aria-label': ariaLabel,
|
|
357
353
|
'aria-labelledby': ariaLabelledBy,
|
|
358
354
|
'aria-describedby': ariaDescribedBy,
|
|
359
|
-
'aria-
|
|
355
|
+
'aria-keyshortcuts': clearable && hasValue ? 'Delete' : undefined,
|
|
360
356
|
'data-state': open ? 'open' : 'closed',
|
|
361
|
-
'data-disabled': disabled || undefined,
|
|
362
357
|
...floatingProps,
|
|
363
358
|
};
|
|
364
359
|
if (asChild && isValidElement(children)) {
|
|
@@ -390,6 +385,7 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
|
|
|
390
385
|
'aria-controls': floatingProps['aria-controls'],
|
|
391
386
|
'aria-activedescendant': floatingProps['aria-activedescendant'],
|
|
392
387
|
'aria-describedby': ariaDescribedBy,
|
|
388
|
+
// asChild: use aria-disabled + data-disabled since element may not support native disabled
|
|
393
389
|
'aria-disabled': disabled || undefined,
|
|
394
390
|
'data-state': open ? 'open' : 'closed',
|
|
395
391
|
'data-disabled': disabled || undefined,
|
|
@@ -402,14 +398,18 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
|
|
|
402
398
|
onPointerDown: composeHandler(triggerProps.onPointerDown, childProps.onPointerDown),
|
|
403
399
|
});
|
|
404
400
|
}
|
|
405
|
-
|
|
401
|
+
const showClear = clearable && hasValue && !disabled;
|
|
402
|
+
// Wrap in a relative container so the clear button can be a sibling of the
|
|
403
|
+
// trigger <button> — nesting <button> inside <button> is invalid HTML and
|
|
404
|
+
// causes browsers to eject the inner button via the adoption agency algorithm.
|
|
405
|
+
return (_jsxs("div", { className: cx('tui-select__trigger-wrap', showClear && 'has-clear'), children: [_jsxs("button", { ref: refs.setReference, type: "button", className: cx('tui-select__trigger', sizeClass, className), disabled: disabled, ...triggerProps, children: [_jsx("span", { className: "tui-select__value", children: displayText ?? _jsx("span", { className: "tui-select__placeholder", children: placeholder }) }), _jsx(Icon, { name: "system/chevron-down", size: "sm", className: "tui-select__icon", "aria-hidden": "true" })] }), showClear && (_jsx("button", { type: "button", className: "tui-select__clear", onClick: handleClear, "aria-label": "Clear selection", tabIndex: -1, children: _jsx(Icon, { name: "system/close", size: "sm" }) }))] }));
|
|
406
406
|
}
|
|
407
407
|
SelectTriggerComponent.displayName = 'Select.Trigger';
|
|
408
408
|
// =============================================================================
|
|
409
409
|
// Select.Content
|
|
410
410
|
// =============================================================================
|
|
411
411
|
function SelectContentComponent({ className, children, }) {
|
|
412
|
-
const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, handleSelect, orderedOptions, } = useSelectContext();
|
|
412
|
+
const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, handleSelect, orderedOptions, optionIndexMap, } = useSelectContext();
|
|
413
413
|
const portalRoot = getPortalRootFor(refs.reference.current);
|
|
414
414
|
// Memoized context for options
|
|
415
415
|
const contentContext = useMemo(() => ({
|
|
@@ -417,8 +417,9 @@ function SelectContentComponent({ className, children, }) {
|
|
|
417
417
|
activeIndex,
|
|
418
418
|
handleSelect,
|
|
419
419
|
orderedOptions,
|
|
420
|
-
|
|
421
|
-
|
|
420
|
+
optionIndexMap,
|
|
421
|
+
}), [listRef, activeIndex, handleSelect, orderedOptions, optionIndexMap]);
|
|
422
|
+
return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(SelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, className: cx('tui-select__content', className), style: {
|
|
422
423
|
...floatingStyles,
|
|
423
424
|
minWidth: refs.reference.current?.offsetWidth,
|
|
424
425
|
pointerEvents: 'auto',
|
|
@@ -430,13 +431,15 @@ SelectContentComponent.displayName = 'Select.Content';
|
|
|
430
431
|
// =============================================================================
|
|
431
432
|
function SelectOptionComponent({ value: optionValue, disabled = false, textValue: explicitTextValue, className, children, }) {
|
|
432
433
|
const { value: selectedValue, setValue, setOpen, listboxId, registerOption, unregisterOption, highlightedValue, getItemProps, } = useSelectContext();
|
|
433
|
-
const { listRef,
|
|
434
|
+
const { listRef, optionIndexMap } = useSelectContentContext();
|
|
434
435
|
const ref = useRef(null);
|
|
435
436
|
// Derive textValue from children if not explicitly provided
|
|
436
437
|
const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
|
|
437
|
-
// Warn in dev if textValue couldn't be derived
|
|
438
|
+
// Warn in dev if textValue couldn't be derived (once per mount)
|
|
439
|
+
const warnedTextValueRef = useRef(false);
|
|
438
440
|
useEffect(() => {
|
|
439
|
-
if (isDev() && !textValue) {
|
|
441
|
+
if (isDev() && !textValue && !warnedTextValueRef.current) {
|
|
442
|
+
warnedTextValueRef.current = true;
|
|
440
443
|
console.warn(`Select.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
|
|
441
444
|
}
|
|
442
445
|
}, [textValue, optionValue]);
|
|
@@ -445,8 +448,8 @@ function SelectOptionComponent({ value: optionValue, disabled = false, textValue
|
|
|
445
448
|
registerOption({ value: optionValue, ref, disabled, textValue });
|
|
446
449
|
return () => unregisterOption(optionValue);
|
|
447
450
|
}, [optionValue, disabled, textValue, registerOption, unregisterOption]);
|
|
448
|
-
//
|
|
449
|
-
const index =
|
|
451
|
+
// O(1) index lookup via Map
|
|
452
|
+
const index = optionIndexMap.get(toKey(optionValue)) ?? -1;
|
|
450
453
|
// Assign ref to listRef for navigation
|
|
451
454
|
useEffect(() => {
|
|
452
455
|
if (index >= 0) {
|
|
@@ -471,7 +474,27 @@ SelectOptionComponent.displayName = 'Select.Option';
|
|
|
471
474
|
// =============================================================================
|
|
472
475
|
function SelectGroupComponent({ className, children }) {
|
|
473
476
|
const groupId = useId();
|
|
474
|
-
|
|
477
|
+
const groupRef = useRef(null);
|
|
478
|
+
// Guard aria-labelledby: only set when a Label child actually exists in the DOM.
|
|
479
|
+
// Without this, aria-labelledby points to a non-existent ID, which per AccName 1.2
|
|
480
|
+
// step 2B overrides all other name sources and resolves to empty string.
|
|
481
|
+
// useLayoutEffect runs before paint, so AT never sees the dangling reference.
|
|
482
|
+
// groupId is stable (from useId) and label presence is stable after mount —
|
|
483
|
+
// only run once to avoid a DOM query on every render.
|
|
484
|
+
useLayoutEffect(() => {
|
|
485
|
+
const groupEl = groupRef.current;
|
|
486
|
+
if (!groupEl)
|
|
487
|
+
return;
|
|
488
|
+
const labelId = `${groupId}-label`;
|
|
489
|
+
const labelEl = groupEl.querySelector(`#${CSS.escape(labelId)}`);
|
|
490
|
+
if (labelEl) {
|
|
491
|
+
groupEl.setAttribute('aria-labelledby', labelId);
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
groupEl.removeAttribute('aria-labelledby');
|
|
495
|
+
}
|
|
496
|
+
}, [groupId]);
|
|
497
|
+
return (_jsx("div", { ref: groupRef, role: "group", className: cx('tui-select__group', className), children: _jsx(SelectGroupContext.Provider, { value: { groupId }, children: children }) }));
|
|
475
498
|
}
|
|
476
499
|
SelectGroupComponent.displayName = 'Select.Group';
|
|
477
500
|
const SelectGroupContext = React.createContext(null);
|
|
@@ -480,6 +503,15 @@ const SelectGroupContext = React.createContext(null);
|
|
|
480
503
|
// =============================================================================
|
|
481
504
|
function SelectLabelComponent({ className, children }) {
|
|
482
505
|
const groupContext = React.useContext(SelectGroupContext);
|
|
506
|
+
// DEV: warn if Label is used outside a Group — it has no semantic effect there
|
|
507
|
+
const warnedRef = useRef(false);
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if (isDev() && !groupContext && !warnedRef.current) {
|
|
510
|
+
warnedRef.current = true;
|
|
511
|
+
console.warn('Select.Label rendered outside Select.Group has no effect. ' +
|
|
512
|
+
'Wrap in Select.Group so aria-labelledby is wired correctly.');
|
|
513
|
+
}
|
|
514
|
+
}, [groupContext]);
|
|
483
515
|
// No aria-hidden — aria-labelledby on Group references this element,
|
|
484
516
|
// so screen readers need access to read the label text.
|
|
485
517
|
return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-select__label', className), children: children }));
|
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare const
|
|
3
|
-
export declare const SelectStateContext: import("react").Context<SelectStateContextValue | null>;
|
|
1
|
+
import type { SelectContextValue, SelectContentContextValue } from './types';
|
|
2
|
+
export declare const SelectContext: import("react").Context<SelectContextValue | null>;
|
|
4
3
|
/**
|
|
5
|
-
* Access
|
|
6
|
-
* Safe to use without causing rerenders on navigation changes.
|
|
4
|
+
* Access Select context: config, IDs, refs, callbacks, and reactive state.
|
|
7
5
|
*/
|
|
8
|
-
export declare function
|
|
9
|
-
/**
|
|
10
|
-
* Access reactive state: open, value, activeIndex, orderedOptions.
|
|
11
|
-
* Subscribing causes rerender when these values change.
|
|
12
|
-
*/
|
|
13
|
-
export declare function useSelectState(): SelectStateContextValue;
|
|
14
|
-
/**
|
|
15
|
-
* Combined hook for components that need both.
|
|
16
|
-
* Convenience for Trigger/Content that need everything.
|
|
17
|
-
*/
|
|
18
|
-
export declare function useSelectContext(): SelectActionsContextValue & SelectStateContextValue;
|
|
6
|
+
export declare function useSelectContext(): SelectContextValue;
|
|
19
7
|
export declare const SelectContentContext: import("react").Context<SelectContentContextValue | null>;
|
|
20
8
|
export declare function useSelectContentContext(): SelectContentContextValue;
|
|
@@ -1,48 +1,18 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
2
|
// =============================================================================
|
|
3
|
-
//
|
|
3
|
+
// Select Context
|
|
4
4
|
// =============================================================================
|
|
5
|
-
|
|
6
|
-
// State context: open, value, activeIndex, etc. — changes on interaction
|
|
7
|
-
//
|
|
8
|
-
// Components subscribe to what they need:
|
|
9
|
-
// - Options: actions (register) + minimal state (isSelected check)
|
|
10
|
-
// - Trigger: both (needs state for display, actions for handlers)
|
|
11
|
-
// - Content: both (needs floating styles, refs)
|
|
12
|
-
// =============================================================================
|
|
13
|
-
export const SelectActionsContext = createContext(null);
|
|
14
|
-
export const SelectStateContext = createContext(null);
|
|
5
|
+
export const SelectContext = createContext(null);
|
|
15
6
|
/**
|
|
16
|
-
* Access
|
|
17
|
-
* Safe to use without causing rerenders on navigation changes.
|
|
7
|
+
* Access Select context: config, IDs, refs, callbacks, and reactive state.
|
|
18
8
|
*/
|
|
19
|
-
export function
|
|
20
|
-
const context = useContext(
|
|
21
|
-
if (!context) {
|
|
22
|
-
throw new Error('Select components must be used within a Select');
|
|
23
|
-
}
|
|
24
|
-
return context;
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Access reactive state: open, value, activeIndex, orderedOptions.
|
|
28
|
-
* Subscribing causes rerender when these values change.
|
|
29
|
-
*/
|
|
30
|
-
export function useSelectState() {
|
|
31
|
-
const context = useContext(SelectStateContext);
|
|
9
|
+
export function useSelectContext() {
|
|
10
|
+
const context = useContext(SelectContext);
|
|
32
11
|
if (!context) {
|
|
33
12
|
throw new Error('Select components must be used within a Select');
|
|
34
13
|
}
|
|
35
14
|
return context;
|
|
36
15
|
}
|
|
37
|
-
/**
|
|
38
|
-
* Combined hook for components that need both.
|
|
39
|
-
* Convenience for Trigger/Content that need everything.
|
|
40
|
-
*/
|
|
41
|
-
export function useSelectContext() {
|
|
42
|
-
const actions = useSelectActions();
|
|
43
|
-
const state = useSelectState();
|
|
44
|
-
return { ...actions, ...state };
|
|
45
|
-
}
|
|
46
16
|
// =============================================================================
|
|
47
17
|
// Content Context (for Option registration)
|
|
48
18
|
// =============================================================================
|