@tangible/ui 0.0.7 → 0.0.9
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.d.ts +1 -1
- package/components/Avatar/Avatar.js +5 -4
- 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 +50 -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.d.ts +1 -1
- package/components/Dropdown/Dropdown.js +32 -12
- 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/Icon/Icon.d.ts +1 -1
- package/components/Icon/Icon.js +2 -2
- package/components/Modal/Modal.d.ts +5 -1
- package/components/Modal/Modal.js +2 -2
- package/components/MoveHandle/MoveHandle.d.ts +1 -1
- package/components/MoveHandle/MoveHandle.js +4 -4
- package/components/MoveHandle/types.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.d.ts +1 -1
- package/components/MultiSelect/MultiSelect.js +58 -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/Progress/Progress.d.ts +2 -1
- package/components/Progress/Progress.js +3 -3
- 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 +131 -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.d.ts +1 -1
- package/components/StepIndicator/StepIndicator.js +14 -10
- 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/components/Tooltip/Tooltip.d.ts +1 -1
- package/components/Tooltip/Tooltip.js +16 -10
- 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 +78 -52
|
@@ -112,12 +112,23 @@ DropdownTriggerComponent.displayName = 'Dropdown.Trigger';
|
|
|
112
112
|
// =============================================================================
|
|
113
113
|
// DropdownContent
|
|
114
114
|
// =============================================================================
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
// Gate component: reads context to decide whether to mount the real content.
|
|
116
|
+
// This ensures useFloating and all Floating UI hooks in DropdownContentInner
|
|
117
|
+
// only run when the dropdown is actually open — not on every render cycle.
|
|
118
|
+
function DropdownContentComponent(props) {
|
|
119
|
+
const { open } = useDropdownContext();
|
|
120
|
+
if (!open)
|
|
121
|
+
return null;
|
|
122
|
+
return _jsx(DropdownContentInner, { ...props });
|
|
123
|
+
}
|
|
124
|
+
DropdownContentComponent.displayName = 'Dropdown.Content';
|
|
125
|
+
// Inner component: only mounted when open. All Floating UI hooks live here.
|
|
126
|
+
function DropdownContentInner({ side = 'bottom', align = 'start', sideOffset = 4, className, style, children, }) {
|
|
127
|
+
const { setOpen, triggerRef, contentId, activeIndex, setActiveIndex, openedVia } = useDropdownContext();
|
|
117
128
|
const listRef = useRef([]);
|
|
118
129
|
const { refs, floatingStyles, context } = useFloating({
|
|
119
130
|
placement: toPlacement(side, align),
|
|
120
|
-
open,
|
|
131
|
+
open: true, // Always true when mounted (gate handles the conditional)
|
|
121
132
|
onOpenChange: setOpen,
|
|
122
133
|
middleware: [offset(sideOffset), flip(), shift({ padding: 8 })],
|
|
123
134
|
whileElementsMounted: autoUpdate,
|
|
@@ -130,9 +141,10 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
130
141
|
}, [triggerRef, refs]);
|
|
131
142
|
// Classify children: count navigable items and collect disabled indices.
|
|
132
143
|
// Separator and Header sub-components are non-navigable.
|
|
133
|
-
const { disabledIndices, totalItemCount } = useMemo(() => {
|
|
144
|
+
const { disabledIndices, totalItemCount, firstEnabledIndex } = useMemo(() => {
|
|
134
145
|
const disabled = [];
|
|
135
146
|
let itemIdx = 0;
|
|
147
|
+
let firstEnabled = -1;
|
|
136
148
|
Children.forEach(children, (child) => {
|
|
137
149
|
if (!isValidElement(child))
|
|
138
150
|
return;
|
|
@@ -146,13 +158,16 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
146
158
|
if (props.disabled) {
|
|
147
159
|
disabled.push(itemIdx);
|
|
148
160
|
}
|
|
161
|
+
else if (firstEnabled === -1) {
|
|
162
|
+
firstEnabled = itemIdx;
|
|
163
|
+
}
|
|
149
164
|
itemIdx++;
|
|
150
165
|
});
|
|
151
|
-
return { disabledIndices: disabled, totalItemCount: itemIdx };
|
|
166
|
+
return { disabledIndices: disabled, totalItemCount: itemIdx, firstEnabledIndex: firstEnabled };
|
|
152
167
|
}, [children]);
|
|
153
168
|
// ArrowUp focus-last: set activeIndex to last valid item before paint
|
|
154
169
|
useLayoutEffect(() => {
|
|
155
|
-
if (
|
|
170
|
+
if (openedVia.current === 'ArrowUp') {
|
|
156
171
|
let lastValid = totalItemCount - 1;
|
|
157
172
|
while (lastValid >= 0 && disabledIndices.includes(lastValid)) {
|
|
158
173
|
lastValid--;
|
|
@@ -162,7 +177,7 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
162
177
|
}
|
|
163
178
|
openedVia.current = null;
|
|
164
179
|
}
|
|
165
|
-
}, [
|
|
180
|
+
}, [openedVia, totalItemCount, disabledIndices, setActiveIndex]);
|
|
166
181
|
const dismiss = useDismiss(context);
|
|
167
182
|
const role = useRole(context, { role: 'menu' });
|
|
168
183
|
const listNavigation = useListNavigation(context, {
|
|
@@ -180,8 +195,6 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
180
195
|
]);
|
|
181
196
|
// Get portal root inside .tui-interface
|
|
182
197
|
const portalRoot = getPortalRootFor(triggerRef.current);
|
|
183
|
-
if (!open)
|
|
184
|
-
return null;
|
|
185
198
|
// Clone children to inject item props and role.
|
|
186
199
|
// Non-navigable children (Separator, Header) are rendered as-is.
|
|
187
200
|
let itemIndex = 0;
|
|
@@ -203,8 +216,16 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
203
216
|
ref: (node) => {
|
|
204
217
|
listRef.current[currentIndex] = node;
|
|
205
218
|
},
|
|
206
|
-
// Disabled items get tabIndex -1 always
|
|
207
|
-
|
|
219
|
+
// Disabled items get tabIndex -1 always.
|
|
220
|
+
// When activeIndex is null (initial render before floating-ui
|
|
221
|
+
// activates), the first non-disabled item gets tabIndex 0 so at
|
|
222
|
+
// least one item is always keyboard-reachable.
|
|
223
|
+
tabIndex: isDisabled
|
|
224
|
+
? -1
|
|
225
|
+
: (activeIndex === currentIndex
|
|
226
|
+
|| (activeIndex === null && !disabledIndices.includes(currentIndex) && currentIndex === firstEnabledIndex))
|
|
227
|
+
? 0
|
|
228
|
+
: -1,
|
|
208
229
|
}),
|
|
209
230
|
// Add menuitem role if not already specified
|
|
210
231
|
role: existingRole || 'menuitem',
|
|
@@ -217,7 +238,6 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
|
|
|
217
238
|
...style,
|
|
218
239
|
}, ...getFloatingProps(), children: items }) }) }));
|
|
219
240
|
}
|
|
220
|
-
DropdownContentComponent.displayName = 'Dropdown.Content';
|
|
221
241
|
// =============================================================================
|
|
222
242
|
// DropdownItem
|
|
223
243
|
// =============================================================================
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import type { FieldLabels } from './FieldContext';
|
|
3
|
+
export type { FieldLabels };
|
|
2
4
|
export type FieldProps = {
|
|
3
5
|
/** Whether the field has an error state */
|
|
4
6
|
error?: boolean;
|
|
@@ -8,6 +10,8 @@ export type FieldProps = {
|
|
|
8
10
|
disabled?: boolean;
|
|
9
11
|
/** Inline layout: label and control on same row */
|
|
10
12
|
inline?: boolean;
|
|
13
|
+
/** Overridable strings for i18n. */
|
|
14
|
+
labels?: FieldLabels;
|
|
11
15
|
/** Additional class name for the field wrapper */
|
|
12
16
|
className?: string;
|
|
13
17
|
children?: React.ReactNode;
|
|
@@ -36,4 +40,3 @@ type FieldCompound = React.ForwardRefExoticComponent<FieldProps & React.RefAttri
|
|
|
36
40
|
Error: React.FC<ErrorProps>;
|
|
37
41
|
};
|
|
38
42
|
export declare const Field: FieldCompound;
|
|
39
|
-
export {};
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { forwardRef, useId, useMemo, cloneElement, isValidElement, Children, } from 'react';
|
|
2
|
+
import React, { forwardRef, useEffect, useId, useMemo, useRef, cloneElement, isValidElement, Children, } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { isDev } from '../../utils/is-dev.js';
|
|
5
|
+
import { FieldContext, useFieldContext, defaultFieldLabels } from './FieldContext.js';
|
|
6
|
+
export const Field = forwardRef(function Field({ error = false, required = false, disabled = false, inline = false, labels: labelsProp, className, children, }, ref) {
|
|
6
7
|
const baseId = useId();
|
|
7
8
|
const controlId = `${baseId}-control`;
|
|
8
9
|
const labelId = `${baseId}-label`;
|
|
9
10
|
const helperTextId = `${baseId}-helper`;
|
|
10
11
|
const errorId = `${baseId}-error`;
|
|
12
|
+
const labelRendered = useRef(false);
|
|
13
|
+
// Reset on each render — Label will set it back to true during its render
|
|
14
|
+
labelRendered.current = false;
|
|
15
|
+
const labels = useMemo(() => ({ ...defaultFieldLabels, ...labelsProp }), [labelsProp]);
|
|
11
16
|
const contextValue = useMemo(() => ({
|
|
12
17
|
controlId,
|
|
13
18
|
labelId,
|
|
@@ -16,7 +21,9 @@ export const Field = forwardRef(function Field({ error = false, required = false
|
|
|
16
21
|
hasError: error,
|
|
17
22
|
required,
|
|
18
23
|
disabled,
|
|
19
|
-
|
|
24
|
+
labelRendered,
|
|
25
|
+
labels,
|
|
26
|
+
}), [controlId, labelId, helperTextId, errorId, error, required, disabled, labels]);
|
|
20
27
|
const classes = cx('tui-field', error && 'is-error', disabled && 'is-disabled', inline && 'is-layout-inline', className);
|
|
21
28
|
return (_jsx(FieldContext.Provider, { value: contextValue, children: _jsx("div", { ref: ref, className: classes, children: children }) }));
|
|
22
29
|
});
|
|
@@ -24,16 +31,20 @@ export const Field = forwardRef(function Field({ error = false, required = false
|
|
|
24
31
|
// Field.Label
|
|
25
32
|
// =============================================================================
|
|
26
33
|
function FieldLabel({ hidden = false, className, children, ...rest }) {
|
|
27
|
-
const { controlId, labelId, required } = useFieldContext();
|
|
34
|
+
const { controlId, labelId, required, labelRendered, labels } = useFieldContext();
|
|
35
|
+
// Signal to Field.Control that a label element exists in the tree.
|
|
36
|
+
// This is read synchronously during the same render pass (Label renders
|
|
37
|
+
// before Control in JSX order) to decide whether aria-labelledby is safe.
|
|
38
|
+
labelRendered.current = true;
|
|
28
39
|
const classes = cx('tui-field__label', hidden && 'tui-visually-hidden', className);
|
|
29
|
-
return (_jsxs("label", { id: labelId, htmlFor: controlId, className: classes, ...rest, children: [children, required && (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-field__required", "aria-hidden": "true", children: "*" }), _jsx("span", { className: "tui-visually-hidden", children:
|
|
40
|
+
return (_jsxs("label", { id: labelId, htmlFor: controlId, className: classes, ...rest, children: [children, required && (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-field__required", "aria-hidden": "true", children: "*" }), _jsx("span", { className: "tui-visually-hidden", children: labels.required })] }))] }));
|
|
30
41
|
}
|
|
31
42
|
FieldLabel.displayName = 'Field.Label';
|
|
32
43
|
// =============================================================================
|
|
33
44
|
// Field.Control
|
|
34
45
|
// =============================================================================
|
|
35
46
|
function FieldControl({ children }) {
|
|
36
|
-
const { controlId, labelId, helperTextId, errorId, hasError, required, disabled, } = useFieldContext();
|
|
47
|
+
const { controlId, labelId, helperTextId, errorId, hasError, required, disabled, labelRendered, } = useFieldContext();
|
|
37
48
|
const child = Children.only(children);
|
|
38
49
|
if (!isValidElement(child)) {
|
|
39
50
|
throw new Error('Field.Control expects a single React element as its child');
|
|
@@ -50,13 +61,23 @@ function FieldControl({ children }) {
|
|
|
50
61
|
describedByParts.push(errorId);
|
|
51
62
|
}
|
|
52
63
|
const describedBy = describedByParts.join(' ');
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const
|
|
64
|
+
// Only inject aria-labelledby when Field.Label is present in the tree.
|
|
65
|
+
// Without a rendered label, the ID points at nothing — and per AccName 1.2
|
|
66
|
+
// step 2B, aria-labelledby overrides ALL other name sources (including
|
|
67
|
+
// implicit <label> wrapping). A phantom ID produces an empty name, which
|
|
68
|
+
// strips the accessible name from self-labelling controls like Checkbox.
|
|
69
|
+
const childLabelledBy = childProps['aria-labelledby'];
|
|
70
|
+
const injectedLabelledBy = labelRendered.current;
|
|
71
|
+
const labelledBy = childLabelledBy ?? (injectedLabelledBy ? labelId : undefined);
|
|
72
|
+
// DEV: warn if Field.Label appears after Field.Control in JSX order —
|
|
73
|
+
// labelRendered will be false at render time but true after mount.
|
|
74
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (isDev() && !injectedLabelledBy && !childLabelledBy && labelRendered.current) {
|
|
77
|
+
console.warn('Field: Field.Label appears after Field.Control in the JSX tree. ' +
|
|
78
|
+
'Move Field.Label before Field.Control so aria-labelledby is wired correctly.');
|
|
79
|
+
}
|
|
80
|
+
}, []);
|
|
60
81
|
// Clone child with a11y props
|
|
61
82
|
// Note: aria-invalid and aria-required must be string "true", not boolean
|
|
62
83
|
return cloneElement(child, {
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overridable strings for Field i18n.
|
|
3
|
+
*/
|
|
4
|
+
export type FieldLabels = {
|
|
5
|
+
/** Visually-hidden text appended to required field labels. @default "required" */
|
|
6
|
+
required?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const defaultFieldLabels: Required<FieldLabels>;
|
|
1
9
|
export type FieldContextValue = {
|
|
2
10
|
/** ID for the form control element */
|
|
3
11
|
controlId: string;
|
|
@@ -13,6 +21,14 @@ export type FieldContextValue = {
|
|
|
13
21
|
required: boolean;
|
|
14
22
|
/** Whether the field is disabled */
|
|
15
23
|
disabled: boolean;
|
|
24
|
+
/** Mutable flag — set by Field.Label during render so Field.Control knows
|
|
25
|
+
* whether to inject aria-labelledby. Avoids injecting a phantom ID that
|
|
26
|
+
* overrides implicit <label> associations in the accessible name algorithm. */
|
|
27
|
+
labelRendered: {
|
|
28
|
+
current: boolean;
|
|
29
|
+
};
|
|
30
|
+
/** Resolved i18n labels for Field sub-components */
|
|
31
|
+
labels: Required<FieldLabels>;
|
|
16
32
|
};
|
|
17
33
|
export declare const FieldContext: import("react").Context<FieldContextValue | null>;
|
|
18
34
|
export declare function useFieldContext(): FieldContextValue;
|
|
@@ -19,4 +19,4 @@ export interface IconProps {
|
|
|
19
19
|
* - Decorative icons (no label): automatically hidden from screen readers
|
|
20
20
|
* - Informative icons: provide a `label` prop for screen reader announcement
|
|
21
21
|
*/
|
|
22
|
-
export declare const Icon: React.
|
|
22
|
+
export declare const Icon: React.NamedExoticComponent<IconProps & React.RefAttributes<HTMLSpanElement>>;
|
package/components/Icon/Icon.js
CHANGED
|
@@ -10,7 +10,7 @@ import { iconRegistry } from '../../icons/registry.js';
|
|
|
10
10
|
* - Decorative icons (no label): automatically hidden from screen readers
|
|
11
11
|
* - Informative icons: provide a `label` prop for screen reader announcement
|
|
12
12
|
*/
|
|
13
|
-
export const Icon = React.forwardRef(({ name, emoji, label, size, className }, ref) => {
|
|
13
|
+
export const Icon = React.memo(React.forwardRef(({ name, emoji, label, size, className }, ref) => {
|
|
14
14
|
const SvgIcon = name ? iconRegistry[name] : null;
|
|
15
15
|
// Dev warning for invalid icon name
|
|
16
16
|
if (isDev() && name && !SvgIcon) {
|
|
@@ -21,5 +21,5 @@ export const Icon = React.forwardRef(({ name, emoji, label, size, className }, r
|
|
|
21
21
|
return (_jsxs("span", { ref: ref, className: cx('tui-icon', size && `is-size-${size}`, className), ...(isDecorative
|
|
22
22
|
? { 'aria-hidden': true }
|
|
23
23
|
: { role: 'img', 'aria-label': label }), children: [SvgIcon && _jsx(SvgIcon, { "aria-hidden": "true", focusable: "false" }), !SvgIcon && emoji] }));
|
|
24
|
-
});
|
|
24
|
+
}));
|
|
25
25
|
Icon.displayName = 'Icon';
|
|
@@ -17,9 +17,13 @@ export type ModalProps = {
|
|
|
17
17
|
closeLabel?: string;
|
|
18
18
|
closeOnBackdropClick?: boolean;
|
|
19
19
|
closeOnEscape?: boolean;
|
|
20
|
+
/** When true, prevents the browser from scrolling to the trigger element
|
|
21
|
+
* when focus is restored on close. Useful when the trigger may be off-screen
|
|
22
|
+
* inside a scrollable container. Default: false. */
|
|
23
|
+
preventScrollOnRestore?: boolean;
|
|
20
24
|
children?: React.ReactNode;
|
|
21
25
|
};
|
|
22
|
-
declare function ModalRoot({ open, onClose, size, stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel, closeOnBackdropClick, closeOnEscape, children, }: ModalProps): React.ReactPortal | null;
|
|
26
|
+
declare function ModalRoot({ open, onClose, size, stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel, closeOnBackdropClick, closeOnEscape, preventScrollOnRestore, children, }: ModalProps): React.ReactPortal | null;
|
|
23
27
|
type ModalCloseProps = {
|
|
24
28
|
label?: string;
|
|
25
29
|
className?: string;
|
|
@@ -8,7 +8,7 @@ import { useFocusTrap, getInitialFocus } from '../../utils/focus-trap.js';
|
|
|
8
8
|
import { ModalContext, useModalContext } from './context.js';
|
|
9
9
|
import { IconButton } from '../IconButton/index.js';
|
|
10
10
|
const isBrowser = typeof document !== 'undefined';
|
|
11
|
-
function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel = 'Close', closeOnBackdropClick = true, closeOnEscape = true, children, }) {
|
|
11
|
+
function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, initialFocusSelector, container, showCloseButton, closeLabel = 'Close', closeOnBackdropClick = true, closeOnEscape = true, preventScrollOnRestore = false, children, }) {
|
|
12
12
|
const dialogRef = useRef(null);
|
|
13
13
|
const restoreRef = useRef(null);
|
|
14
14
|
const warnedRef = useRef(false);
|
|
@@ -45,7 +45,7 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
|
|
|
45
45
|
return;
|
|
46
46
|
const el = restoreRef.current;
|
|
47
47
|
if (el && typeof el.focus === 'function') {
|
|
48
|
-
el.focus();
|
|
48
|
+
el.focus({ preventScroll: preventScrollOnRestore });
|
|
49
49
|
}
|
|
50
50
|
restoreRef.current = null;
|
|
51
51
|
setMount(null);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { MoveHandleProps } from './types';
|
|
2
|
-
export declare const MoveHandle: import("react").
|
|
2
|
+
export declare const MoveHandle: import("react").NamedExoticComponent<MoveHandleProps & import("react").RefAttributes<HTMLElement>>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef, useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import { forwardRef, memo, useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { isDev } from '../../utils/is-dev.js';
|
|
5
5
|
import { Icon } from '../Icon/index.js';
|
|
@@ -24,7 +24,7 @@ import { Icon } from '../Icon/index.js';
|
|
|
24
24
|
// --tui-move-handle-icon-size Override icon size
|
|
25
25
|
//
|
|
26
26
|
// =============================================================================
|
|
27
|
-
export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size = 'md', index, locked = false, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true, labels, dragHandleProps, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, }, ref) {
|
|
27
|
+
export const MoveHandle = memo(forwardRef(function MoveHandle({ mode = 'full', size = 'md', index, locked = false, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true, labels, dragHandleProps, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, }, ref) {
|
|
28
28
|
// All hooks must be called unconditionally (rules of hooks)
|
|
29
29
|
const innerRef = useRef(null);
|
|
30
30
|
const mergedRef = useCallback((node) => {
|
|
@@ -83,7 +83,7 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
|
|
|
83
83
|
// Uses ref directly (not mergedRef) — innerRef is unused for this path.
|
|
84
84
|
// Focus recovery and dev warning effects early-return for handle mode.
|
|
85
85
|
if (mode === 'handle') {
|
|
86
|
-
return (_jsx("button", { ref: ref, type: "button", className: cx('tui-move-handle', 'is-handle', className), "aria-label": resolvedDragLabel, ...restDragProps, children: _jsx(Icon, { name: "system/drag" }) }));
|
|
86
|
+
return (_jsx("button", { ref: ref, type: "button", className: cx('tui-move-handle', 'is-handle', `is-size-${size}`, className), "aria-label": resolvedDragLabel, ...restDragProps, children: _jsx(Icon, { name: "system/drag" }) }));
|
|
87
87
|
}
|
|
88
88
|
// ----- Full mode -----
|
|
89
89
|
const hasIndex = index != null;
|
|
@@ -92,4 +92,4 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
|
|
|
92
92
|
? (labels?.locked ?? 'This item is locked and cannot be reordered')
|
|
93
93
|
: undefined;
|
|
94
94
|
return (_jsxs("div", { ref: mergedRef, role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": locked ? lockedDescId : undefined, "aria-disabled": locked || undefined, className: cx('tui-move-handle', `is-size-${size}`, locked && 'is-locked', hasIndex && 'has-index', className), children: [locked && (_jsx("span", { id: lockedDescId, className: "tui-visually-hidden", children: resolvedLockedDesc })), onMoveUp && (_jsx("button", { type: "button", className: "tui-move-handle__up", "data-direction": "up", "aria-label": labels?.moveUp ?? 'Move up', disabled: locked || !canMoveUp, onClick: onMoveUp, children: _jsx(Icon, { name: "system/chevron-up" }) })), _jsx("div", { className: "tui-move-handle__center", children: showLockIcon ? (_jsx("span", { className: "tui-move-handle__lock", "aria-hidden": "true", children: _jsx(Icon, { name: "system/lock" }) })) : (_jsxs(_Fragment, { children: [hasIndex && (_jsx("span", { className: "tui-move-handle__index", "aria-hidden": "true", children: index })), _jsx("button", { type: "button", className: "tui-move-handle__handle", "data-role": "drag-handle", "aria-label": resolvedDragLabel, tabIndex: hasArrows ? -1 : 0, ...restDragProps, children: _jsx(Icon, { name: "system/handle-alt" }) })] })) }), onMoveDown && (_jsx("button", { type: "button", className: "tui-move-handle__down", "data-direction": "down", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
|
|
95
|
-
});
|
|
95
|
+
}));
|
|
@@ -17,7 +17,7 @@ export interface MoveHandleLabels {
|
|
|
17
17
|
export interface MoveHandleProps {
|
|
18
18
|
/** Structural mode. 'full' (default) shows background panel with arrows/index. 'handle' shows only the bare drag icon button. */
|
|
19
19
|
mode?: MoveHandleMode;
|
|
20
|
-
/** Component scale. sm = 32px, md = 40px.
|
|
20
|
+
/** Component scale. Full mode: sm = 32px, md = 40px. Handle mode: sm = 24px, md = 32px. */
|
|
21
21
|
size?: MoveHandleSize;
|
|
22
22
|
/** Position index. When provided, shows number at rest, drag handle on hover. */
|
|
23
23
|
index?: number;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MultiSelectProps, type MultiSelectTriggerProps, type MultiSelectContentProps, type MultiSelectOptionProps, type MultiSelectGroupProps, type MultiSelectLabelProps } from './types';
|
|
2
|
-
declare function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, display, maxChips, max, onMaxReached, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }: MultiSelectProps): import("react/jsx-runtime").JSX.Element;
|
|
2
|
+
declare function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, display, maxChips, max, onMaxReached, labels: labelsProp, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }: MultiSelectProps): import("react/jsx-runtime").JSX.Element;
|
|
3
3
|
declare namespace MultiSelectRoot {
|
|
4
4
|
var displayName: string;
|
|
5
5
|
}
|
|
@@ -6,20 +6,24 @@ import { cx } from '../../utils/cx.js';
|
|
|
6
6
|
import { getPortalRootFor } from '../../utils/portal.js';
|
|
7
7
|
import { Icon } from '../Icon/index.js';
|
|
8
8
|
import { MultiSelectActionsContext, MultiSelectStateContext, MultiSelectContentContext, useMultiSelectContext, useMultiSelectContentContext, } from './MultiSelectContext.js';
|
|
9
|
-
import { toKey, } from './types.js';
|
|
9
|
+
import { toKey, defaultMultiSelectLabels, } from './types.js';
|
|
10
10
|
// =============================================================================
|
|
11
11
|
// MultiSelect Root
|
|
12
12
|
// =============================================================================
|
|
13
|
-
function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', display = 'count', maxChips = 3, max, onMaxReached, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }) {
|
|
13
|
+
function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultValue, onValueChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', display = 'count', maxChips = 3, max, onMaxReached, labels: labelsProp, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, children, }) {
|
|
14
|
+
// Merge caller labels with defaults
|
|
15
|
+
const labels = useMemo(() => ({ ...defaultMultiSelectLabels, ...labelsProp }), [labelsProp]);
|
|
14
16
|
// Controlled/uncontrolled value
|
|
15
17
|
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? []);
|
|
16
|
-
const isValueControlled = controlledValue !== undefined;
|
|
17
|
-
const value = isValueControlled ? controlledValue : uncontrolledValue;
|
|
18
|
+
const isValueControlled = useRef(controlledValue !== undefined).current;
|
|
19
|
+
const value = (isValueControlled ? controlledValue : uncontrolledValue);
|
|
18
20
|
// Track selected text values for display when closed
|
|
19
21
|
const selectedTextMapRef = useRef(new Map());
|
|
20
22
|
// Option registration
|
|
21
23
|
const optionsRef = useRef(new Map());
|
|
22
24
|
const [registryVersion, setRegistryVersion] = useState(0);
|
|
25
|
+
// Track open state via ref so unregisterOption can check synchronously
|
|
26
|
+
const openRef = useRef(false);
|
|
23
27
|
// Is selected helper
|
|
24
28
|
const isSelected = useCallback((optionValue) => {
|
|
25
29
|
const key = toKey(optionValue);
|
|
@@ -104,6 +108,7 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
|
|
|
104
108
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
|
|
105
109
|
const isOpenControlled = controlledOpen !== undefined;
|
|
106
110
|
const open = isOpenControlled ? controlledOpen : uncontrolledOpen;
|
|
111
|
+
openRef.current = open;
|
|
107
112
|
const setOpen = useCallback((nextOpen) => {
|
|
108
113
|
if (disabled)
|
|
109
114
|
return;
|
|
@@ -213,9 +218,21 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
|
|
|
213
218
|
setRegistryVersion((v) => v + 1);
|
|
214
219
|
}, []);
|
|
215
220
|
const unregisterOption = useCallback((optionValue) => {
|
|
221
|
+
// Skip when closing — preserve registry for chip display text.
|
|
222
|
+
// Options re-register on next open (same keys).
|
|
223
|
+
if (!openRef.current)
|
|
224
|
+
return;
|
|
216
225
|
optionsRef.current.delete(toKey(optionValue));
|
|
217
226
|
setRegistryVersion((v) => v + 1);
|
|
218
227
|
}, []);
|
|
228
|
+
// Flush stale registry on open. Options that were registered before close
|
|
229
|
+
// may no longer exist (parent changed children while closed). Clearing
|
|
230
|
+
// before the new options mount ensures no orphaned entries accumulate.
|
|
231
|
+
useLayoutEffect(() => {
|
|
232
|
+
if (open) {
|
|
233
|
+
optionsRef.current.clear();
|
|
234
|
+
}
|
|
235
|
+
}, [open]);
|
|
219
236
|
// Get selected options for trigger display
|
|
220
237
|
const getSelectedOptions = useCallback(() => {
|
|
221
238
|
return value
|
|
@@ -273,6 +290,7 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
|
|
|
273
290
|
maxChips,
|
|
274
291
|
max,
|
|
275
292
|
size,
|
|
293
|
+
labels,
|
|
276
294
|
// ARIA IDs (stable)
|
|
277
295
|
triggerId,
|
|
278
296
|
listboxId,
|
|
@@ -300,6 +318,7 @@ function MultiSelectRoot({ id: triggerIdProp, value: controlledValue, defaultVal
|
|
|
300
318
|
maxChips,
|
|
301
319
|
max,
|
|
302
320
|
size,
|
|
321
|
+
labels,
|
|
303
322
|
// IDs are stable (from useId)
|
|
304
323
|
triggerId,
|
|
305
324
|
listboxId,
|
|
@@ -356,7 +375,7 @@ MultiSelectRoot.displayName = 'MultiSelect';
|
|
|
356
375
|
// MultiSelect.Trigger
|
|
357
376
|
// =============================================================================
|
|
358
377
|
function MultiSelectTriggerComponent({ asChild = false, className, children, }) {
|
|
359
|
-
const { open, setOpen, disabled, placeholder, size, display, maxChips, max, maxReached, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, getSelectedOptions, clearAll, refs, getReferenceProps, activeIndex, orderedOptions, toggleOption, isSelected, } = useMultiSelectContext();
|
|
378
|
+
const { open, setOpen, disabled, placeholder, size, display, maxChips, max, maxReached, labels, triggerId, listboxId, ariaLabel, ariaLabelledBy, ariaDescribedBy, getSelectedOptions, clearAll, refs, getReferenceProps, activeIndex, orderedOptions, toggleOption, isSelected, } = useMultiSelectContext();
|
|
360
379
|
const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
|
|
361
380
|
const selectedOptions = getSelectedOptions();
|
|
362
381
|
const hasSelection = selectedOptions.length > 0;
|
|
@@ -422,19 +441,17 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
|
|
|
422
441
|
return _jsx("span", { className: "tui-multiselect__placeholder", children: placeholder });
|
|
423
442
|
}
|
|
424
443
|
if (display === 'count') {
|
|
425
|
-
return (
|
|
444
|
+
return (_jsx("span", { className: "tui-multiselect__count", children: labels.selected(selectedOptions.length) }));
|
|
426
445
|
}
|
|
427
446
|
// chips mode
|
|
428
447
|
const visibleChips = selectedOptions.slice(0, maxChips);
|
|
429
448
|
const overflow = selectedOptions.length - maxChips;
|
|
430
|
-
return (_jsxs("span", { className: "tui-multiselect__chips", children: [visibleChips.map((opt) => (_jsx("span", { className: "tui-multiselect__chip", children: opt.textValue }, toKey(opt.value)))), overflow > 0 && (
|
|
449
|
+
return (_jsxs("span", { className: "tui-multiselect__chips", children: [visibleChips.map((opt) => (_jsx("span", { className: "tui-multiselect__chip", children: opt.textValue }, toKey(opt.value)))), overflow > 0 && (_jsx("span", { className: "tui-multiselect__more", children: labels.more(overflow) }))] }));
|
|
431
450
|
};
|
|
432
451
|
// Default trigger content (when not using asChild or custom children)
|
|
433
452
|
const defaultTriggerContent = (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-multiselect__value", children: renderTriggerContent() }), hasSelection && (_jsx("span", { className: "tui-multiselect__clear", onClick: handleClearClick, "aria-hidden": "true", children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx(Icon, { name: "system/chevron-down", size: "sm", className: "tui-multiselect__icon", "aria-hidden": "true" })] }));
|
|
434
453
|
// Generate status message for screen readers
|
|
435
|
-
const statusMessage =
|
|
436
|
-
? `${selectedOptions.length} item${selectedOptions.length === 1 ? '' : 's'} selected${maxReached && max ? `. Maximum of ${max} reached` : ''}`
|
|
437
|
-
: '';
|
|
454
|
+
const statusMessage = labels.status(selectedOptions.length, max);
|
|
438
455
|
// Live region (rendered outside button, sibling to trigger)
|
|
439
456
|
const liveRegion = (_jsx("span", { className: "tui-visually-hidden", role: "status", "aria-live": "polite", "aria-atomic": "true", children: statusMessage }));
|
|
440
457
|
// Base trigger props
|
|
@@ -446,9 +463,8 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
|
|
|
446
463
|
'aria-haspopup': 'listbox',
|
|
447
464
|
'aria-expanded': open,
|
|
448
465
|
'aria-controls': listboxId,
|
|
449
|
-
'aria-
|
|
466
|
+
'aria-keyshortcuts': hasSelection && !open ? 'Delete' : undefined,
|
|
450
467
|
'data-state': open ? 'open' : 'closed',
|
|
451
|
-
'data-disabled': disabled || undefined,
|
|
452
468
|
...floatingProps,
|
|
453
469
|
};
|
|
454
470
|
// asChild: merge props onto child element
|
|
@@ -481,6 +497,7 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
|
|
|
481
497
|
'aria-controls': listboxId,
|
|
482
498
|
'aria-activedescendant': floatingProps['aria-activedescendant'],
|
|
483
499
|
'aria-describedby': ariaDescribedBy,
|
|
500
|
+
// asChild: use aria-disabled + data-disabled since element may not support native disabled
|
|
484
501
|
'aria-disabled': disabled || undefined,
|
|
485
502
|
'data-state': open ? 'open' : 'closed',
|
|
486
503
|
'data-disabled': disabled || undefined,
|
|
@@ -505,7 +522,7 @@ function MultiSelectTriggerComponent({ asChild = false, className, children, })
|
|
|
505
522
|
}
|
|
506
523
|
// Default: render button with optional custom content
|
|
507
524
|
const triggerContent = children ?? defaultTriggerContent;
|
|
508
|
-
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: refs.setReference, type: "button", id: triggerId, className: cx('tui-multiselect__trigger', sizeClass, className), disabled: disabled, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": listboxId, "
|
|
525
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { ref: refs.setReference, type: "button", id: triggerId, className: cx('tui-multiselect__trigger', sizeClass, className), disabled: disabled, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": listboxId, "data-state": open ? 'open' : 'closed', ...floatingProps,
|
|
509
526
|
// Handle Backspace/Delete AFTER floatingProps to ensure we catch it
|
|
510
527
|
// (typeahead may intercept these for its buffer)
|
|
511
528
|
onKeyDown: (e) => {
|
|
@@ -526,13 +543,19 @@ MultiSelectTriggerComponent.displayName = 'MultiSelect.Trigger';
|
|
|
526
543
|
function MultiSelectContentComponent({ className, children }) {
|
|
527
544
|
const { open, listboxId, triggerId, refs, floatingStyles, getFloatingProps, listRef, activeIndex, orderedOptions, } = useMultiSelectContext();
|
|
528
545
|
const portalRoot = getPortalRootFor(refs.reference.current);
|
|
546
|
+
// Track whether dropdown has ever been opened. Before first open, mount
|
|
547
|
+
// children in a hidden div for option registration. After first open,
|
|
548
|
+
// only mount children when open (in portal).
|
|
549
|
+
const hasEverOpened = useRef(false);
|
|
550
|
+
if (open)
|
|
551
|
+
hasEverOpened.current = true;
|
|
529
552
|
// Memoized context for options
|
|
530
553
|
const contentContext = useMemo(() => ({
|
|
531
554
|
listRef,
|
|
532
555
|
activeIndex,
|
|
533
556
|
orderedOptions,
|
|
534
557
|
}), [listRef, activeIndex, orderedOptions]);
|
|
535
|
-
return (_jsxs(_Fragment, { children: [!open && (_jsx("div", { style: { display: 'none' }, "aria-hidden": "true", children: _jsx(MultiSelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, "aria-multiselectable": "true", className: cx('tui-multiselect__content', className), style: {
|
|
558
|
+
return (_jsxs(_Fragment, { children: [!open && !hasEverOpened.current && (_jsx("div", { id: listboxId, role: "listbox", style: { display: 'none' }, "aria-hidden": "true", children: _jsx(MultiSelectContentContext.Provider, { value: contentContext, children: children }) })), open && (_jsx(FloatingPortal, { root: portalRoot, children: _jsx("div", { ref: refs.setFloating, id: listboxId, role: "listbox", "aria-labelledby": triggerId, "aria-multiselectable": "true", className: cx('tui-multiselect__content', className), style: {
|
|
536
559
|
...floatingStyles,
|
|
537
560
|
minWidth: refs.reference.current?.offsetWidth,
|
|
538
561
|
pointerEvents: 'auto',
|
|
@@ -548,9 +571,11 @@ function MultiSelectOptionComponent({ value: optionValue, disabled = false, text
|
|
|
548
571
|
const ref = useRef(null);
|
|
549
572
|
// Derive textValue from children if not explicitly provided
|
|
550
573
|
const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
|
|
551
|
-
// Warn in dev if textValue couldn't be derived
|
|
574
|
+
// Warn in dev if textValue couldn't be derived (fire once per mount)
|
|
575
|
+
const warnedTextValueRef = useRef(false);
|
|
552
576
|
useEffect(() => {
|
|
553
|
-
if (isDev() && !textValue) {
|
|
577
|
+
if (isDev() && !textValue && !warnedTextValueRef.current) {
|
|
578
|
+
warnedTextValueRef.current = true;
|
|
554
579
|
console.warn(`MultiSelect.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
|
|
555
580
|
}
|
|
556
581
|
}, [textValue, optionValue]);
|
|
@@ -593,7 +618,16 @@ MultiSelectOptionComponent.displayName = 'MultiSelect.Option';
|
|
|
593
618
|
// =============================================================================
|
|
594
619
|
function MultiSelectGroupComponent({ className, children }) {
|
|
595
620
|
const groupId = useId();
|
|
596
|
-
|
|
621
|
+
const groupRef = useRef(null);
|
|
622
|
+
const [hasLabel, setHasLabel] = useState(false);
|
|
623
|
+
// Check if a Label child rendered — guard aria-labelledby to prevent dangling reference
|
|
624
|
+
useLayoutEffect(() => {
|
|
625
|
+
if (groupRef.current) {
|
|
626
|
+
const labelEl = groupRef.current.querySelector(`#${CSS.escape(`${groupId}-label`)}`);
|
|
627
|
+
setHasLabel(!!labelEl);
|
|
628
|
+
}
|
|
629
|
+
}, [groupId, children]);
|
|
630
|
+
return (_jsx("div", { ref: groupRef, role: "group", "aria-labelledby": hasLabel ? `${groupId}-label` : undefined, className: cx('tui-multiselect__group', className), children: _jsx(MultiSelectGroupContext.Provider, { value: { groupId }, children: children }) }));
|
|
597
631
|
}
|
|
598
632
|
MultiSelectGroupComponent.displayName = 'MultiSelect.Group';
|
|
599
633
|
const MultiSelectGroupContext = React.createContext(null);
|
|
@@ -602,8 +636,13 @@ const MultiSelectGroupContext = React.createContext(null);
|
|
|
602
636
|
// =============================================================================
|
|
603
637
|
function MultiSelectLabelComponent({ className, children }) {
|
|
604
638
|
const groupContext = React.useContext(MultiSelectGroupContext);
|
|
605
|
-
|
|
606
|
-
|
|
639
|
+
const warnedRef = useRef(false);
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
if (isDev() && !groupContext && !warnedRef.current) {
|
|
642
|
+
warnedRef.current = true;
|
|
643
|
+
console.warn('MultiSelect.Label should be used inside MultiSelect.Group for accessibility.');
|
|
644
|
+
}
|
|
645
|
+
}, [groupContext]);
|
|
607
646
|
return (_jsx("div", { id: groupContext ? `${groupContext.groupId}-label` : undefined, className: cx('tui-multiselect__label', className), children: children }));
|
|
608
647
|
}
|
|
609
648
|
MultiSelectLabelComponent.displayName = 'MultiSelect.Label';
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { MultiSelect, MultiSelectTrigger, MultiSelectContent, MultiSelectOption, MultiSelectGroup, MultiSelectLabel, useMultiSelect, } from './MultiSelect';
|
|
2
|
-
export
|
|
2
|
+
export { defaultMultiSelectLabels } from './types';
|
|
3
|
+
export type { MultiSelectProps, MultiSelectTriggerProps, MultiSelectContentProps, MultiSelectOptionProps, MultiSelectGroupProps, MultiSelectLabelProps, MultiSelectLabels, MultiSelectValue, OptionValue, DisplayMode, RegisteredOption as MultiSelectRegisteredOption, } from './types';
|