@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.
Files changed (103) hide show
  1. package/components/Accordion/Accordion.js +11 -3
  2. package/components/Avatar/Avatar.d.ts +1 -1
  3. package/components/Avatar/Avatar.js +5 -4
  4. package/components/Avatar/AvatarGroup.js +7 -5
  5. package/components/Avatar/index.d.ts +2 -2
  6. package/components/Avatar/index.js +1 -1
  7. package/components/Avatar/types.d.ts +27 -0
  8. package/components/Avatar/types.js +8 -0
  9. package/components/Button/Button.js +4 -2
  10. package/components/Button/index.d.ts +2 -1
  11. package/components/Button/index.js +1 -0
  12. package/components/Button/types.d.ts +10 -0
  13. package/components/Button/types.js +3 -1
  14. package/components/Checkbox/Checkbox.js +46 -11
  15. package/components/Checkbox/types.d.ts +9 -0
  16. package/components/Combobox/Combobox.d.ts +1 -1
  17. package/components/Combobox/Combobox.js +50 -7
  18. package/components/Combobox/index.d.ts +2 -1
  19. package/components/Combobox/index.js +1 -0
  20. package/components/Combobox/types.d.ts +9 -0
  21. package/components/Combobox/types.js +3 -1
  22. package/components/Dropdown/Dropdown.d.ts +1 -1
  23. package/components/Dropdown/Dropdown.js +32 -12
  24. package/components/Field/Field.d.ts +4 -1
  25. package/components/Field/Field.js +35 -14
  26. package/components/Field/FieldContext.d.ts +16 -0
  27. package/components/Field/FieldContext.js +3 -0
  28. package/components/Field/index.d.ts +2 -1
  29. package/components/Field/index.js +1 -0
  30. package/components/Icon/Icon.d.ts +1 -1
  31. package/components/Icon/Icon.js +2 -2
  32. package/components/Modal/Modal.d.ts +5 -1
  33. package/components/Modal/Modal.js +2 -2
  34. package/components/MoveHandle/MoveHandle.d.ts +1 -1
  35. package/components/MoveHandle/MoveHandle.js +4 -4
  36. package/components/MoveHandle/types.d.ts +1 -1
  37. package/components/MultiSelect/MultiSelect.d.ts +1 -1
  38. package/components/MultiSelect/MultiSelect.js +58 -19
  39. package/components/MultiSelect/index.d.ts +2 -1
  40. package/components/MultiSelect/index.js +1 -0
  41. package/components/MultiSelect/types.d.ts +34 -0
  42. package/components/MultiSelect/types.js +10 -0
  43. package/components/Pager/Pager.d.ts +7 -1
  44. package/components/Pager/Pager.js +7 -5
  45. package/components/Pager/index.d.ts +2 -0
  46. package/components/Pager/index.js +1 -0
  47. package/components/Pager/types.d.ts +37 -0
  48. package/components/Pager/types.js +12 -0
  49. package/components/Progress/Progress.d.ts +2 -1
  50. package/components/Progress/Progress.js +3 -3
  51. package/components/Rating/Rating.d.ts +2 -32
  52. package/components/Rating/Rating.js +5 -3
  53. package/components/Rating/index.d.ts +2 -1
  54. package/components/Rating/index.js +1 -0
  55. package/components/Rating/types.d.ts +41 -0
  56. package/components/Rating/types.js +4 -0
  57. package/components/SegmentedControl/SegmentedControl.js +6 -5
  58. package/components/SegmentedControl/types.d.ts +17 -5
  59. package/components/Select/Select.d.ts +1 -0
  60. package/components/Select/Select.js +131 -77
  61. package/components/Select/SelectContext.d.ts +4 -16
  62. package/components/Select/SelectContext.js +5 -35
  63. package/components/Select/types.d.ts +19 -19
  64. package/components/Sidebar/Sidebar.js +25 -20
  65. package/components/StepIndicator/StepIndicator.d.ts +1 -1
  66. package/components/StepIndicator/StepIndicator.js +14 -10
  67. package/components/StepIndicator/index.d.ts +2 -1
  68. package/components/StepIndicator/index.js +1 -0
  69. package/components/StepIndicator/types.d.ts +18 -0
  70. package/components/StepIndicator/types.js +7 -1
  71. package/components/Table/BulkActionsBar.d.ts +4 -1
  72. package/components/Table/BulkActionsBar.js +5 -4
  73. package/components/Table/DataTable.d.ts +4 -1
  74. package/components/Table/DataTable.js +10 -8
  75. package/components/Table/index.d.ts +3 -0
  76. package/components/Table/index.js +2 -0
  77. package/components/Table/types.d.ts +20 -0
  78. package/components/Table/types.js +11 -0
  79. package/components/Tabs/Tabs.js +11 -4
  80. package/components/TextInput/TextInput.js +2 -1
  81. package/components/TextInput/types.d.ts +7 -1
  82. package/components/Textarea/Textarea.js +3 -2
  83. package/components/Textarea/types.d.ts +6 -1
  84. package/components/Tooltip/Tooltip.d.ts +1 -1
  85. package/components/Tooltip/Tooltip.js +16 -10
  86. package/icons/icons.svg +29 -15
  87. package/icons/lms/index.d.ts +8 -0
  88. package/icons/lms/index.js +48 -4
  89. package/icons/manifest.json +112 -0
  90. package/icons/player/index.js +9 -9
  91. package/icons/registry.d.ts +28 -0
  92. package/icons/registry.js +14 -0
  93. package/icons/system/index.d.ts +20 -0
  94. package/icons/system/index.js +112 -2
  95. package/package.json +1 -1
  96. package/styles/all.css +1 -1
  97. package/styles/all.expanded.css +266 -59
  98. package/styles/all.expanded.unlayered.css +266 -59
  99. package/styles/all.unlayered.css +1 -1
  100. package/styles/components/input/index.scss +29 -7
  101. package/styles/system/_constants.scss +1 -1
  102. package/styles/system/_tokens.scss +1 -0
  103. package/tui-manifest.json +78 -52
@@ -112,12 +112,23 @@ DropdownTriggerComponent.displayName = 'Dropdown.Trigger';
112
112
  // =============================================================================
113
113
  // DropdownContent
114
114
  // =============================================================================
115
- function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset = 4, className, style, children, }) {
116
- const { open, setOpen, triggerRef, contentId, activeIndex, setActiveIndex, openedVia } = useDropdownContext();
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 (open && openedVia.current === 'ArrowUp') {
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
- }, [open, openedVia, totalItemCount, disabledIndices, setActiveIndex]);
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
- tabIndex: isDisabled ? -1 : (activeIndex === currentIndex ? 0 : -1),
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 { FieldContext, useFieldContext } from './FieldContext.js';
5
- export const Field = forwardRef(function Field({ error = false, required = false, disabled = false, inline = false, className, children, }, ref) {
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
- }), [controlId, labelId, helperTextId, errorId, error, required, disabled]);
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: "required" })] }))] }));
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
- // Build aria-labelledby for non-labelable elements (<button>, <div>, etc.)
54
- // <label htmlFor> only works with labelable elements (input, textarea, select,
55
- // meter, output, progress). For everything else (Switch, future Slider, etc.)
56
- // aria-labelledby provides the accessible name. For native inputs this is
57
- // redundant with htmlFor but harmless — aria-labelledby takes priority in the
58
- // accessible name algorithm and points at the same label text.
59
- const labelledBy = childProps['aria-labelledby'] ?? labelId;
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;
@@ -1,4 +1,7 @@
1
1
  import { createContext, useContext } from 'react';
2
+ export const defaultFieldLabels = {
3
+ required: 'required',
4
+ };
2
5
  export const FieldContext = createContext(null);
3
6
  export function useFieldContext() {
4
7
  const context = useContext(FieldContext);
@@ -1,2 +1,3 @@
1
1
  export { Field } from './Field';
2
- export type { FieldProps } from './Field';
2
+ export type { FieldProps, FieldLabels } from './Field';
3
+ export { defaultFieldLabels } from './FieldContext';
@@ -1 +1,2 @@
1
1
  export { Field } from './Field.js';
2
+ export { defaultFieldLabels } from './FieldContext.js';
@@ -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.ForwardRefExoticComponent<IconProps & React.RefAttributes<HTMLSpanElement>>;
22
+ export declare const Icon: React.NamedExoticComponent<IconProps & React.RefAttributes<HTMLSpanElement>>;
@@ -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").ForwardRefExoticComponent<MoveHandleProps & import("react").RefAttributes<HTMLElement>>;
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. Ignored when mode is 'handle'. */
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 (_jsxs("span", { className: "tui-multiselect__count", children: [selectedOptions.length, " selected"] }));
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 && (_jsxs("span", { className: "tui-multiselect__more", children: ["+", overflow, " more"] }))] }));
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 = hasSelection
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-disabled': disabled || undefined,
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, "aria-disabled": disabled || undefined, "data-state": open ? 'open' : 'closed', "data-disabled": disabled || undefined, ...floatingProps,
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
- return (_jsx("div", { role: "group", "aria-labelledby": `${groupId}-label`, className: cx('tui-multiselect__group', className), children: _jsx(MultiSelectGroupContext.Provider, { value: { groupId }, children: children }) }));
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
- // No aria-hidden or role="presentation" — aria-labelledby on Group references this element,
606
- // so screen readers need access to read the label text.
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 type { MultiSelectProps, MultiSelectTriggerProps, MultiSelectContentProps, MultiSelectOptionProps, MultiSelectGroupProps, MultiSelectLabelProps, MultiSelectValue, OptionValue, DisplayMode, RegisteredOption as MultiSelectRegisteredOption, } from './types';
2
+ export { defaultMultiSelectLabels } from './types';
3
+ export type { MultiSelectProps, MultiSelectTriggerProps, MultiSelectContentProps, MultiSelectOptionProps, MultiSelectGroupProps, MultiSelectLabelProps, MultiSelectLabels, MultiSelectValue, OptionValue, DisplayMode, RegisteredOption as MultiSelectRegisteredOption, } from './types';
@@ -1 +1,2 @@
1
1
  export { MultiSelect, MultiSelectTrigger, MultiSelectContent, MultiSelectOption, MultiSelectGroup, MultiSelectLabel, useMultiSelect, } from './MultiSelect.js';
2
+ export { defaultMultiSelectLabels } from './types.js';