@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
@@ -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
  // =============================================================================
@@ -59,8 +59,8 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
59
59
  if (active instanceof HTMLButtonElement &&
60
60
  active.disabled &&
61
61
  group.contains(active)) {
62
- const fallback = group.querySelector('.tui-move-handle__up:not(:disabled), .tui-move-handle__down:not(:disabled)') ??
63
- group.querySelector('.tui-move-handle__handle');
62
+ const fallback = group.querySelector('[data-direction="up"]:not(:disabled), [data-direction="down"]:not(:disabled)') ??
63
+ group.querySelector('[data-role="drag-handle"]');
64
64
  fallback?.focus();
65
65
  }
66
66
  }, [mode, canMoveUp, canMoveDown]);
@@ -80,5 +80,5 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
80
80
  const resolvedLockedDesc = locked
81
81
  ? (labels?.locked ?? 'This item is locked and cannot be reordered')
82
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", "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", "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", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
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
84
  });
@@ -23,9 +23,19 @@ export interface MoveHandleProps {
23
23
  index?: number;
24
24
  /** When true, shows lock icon and disables all interaction. */
25
25
  locked?: boolean;
26
- /** Called when the "move up" button is clicked. Button not rendered when omitted. */
26
+ /**
27
+ * Called when the "move up" button is clicked. Button not rendered when omitted.
28
+ *
29
+ * Consumers should announce the result via a live region (e.g. "Item moved to position 2 of 5")
30
+ * since the DOM reorder is not conveyed to screen readers automatically.
31
+ */
27
32
  onMoveUp?: () => void;
28
- /** Called when the "move down" button is clicked. Button not rendered when omitted. */
33
+ /**
34
+ * Called when the "move down" button is clicked. Button not rendered when omitted.
35
+ *
36
+ * Consumers should announce the result via a live region (e.g. "Item moved to position 4 of 5")
37
+ * since the DOM reorder is not conveyed to screen readers automatically.
38
+ */
29
39
  onMoveDown?: () => void;
30
40
  /** When false, disables the move-up button without hiding it. Default: true. */
31
41
  canMoveUp?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { forwardRef, useId, useMemo, useState, useRef, useImperativeHandle, useCallback, useEffect, useContext, createContext } from 'react';
2
+ import React, { forwardRef, useId, useMemo, useState, useRef, useImperativeHandle, useCallback, useEffect, useLayoutEffect, useContext, createContext } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { IconButton } from '../IconButton/index.js';
5
5
  const NoticeContext = createContext(null);
@@ -15,9 +15,11 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
15
15
  const [isExiting, setIsExiting] = useState(false);
16
16
  const [exitType, setExitType] = useState(null);
17
17
  const exitResolveRef = useRef(null);
18
- // Track titleId from Notice.Head for aria-labelledby coordination
19
- const [titleId, setTitleId] = useState(null);
20
- const contextValue = useMemo(() => ({ setTitleId }), []);
18
+ // Pre-compute a stable title ID so aria-labelledby is wired on first render
19
+ const titleId = useId();
20
+ // Track whether Notice.Head has a title (for role="region" on non-section elements)
21
+ const [hasTitle, setHasTitle] = useState(false);
22
+ const contextValue = useMemo(() => ({ titleId, setHasTitle }), [titleId]);
21
23
  // Cleanup on unmount - resolve any pending exit promise
22
24
  useEffect(() => {
23
25
  return () => {
@@ -72,6 +74,16 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
72
74
  setIsExiting(true);
73
75
  });
74
76
  }, [exitAnimation]);
77
+ // Announce dismiss to screen readers via a short-lived live region
78
+ const announceDismiss = useCallback(() => {
79
+ const el = document.createElement('div');
80
+ el.setAttribute('role', 'status');
81
+ el.setAttribute('aria-live', 'polite');
82
+ el.className = 'tui-visually-hidden';
83
+ el.textContent = 'Notification dismissed';
84
+ document.body.appendChild(el);
85
+ setTimeout(() => el.remove(), 1000);
86
+ }, []);
75
87
  // Handle dismiss button click
76
88
  const handleDismiss = useCallback(async () => {
77
89
  if (disabled)
@@ -79,8 +91,9 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
79
91
  if (exitAnimation !== 'none') {
80
92
  await exit(exitAnimation);
81
93
  }
94
+ announceDismiss();
82
95
  onDismiss?.();
83
- }, [disabled, exitAnimation, exit, onDismiss]);
96
+ }, [disabled, exitAnimation, exit, onDismiss, announceDismiss]);
84
97
  // Expose imperative handle as plain object (not mutating DOM element)
85
98
  useImperativeHandle(ref, () => ({
86
99
  element: innerRef.current,
@@ -97,15 +110,15 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
97
110
  }, [interactive, onClick, disabled]);
98
111
  // A11y role mapping
99
112
  const liveRole = announce === 'assertive' ? 'alert' : announce === 'polite' ? 'status' : undefined;
100
- // Use titleId from Notice.Head if available, otherwise check ariaLabel
101
- const isNamed = Boolean(titleId) || Boolean(ariaLabel);
113
+ const isNamed = hasTitle || Boolean(ariaLabel);
102
114
  const regionRole = !liveRole && isNamed ? 'region' : undefined;
103
115
  // If interactive + onClick, behave as button
104
116
  const isClickable = interactive && onClick && !disabled;
105
117
  const computedRole = isClickable ? 'button' : (liveRole ?? regionRole);
106
- // aria-labelledby takes precedence over aria-label when titleId exists
107
- const computedLabelledBy = titleId || undefined;
108
- const computedAriaLabel = titleId ? undefined : ariaLabel;
118
+ // aria-labelledby uses pre-computed ID, gated on hasTitle to prevent landmark pollution.
119
+ // useLayoutEffect in NoticeHead ensures hasTitle is set before first paint.
120
+ const computedLabelledBy = hasTitle ? titleId : undefined;
121
+ const computedAriaLabel = hasTitle ? undefined : (ariaLabel || undefined);
109
122
  const classes = cx('tui-notice', `is-theme-${theme}`, inline && 'is-layout-inline', elevated && 'is-style-elevated', interactive && 'has-interaction', disabled && 'is-disabled', stripe && 'has-stripe', dismissible && 'is-dismissible', focusable && 'is-focusable', isExiting && exitType && `is-exiting-${exitType}`, className);
110
123
  // Dismiss button component
111
124
  const dismissButton = dismissible && (_jsx(IconButton, { icon: "system/close", label: dismissLabel, size: "sm", variant: "ghost", className: "tui-notice__dismiss", onClick: (e) => {
@@ -119,16 +132,16 @@ export const Notice = forwardRef(function Notice({ as = 'section', inline, eleva
119
132
  // Notice.Head renders icon and title (consumers can add custom actions via children)
120
133
  function NoticeHead({ className, icon, title, titleAs = 'h3', children, ...rest }) {
121
134
  const context = useContext(NoticeContext);
122
- const reactId = useId();
123
- const titleId = useMemo(() => title ? `tui-notice-title-${reactId}` : null, [title, reactId]);
124
- // Register titleId with parent Notice for aria-labelledby coordination
125
- useEffect(() => {
126
- if (context && titleId) {
127
- context.setTitleId(titleId);
128
- return () => context.setTitleId(null);
135
+ // Tell parent whether a title is present — useLayoutEffect ensures
136
+ // aria-labelledby and role="region" are set before the first paint.
137
+ const hasTitle = Boolean(title);
138
+ useLayoutEffect(() => {
139
+ if (context && hasTitle) {
140
+ context.setHasTitle(true);
141
+ return () => context.setHasTitle(false);
129
142
  }
130
- }, [context, titleId]);
131
- return (_jsxs("div", { className: cx('tui-notice__head', className), ...rest, children: [icon && _jsx("div", { className: "tui-notice__icon", children: icon }), title && React.createElement(titleAs, { id: titleId, className: 'tui-notice__title' }, title), children] }));
143
+ }, [context, hasTitle]);
144
+ return (_jsxs("div", { className: cx('tui-notice__head', className), ...rest, children: [icon && _jsx("div", { className: "tui-notice__icon", children: icon }), title && React.createElement(titleAs, { id: context?.titleId, className: 'tui-notice__title' }, title), children] }));
132
145
  }
133
146
  NoticeHead.displayName = 'Notice.Head';
134
147
  function NoticeBody({ className, children, ...rest }) {
@@ -306,13 +306,17 @@ function SelectTriggerComponent({ asChild = false, className, children, }) {
306
306
  refs.reference.current.focus();
307
307
  }
308
308
  }, [open, refs.reference]);
309
- // Handle Enter/Space for selection when open
309
+ // Handle Enter/Space for selection when open, Delete/Backspace to clear when closed
310
310
  const handleKeyDown = useCallback((e) => {
311
311
  if (open && (e.key === 'Enter' || e.key === ' ')) {
312
312
  e.preventDefault();
313
313
  handleSelect(activeIndex);
314
314
  }
315
- }, [open, activeIndex, handleSelect]);
315
+ if (!open && clearable && hasValue && (e.key === 'Delete' || e.key === 'Backspace')) {
316
+ e.preventDefault();
317
+ setValue(undefined);
318
+ }
319
+ }, [open, activeIndex, handleSelect, clearable, hasValue, setValue]);
316
320
  // Close dropdown when focus leaves trigger
317
321
  // Uses blur guard pattern: only close if focus moved outside controlled elements
318
322
  const handleBlur = useCallback((e) => {
@@ -1,8 +1,12 @@
1
- import type { SidebarProps, SidebarHeaderProps, SidebarNavProps } from './types';
1
+ import type { SidebarProps, SidebarHeaderProps, SidebarFooterProps, SidebarNavProps } from './types';
2
2
  declare function SidebarHeader(props: SidebarHeaderProps): import("react/jsx-runtime").JSX.Element;
3
3
  declare namespace SidebarHeader {
4
4
  var displayName: string;
5
5
  }
6
+ declare function SidebarFooter(props: SidebarFooterProps): import("react/jsx-runtime").JSX.Element;
7
+ declare namespace SidebarFooter {
8
+ var displayName: string;
9
+ }
6
10
  declare function SidebarNav(props: SidebarNavProps): import("react/jsx-runtime").JSX.Element;
7
11
  declare namespace SidebarNav {
8
12
  var displayName: string;
@@ -11,6 +15,7 @@ type SidebarCompound = {
11
15
  (props: SidebarProps): React.JSX.Element | null;
12
16
  displayName?: string;
13
17
  Header: typeof SidebarHeader;
18
+ Footer: typeof SidebarFooter;
14
19
  Nav: typeof SidebarNav;
15
20
  };
16
21
  export declare const Sidebar: SidebarCompound;
@@ -1,14 +1,50 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef } from 'react';
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
3
  import { useFocusTrap, getInitialFocus } from '../../utils/focus-trap.js';
4
+ import { isDev } from '../../utils/is-dev.js';
4
5
  const isBrowser = typeof document !== 'undefined';
5
6
  // =============================================================================
6
7
  // Sidebar (Root)
7
8
  // =============================================================================
8
9
  function SidebarRoot(props) {
9
- const { position = 'left', drawer = false, open = false, onClose, 'aria-label': ariaLabel, children, className, } = props;
10
+ const { position = 'left', 'aria-label': ariaLabel, children, className, } = props;
11
+ // Drawer-specific props — narrowed via discriminant property access
12
+ const drawer = props.drawer === true;
13
+ const open = props.drawer ? (props.open ?? false) : false;
14
+ const onClose = props.drawer ? props.onClose : undefined;
10
15
  const sidebarRef = useRef(null);
11
16
  const restoreRef = useRef(null);
17
+ // Exit animation: keep DOM mounted until slide-out animation completes.
18
+ // `visible` stays true after `open` goes false, until animationend fires.
19
+ const [visible, setVisible] = useState(open);
20
+ const panelRef = useRef(null);
21
+ // When open becomes true, immediately make visible
22
+ useEffect(() => {
23
+ if (open)
24
+ setVisible(true);
25
+ }, [open]);
26
+ const isClosing = drawer && !open && visible;
27
+ const shouldRender = drawer && (open || visible);
28
+ // When closing, check if animation will actually play (reduced motion).
29
+ // If not, unmount immediately to avoid getting stuck.
30
+ useLayoutEffect(() => {
31
+ if (!isClosing)
32
+ return;
33
+ const panel = panelRef.current;
34
+ if (!panel) {
35
+ setVisible(false);
36
+ return;
37
+ }
38
+ const style = getComputedStyle(panel);
39
+ if (!style.animationName || style.animationName === 'none') {
40
+ setVisible(false);
41
+ }
42
+ }, [isClosing]);
43
+ const handleAnimationEnd = useCallback((e) => {
44
+ if (e.target === panelRef.current) {
45
+ setVisible(false);
46
+ }
47
+ }, []);
12
48
  const rootClassName = ['tui-sidebar', className].filter(Boolean).join(' ');
13
49
  // ---------------------------------------------------------------------------
14
50
  // Drawer mode: capture trigger for focus restoration
@@ -21,9 +57,9 @@ function SidebarRoot(props) {
21
57
  restoreRef.current = document.activeElement;
22
58
  }
23
59
  else {
24
- // Restore focus when closing
60
+ // Restore focus when closing (with DOM containment guard)
25
61
  const el = restoreRef.current;
26
- if (el && typeof el.focus === 'function') {
62
+ if (el && typeof el.focus === 'function' && document.contains(el)) {
27
63
  el.focus();
28
64
  }
29
65
  restoreRef.current = null;
@@ -48,9 +84,9 @@ function SidebarRoot(props) {
48
84
  onEscape: onClose,
49
85
  });
50
86
  // ---------------------------------------------------------------------------
51
- // Drawer mode: initial focus
87
+ // Drawer mode: initial focus (useLayoutEffect to avoid flash/race)
52
88
  // ---------------------------------------------------------------------------
53
- useEffect(() => {
89
+ useLayoutEffect(() => {
54
90
  if (!drawer || !open)
55
91
  return;
56
92
  const sidebar = sidebarRef.current;
@@ -60,28 +96,36 @@ function SidebarRoot(props) {
60
96
  target.focus({ preventScroll: true });
61
97
  }, [drawer, open]);
62
98
  // ---------------------------------------------------------------------------
63
- // Sidebar content (shared between static and drawer modes)
99
+ // Dev warning: drawer without accessible name
64
100
  // ---------------------------------------------------------------------------
65
- const sidebarContent = (_jsx("aside", { ref: sidebarRef, className: rootClassName, "data-position": position, "aria-label": ariaLabel, "aria-modal": drawer && open ? 'true' : undefined, tabIndex: drawer ? -1 : undefined, children: children }));
101
+ const hasWarnedRef = useRef(false);
102
+ useEffect(() => {
103
+ if (isDev() && drawer && open && !ariaLabel && !hasWarnedRef.current) {
104
+ console.warn('[TUI Sidebar] Drawer mode is missing aria-label. ' +
105
+ 'The dialog role requires an accessible name (WCAG 4.1.2).');
106
+ hasWarnedRef.current = true;
107
+ }
108
+ }, [drawer, open, ariaLabel]);
66
109
  // ---------------------------------------------------------------------------
67
110
  // Static mode: render directly
68
111
  // ---------------------------------------------------------------------------
69
112
  if (!drawer) {
70
- return sidebarContent;
113
+ return (_jsx("aside", { ref: sidebarRef, className: rootClassName, "data-position": position, "aria-label": ariaLabel, children: children }));
71
114
  }
72
115
  // ---------------------------------------------------------------------------
73
116
  // Drawer mode: render inline with fixed positioning (no portal needed)
74
117
  // ---------------------------------------------------------------------------
75
- if (!open) {
118
+ if (!shouldRender) {
76
119
  return null;
77
120
  }
121
+ const drawerState = isClosing ? 'closing' : 'open';
78
122
  const drawerClassName = [
79
123
  'tui-sidebar-drawer',
80
124
  position === 'right' && 'is-position-right',
81
125
  ]
82
126
  .filter(Boolean)
83
127
  .join(' ');
84
- return (_jsxs("div", { className: drawerClassName, "data-position": position, "data-state": "open", children: [_jsx("div", { className: "tui-sidebar-drawer__backdrop", onClick: onClose, "aria-hidden": "true" }), _jsx("div", { className: "tui-sidebar-drawer__panel", children: sidebarContent })] }));
128
+ return (_jsxs("div", { className: drawerClassName, "data-position": position, "data-state": drawerState, children: [_jsx("div", { className: "tui-sidebar-drawer__backdrop", onClick: onClose, "aria-hidden": "true" }), _jsx("div", { ref: panelRef, className: "tui-sidebar-drawer__panel", role: "dialog", "aria-modal": "true", "aria-label": ariaLabel, onAnimationEnd: handleAnimationEnd, children: _jsx("aside", { ref: sidebarRef, className: rootClassName, "data-position": position, tabIndex: -1, children: children }) })] }));
85
129
  }
86
130
  // =============================================================================
87
131
  // Sidebar.Header
@@ -92,6 +136,14 @@ function SidebarHeader(props) {
92
136
  return _jsx("header", { className: headerClassName, children: children });
93
137
  }
94
138
  // =============================================================================
139
+ // Sidebar.Footer
140
+ // =============================================================================
141
+ function SidebarFooter(props) {
142
+ const { children, className } = props;
143
+ const footerClassName = ['tui-sidebar__footer', className].filter(Boolean).join(' ');
144
+ return _jsx("footer", { className: footerClassName, children: children });
145
+ }
146
+ // =============================================================================
95
147
  // Sidebar.Nav
96
148
  // =============================================================================
97
149
  function SidebarNav(props) {
@@ -100,8 +152,10 @@ function SidebarNav(props) {
100
152
  return (_jsx("nav", { className: navClassName, "aria-label": navAriaLabel, "aria-labelledby": navAriaLabelledBy, children: children }));
101
153
  }
102
154
  SidebarHeader.displayName = 'Sidebar.Header';
155
+ SidebarFooter.displayName = 'Sidebar.Footer';
103
156
  SidebarNav.displayName = 'Sidebar.Nav';
104
157
  export const Sidebar = SidebarRoot;
105
158
  Sidebar.displayName = 'Sidebar';
106
159
  Sidebar.Header = SidebarHeader;
160
+ Sidebar.Footer = SidebarFooter;
107
161
  Sidebar.Nav = SidebarNav;
@@ -1,2 +1,2 @@
1
1
  export { Sidebar } from './Sidebar';
2
- export type { SidebarProps, SidebarHeaderProps, SidebarNavProps, } from './types';
2
+ export type { SidebarProps, SidebarHeaderProps, SidebarFooterProps, SidebarNavProps, } from './types';
@@ -1,32 +1,55 @@
1
1
  import type { ReactNode } from 'react';
2
- export type SidebarProps = {
2
+ type SidebarBaseProps = {
3
3
  /**
4
4
  * Sidebar position. Affects border placement and drawer slide direction.
5
5
  * @default 'left'
6
6
  */
7
7
  position?: 'left' | 'right';
8
+ /**
9
+ * Accessible label for the sidebar landmark (static) or dialog (drawer).
10
+ * When a page has multiple sidebars, each must have a unique label so
11
+ * screen reader users can distinguish between landmarks
12
+ * (e.g. "Course navigation", "Lesson settings").
13
+ */
14
+ 'aria-label'?: string;
15
+ /**
16
+ * Sidebar content (Header, Nav, etc.).
17
+ */
18
+ children: ReactNode;
19
+ /**
20
+ * Additional CSS class names.
21
+ */
22
+ className?: string;
23
+ };
24
+ type SidebarStaticProps = SidebarBaseProps & {
8
25
  /**
9
26
  * Enable drawer mode (mobile overlay).
10
- * When true, sidebar renders via portal with backdrop and focus trap.
11
27
  * @default false
12
28
  */
13
- drawer?: boolean;
29
+ drawer?: false;
30
+ };
31
+ type SidebarDrawerProps = SidebarBaseProps & {
14
32
  /**
15
- * Drawer open state. Required when `drawer={true}`.
33
+ * Enable drawer mode (mobile overlay).
34
+ * When true, sidebar renders with backdrop, focus trap, and dialog semantics.
16
35
  */
17
- open?: boolean;
36
+ drawer: true;
18
37
  /**
19
- * Callback to close the drawer.
20
- * Called when ESC is pressed or backdrop is clicked.
38
+ * Drawer open state.
21
39
  */
22
- onClose?: () => void;
40
+ open?: boolean;
23
41
  /**
24
- * Accessible label for the aside landmark.
25
- * Typically omit this if using `aria-label` on `Sidebar.Nav` instead.
42
+ * Callback to close the drawer. Required when `drawer={true}`.
43
+ * Called when ESC is pressed or backdrop is clicked.
44
+ * Omitting this in drawer mode means the user cannot dismiss the overlay,
45
+ * which is a WCAG 2.1.1 (Keyboard) failure.
26
46
  */
27
- 'aria-label'?: string;
47
+ onClose: () => void;
48
+ };
49
+ export type SidebarProps = SidebarStaticProps | SidebarDrawerProps;
50
+ export type SidebarHeaderProps = {
28
51
  /**
29
- * Sidebar content (Header, Nav, etc.).
52
+ * Header content (back button, title, progress, CTAs).
30
53
  */
31
54
  children: ReactNode;
32
55
  /**
@@ -34,9 +57,10 @@ export type SidebarProps = {
34
57
  */
35
58
  className?: string;
36
59
  };
37
- export type SidebarHeaderProps = {
60
+ export type SidebarFooterProps = {
38
61
  /**
39
- * Header content (back button, title, progress, CTAs).
62
+ * Footer content (CTAs, user info, etc.).
63
+ * Sticks to the bottom of the sidebar.
40
64
  */
41
65
  children: ReactNode;
42
66
  /**
@@ -63,3 +87,4 @@ export type SidebarNavProps = {
63
87
  */
64
88
  className?: string;
65
89
  };
90
+ export {};
@@ -11,7 +11,7 @@ declare function TabsList({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabe
11
11
  declare namespace TabsList {
12
12
  var displayName: string;
13
13
  }
14
- declare function Tab({ value, icon, disabled, className, children }: TabProps): import("react/jsx-runtime").JSX.Element;
14
+ declare function Tab({ value, icon, 'aria-label': ariaLabel, disabled, className, children }: TabProps): import("react/jsx-runtime").JSX.Element;
15
15
  declare namespace Tab {
16
16
  var displayName: string;
17
17
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
+ import { isDev } from '../../utils/is-dev.js';
4
5
  import { Icon } from '../Icon/index.js';
5
6
  import { TabsContext, useTabsContext } from './TabsContext.js';
6
7
  // =============================================================================
@@ -243,11 +244,19 @@ function TabsList({ 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy,
243
244
  // =============================================================================
244
245
  // Tabs.Tab
245
246
  // =============================================================================
246
- function Tab({ value, icon, disabled = false, className, children }) {
247
+ function Tab({ value, icon, 'aria-label': ariaLabel, disabled = false, className, children }) {
247
248
  const { activationMode, activeValue, focusedValue, registerTab, unregisterTab, onSelect, getTabId, getPanelId, } = useTabsContext();
248
249
  const tabId = getTabId(value);
249
250
  const panelId = getPanelId(value);
250
251
  const isActive = activeValue === value;
252
+ // Dev-only: Warn if icon-only tab has no accessible name
253
+ const warnedRef = useRef(false);
254
+ useEffect(() => {
255
+ if (isDev() && !warnedRef.current && icon && !children && !ariaLabel) {
256
+ console.warn(`Tabs.Tab "${value}": Icon-only tab without aria-label. Provide aria-label for an accessible name.`);
257
+ warnedRef.current = true;
258
+ }
259
+ }, [icon, children, ariaLabel, value]);
251
260
  // Determine which tab gets tabIndex={0}
252
261
  const getFocusableValue = () => {
253
262
  if (activationMode === 'auto') {
@@ -275,7 +284,7 @@ function Tab({ value, icon, disabled = false, className, children }) {
275
284
  onSelect(value);
276
285
  }
277
286
  };
278
- return (_jsxs("button", { ref: callbackRef, type: "button", role: "tab", id: tabId, className: cx('tui-tabs__tab', className), "data-tui-tab-value": value, "data-state": isActive ? 'active' : 'inactive', "aria-selected": isActive, "aria-controls": panelId, tabIndex: disabled ? -1 : isFocusable ? 0 : -1, disabled: disabled, onClick: handleClick, children: [icon && _jsx(Icon, { name: icon, size: "sm", "aria-hidden": "true" }), children && _jsx("span", { className: "tui-tabs__tab-label", children: children })] }));
287
+ return (_jsxs("button", { ref: callbackRef, type: "button", role: "tab", id: tabId, className: cx('tui-tabs__tab', className), "data-tui-tab-value": value, "data-state": isActive ? 'active' : 'inactive', "aria-selected": isActive, "aria-controls": panelId, "aria-label": ariaLabel, "aria-disabled": disabled || undefined, tabIndex: disabled ? -1 : isFocusable ? 0 : -1, "data-disabled": disabled || undefined, onClick: handleClick, children: [icon && _jsx(Icon, { name: icon, size: "md", "aria-hidden": "true" }), children && _jsx("span", { className: "tui-tabs__tab-label", children: children })] }));
279
288
  }
280
289
  // =============================================================================
281
290
  // Tabs.Panel
@@ -290,7 +299,7 @@ function TabPanel({ value, className, children }) {
290
299
  registerPanel(value);
291
300
  return () => unregisterPanel(value);
292
301
  }, [value, registerPanel, unregisterPanel]);
293
- return (_jsx("div", { role: "tabpanel", id: panelId, className: cx('tui-tabs__panel', className), "data-state": isActive ? 'active' : 'inactive', "aria-labelledby": tabId, "aria-hidden": !isActive,
302
+ return (_jsx("div", { role: "tabpanel", id: panelId, className: cx('tui-tabs__panel', className), "data-state": isActive ? 'active' : 'inactive', "aria-labelledby": tabId, "aria-hidden": isActive ? undefined : true,
294
303
  // tabIndex={0} allows direct focus on panel for screen reader navigation
295
304
  tabIndex: isActive ? 0 : undefined,
296
305
  // inert prevents Tab navigation into hidden panel content
@@ -20,20 +20,34 @@ export type TabsProps = {
20
20
  className?: string;
21
21
  children: ReactNode;
22
22
  };
23
- export type TabsListProps = {
24
- /** Accessible label (required) */
25
- 'aria-label'?: string;
26
- /** Alternative to aria-label */
27
- 'aria-labelledby'?: string;
23
+ type TabsListBaseProps = {
28
24
  /** Additional classes */
29
25
  className?: string;
30
26
  children: ReactNode;
31
27
  };
28
+ /**
29
+ * Tabs.List requires an accessible name via either `aria-label` or `aria-labelledby`.
30
+ * Omitting both leaves the tablist unnamed, which makes it difficult for screen reader
31
+ * users to identify the purpose of the tab group.
32
+ */
33
+ export type TabsListProps = TabsListBaseProps & ({
34
+ 'aria-label': string;
35
+ 'aria-labelledby'?: string;
36
+ } | {
37
+ 'aria-label'?: string; /** ID of a visible heading that labels the tablist */
38
+ 'aria-labelledby': string;
39
+ });
32
40
  export type TabProps = {
33
41
  /** Unique identifier, must match a Panel */
34
42
  value: string;
35
43
  /** Icon name from registry */
36
44
  icon?: IconName;
45
+ /**
46
+ * Accessible label for the tab button. Required for icon-only tabs
47
+ * (no `children`). Omitting this on an icon-only tab leaves the button
48
+ * without an accessible name, making it invisible to screen readers.
49
+ */
50
+ 'aria-label'?: string;
37
51
  /** Disable tab (skipped in keyboard nav) */
38
52
  disabled?: boolean;
39
53
  /** Additional classes */
@@ -73,3 +87,4 @@ export type TabsContextValue = {
73
87
  getPanelId: (value: string) => string;
74
88
  tabsRef: React.MutableRefObject<Map<string, TabRecord>>;
75
89
  };
90
+ export {};