@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
@@ -0,0 +1,53 @@
1
+ import type { HTMLAttributes } from 'react';
2
+ export type MoveHandleSize = 'sm' | 'md';
3
+ export type MoveHandleMode = 'full' | 'handle';
4
+ export interface MoveHandleLabels {
5
+ /** Label for the move-up button. Include item name for clear AT context, e.g. "Move Introduction up". Default: "Move up" */
6
+ moveUp?: string;
7
+ /** Label for the move-down button. Include item name for clear AT context, e.g. "Move Introduction down". Default: "Move down" */
8
+ moveDown?: string;
9
+ /**
10
+ * Label for the drag handle button. Default: "Drag to reorder".
11
+ * Consumers add position info if needed. dragHandleProps['aria-label'] takes precedence.
12
+ */
13
+ drag?: string;
14
+ /** Descriptive text announced when locked (via aria-describedby). Default: "This item is locked and cannot be reordered". */
15
+ locked?: string;
16
+ }
17
+ export interface MoveHandleProps {
18
+ /** Structural mode. 'full' (default) shows background panel with arrows/index. 'handle' shows only the bare drag icon button. */
19
+ mode?: MoveHandleMode;
20
+ /** Component scale. sm = 32px, md = 40px. Ignored when mode is 'handle'. */
21
+ size?: MoveHandleSize;
22
+ /** Position index. When provided, shows number at rest, drag handle on hover. */
23
+ index?: number;
24
+ /** When true, shows lock icon and disables all interaction. */
25
+ locked?: boolean;
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
+ */
32
+ onMoveUp?: () => void;
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
+ */
39
+ onMoveDown?: () => void;
40
+ /** When false, disables the move-up button without hiding it. Default: true. */
41
+ canMoveUp?: boolean;
42
+ /** When false, disables the move-down button without hiding it. Default: true. */
43
+ canMoveDown?: boolean;
44
+ /** Override internal button labels for i18n. */
45
+ labels?: MoveHandleLabels;
46
+ /** Props to spread on the drag handle button (e.g., from dnd-kit useSortable). */
47
+ dragHandleProps?: HTMLAttributes<HTMLButtonElement>;
48
+ /** Accessible label for the control group. Required if aria-labelledby is not set. */
49
+ 'aria-label'?: string;
50
+ /** ID of element labelling this group. Required if aria-label is not set. */
51
+ 'aria-labelledby'?: string;
52
+ className?: string;
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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 {};
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef } from 'react';
2
+ import { forwardRef, useCallback } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  // =============================================================================
5
5
  // TextInput Component
@@ -17,9 +17,17 @@ import { cx } from '../../utils/cx.js';
17
17
  // --tui-input-radius Border radius
18
18
  //
19
19
  // =============================================================================
20
+ /** String or number content gets the `.is-text` visual treatment. */
21
+ const isTextContent = (node) => typeof node === 'string' || typeof node === 'number';
20
22
  export const TextInput = forwardRef(function TextInput({ type = 'text', size = 'md', prefix, suffix, className, inputClassName, disabled, ...rest }, ref) {
21
23
  const sizeClass = size !== 'md' ? `is-size-${size}` : undefined;
24
+ const handleGroupClick = useCallback((e) => {
25
+ const target = e.target;
26
+ if (target.closest('button, a, [role="button"], input'))
27
+ return;
28
+ e.currentTarget.querySelector('input')?.focus();
29
+ }, []);
22
30
  // Always wrap in tui-input-group so className consistently targets the root.
23
31
  // ARIA/form props from Field.Control reach the <input> via ...rest.
24
- return (_jsxs("div", { className: cx('tui-input-group', sizeClass, disabled && 'is-disabled', className), children: [prefix && _jsx("span", { className: "tui-input-group__prefix", children: prefix }), _jsx("input", { ref: ref, type: type, disabled: disabled, className: cx('tui-input', inputClassName), ...rest }), suffix && _jsx("span", { className: "tui-input-group__suffix", children: suffix })] }));
32
+ return (_jsxs("div", { className: cx('tui-input-group', sizeClass, disabled && 'is-disabled', className), onClick: handleGroupClick, children: [prefix && (_jsx("span", { className: cx('tui-input-group__prefix', isTextContent(prefix) && 'is-text'), children: prefix })), _jsx("input", { ref: ref, type: type, disabled: disabled, className: cx('tui-input', inputClassName), ...rest }), suffix && (_jsx("span", { className: cx('tui-input-group__suffix', isTextContent(suffix) && 'is-text'), children: suffix }))] }));
25
33
  });
@@ -1,8 +1,8 @@
1
1
  import type { TooltipProviderProps, TooltipProps, TooltipTriggerProps, TooltipContentProps } from './types';
2
2
  declare function TooltipProviderComponent({ delayDuration, closeDelayDuration, children, }: TooltipProviderProps): import("react/jsx-runtime").JSX.Element;
3
3
  declare function TooltipRoot({ open: controlledOpen, onOpenChange, defaultOpen, delayDuration: localDelay, children, }: TooltipProps): import("react/jsx-runtime").JSX.Element;
4
- declare function TooltipTriggerComponent({ asChild, children }: TooltipTriggerProps): import("react/jsx-runtime").JSX.Element;
5
- declare function TooltipContentComponent({ side, align, sideOffset, className, children, }: TooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
4
+ declare function TooltipTriggerComponent({ asChild, 'aria-label': ariaLabel, children, }: TooltipTriggerProps): import("react/jsx-runtime").JSX.Element;
5
+ declare function TooltipContentComponent({ side, align, sideOffset, theme, className, children, }: TooltipContentProps): import("react/jsx-runtime").JSX.Element | null;
6
6
  type TooltipCompound = typeof TooltipRoot & {
7
7
  Provider: typeof TooltipProviderComponent;
8
8
  Trigger: typeof TooltipTriggerComponent;