@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
package/README.md CHANGED
@@ -6,10 +6,12 @@ Design system for Tangible WordPress plugins. React components + CSS tokens + ut
6
6
 
7
7
  ## Components
8
8
 
9
- - **Primitives:** Button, Chip, Icon, IconButton, Progress, Rating, Tooltip
10
- - **Layout:** Accordion, Card, Modal, Notice, DataTable
11
- - **Composites:** Chips (multi-select chip group), StepIndicator
12
- - **Form Inputs:** Text, textarea, select, checkbox, radio, toggle, file (CSS-only)
9
+ - **Primitives:** Button, Chip, ChipGroup, Icon, IconButton, Progress, Rating, Tooltip
10
+ - **Layout:** Accordion, Card, Modal, Notice, Sidebar, Tabs, Toolbar
11
+ - **Data:** DataTable, StepList, StepIndicator, Pager
12
+ - **Form Controls:** Select, MultiSelect, Combobox, TextInput, Textarea, Checkbox, Switch, Radio
13
+ - **Composites:** Avatar, Dropdown, MoveHandle, OverlapStack, SegmentedControl, Field
14
+ - **CSS-only Inputs:** Text, textarea, select, checkbox, radio, toggle, file
13
15
 
14
16
  ## Getting Started
15
17
 
@@ -26,9 +28,9 @@ npm install @tangible/ui
26
28
  import '@tangible/ui/styles';
27
29
  ```
28
30
 
29
- Or in SCSS:
30
- ```scss
31
- @use '@tangible/ui/styles/scss';
31
+ Or for WordPress contexts (no CSS layers):
32
+ ```tsx
33
+ import '@tangible/ui/styles/unlayered';
32
34
  ```
33
35
 
34
36
  ### Wrap your app
@@ -45,6 +47,8 @@ function App() {
45
47
  }
46
48
  ```
47
49
 
50
+ Dark mode via `data-theme="dark"` on the wrapper.
51
+
48
52
  ### Use components
49
53
 
50
54
  ```tsx
@@ -82,11 +86,13 @@ npm run storybook # Dev server on port 6006
82
86
  ## Commands
83
87
 
84
88
  ```bash
85
- npm run storybook # Dev server
86
- npm run build:lib # Build library (outputs to publish/)
87
- npm run lint # ESLint
88
- npm run test # Unit tests
89
- npm run test:storybook # Story tests (requires Playwright)
89
+ npm run storybook # Dev server
90
+ npm run build:lib # Build library (outputs to publish/)
91
+ npm run lint # ESLint
92
+ npm run test # Unit tests (vitest, jsdom)
93
+ npm run test:storybook # Story + a11y tests (vitest, Playwright chromium)
94
+ npm run test:visual # Visual regression (Playwright)
95
+ npm run test:visual:update # Regenerate visual baselines
90
96
  ```
91
97
 
92
98
  ## Documentation
@@ -94,7 +100,9 @@ npm run test:storybook # Story tests (requires Playwright)
94
100
  - `CLAUDE.md` — Development guide (architecture, patterns, conventions)
95
101
  - `CONTEXT.md` — Project background and LMS requirements
96
102
  - `TIMELINE.md` — Development roadmap (Jan–Mar 2026)
103
+ - `TESTING.md` — Testing strategy and infrastructure
104
+ - `AGENTS.md` — Quality gate agent configurations
97
105
 
98
106
  ## Status
99
107
 
100
- Under active development for Course Viewer (LMS) and Quiz modules. Component APIs are stabilising but may change.
108
+ Under active development for Course Builder (LMS) and Quiz modules. Component APIs are stabilising but may change before 1.0.
@@ -10,11 +10,11 @@ declare function AccordionItem({ value, disabled, headingLevel, children, classN
10
10
  declare namespace AccordionItem {
11
11
  var displayName: string;
12
12
  }
13
- declare function AccordionTrigger({ children, className }: AccordionTriggerProps): import("react/jsx-runtime").JSX.Element;
13
+ declare function AccordionTrigger({ asChild, 'aria-label': ariaLabel, children, className }: AccordionTriggerProps): import("react/jsx-runtime").JSX.Element;
14
14
  declare namespace AccordionTrigger {
15
15
  var displayName: string;
16
16
  }
17
- declare function AccordionPanel({ children, className }: AccordionPanelProps): import("react/jsx-runtime").JSX.Element;
17
+ declare function AccordionPanel({ landmark, children, className }: AccordionPanelProps): import("react/jsx-runtime").JSX.Element;
18
18
  declare namespace AccordionPanel {
19
19
  var displayName: string;
20
20
  }
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
2
+ import React, { cloneElement, isValidElement, useCallback, useId, useMemo, useRef, useState } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
+ import { composeRefs } from '../../utils/compose-refs.js';
5
+ import { mergeProps } from '../../utils/merge-props.js';
4
6
  import { Icon } from '../Icon/index.js';
5
7
  import { AccordionContext, AccordionItemContext, useAccordionContext, useAccordionItemContext } from './AccordionContext.js';
6
8
  function AccordionRoot(props) {
7
- const { type, children, className } = props;
9
+ const { type, variant = 'card', children, className } = props;
8
10
  // Track trigger refs for keyboard navigation
9
11
  const triggersRef = useRef(new Map());
10
12
  const registerTrigger = useCallback((value, element) => {
@@ -18,11 +20,11 @@ function AccordionRoot(props) {
18
20
  const getTriggers = useCallback(() => triggersRef.current, []);
19
21
  // State management differs by type
20
22
  if (type === 'single') {
21
- return (_jsx(AccordionSingle, { ...props, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
23
+ return (_jsx(AccordionSingle, { ...props, variant: variant, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
22
24
  }
23
- return (_jsx(AccordionMultiple, { ...props, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
25
+ return (_jsx(AccordionMultiple, { ...props, variant: variant, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
24
26
  }
25
- function AccordionSingle({ value: controlledValue, defaultValue, onValueChange, collapsible = false, children, className, registerTrigger, getTriggers, }) {
27
+ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange, collapsible = false, variant, children, className, registerTrigger, getTriggers, }) {
26
28
  const [internalValue, setInternalValue] = useState(defaultValue);
27
29
  const isControlled = controlledValue !== undefined;
28
30
  const currentValue = isControlled ? controlledValue : internalValue;
@@ -37,6 +39,8 @@ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange,
37
39
  // Opening new item
38
40
  newValue = itemValue;
39
41
  }
42
+ if (newValue === currentValue)
43
+ return;
40
44
  if (!isControlled) {
41
45
  setInternalValue(newValue);
42
46
  }
@@ -50,9 +54,9 @@ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange,
50
54
  registerTrigger,
51
55
  getTriggers,
52
56
  }), [collapsible, isOpen, toggle, registerTrigger, getTriggers]);
53
- return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', className), "data-type": "single", children: children }) }));
57
+ return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', `is-variant-${variant}`, className), "data-type": "single", children: children }) }));
54
58
  }
55
- function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange, children, className, registerTrigger, getTriggers, }) {
59
+ function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange, variant, children, className, registerTrigger, getTriggers, }) {
56
60
  const [internalValue, setInternalValue] = useState(defaultValue ?? []);
57
61
  const isControlled = controlledValue !== undefined;
58
62
  const currentValue = isControlled ? controlledValue : internalValue;
@@ -74,7 +78,7 @@ function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange
74
78
  registerTrigger,
75
79
  getTriggers,
76
80
  }), [isOpen, toggle, registerTrigger, getTriggers]);
77
- return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', className), "data-type": "multiple", children: children }) }));
81
+ return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', `is-variant-${variant}`, className), "data-type": "multiple", children: children }) }));
78
82
  }
79
83
  // =============================================================================
80
84
  // Accordion Item
@@ -99,21 +103,49 @@ function AccordionItem({ value, disabled = false, headingLevel, children, classN
99
103
  // =============================================================================
100
104
  // Accordion Trigger
101
105
  // =============================================================================
102
- function AccordionTrigger({ children, className }) {
106
+ function AccordionTrigger({ asChild = false, 'aria-label': ariaLabel, children, className }) {
103
107
  const { toggle, registerTrigger, getTriggers } = useAccordionContext();
104
108
  const { value, disabled, headingLevel, triggerId, panelId, isOpen } = useAccordionItemContext();
105
- const buttonRef = useRef(null);
109
+ const triggerRef = useRef(null);
106
110
  // Register trigger for keyboard navigation
107
111
  React.useEffect(() => {
108
- registerTrigger(value, buttonRef.current);
112
+ registerTrigger(value, triggerRef.current);
109
113
  return () => registerTrigger(value, null);
110
114
  }, [value, registerTrigger]);
111
- const handleClick = () => {
112
- if (!disabled) {
113
- toggle(value);
115
+ const handleClick = (event) => {
116
+ if (disabled)
117
+ return;
118
+ // In asChild mode, the trigger is a container with nested interactive elements.
119
+ // Only toggle when the click is on the trigger itself or passive content (text,
120
+ // chips, icons) — not when it originates from a nested button, link, or input.
121
+ if (asChild) {
122
+ const target = event.target;
123
+ const currentTarget = event.currentTarget;
124
+ if (target !== currentTarget) {
125
+ const interactive = target.closest('button, a, input, select, textarea, [role="button"], [role="link"]');
126
+ if (interactive && interactive !== currentTarget && currentTarget.contains(interactive)) {
127
+ return;
128
+ }
129
+ }
114
130
  }
131
+ toggle(value);
115
132
  };
116
133
  const handleKeyDown = (event) => {
134
+ // In asChild mode, only handle keyboard events when the trigger element
135
+ // itself has focus — not when focus is on a nested interactive child.
136
+ // (A native <button> trigger can't have focusable children, so this
137
+ // check is only needed for asChild.)
138
+ if (asChild && event.target !== event.currentTarget)
139
+ return;
140
+ // Enter/Space activation for asChild mode. Native <button> elements fire
141
+ // click on Enter/Space automatically; role="button" elements do not.
142
+ if (asChild && (event.key === 'Enter' || event.key === ' ')) {
143
+ event.preventDefault();
144
+ if (!disabled) {
145
+ toggle(value);
146
+ }
147
+ return;
148
+ }
117
149
  const triggers = getTriggers();
118
150
  // Sort triggers by DOM order (not Map insertion order) to handle
119
151
  // conditional rendering, async mounting, or reordered items
@@ -124,14 +156,16 @@ function AccordionTrigger({ children, className }) {
124
156
  return 1;
125
157
  });
126
158
  const currentIndex = sortedTriggers.findIndex(([v]) => v === value);
127
- // Find next/prev non-disabled trigger
159
+ // Find next/prev non-disabled trigger — check both native disabled (button)
160
+ // and aria-disabled (asChild elements)
161
+ const isElementDisabled = (el) => el.disabled || el.getAttribute('aria-disabled') === 'true';
128
162
  const findNextIndex = (start, direction) => {
129
163
  let index = start;
130
164
  const len = sortedTriggers.length;
131
165
  for (let i = 0; i < len; i++) {
132
166
  index = (index + direction + len) % len;
133
167
  const [, triggerElement] = sortedTriggers[index];
134
- if (triggerElement && !triggerElement.disabled) {
168
+ if (triggerElement && !isElementDisabled(triggerElement)) {
135
169
  return index;
136
170
  }
137
171
  }
@@ -147,14 +181,24 @@ function AccordionTrigger({ children, className }) {
147
181
  event.preventDefault();
148
182
  targetIndex = findNextIndex(currentIndex, -1);
149
183
  break;
150
- case 'Home':
184
+ case 'Home': {
151
185
  event.preventDefault();
152
- targetIndex = findNextIndex(sortedTriggers.length - 1, 1); // Start from end, go forward
186
+ const first = sortedTriggers.findIndex(([, el]) => !isElementDisabled(el));
187
+ if (first !== -1)
188
+ targetIndex = first;
153
189
  break;
154
- case 'End':
190
+ }
191
+ case 'End': {
155
192
  event.preventDefault();
156
- targetIndex = findNextIndex(0, -1); // Start from beginning, go backward
193
+ // Scan from end to find last non-disabled trigger
194
+ for (let i = sortedTriggers.length - 1; i >= 0; i--) {
195
+ if (!isElementDisabled(sortedTriggers[i][1])) {
196
+ targetIndex = i;
197
+ break;
198
+ }
199
+ }
157
200
  break;
201
+ }
158
202
  }
159
203
  if (targetIndex !== null && targetIndex !== currentIndex) {
160
204
  const [, targetElement] = sortedTriggers[targetIndex];
@@ -162,7 +206,34 @@ function AccordionTrigger({ children, className }) {
162
206
  }
163
207
  };
164
208
  const state = isOpen ? 'open' : 'closed';
165
- const button = (_jsxs("button", { ref: buttonRef, type: "button", id: triggerId, className: cx('tui-accordion__trigger', className), "aria-expanded": isOpen, "aria-controls": panelId, disabled: disabled, "data-state": state, "data-disabled": disabled || undefined, onClick: handleClick, onKeyDown: handleKeyDown, children: [_jsx("span", { className: "tui-accordion__trigger-content", children: children }), _jsx(Icon, { name: "system/chevron-down", size: "sm", className: "tui-accordion__indicator", "aria-hidden": "true" })] }));
209
+ // asChild: merge trigger props onto the child element
210
+ if (asChild && isValidElement(children)) {
211
+ const triggerProps = {
212
+ id: triggerId,
213
+ role: 'button',
214
+ tabIndex: disabled ? -1 : 0,
215
+ 'aria-expanded': isOpen,
216
+ 'aria-controls': panelId,
217
+ 'aria-disabled': disabled || undefined,
218
+ 'aria-label': ariaLabel,
219
+ 'data-state': state,
220
+ 'data-disabled': disabled || undefined,
221
+ className: cx('tui-accordion__trigger', className),
222
+ onClick: handleClick,
223
+ onKeyDown: handleKeyDown,
224
+ };
225
+ const childRef = children.ref;
226
+ const merged = mergeProps(children.props, triggerProps);
227
+ merged.ref = composeRefs(triggerRef, childRef);
228
+ const element = cloneElement(children, merged);
229
+ if (headingLevel) {
230
+ const Heading = `h${headingLevel}`;
231
+ return _jsx(Heading, { className: "tui-accordion__heading", children: element });
232
+ }
233
+ return element;
234
+ }
235
+ // Default: render as button with built-in chevron indicator
236
+ const button = (_jsxs("button", { ref: triggerRef, type: "button", id: triggerId, className: cx('tui-accordion__trigger', className), "aria-expanded": isOpen, "aria-controls": panelId, "aria-label": ariaLabel, disabled: disabled, "data-state": state, "data-disabled": disabled || undefined, onClick: handleClick, onKeyDown: handleKeyDown, children: [_jsx("span", { className: "tui-accordion__trigger-content", children: children }), _jsx(Icon, { name: "system/chevron-down", size: "lg", className: "tui-accordion__indicator", "aria-hidden": "true" })] }));
166
237
  // Wrap in heading if headingLevel is specified
167
238
  if (headingLevel) {
168
239
  const Heading = `h${headingLevel}`;
@@ -173,10 +244,10 @@ function AccordionTrigger({ children, className }) {
173
244
  // =============================================================================
174
245
  // Accordion Panel
175
246
  // =============================================================================
176
- function AccordionPanel({ children, className }) {
247
+ function AccordionPanel({ landmark = false, children, className }) {
177
248
  const { triggerId, panelId, isOpen } = useAccordionItemContext();
178
249
  const state = isOpen ? 'open' : 'closed';
179
- return (_jsx("div", { id: panelId, role: "region", "aria-labelledby": triggerId, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen,
250
+ return (_jsx("div", { id: panelId, role: landmark ? 'region' : undefined, "aria-labelledby": landmark ? triggerId : undefined, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen,
180
251
  // Prevent keyboard focus into collapsed panels
181
252
  inert: !isOpen || undefined, children: _jsx("div", { className: "tui-accordion__panel-content", children: children }) }));
182
253
  }
@@ -1,2 +1,2 @@
1
1
  export { Accordion } from './Accordion';
2
- export type { AccordionProps, AccordionSingleProps, AccordionMultipleProps, AccordionItemProps, AccordionTriggerProps, AccordionPanelProps, } from './types';
2
+ export type { AccordionProps, AccordionSingleProps, AccordionMultipleProps, AccordionItemProps, AccordionTriggerProps, AccordionPanelProps, AccordionVariant, } from './types';
@@ -1,5 +1,12 @@
1
1
  import type { ReactNode } from 'react';
2
+ export type AccordionVariant = 'card' | 'flush';
2
3
  type AccordionBaseProps = {
4
+ /**
5
+ * Visual treatment.
6
+ * - `card` (default): Bordered cards with gap between items.
7
+ * - `flush`: Items share borders, no gap, no radius — denser layout.
8
+ */
9
+ variant?: AccordionVariant;
3
10
  children: ReactNode;
4
11
  className?: string;
5
12
  };
@@ -29,16 +36,33 @@ export type AccordionItemProps = {
29
36
  value: string;
30
37
  /** Prevent interaction */
31
38
  disabled?: boolean;
32
- /** Wrap trigger in heading element */
39
+ /**
40
+ * Wrap trigger in heading element (h2–h6).
41
+ * Omitting this reduces discoverability for screen reader users who
42
+ * navigate by headings (NVDA/JAWS `H` key). Only omit when the
43
+ * accordion is already inside an appropriate heading context.
44
+ */
33
45
  headingLevel?: 2 | 3 | 4 | 5 | 6;
34
46
  children: ReactNode;
35
47
  className?: string;
36
48
  };
37
49
  export type AccordionTriggerProps = {
38
- children: ReactNode;
50
+ /**
51
+ * When true, merges trigger props (aria attributes, event handlers, keyboard
52
+ * navigation) onto the child element instead of rendering a built-in button.
53
+ * The child must be a single React element that accepts ref and event handlers.
54
+ * The built-in chevron indicator is not rendered in asChild mode.
55
+ * @default false
56
+ */
57
+ asChild?: boolean;
58
+ /** Accessible label for the trigger. Required when children is empty (e.g. icon-only triggers). */
59
+ 'aria-label'?: string;
60
+ children?: ReactNode;
39
61
  className?: string;
40
62
  };
41
63
  export type AccordionPanelProps = {
64
+ /** Render panel as a landmark region (role="region"). Default false to avoid landmark pollution with many panels. */
65
+ landmark?: boolean;
42
66
  children: ReactNode;
43
67
  className?: string;
44
68
  };
@@ -47,8 +71,8 @@ export type AccordionContextValue = {
47
71
  collapsible: boolean;
48
72
  isOpen: (value: string) => boolean;
49
73
  toggle: (value: string) => void;
50
- registerTrigger: (value: string, element: HTMLButtonElement | null) => void;
51
- getTriggers: () => Map<string, HTMLButtonElement>;
74
+ registerTrigger: (value: string, element: HTMLElement | null) => void;
75
+ getTriggers: () => Map<string, HTMLElement>;
52
76
  };
53
77
  export type AccordionItemContextValue = {
54
78
  value: string;
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useState, useMemo } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { Icon } from '../Icon/index.js';
5
+ import { Tooltip } from '../Tooltip/index.js';
5
6
  import { AVATAR_COLORS } from './types.js';
6
7
  /**
7
8
  * Generate initials from a name.
@@ -45,7 +46,7 @@ function getColorFromName(name, colors) {
45
46
  * - Shows placeholder icon if neither `src` nor `name` provided
46
47
  * - Colors for initials are derived from the name hash for consistency
47
48
  */
48
- export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circle', color, indicator, indicatorLabel, indicatorPosition = 'bottom-right', className, }, ref) => {
49
+ export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circle', color, indicator, indicatorLabel, indicatorPosition = 'bottom-right', tooltip, className, }, ref) => {
49
50
  const [imgError, setImgError] = useState(false);
50
51
  // Reset error state when src changes
51
52
  React.useEffect(() => {
@@ -56,12 +57,20 @@ export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circl
56
57
  const showImage = src && !imgError;
57
58
  const showInitials = !showImage && initials;
58
59
  const showPlaceholder = !showImage && !initials;
59
- // Placeholder avatars are decorative (no meaningful identity)
60
- const isDecorative = showPlaceholder;
61
- return (_jsxs("span", { ref: ref, className: cx('tui-avatar', `is-size-${size}`, `is-shape-${shape}`, !showImage && `is-color-${derivedColor}`, className), ...(isDecorative
60
+ // Avatars with no name and no indicator label have no meaningful identity
61
+ const isDecorative = !name && !indicatorLabel;
62
+ const showTooltip = tooltip && !!name;
63
+ const avatarElement = (_jsxs("span", { ref: ref, className: cx('tui-avatar', `is-size-${size}`, `is-shape-${shape}`, !showImage && `is-color-${derivedColor}`, className), ...(isDecorative
62
64
  ? { 'aria-hidden': true }
63
- : { role: 'img', 'aria-label': name }), children: [_jsxs("span", { className: "tui-avatar__content", children: [showImage && (_jsx("img", { src: src, alt: "", className: "tui-avatar__image", onError: () => setImgError(true) })), showInitials && (_jsx("span", { className: "tui-avatar__initials", "aria-hidden": "true", children: initials })), showPlaceholder && (_jsx(Icon, { name: "system/user-circle-outline", className: "tui-avatar__placeholder" }))] }), indicator && (_jsx("span", { className: cx('tui-avatar__indicator', `is-position-${indicatorPosition}`), ...(indicatorLabel
64
- ? { role: 'img', 'aria-label': indicatorLabel }
65
- : { 'aria-hidden': true }), children: indicator }))] }));
65
+ : {
66
+ role: 'img',
67
+ 'aria-label': name && indicatorLabel
68
+ ? `${name}, ${indicatorLabel}`
69
+ : name || indicatorLabel,
70
+ }), children: [_jsxs("span", { className: "tui-avatar__content", children: [showImage && (_jsx("img", { src: src, alt: "", className: "tui-avatar__image", onError: () => setImgError(true) })), showInitials && (_jsx("span", { className: "tui-avatar__initials", "aria-hidden": "true", children: initials })), showPlaceholder && (_jsx(Icon, { name: "system/user-circle-outline", className: "tui-avatar__placeholder" }))] }), indicator && (_jsx("span", { className: cx('tui-avatar__indicator', `is-position-${indicatorPosition}`), "aria-hidden": "true", children: indicator }))] }));
71
+ if (showTooltip) {
72
+ return (_jsxs(Tooltip, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: avatarElement }), _jsx(Tooltip.Content, { "aria-hidden": "true", children: name })] }));
73
+ }
74
+ return avatarElement;
66
75
  });
67
76
  Avatar.displayName = 'Avatar';
@@ -10,7 +10,7 @@ import { OverlapStack } from '../OverlapStack/index.js';
10
10
  * - Non-overlap mode uses flex with gap
11
11
  * - Override overlap amount via `--tui-avatar-group-overlap` CSS property
12
12
  */
13
- export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true, children, className }, ref) => {
13
+ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true, groupLabel: groupLabelFn, children, className }, ref) => {
14
14
  const childArray = Children.toArray(children).filter(isValidElement);
15
15
  const total = childArray.length;
16
16
  // Clone children to inject size/shape props if provided at group level
@@ -26,9 +26,11 @@ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true,
26
26
  // Descriptive label for the group
27
27
  const hasOverflow = max !== undefined && total > max;
28
28
  const visibleCount = hasOverflow ? max : total;
29
- const groupLabel = hasOverflow
30
- ? `${total} users, showing ${visibleCount}`
31
- : `${total} users`;
29
+ const groupLabel = groupLabelFn
30
+ ? groupLabelFn(total, visibleCount)
31
+ : hasOverflow
32
+ ? `${total} users, showing ${visibleCount}`
33
+ : `${total} users`;
32
34
  // Non-overlap mode: simple flex layout
33
35
  if (!overlap) {
34
36
  return (_jsx("div", { ref: ref, className: cx('tui-avatar-group', className), role: "group", "aria-label": groupLabel, children: clonedChildren }));
@@ -41,5 +43,5 @@ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true,
41
43
  });
42
44
  AvatarGroup.displayName = 'AvatarGroup';
43
45
  function AvatarOverflow({ count, size, shape }) {
44
- return (_jsx("span", { className: cx('tui-avatar', 'tui-avatar--overflow', size && `is-size-${size}`, shape && `is-shape-${shape}`), role: "img", "aria-hidden": "true", children: _jsx("span", { className: "tui-avatar__content", children: _jsxs("span", { className: "tui-avatar__initials", children: ["+", count] }) }) }));
46
+ return (_jsx("span", { className: cx('tui-avatar', 'tui-avatar--overflow', size && `is-size-${size}`, shape && `is-shape-${shape}`), "aria-hidden": "true", children: _jsx("span", { className: "tui-avatar__content", children: _jsxs("span", { className: "tui-avatar__initials", children: ["+", count] }) }) }));
45
47
  }
@@ -25,6 +25,12 @@ export type AvatarProps = {
25
25
  indicatorLabel?: string;
26
26
  /** Position of the indicator */
27
27
  indicatorPosition?: IndicatorPosition;
28
+ /**
29
+ * When true, wraps the avatar with a Tooltip showing the `name`.
30
+ * Helps sighted users discover the user's name on hover/focus.
31
+ * Has no effect when `name` is not provided.
32
+ */
33
+ tooltip?: boolean;
28
34
  /** Additional CSS class */
29
35
  className?: string;
30
36
  };
@@ -37,6 +43,11 @@ export type AvatarGroupProps = {
37
43
  shape?: AvatarShape;
38
44
  /** Whether avatars overlap (default: true) */
39
45
  overlap?: boolean;
46
+ /**
47
+ * Custom label function for i18n. Receives total and visible counts.
48
+ * Default: "N users" or "N users, showing M".
49
+ */
50
+ groupLabel?: (total: number, visible: number) => string;
40
51
  /** Children (Avatar components) */
41
52
  children: React.ReactNode;
42
53
  /** Additional CSS class */
@@ -3,9 +3,16 @@ import React, { forwardRef } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { Icon } from '../Icon/index.js';
5
5
  import { getSafeRel } from '../../utils/polymorphic.js';
6
- export const Button = forwardRef(({ label, children, size = 'md', theme = 'primary', variant = 'solid', fullWidth, disabled = false, loading = false, leftIconName, rightIconName, leftIcon, rightIcon, iconSize = 'sm', className, target, rel, onClick, style, ...rest }, ref) => {
6
+ export const Button = forwardRef(({ label, children, size = 'md', theme = 'primary', variant = 'solid', fullWidth, disabled = false, loading = false, loadingLabel: loadingLabelProp, leftIconName, rightIconName, leftIcon, rightIcon, iconSize: iconSizeProp, className, target, rel, onClick, style, ...rest }, ref) => {
7
7
  const isLink = typeof rest.href === 'string';
8
8
  const isDisabled = disabled || loading;
9
+ // Auto-scale icon size with button size when not explicitly set
10
+ const iconSizeMap = { xs: 'xs', sm: 'xs', md: 'sm', lg: 'md' };
11
+ const iconSize = iconSizeProp ?? iconSizeMap[size];
12
+ // Compose loading state into aria-label for screen readers
13
+ const loadingLabel = loading
14
+ ? (loadingLabelProp ?? (typeof label === 'string' ? `${label}, loading` : undefined))
15
+ : undefined;
9
16
  // Normalize destructive → danger for CSS class
10
17
  const themeClass = theme === 'destructive' ? 'danger' : theme;
11
18
  const classes = cx('tui-button', `is-size-${size}`, `is-theme-${themeClass}`, variant !== 'solid' && `is-style-${variant}`, fullWidth && 'is-width-full', isDisabled && 'is-disabled', className);
@@ -25,9 +32,9 @@ export const Button = forwardRef(({ label, children, size = 'md', theme = 'prima
25
32
  }
26
33
  onClick?.(e);
27
34
  };
28
- return (_jsx("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, tabIndex: isDisabled ? -1 : tabIndex, onClick: handleClick, "data-loading": loading || undefined, target: target, rel: safeRel, style: style, ...anchorRest, children: content }));
35
+ return (_jsxs("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-label": loadingLabel, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, tabIndex: isDisabled ? -1 : tabIndex, onClick: handleClick, "data-loading": loading || undefined, target: target, rel: safeRel, style: style, ...anchorRest, children: [content, target === '_blank' && (_jsx("span", { className: "tui-visually-hidden", children: " (opens in new tab)" }))] }));
29
36
  }
30
37
  const buttonRest = rest;
31
- return (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes, disabled: isDisabled, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, onClick: onClick, "data-loading": loading || undefined, style: style, ...buttonRest, children: content }));
38
+ return (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes, "aria-label": loadingLabel, disabled: isDisabled, "aria-busy": loading || undefined, onClick: onClick, "data-loading": loading || undefined, style: style, ...buttonRest, children: content }));
32
39
  });
33
40
  Button.displayName = 'Button';
@@ -6,7 +6,9 @@ export type { Size };
6
6
  * - `'primary'`: Primary action (default)
7
7
  * - `'secondary'`: Secondary/neutral action
8
8
  * - `'danger'`: Destructive action
9
- * - `'destructive'`: Alias for danger
9
+ * - `'destructive'`: Alias for `'danger'` — mapped internally before
10
+ * class generation. Provided for readability in consumer code where
11
+ * "destructive" better describes the intent (e.g. `theme="destructive"`).
10
12
  */
11
13
  export type Theme = ThemeIntent | 'destructive';
12
14
  /**
@@ -60,6 +62,12 @@ type CommonProps = {
60
62
  * @default false
61
63
  */
62
64
  loading?: boolean;
65
+ /**
66
+ * Accessible label override during loading state. For i18n support.
67
+ * When loading, this replaces the auto-composed label.
68
+ * @default `${label}, loading` (English)
69
+ */
70
+ loadingLabel?: string;
63
71
  /**
64
72
  * Link target (for anchor variant).
65
73
  */
@@ -1,26 +1,38 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import React, { forwardRef, useRef, useEffect } from 'react';
2
+ import React, { forwardRef, useRef, useEffect, createContext, useContext } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { isDev } from '../../utils/is-dev.js';
5
+ const CardDisabledContext = createContext(false);
5
6
  export const Card = forwardRef(function Card({ as = 'article', inline, elevated, interactive, disabled, className, style, onClick, children, ...rest }, ref) {
6
7
  const Tag = as;
7
- const hasCardLinkChild = React.Children.toArray(children).some((child) => {
8
- if (!React.isValidElement(child))
9
- return false;
10
- const type = child.type;
11
- return child.type === CardLink || type.displayName === 'Card.Link';
12
- });
13
- // Dev warning: suggest Card.Link for interactive cards
8
+ // Dev warning: suggest Card.Link for interactive cards.
9
+ // The recursive children walk is gated behind isDev() to avoid
10
+ // unnecessary work in production on every render.
14
11
  const warnedRef = useRef(false);
15
12
  useEffect(() => {
16
- if (isDev() && !warnedRef.current && (interactive || onClick) && !hasCardLinkChild) {
13
+ if (!isDev() || warnedRef.current || !(interactive || onClick))
14
+ return;
15
+ const hasCardLinkChild = (function checkChildren(nodes) {
16
+ return React.Children.toArray(nodes).some((child) => {
17
+ if (!React.isValidElement(child))
18
+ return false;
19
+ const type = child.type;
20
+ if (child.type === CardLink || type.displayName === 'Card.Link')
21
+ return true;
22
+ const childProps = child.props;
23
+ if (childProps.children)
24
+ return checkChildren(childProps.children);
25
+ return false;
26
+ });
27
+ })(children);
28
+ if (!hasCardLinkChild) {
17
29
  warnedRef.current = true;
18
30
  console.warn('[TUI Card] Interactive cards should use <Card.Link> for accessible click targets. ' +
19
31
  '`interactive` and `onClick` provide visual hover styles but no keyboard/screen-reader semantics.');
20
32
  }
21
- }, [interactive, onClick, hasCardLinkChild]);
33
+ });
22
34
  const classes = cx('tui-card', inline && 'is-layout-inline', elevated && 'is-style-elevated', interactive && 'has-interaction', className);
23
- return (_jsx(Tag, { ref: ref, className: classes, style: style, "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onClick, ...rest, children: _jsx("div", { className: "tui-card__inner", children: children }) }));
35
+ return (_jsx(Tag, { ref: ref, className: classes, style: style, "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onClick, ...rest, children: _jsx(CardDisabledContext.Provider, { value: !!disabled, children: _jsx("div", { className: "tui-card__inner", children: children }) }) }));
24
36
  });
25
37
  function CardHead({ className, children, ...rest }) {
26
38
  return (_jsx("div", { className: cx('tui-card__head', className), ...rest, children: children }));
@@ -34,8 +46,9 @@ function CardFoot({ className, children, ...rest }) {
34
46
  return (_jsx("div", { className: cx('tui-card__foot', className), ...rest, children: children }));
35
47
  }
36
48
  CardFoot.displayName = 'Card.Foot';
37
- function CardLink({ className, children, rel, target, ...rest }) {
38
- return (_jsx("a", { className: cx('tui-stretched-link', className), rel: target === '_blank' ? ['noopener', 'noreferrer', rel].filter(Boolean).join(' ') : rel, target: target, ...rest, children: children }));
49
+ function CardLink({ className, children, rel, target, href, ...rest }) {
50
+ const isDisabled = useContext(CardDisabledContext);
51
+ return (_jsx("a", { className: cx('tui-stretched-link', className), rel: target === '_blank' ? ['noopener', 'noreferrer', rel].filter(Boolean).join(' ') : rel, target: isDisabled ? undefined : target, href: isDisabled ? undefined : href, role: isDisabled ? 'link' : undefined, "aria-disabled": isDisabled || undefined, tabIndex: isDisabled ? -1 : undefined, ...rest, children: children }));
39
52
  }
40
53
  CardLink.displayName = 'Card.Link';
41
54
  Card.Head = CardHead;
@@ -1,4 +1,4 @@
1
- export declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<import("react").InputHTMLAttributes<HTMLInputElement>, "role" | "type" | "defaultChecked" | "onChange" | "checked"> & {
1
+ export declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<import("react").InputHTMLAttributes<HTMLInputElement>, "role" | "type" | "onChange" | "defaultChecked" | "checked"> & {
2
2
  checked?: boolean;
3
3
  defaultChecked?: boolean;
4
4
  onCheckedChange?: (checked: boolean) => void;