@tangible/ui 0.0.2 → 0.0.4

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 (64) hide show
  1. package/README.md +21 -13
  2. package/components/Accordion/Accordion.d.ts +1 -1
  3. package/components/Accordion/Accordion.js +3 -3
  4. package/components/Accordion/types.d.ts +8 -1
  5. package/components/Avatar/Avatar.js +16 -7
  6. package/components/Avatar/AvatarGroup.js +7 -5
  7. package/components/Avatar/types.d.ts +11 -0
  8. package/components/Button/Button.js +10 -3
  9. package/components/Button/types.d.ts +9 -1
  10. package/components/Card/Card.js +26 -13
  11. package/components/Checkbox/Checkbox.d.ts +1 -1
  12. package/components/Chip/Chip.d.ts +37 -1
  13. package/components/Chip/Chip.js +10 -8
  14. package/components/ChipGroup/ChipGroup.js +5 -4
  15. package/components/ChipGroup/types.d.ts +3 -0
  16. package/components/Dropdown/Dropdown.d.ts +19 -1
  17. package/components/Dropdown/Dropdown.js +84 -28
  18. package/components/Dropdown/index.d.ts +2 -2
  19. package/components/Dropdown/index.js +1 -1
  20. package/components/Dropdown/types.d.ts +15 -0
  21. package/components/IconButton/IconButton.js +5 -4
  22. package/components/IconButton/index.d.ts +1 -1
  23. package/components/IconButton/types.d.ts +24 -4
  24. package/components/Modal/Modal.d.ts +16 -2
  25. package/components/Modal/Modal.js +45 -20
  26. package/components/MoveHandle/MoveHandle.d.ts +2 -0
  27. package/components/MoveHandle/MoveHandle.js +84 -0
  28. package/components/MoveHandle/index.d.ts +2 -0
  29. package/components/MoveHandle/index.js +1 -0
  30. package/components/MoveHandle/types.d.ts +53 -0
  31. package/components/MoveHandle/types.js +1 -0
  32. package/components/Notice/Notice.js +32 -19
  33. package/components/Select/Select.js +6 -2
  34. package/components/Sidebar/Sidebar.d.ts +6 -1
  35. package/components/Sidebar/Sidebar.js +65 -11
  36. package/components/Sidebar/index.d.ts +1 -1
  37. package/components/Sidebar/types.d.ts +39 -14
  38. package/components/Tabs/Tabs.d.ts +1 -1
  39. package/components/Tabs/Tabs.js +12 -3
  40. package/components/Tabs/types.d.ts +20 -5
  41. package/components/TextInput/TextInput.js +10 -2
  42. package/components/Tooltip/Tooltip.d.ts +2 -2
  43. package/components/Tooltip/Tooltip.js +61 -40
  44. package/components/Tooltip/index.d.ts +1 -1
  45. package/components/Tooltip/types.d.ts +28 -1
  46. package/components/index.d.ts +4 -2
  47. package/components/index.js +2 -1
  48. package/icons/icons.svg +1 -0
  49. package/icons/manifest.json +8 -0
  50. package/icons/registry.d.ts +2 -0
  51. package/icons/registry.js +1 -0
  52. package/icons/system/index.d.ts +2 -0
  53. package/icons/system/index.js +11 -0
  54. package/package.json +1 -1
  55. package/styles/all.css +1 -1
  56. package/styles/all.expanded.css +961 -97
  57. package/styles/all.expanded.unlayered.css +961 -97
  58. package/styles/all.unlayered.css +1 -1
  59. package/styles/components/_bundle.scss +2 -0
  60. package/styles/index.scss +5 -0
  61. package/styles/system/_control.scss +18 -3
  62. package/styles/system/_tokens.scss +119 -2
  63. package/tui-manifest.json +526 -88
  64. package/utils/focus-trap.js +8 -1
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import React, { useCallback, useEffect, useId, useMemo, useRef, useState, cloneElement, isValidElement, Children, } from 'react';
2
+ import React, { useCallback, useEffect, useLayoutEffect, useId, useMemo, useRef, useState, cloneElement, isValidElement, Children, } from 'react';
3
3
  import { useFloating, offset, flip, shift, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useRole, FloatingFocusManager, } from '@floating-ui/react';
4
4
  import { cx } from '../../utils/cx.js';
5
5
  import { getPortalRootFor } from '../../utils/portal.js';
@@ -25,6 +25,7 @@ function DropdownRoot({ open: controlledOpen, onOpenChange, defaultOpen = false,
25
25
  }, [isControlled, onOpenChange]);
26
26
  const triggerRef = useRef(null);
27
27
  const contentId = useId();
28
+ const openedVia = useRef(null);
28
29
  // Focus restoration: track if we need to restore focus on close
29
30
  const shouldRestoreFocus = useRef(false);
30
31
  const prevOpen = useRef(open);
@@ -47,6 +48,7 @@ function DropdownRoot({ open: controlledOpen, onOpenChange, defaultOpen = false,
47
48
  contentId,
48
49
  activeIndex,
49
50
  setActiveIndex,
51
+ openedVia,
50
52
  }), [open, setOpen, contentId, activeIndex]);
51
53
  return (_jsx(DropdownContext.Provider, { value: contextValue, children: children }));
52
54
  }
@@ -55,24 +57,28 @@ DropdownRoot.displayName = 'Dropdown';
55
57
  // DropdownTrigger
56
58
  // =============================================================================
57
59
  function DropdownTriggerComponent({ asChild = false, children }) {
58
- const { open, setOpen, triggerRef, contentId } = useDropdownContext();
60
+ const { open, setOpen, triggerRef, openedVia } = useDropdownContext();
59
61
  const handleClick = useCallback(() => {
62
+ openedVia.current = 'click';
60
63
  setOpen(!open);
61
- }, [open, setOpen]);
64
+ }, [open, setOpen, openedVia]);
62
65
  const handleKeyDown = useCallback((e) => {
63
66
  if (e.key === 'Enter' || e.key === ' ') {
64
67
  e.preventDefault();
68
+ openedVia.current = 'click';
65
69
  setOpen(!open);
66
70
  }
67
71
  else if (e.key === 'ArrowDown') {
68
72
  e.preventDefault();
73
+ openedVia.current = 'ArrowDown';
69
74
  setOpen(true);
70
75
  }
71
76
  else if (e.key === 'ArrowUp') {
72
77
  e.preventDefault();
78
+ openedVia.current = 'ArrowUp';
73
79
  setOpen(true);
74
80
  }
75
- }, [open, setOpen]);
81
+ }, [open, setOpen, openedVia]);
76
82
  // Merge child ref with our triggerRef
77
83
  const setRefs = useCallback((node) => {
78
84
  triggerRef.current = node;
@@ -91,7 +97,6 @@ function DropdownTriggerComponent({ asChild = false, children }) {
91
97
  onKeyDown: handleKeyDown,
92
98
  'aria-haspopup': 'menu',
93
99
  'aria-expanded': open,
94
- 'aria-controls': open ? contentId : undefined,
95
100
  'data-dropdown-open': open || undefined,
96
101
  };
97
102
  if (asChild && isValidElement(children)) {
@@ -108,7 +113,7 @@ DropdownTriggerComponent.displayName = 'Dropdown.Trigger';
108
113
  // DropdownContent
109
114
  // =============================================================================
110
115
  function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset = 4, className, style, children, }) {
111
- const { open, setOpen, triggerRef, contentId, activeIndex, setActiveIndex } = useDropdownContext();
116
+ const { open, setOpen, triggerRef, contentId, activeIndex, setActiveIndex, openedVia } = useDropdownContext();
112
117
  const listRef = useRef([]);
113
118
  const { refs, floatingStyles, context } = useFloating({
114
119
  placement: toPlacement(side, align),
@@ -123,19 +128,41 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
123
128
  refs.setReference(triggerRef.current);
124
129
  }
125
130
  }, [triggerRef, refs]);
126
- // Collect disabled indices from children for keyboard navigation
127
- const disabledIndices = useMemo(() => {
128
- const indices = [];
129
- Children.forEach(children, (child, index) => {
130
- if (isValidElement(child)) {
131
- const props = child.props;
132
- if (props.disabled) {
133
- indices.push(index);
134
- }
131
+ // Classify children: count navigable items and collect disabled indices.
132
+ // Separator and Header sub-components are non-navigable.
133
+ const { disabledIndices, totalItemCount } = useMemo(() => {
134
+ const disabled = [];
135
+ let itemIdx = 0;
136
+ Children.forEach(children, (child) => {
137
+ if (!isValidElement(child))
138
+ return;
139
+ // Skip non-navigable sub-components
140
+ const childType = child.type;
141
+ if (childType === DropdownSeparatorComponent ||
142
+ childType === DropdownHeaderComponent) {
143
+ return;
135
144
  }
145
+ const props = child.props;
146
+ if (props.disabled) {
147
+ disabled.push(itemIdx);
148
+ }
149
+ itemIdx++;
136
150
  });
137
- return indices;
151
+ return { disabledIndices: disabled, totalItemCount: itemIdx };
138
152
  }, [children]);
153
+ // ArrowUp focus-last: set activeIndex to last valid item before paint
154
+ useLayoutEffect(() => {
155
+ if (open && openedVia.current === 'ArrowUp') {
156
+ let lastValid = totalItemCount - 1;
157
+ while (lastValid >= 0 && disabledIndices.includes(lastValid)) {
158
+ lastValid--;
159
+ }
160
+ if (lastValid >= 0) {
161
+ setActiveIndex(lastValid);
162
+ }
163
+ openedVia.current = null;
164
+ }
165
+ }, [open, openedVia, totalItemCount, disabledIndices, setActiveIndex]);
139
166
  const dismiss = useDismiss(context);
140
167
  const role = useRole(context, { role: 'menu' });
141
168
  const listNavigation = useListNavigation(context, {
@@ -155,25 +182,34 @@ function DropdownContentComponent({ side = 'bottom', align = 'start', sideOffset
155
182
  const portalRoot = getPortalRootFor(triggerRef.current);
156
183
  if (!open)
157
184
  return null;
158
- // Clone children to inject item props and role
159
- const items = Children.map(children, (child, index) => {
185
+ // Clone children to inject item props and role.
186
+ // Non-navigable children (Separator, Header) are rendered as-is.
187
+ let itemIndex = 0;
188
+ const items = Children.map(children, (child) => {
160
189
  if (!isValidElement(child))
161
190
  return child;
191
+ // Non-navigable sub-components — render without list navigation props
192
+ const childType = child.type;
193
+ if (childType === DropdownSeparatorComponent ||
194
+ childType === DropdownHeaderComponent) {
195
+ return child;
196
+ }
197
+ const currentIndex = itemIndex++;
162
198
  const childProps = child.props;
163
199
  const isDisabled = childProps.disabled === true;
164
200
  const existingRole = childProps.role;
165
201
  return cloneElement(child, {
166
202
  ...getItemProps({
167
203
  ref: (node) => {
168
- listRef.current[index] = node;
204
+ listRef.current[currentIndex] = node;
169
205
  },
170
206
  // Disabled items get tabIndex -1 always
171
- tabIndex: isDisabled ? -1 : (activeIndex === index ? 0 : -1),
207
+ tabIndex: isDisabled ? -1 : (activeIndex === currentIndex ? 0 : -1),
172
208
  }),
173
209
  // Add menuitem role if not already specified
174
210
  role: existingRole || 'menuitem',
175
211
  'aria-disabled': isDisabled || undefined,
176
- 'data-active': activeIndex === index || undefined,
212
+ 'data-active': activeIndex === currentIndex || undefined,
177
213
  });
178
214
  });
179
215
  return (_jsx(FloatingPortal, { root: portalRoot, children: _jsx(FloatingFocusManager, { context: context, modal: false, initialFocus: -1, children: _jsx("div", { ref: refs.setFloating, id: contentId, className: cx('tui-dropdown', className), style: {
@@ -190,31 +226,51 @@ DropdownContentComponent.displayName = 'Dropdown.Content';
190
226
  * When href is provided, renders as an anchor.
191
227
  */
192
228
  function DropdownItemComponent({ onSelect, href, target = '_self', disabled = false, keepOpen = false, className, children, ...props }) {
193
- const { setOpen, triggerRef } = useDropdownContext();
229
+ const { setOpen } = useDropdownContext();
194
230
  const handleClick = useCallback(() => {
195
231
  if (disabled)
196
232
  return;
197
233
  onSelect?.();
198
234
  if (!keepOpen) {
235
+ // Focus restoration is handled by DropdownRoot's useEffect on `open`
199
236
  setOpen(false);
200
- // Explicitly restore focus after programmatic close
201
- // Small delay to let the dropdown unmount first
202
- requestAnimationFrame(() => {
203
- triggerRef.current?.focus();
204
- });
205
237
  }
206
- }, [disabled, onSelect, keepOpen, setOpen, triggerRef]);
238
+ }, [disabled, onSelect, keepOpen, setOpen]);
207
239
  return (_jsx(Button, { ...props, variant: "ghost", href: disabled ? undefined : href, target: href ? target : undefined, disabled: disabled, onClick: handleClick, className: cx('tui-dropdown__item', className), children: children }));
208
240
  }
209
241
  DropdownItemComponent.displayName = 'Dropdown.Item';
242
+ // =============================================================================
243
+ // DropdownSeparator
244
+ // =============================================================================
245
+ /**
246
+ * Non-interactive separator for visually grouping menu items.
247
+ */
248
+ function DropdownSeparatorComponent({ className }) {
249
+ return (_jsx("hr", { role: "separator", className: cx('tui-dropdown__separator', className) }));
250
+ }
251
+ DropdownSeparatorComponent.displayName = 'Dropdown.Separator';
252
+ // =============================================================================
253
+ // DropdownHeader
254
+ // =============================================================================
255
+ /**
256
+ * Non-interactive section label for grouping menu items.
257
+ */
258
+ function DropdownHeaderComponent({ className, children }) {
259
+ return (_jsx("div", { role: "presentation", className: cx('tui-dropdown__header', className), children: children }));
260
+ }
261
+ DropdownHeaderComponent.displayName = 'Dropdown.Header';
210
262
  export const Dropdown = DropdownRoot;
211
263
  Dropdown.Trigger = DropdownTriggerComponent;
212
264
  Dropdown.Content = DropdownContentComponent;
213
265
  Dropdown.Item = DropdownItemComponent;
266
+ Dropdown.Separator = DropdownSeparatorComponent;
267
+ Dropdown.Header = DropdownHeaderComponent;
214
268
  // Named exports for direct imports
215
269
  export const DropdownTrigger = DropdownTriggerComponent;
216
270
  export const DropdownContent = DropdownContentComponent;
217
271
  export const DropdownItem = DropdownItemComponent;
272
+ export const DropdownSeparator = DropdownSeparatorComponent;
273
+ export const DropdownHeader = DropdownHeaderComponent;
218
274
  // Hook for advanced use cases (custom items that need to close the dropdown)
219
275
  // eslint-disable-next-line react-refresh/only-export-components
220
276
  export { useDropdownContext as useDropdown } from './DropdownContext.js';
@@ -1,2 +1,2 @@
1
- export { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, useDropdown, } from './Dropdown';
2
- export type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps, } from './types';
1
+ export { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator, DropdownHeader, useDropdown, } from './Dropdown';
2
+ export type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps, DropdownSeparatorProps, DropdownHeaderProps, } from './types';
@@ -1 +1 @@
1
- export { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, useDropdown, } from './Dropdown.js';
1
+ export { Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator, DropdownHeader, useDropdown, } from './Dropdown.js';
@@ -88,6 +88,19 @@ export type DropdownItemProps = {
88
88
  className?: string;
89
89
  children: React.ReactNode;
90
90
  };
91
+ export type DropdownSeparatorProps = {
92
+ /**
93
+ * Additional CSS class names.
94
+ */
95
+ className?: string;
96
+ };
97
+ export type DropdownHeaderProps = {
98
+ /**
99
+ * Additional CSS class names.
100
+ */
101
+ className?: string;
102
+ children: React.ReactNode;
103
+ };
91
104
  export type DropdownContextValue = {
92
105
  open: boolean;
93
106
  setOpen: (open: boolean) => void;
@@ -95,6 +108,8 @@ export type DropdownContextValue = {
95
108
  contentId: string;
96
109
  activeIndex: number | null;
97
110
  setActiveIndex: (index: number | null) => void;
111
+ /** Tracks how the dropdown was opened (e.g. 'ArrowUp') for initial focus. */
112
+ openedVia: React.MutableRefObject<string | null>;
98
113
  };
99
114
  /**
100
115
  * Convert side + align to Floating UI placement.
@@ -17,10 +17,11 @@ import { getSafeRel } from '../../utils/polymorphic.js';
17
17
  //
18
18
  // =============================================================================
19
19
  export const IconButton = forwardRef((props, ref) => {
20
- const { icon, label, size = 'sm', theme = 'secondary', variant = 'ghost', disabled = false, loading = false, pressed, showTooltip = false, tooltipSide = 'top', className, ...rest } = props;
20
+ const { icon, label, size = 'sm', theme = 'secondary', variant = 'ghost', disabled = false, loading = false, loadingLabel: loadingLabelProp, pressed, shape = 'square', showTooltip = false, tooltipSide = 'top', className, ...rest } = props;
21
21
  const isLink = typeof rest.href === 'string';
22
22
  const isDisabled = disabled || loading;
23
- const classes = cx('tui-icon-button', `is-size-${size}`, `is-theme-${theme}`, `is-style-${variant}`, isDisabled && 'is-disabled', className);
23
+ const resolvedLabel = loading ? (loadingLabelProp ?? `${label}, loading`) : label;
24
+ const classes = cx('tui-icon-button', `is-size-${size}`, `is-theme-${theme}`, `is-style-${variant}`, isDisabled && 'is-disabled', shape === 'circle' && 'is-shape-circle', className);
24
25
  const iconContent = (_jsxs(_Fragment, { children: [_jsx(Icon, { name: icon }), loading && _jsx("span", { className: "tui-icon-button__spinner", "aria-hidden": "true" })] }));
25
26
  // Render the base element (button or anchor)
26
27
  let element;
@@ -35,11 +36,11 @@ export const IconButton = forwardRef((props, ref) => {
35
36
  }
36
37
  onClick?.(e);
37
38
  };
38
- element = (_jsx("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-label": label, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, tabIndex: isDisabled ? -1 : tabIndex, onClick: handleClick, target: target, rel: safeRel, ...anchorRest, children: iconContent }));
39
+ element = (_jsx("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-label": resolvedLabel, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, tabIndex: isDisabled ? -1 : tabIndex, onClick: handleClick, target: target, rel: safeRel, ...anchorRest, children: iconContent }));
39
40
  }
40
41
  else {
41
42
  const buttonRest = rest;
42
- element = (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes, "aria-label": label, disabled: isDisabled, "aria-busy": loading || undefined, "aria-pressed": pressed, ...buttonRest, children: iconContent }));
43
+ element = (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes, "aria-label": resolvedLabel, disabled: isDisabled, "aria-busy": loading || undefined, "aria-pressed": pressed, ...buttonRest, children: iconContent }));
43
44
  }
44
45
  // Wrap in tooltip if requested
45
46
  if (showTooltip) {
@@ -1,2 +1,2 @@
1
1
  export { IconButton } from './IconButton';
2
- export type { IconButtonProps, Size, Theme, Variant } from './types';
2
+ export type { IconButtonProps, Shape, Size, Theme, Variant } from './types';
@@ -3,6 +3,7 @@ import type { Size, ThemeIntent } from '../../types';
3
3
  export type { Size };
4
4
  export type Theme = ThemeIntent;
5
5
  export type Variant = 'solid' | 'outline' | 'ghost';
6
+ export type Shape = 'square' | 'circle';
6
7
  type IconButtonBaseProps = {
7
8
  /**
8
9
  * Icon to display. Required — this is the button's visual content.
@@ -17,7 +18,10 @@ type IconButtonBaseProps = {
17
18
  label: string;
18
19
  /**
19
20
  * Size of the button.
20
- * - `'xs'`: 24px
21
+ * - `'xs'`: 24px — below WCAG 2.5.8 minimum target size (24×24 CSS px).
22
+ * Use only in dense, mouse-first UIs (toolbars, table rows) where touch
23
+ * is not expected. Consider pairing with adequate spacing or a larger
24
+ * touch target via padding/margin.
21
25
  * - `'sm'`: 32px (default)
22
26
  * - `'md'`: 40px
23
27
  * - `'lg'`: 48px
@@ -35,7 +39,9 @@ type IconButtonBaseProps = {
35
39
  * Visual style variant.
36
40
  * - `'solid'`: Filled background
37
41
  * - `'outline'`: Border only, transparent background
38
- * - `'ghost'`: No border or background, icon only
42
+ * - `'ghost'`: No border or background, icon only — has no at-rest visual
43
+ * affordance. Best suited for toolbar groups, icon bars, or other contexts
44
+ * where surrounding UI makes the interactive nature obvious.
39
45
  * @default 'ghost'
40
46
  */
41
47
  variant?: Variant;
@@ -47,6 +53,11 @@ type IconButtonBaseProps = {
47
53
  * Loading state — shows spinner, disables interaction.
48
54
  */
49
55
  loading?: boolean;
56
+ /**
57
+ * Accessible label override during loading state. For i18n support.
58
+ * @default `${label}, loading` (English)
59
+ */
60
+ loadingLabel?: string;
50
61
  /**
51
62
  * Pressed state for toggle buttons.
52
63
  * When true, applies aria-pressed="true" and active styling.
@@ -55,8 +66,11 @@ type IconButtonBaseProps = {
55
66
  pressed?: boolean;
56
67
  /**
57
68
  * Show the label as a tooltip on hover.
58
- * Since icon-only buttons have no visible text, this helps sighted
59
- * users discover what the button does.
69
+ * Since icon-only buttons have no visible text, enabling this helps
70
+ * sighted users discover what the button does. Recommended for most
71
+ * standalone icon buttons. Can be omitted in toolbars where a shared
72
+ * Tooltip provider handles it, or where adjacent visible text already
73
+ * communicates the action.
60
74
  * @default false
61
75
  */
62
76
  showTooltip?: boolean;
@@ -65,6 +79,12 @@ type IconButtonBaseProps = {
65
79
  * @default 'top'
66
80
  */
67
81
  tooltipSide?: 'top' | 'right' | 'bottom' | 'left';
82
+ /**
83
+ * Button shape. Use `'circle'` for avatar-adjacent actions, FABs,
84
+ * or anywhere a round affordance is expected.
85
+ * @default 'square'
86
+ */
87
+ shape?: Shape;
68
88
  /**
69
89
  * Additional CSS class names.
70
90
  */
@@ -7,6 +7,8 @@ export type ModalProps = {
7
7
  size?: Size;
8
8
  stickyHead?: boolean;
9
9
  stickyFoot?: boolean;
10
+ /** ID of the element that labels this dialog. Omitting this leaves the dialog
11
+ * without an accessible name, which violates WCAG 4.1.2 (Name, Role, Value). */
10
12
  'aria-labelledby'?: string;
11
13
  'aria-describedby'?: string;
12
14
  initialFocusSelector?: string;
@@ -30,11 +32,23 @@ type ModalSlotProps = {
30
32
  className?: string;
31
33
  children?: React.ReactNode;
32
34
  } & React.HTMLAttributes<HTMLDivElement>;
33
- declare function ModalHead({ className, children, ...rest }: ModalSlotProps): import("react/jsx-runtime").JSX.Element;
35
+ type ModalHeadProps = ModalSlotProps & {
36
+ /** Optional subtitle displayed below the title in smaller, muted text. */
37
+ subtitle?: React.ReactNode;
38
+ /** Optional icon displayed to the left of the title/subtitle block. */
39
+ icon?: React.ReactNode;
40
+ };
41
+ declare function ModalHead({ className, children, subtitle, icon, ...rest }: ModalHeadProps): import("react/jsx-runtime").JSX.Element;
34
42
  declare namespace ModalHead {
35
43
  var displayName: string;
36
44
  }
37
- declare function ModalBody({ className, children, ...rest }: ModalSlotProps): import("react/jsx-runtime").JSX.Element;
45
+ type ModalBodyProps = ModalSlotProps & {
46
+ /** Accessible label for the scrollable region. Passed to the inner scroll container.
47
+ * When the body content overflows, screen readers announce this as the region name.
48
+ * Omitting this on a scrollable modal body leaves the scroll region unlabelled. */
49
+ scrollLabel?: string;
50
+ };
51
+ declare function ModalBody({ className, children, scrollLabel, ...rest }: ModalBodyProps): import("react/jsx-runtime").JSX.Element;
38
52
  declare namespace ModalBody {
39
53
  var displayName: string;
40
54
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { cx } from '../../utils/cx.js';
5
- import { PREFIX } from '../../constants.js';
5
+ import { isDev } from '../../utils/is-dev.js';
6
6
  import { getPortalRootFor } from '../../utils/portal.js';
7
7
  import { useFocusTrap, getInitialFocus } from '../../utils/focus-trap.js';
8
8
  import { ModalContext, useModalContext } from './context.js';
@@ -11,7 +11,16 @@ const isBrowser = typeof document !== 'undefined';
11
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, }) {
12
12
  const dialogRef = useRef(null);
13
13
  const restoreRef = useRef(null);
14
+ const warnedRef = useRef(false);
14
15
  const [mount, setMount] = useState(null);
16
+ // Dev warning: Modal should have an accessible name
17
+ useEffect(() => {
18
+ if (isDev() && !warnedRef.current && open && !labelledBy) {
19
+ warnedRef.current = true;
20
+ console.warn('[TUI Modal] Missing `aria-labelledby` prop. Dialogs should reference a visible heading ' +
21
+ 'to provide an accessible name (WCAG 4.1.2).');
22
+ }
23
+ }, [open, labelledBy]);
15
24
  // Capture trigger element and compute portal mount point when modal opens.
16
25
  // Uses useLayoutEffect to run before paint — both renders complete before
17
26
  // the browser shows anything, so no visible delay.
@@ -50,6 +59,36 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
50
59
  document.body.classList.remove('tui-modal-open');
51
60
  };
52
61
  }, [open]);
62
+ // Inert sibling trees so screen readers cannot escape the dialog.
63
+ // aria-modal alone is unreliable — NVDA/JAWS virtual cursor can still
64
+ // reach background content via arrow keys and document-structure shortcuts.
65
+ useEffect(() => {
66
+ if (!open || !mount)
67
+ return;
68
+ const inerted = [];
69
+ // Walk from the portal root up to <body>, inerting siblings at each level.
70
+ let node = mount;
71
+ while (node && node !== document.body) {
72
+ const parent = node.parentElement;
73
+ if (parent) {
74
+ const children = Array.from(parent.children);
75
+ for (const sibling of children) {
76
+ if (sibling === node || sibling.tagName === 'SCRIPT' || sibling.tagName === 'STYLE')
77
+ continue;
78
+ if (!sibling.hasAttribute('inert')) {
79
+ sibling.inert = true;
80
+ inerted.push(sibling);
81
+ }
82
+ }
83
+ }
84
+ node = parent;
85
+ }
86
+ return () => {
87
+ for (const el of inerted) {
88
+ el.inert = false;
89
+ }
90
+ };
91
+ }, [open, mount]);
53
92
  // Focus trap (handles Tab cycling and ESC to close).
54
93
  useFocusTrap(dialogRef, {
55
94
  // Modal mount is two-phase (capture portal root, then render portal).
@@ -65,18 +104,6 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
65
104
  const dialog = dialogRef.current;
66
105
  if (!dialog)
67
106
  return;
68
- // Ensure scrollable body section is keyboard-focusable for a11y audits.
69
- // WCAG 4.1.2: focusable elements need accessible names.
70
- const scrollables = dialog.querySelectorAll(`.${PREFIX}-modal__body-inner, [data-scrollable="true"]`);
71
- scrollables.forEach((el) => {
72
- const hasOverflow = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
73
- if (hasOverflow && !el.hasAttribute('tabindex')) {
74
- el.setAttribute('tabindex', '0');
75
- if (!el.hasAttribute('aria-label') && !el.hasAttribute('aria-labelledby')) {
76
- el.setAttribute('aria-label', 'Scrollable content');
77
- }
78
- }
79
- });
80
107
  // Initial focus target.
81
108
  let target = null;
82
109
  if (initialFocusSelector) {
@@ -110,15 +137,13 @@ function ModalClose({ label = 'Close', className }) {
110
137
  return (_jsx(IconButton, { icon: "system/close", label: label, variant: "ghost", size: "sm", onClick: onClose, className: cx('tui-modal__close', className), showTooltip: true }));
111
138
  }
112
139
  ModalClose.displayName = 'Modal.Close';
113
- function ModalHead({ className, children, ...rest }) {
114
- return (_jsx("div", { className: cx('tui-modal__head', className), ...rest, children: children }));
140
+ function ModalHead({ className, children, subtitle, icon, ...rest }) {
141
+ const hasIcon = !!icon;
142
+ return (_jsxs("div", { className: cx('tui-modal__head', hasIcon && 'has-icon', className), ...rest, children: [hasIcon && _jsx("div", { className: "tui-modal__head-icon", children: icon }), _jsxs("div", { className: "tui-modal__head-content", children: [children, subtitle && _jsx("div", { className: "tui-modal__head-subtitle", children: subtitle })] })] }));
115
143
  }
116
144
  ModalHead.displayName = 'Modal.Head';
117
- // =============================================================================
118
- // Modal.Body Scrollable content area with min-height
119
- // =============================================================================
120
- function ModalBody({ className, children, ...rest }) {
121
- return (_jsx("div", { className: cx('tui-modal__body', className), ...rest, children: _jsx("div", { className: "tui-modal__body-inner", children: children }) }));
145
+ function ModalBody({ className, children, scrollLabel, ...rest }) {
146
+ return (_jsx("div", { className: cx('tui-modal__body', className), ...rest, children: _jsx("div", { className: "tui-modal__body-inner", "aria-label": scrollLabel, tabIndex: scrollLabel ? 0 : undefined, role: scrollLabel ? 'region' : undefined, children: children }) }));
122
147
  }
123
148
  ModalBody.displayName = 'Modal.Body';
124
149
  // =============================================================================
@@ -0,0 +1,2 @@
1
+ import type { MoveHandleProps } from './types';
2
+ export declare const MoveHandle: import("react").ForwardRefExoticComponent<MoveHandleProps & import("react").RefAttributes<HTMLElement>>;
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
3
+ import { cx } from '../../utils/cx.js';
4
+ import { isDev } from '../../utils/is-dev.js';
5
+ import { Icon } from '../Icon/index.js';
6
+ // =============================================================================
7
+ // MoveHandle Component
8
+ // =============================================================================
9
+ //
10
+ // Compact reorder control for sortable lists. Shows a drag handle with optional
11
+ // up/down chevron buttons and a position index badge.
12
+ //
13
+ // When `index` is provided, shows the number at rest and swaps to the drag
14
+ // handle icon on hover/focus-within (CSS-driven, no JS state).
15
+ //
16
+ // Forwards ref to the root element (div for full, button for handle).
17
+ //
18
+ // Modes:
19
+ // full — Background panel with arrows, index, lock (default)
20
+ // handle — Bare drag icon button, no chrome
21
+ //
22
+ // CSS token API (never defined, read via fallback):
23
+ // --tui-move-handle-size Override container size
24
+ // --tui-move-handle-icon-size Override icon size
25
+ //
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) {
28
+ // All hooks must be called unconditionally (rules of hooks)
29
+ const innerRef = useRef(null);
30
+ const mergedRef = useCallback((node) => {
31
+ innerRef.current = node;
32
+ if (typeof ref === 'function')
33
+ ref(node);
34
+ else if (ref)
35
+ ref.current = node;
36
+ }, [ref]);
37
+ const hasWarnedRef = useRef(false);
38
+ useEffect(() => {
39
+ if (mode === 'handle')
40
+ return;
41
+ if (hasWarnedRef.current)
42
+ return;
43
+ if (isDev() && !ariaLabel && !ariaLabelledBy) {
44
+ console.warn('MoveHandle: Missing accessible name. Provide aria-label or aria-labelledby.');
45
+ hasWarnedRef.current = true;
46
+ }
47
+ // eslint-disable-next-line react-hooks/exhaustive-deps
48
+ }, []);
49
+ const lockedDescId = useId();
50
+ // Focus recovery: when a move button becomes disabled after a reorder,
51
+ // redirect focus to the opposite button or drag handle
52
+ useEffect(() => {
53
+ if (mode === 'handle')
54
+ return;
55
+ const group = innerRef.current;
56
+ if (!group)
57
+ return;
58
+ const active = document.activeElement;
59
+ if (active instanceof HTMLButtonElement &&
60
+ active.disabled &&
61
+ group.contains(active)) {
62
+ const fallback = group.querySelector('[data-direction="up"]:not(:disabled), [data-direction="down"]:not(:disabled)') ??
63
+ group.querySelector('[data-role="drag-handle"]');
64
+ fallback?.focus();
65
+ }
66
+ }, [mode, canMoveUp, canMoveDown]);
67
+ // Drag handle label precedence: dragHandleProps > labels.drag > default
68
+ const resolvedDragLabel = dragHandleProps?.['aria-label'] ?? labels?.drag ?? 'Drag to reorder';
69
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
70
+ const { 'aria-label': _dhLabel, ...restDragProps } = dragHandleProps ?? {};
71
+ // ----- Handle mode: just the drag icon button -----
72
+ // Uses ref directly (not mergedRef) — innerRef is unused for this path.
73
+ // Focus recovery and dev warning effects early-return for handle mode.
74
+ if (mode === 'handle') {
75
+ 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" }) }));
76
+ }
77
+ // ----- Full mode -----
78
+ const hasIndex = index != null;
79
+ const hasArrows = !!(onMoveUp || onMoveDown);
80
+ const resolvedLockedDesc = locked
81
+ ? (labels?.locked ?? 'This item is locked and cannot be reordered')
82
+ : undefined;
83
+ 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: locked ? (_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" }) }))] }));
84
+ });
@@ -0,0 +1,2 @@
1
+ export { MoveHandle } from './MoveHandle';
2
+ export type { MoveHandleLabels, MoveHandleProps, MoveHandleSize, MoveHandleMode } from './types';
@@ -0,0 +1 @@
1
+ export { MoveHandle } from './MoveHandle.js';