@tangible/ui 0.0.3 → 0.0.5

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 (52) hide show
  1. package/README.md +21 -13
  2. package/components/Accordion/Accordion.d.ts +2 -2
  3. package/components/Accordion/Accordion.js +94 -23
  4. package/components/Accordion/index.d.ts +1 -1
  5. package/components/Accordion/types.d.ts +28 -4
  6. package/components/Avatar/Avatar.js +16 -7
  7. package/components/Avatar/AvatarGroup.js +7 -5
  8. package/components/Avatar/types.d.ts +11 -0
  9. package/components/Button/Button.js +10 -3
  10. package/components/Button/types.d.ts +9 -1
  11. package/components/Card/Card.js +26 -13
  12. package/components/Checkbox/Checkbox.d.ts +1 -1
  13. package/components/Chip/Chip.d.ts +37 -1
  14. package/components/Chip/Chip.js +10 -8
  15. package/components/ChipGroup/ChipGroup.js +5 -4
  16. package/components/ChipGroup/types.d.ts +3 -0
  17. package/components/Dropdown/Dropdown.d.ts +19 -1
  18. package/components/Dropdown/Dropdown.js +84 -28
  19. package/components/Dropdown/index.d.ts +2 -2
  20. package/components/Dropdown/index.js +1 -1
  21. package/components/Dropdown/types.d.ts +15 -0
  22. package/components/IconButton/IconButton.js +5 -4
  23. package/components/IconButton/index.d.ts +1 -1
  24. package/components/IconButton/types.d.ts +24 -4
  25. package/components/Modal/Modal.d.ts +16 -2
  26. package/components/Modal/Modal.js +45 -20
  27. package/components/MoveHandle/MoveHandle.js +3 -3
  28. package/components/MoveHandle/types.d.ts +12 -2
  29. package/components/Notice/Notice.js +32 -19
  30. package/components/Select/Select.js +6 -2
  31. package/components/Sidebar/Sidebar.d.ts +6 -1
  32. package/components/Sidebar/Sidebar.js +65 -11
  33. package/components/Sidebar/index.d.ts +1 -1
  34. package/components/Sidebar/types.d.ts +39 -14
  35. package/components/Tabs/Tabs.d.ts +1 -1
  36. package/components/Tabs/Tabs.js +12 -3
  37. package/components/Tabs/types.d.ts +20 -5
  38. package/components/TextInput/TextInput.js +10 -2
  39. package/components/Tooltip/Tooltip.d.ts +2 -2
  40. package/components/Tooltip/Tooltip.js +61 -40
  41. package/components/Tooltip/index.d.ts +1 -1
  42. package/components/Tooltip/types.d.ts +28 -1
  43. package/components/index.d.ts +2 -2
  44. package/components/index.js +1 -1
  45. package/package.json +1 -1
  46. package/styles/all.css +1 -1
  47. package/styles/all.expanded.css +354 -64
  48. package/styles/all.expanded.unlayered.css +354 -64
  49. package/styles/all.unlayered.css +1 -1
  50. package/styles/system/_tokens.scss +3 -0
  51. package/tui-manifest.json +291 -66
  52. package/utils/focus-trap.js +8 -1
@@ -5,6 +5,15 @@ type Size = SizeCompact;
5
5
  type Theme = ThemeFull;
6
6
  type Variant = 'default' | 'outline' | 'ghost' | 'solid' | 'flush';
7
7
  export type ChipProps = {
8
+ /**
9
+ * HTML element to render.
10
+ * - `'span'` (default): Inline element. Use for chips within text flow,
11
+ * inline lists, or inside `<p>`/`<li>` elements.
12
+ * - `'div'`: Block element. Use when the chip contains block-level content
13
+ * or needs to be a flex/grid child without inline constraints.
14
+ * - `'a'`: Anchor element. Use for navigational chips with an `href`.
15
+ * @default 'span'
16
+ */
8
17
  as?: 'span' | 'div' | 'a';
9
18
  href?: string;
10
19
  target?: React.HTMLAttributeAnchorTarget;
@@ -23,5 +32,32 @@ export type ChipProps = {
23
32
  /** When inside ChipGroup, identifies this chip for selection tracking */
24
33
  value?: OptionValue;
25
34
  } & Omit<React.HTMLAttributes<HTMLSpanElement>, 'onClick'>;
26
- export declare function Chip({ as, href, target, rel, children, size, theme, variant, selected: selectedProp, disabled: disabledProp, interactive, className, leftIcon, rightIcon, onClick: onClickProp, value, ...rest }: ChipProps): import("react/jsx-runtime").JSX.Element;
35
+ export declare const Chip: React.ForwardRefExoticComponent<{
36
+ /**
37
+ * HTML element to render.
38
+ * - `'span'` (default): Inline element. Use for chips within text flow,
39
+ * inline lists, or inside `<p>`/`<li>` elements.
40
+ * - `'div'`: Block element. Use when the chip contains block-level content
41
+ * or needs to be a flex/grid child without inline constraints.
42
+ * - `'a'`: Anchor element. Use for navigational chips with an `href`.
43
+ * @default 'span'
44
+ */
45
+ as?: "span" | "div" | "a";
46
+ href?: string;
47
+ target?: React.HTMLAttributeAnchorTarget;
48
+ rel?: string;
49
+ children: React.ReactNode;
50
+ size?: Size;
51
+ theme?: Theme;
52
+ variant?: Variant;
53
+ selected?: boolean;
54
+ disabled?: boolean;
55
+ interactive?: boolean;
56
+ className?: string;
57
+ leftIcon?: React.ReactNode;
58
+ rightIcon?: React.ReactNode;
59
+ onClick?: React.MouseEventHandler<HTMLElement>;
60
+ /** When inside ChipGroup, identifies this chip for selection tracking */
61
+ value?: OptionValue;
62
+ } & Omit<React.HTMLAttributes<HTMLSpanElement>, "onClick"> & React.RefAttributes<HTMLElement>>;
27
63
  export {};
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
- import { useCallback, useEffect } from 'react';
3
+ import { useCallback, useEffect, forwardRef } from 'react';
4
4
  import { cx } from '../../utils/cx.js';
5
5
  import { toKey } from '../../utils/value-key.js';
6
6
  import { isDev } from '../../utils/is-dev.js';
7
7
  import { useChipGroupContext } from '../ChipGroup/ChipGroupContext.js';
8
- export function Chip({ as = 'span', href, target, rel, children, size = 'md', theme = 'secondary', variant = 'default', selected: selectedProp, disabled: disabledProp, interactive, className, leftIcon, rightIcon, onClick: onClickProp, value, ...rest }) {
8
+ export const Chip = forwardRef(function Chip({ as = 'span', href, target, rel, children, size = 'md', theme = 'secondary', variant = 'default', selected: selectedProp, disabled: disabledProp, interactive, className, leftIcon, rightIcon, onClick: onClickProp, value, ...rest }, ref) {
9
9
  const groupContext = useChipGroupContext();
10
10
  // Dev warning: inside ChipGroup without value
11
11
  useEffect(() => {
@@ -25,7 +25,7 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
25
25
  const onClick = isManaged ? managedClick : onClickProp;
26
26
  const Tag = (as === 'a' ? 'a' : as);
27
27
  // Determine if this chip is clickable (needs button semantics)
28
- const isClickable = (interactive || onClick || isManaged) && !disabled;
28
+ const isClickable = (!!onClick || isManaged) && !disabled;
29
29
  // Keyboard handler for interactive chips
30
30
  const handleKeyDown = useCallback((e) => {
31
31
  if (!isClickable || !onClick)
@@ -38,7 +38,7 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
38
38
  const classes = cx('tui-chip', size && `is-size-${size}`, theme && `is-theme-${theme}`, variant !== 'default' && `is-style-${variant}`, selected && 'is-selected', (interactive || isManaged) && 'is-interactive', className);
39
39
  const anchorProps = as === 'a'
40
40
  ? {
41
- href: disabled ? undefined : href ?? '#',
41
+ href: disabled || isManaged ? undefined : href ?? '#',
42
42
  target,
43
43
  rel,
44
44
  'aria-disabled': disabled || undefined,
@@ -49,14 +49,16 @@ export function Chip({ as = 'span', href, target, rel, children, size = 'md', th
49
49
  // Non-anchor clickable chips always need role="button".
50
50
  // Managed anchor chips also get role="button" — toggle semantics
51
51
  // take priority over link semantics inside a ChipGroup.
52
- const needsButtonRole = isClickable && (as !== 'a' || isManaged);
52
+ // Disabled managed chips keep role="button" so they remain visible to AT.
53
+ const needsButtonRole = (isClickable || (isManaged && disabled)) && (as !== 'a' || isManaged);
53
54
  const buttonProps = needsButtonRole
54
55
  ? {
55
56
  role: 'button',
56
- tabIndex: as !== 'a' ? 0 : undefined, // anchors are natively focusable
57
+ tabIndex: disabled ? -1 : (as !== 'a' ? 0 : undefined),
57
58
  onKeyDown: handleKeyDown,
58
59
  'aria-pressed': isManaged ? (selected ?? false) : undefined,
60
+ 'aria-disabled': disabled || undefined,
59
61
  }
60
62
  : {};
61
- return (_jsxs(Tag, { className: classes, ...anchorProps, ...buttonProps, onClick: disabled ? undefined : onClick, ...rest, children: [leftIcon && leftIcon, _jsx("span", { className: "tui-chip__text", children: children }), rightIcon && rightIcon] }));
62
- }
63
+ return (_jsxs(Tag, { ref: ref, className: classes, ...anchorProps, ...buttonProps, onClick: disabled ? undefined : onClick, ...rest, children: [leftIcon && leftIcon, _jsx("span", { className: "tui-chip__text", children: children }), rightIcon && rightIcon] }));
64
+ });
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo } from 'react';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useId, useMemo } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { useControllableState } from '../../utils/use-controllable-state.js';
5
5
  import { toKey } from '../../utils/value-key.js';
@@ -17,7 +17,7 @@ import { isDev } from '../../utils/is-dev.js';
17
17
  //
18
18
  // =============================================================================
19
19
  export function ChipGroup(props) {
20
- const { multiple = false, disabled = false, density = 'sm', direction = 'inline', alignment, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, } = props;
20
+ const { multiple = false, disabled = false, density = 'sm', direction = 'inline', alignment, multipleLabel = 'Multiple selections allowed', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, } = props;
21
21
  // --- Single mode ---
22
22
  // isControlled override: 'value' in props detects explicit value={undefined}
23
23
  // (deselection) vs prop not passed at all (uncontrolled).
@@ -57,12 +57,13 @@ export function ChipGroup(props) {
57
57
  }
58
58
  }, [multiple, setSingleValue, setMultiValue]);
59
59
  const contextValue = useMemo(() => ({ selectedValues, multiple, disabled, onSelect }), [selectedValues, multiple, disabled, onSelect]);
60
+ const descriptionId = useId();
60
61
  // Dev-only: warn if group has no accessible name
61
62
  useEffect(() => {
62
63
  if (isDev() && !ariaLabel && !ariaLabelledBy) {
63
64
  console.warn('ChipGroup: Missing accessible name. Provide aria-label or aria-labelledby.');
64
65
  }
65
66
  }, [ariaLabel, ariaLabelledBy]);
66
- return (_jsx(ChipGroupContext.Provider, { value: contextValue, children: _jsx("div", { role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: cx('tui-chip-group', `is-density-${density}`, direction === 'stack' && 'is-direction-stack', alignment && `is-align-${alignment}`, className), children: children }) }));
67
+ return (_jsx(ChipGroupContext.Provider, { value: contextValue, children: _jsxs("div", { role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-describedby": multiple ? descriptionId : undefined, className: cx('tui-chip-group', `is-density-${density}`, direction === 'stack' && 'is-direction-stack', alignment && `is-align-${alignment}`, className), children: [children, multiple && (_jsx("span", { id: descriptionId, className: "tui-visually-hidden", children: multipleLabel }))] }) }));
67
68
  }
68
69
  ChipGroup.displayName = 'ChipGroup';
@@ -8,6 +8,9 @@ type ChipGroupBaseProps = {
8
8
  direction?: 'inline' | 'stack';
9
9
  /** Alignment along main axis. */
10
10
  alignment?: 'start' | 'center' | 'end';
11
+ /** Visually hidden label describing multi-select behaviour. For i18n support.
12
+ * @default 'Multiple selections allowed' */
13
+ multipleLabel?: string;
11
14
  'aria-label'?: string;
12
15
  'aria-labelledby'?: string;
13
16
  className?: string;
@@ -1,4 +1,4 @@
1
- import type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps } from './types';
1
+ import type { DropdownProps, DropdownTriggerProps, DropdownContentProps, DropdownItemProps, DropdownSeparatorProps, DropdownHeaderProps } from './types';
2
2
  declare function DropdownRoot({ open: controlledOpen, onOpenChange, defaultOpen, children, }: DropdownProps): import("react/jsx-runtime").JSX.Element;
3
3
  declare namespace DropdownRoot {
4
4
  var displayName: string;
@@ -19,13 +19,31 @@ declare function DropdownItemComponent({ onSelect, href, target, disabled, keepO
19
19
  declare namespace DropdownItemComponent {
20
20
  var displayName: string;
21
21
  }
22
+ /**
23
+ * Non-interactive separator for visually grouping menu items.
24
+ */
25
+ declare function DropdownSeparatorComponent({ className }: DropdownSeparatorProps): import("react/jsx-runtime").JSX.Element;
26
+ declare namespace DropdownSeparatorComponent {
27
+ var displayName: string;
28
+ }
29
+ /**
30
+ * Non-interactive section label for grouping menu items.
31
+ */
32
+ declare function DropdownHeaderComponent({ className, children }: DropdownHeaderProps): import("react/jsx-runtime").JSX.Element;
33
+ declare namespace DropdownHeaderComponent {
34
+ var displayName: string;
35
+ }
22
36
  type DropdownCompound = typeof DropdownRoot & {
23
37
  Trigger: typeof DropdownTriggerComponent;
24
38
  Content: typeof DropdownContentComponent;
25
39
  Item: typeof DropdownItemComponent;
40
+ Separator: typeof DropdownSeparatorComponent;
41
+ Header: typeof DropdownHeaderComponent;
26
42
  };
27
43
  export declare const Dropdown: DropdownCompound;
28
44
  export declare const DropdownTrigger: typeof DropdownTriggerComponent;
29
45
  export declare const DropdownContent: typeof DropdownContentComponent;
30
46
  export declare const DropdownItem: typeof DropdownItemComponent;
47
+ export declare const DropdownSeparator: typeof DropdownSeparatorComponent;
48
+ export declare const DropdownHeader: typeof DropdownHeaderComponent;
31
49
  export { useDropdownContext as useDropdown } from './DropdownContext';
@@ -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
  }