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