@tangible/ui 0.0.5 → 0.0.7

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.
package/README.md CHANGED
@@ -1,17 +1,17 @@
1
1
  # Tangible UI
2
2
 
3
- Design system for Tangible WordPress plugins. React components + CSS tokens + utility classes.
3
+ Design system for Tangible WordPress plugins. React components, CSS custom property tokens, and CSS-only form elements.
4
4
 
5
5
  **Live Storybook:** https://storybook-tangible-ui.pages.dev
6
6
 
7
7
  ## Components
8
8
 
9
- - **Primitives:** Button, Chip, ChipGroup, Icon, IconButton, Progress, Rating, Tooltip
9
+ - **Primitives:** Button, IconButton, Chip, ChipGroup, Icon, Progress, Rating, Tooltip
10
10
  - **Layout:** Accordion, Card, Modal, Notice, Sidebar, Tabs, Toolbar
11
11
  - **Data:** DataTable, StepList, StepIndicator, Pager
12
- - **Form Controls:** Select, MultiSelect, Combobox, TextInput, Textarea, Checkbox, Switch, Radio
12
+ - **Form Controls:** Select, MultiSelect, Combobox, TextInput, Textarea, Checkbox, Switch, Radio/RadioGroup
13
13
  - **Composites:** Avatar, Dropdown, MoveHandle, OverlapStack, SegmentedControl, Field
14
- - **CSS-only Inputs:** Text, textarea, select, checkbox, radio, toggle, file
14
+ - **CSS-only Inputs:** Text, textarea, select, checkbox, radio, toggle, file (no JS required)
15
15
 
16
16
  ## Getting Started
17
17
 
@@ -21,21 +21,40 @@ Design system for Tangible WordPress plugins. React components + CSS tokens + ut
21
21
  npm install @tangible/ui
22
22
  ```
23
23
 
24
+ #### Optional peer dependencies
25
+
26
+ Some components require additional packages. Install only what you use:
27
+
28
+ | Package | Required by | Size |
29
+ |---------|-------------|------|
30
+ | `@floating-ui/react` | Select, MultiSelect, Combobox, Dropdown, Tooltip | ~90 KB |
31
+ | `@tanstack/react-table` | DataTable | ~50 KB |
32
+
33
+ ```bash
34
+ # If using Select, Dropdown, Tooltip, etc.
35
+ npm install @floating-ui/react
36
+
37
+ # If using DataTable
38
+ npm install @tanstack/react-table
39
+ ```
40
+
41
+ Components without these dependencies (Button, Card, Accordion, Modal, Tabs, etc.) work with zero additional installs.
42
+
24
43
  ### Import styles
25
44
 
26
45
  ```tsx
27
- // In your app entry point
28
46
  import '@tangible/ui/styles';
29
47
  ```
30
48
 
31
- Or for WordPress contexts (no CSS layers):
49
+ For WordPress plugin contexts where CSS layers can lose to unlayered theme styles:
50
+
32
51
  ```tsx
33
52
  import '@tangible/ui/styles/unlayered';
34
53
  ```
35
54
 
36
- ### Wrap your app
55
+ ### Set up the interface wrapper
37
56
 
38
- Components require the `.tui-interface` wrapper to access design tokens:
57
+ All components require the `.tui-interface` wrapper to access design tokens:
39
58
 
40
59
  ```tsx
41
60
  function App() {
@@ -47,34 +66,112 @@ function App() {
47
66
  }
48
67
  ```
49
68
 
50
- Dark mode via `data-theme="dark"` on the wrapper.
69
+ ### Dark mode
70
+
71
+ Set `data-theme` on the wrapper:
72
+
73
+ ```tsx
74
+ <div className="tui-interface" data-theme="dark">
75
+ ```
76
+
77
+ - `"dark"` — force dark mode
78
+ - `"auto"` — follow `prefers-color-scheme`
79
+ - Omit attribute — light mode (inherits host colour)
51
80
 
52
81
  ### Use components
53
82
 
54
83
  ```tsx
55
- import { Button, Card, Tooltip, IconButton } from '@tangible/ui';
84
+ import { Button, Card, Select, SelectOption } from '@tangible/ui';
56
85
 
57
86
  function Example() {
58
87
  return (
59
88
  <Card>
60
89
  <Card.Body>
61
- <Button label="Click me" theme="primary" />
62
-
63
- <IconButton icon="system/settings" label="Settings" showTooltip />
64
-
65
- <Tooltip>
66
- <Tooltip.Trigger asChild>
67
- <Button label="Hover me" variant="outline" />
68
- </Tooltip.Trigger>
69
- <Tooltip.Content>Hello!</Tooltip.Content>
70
- </Tooltip>
90
+ <Button label="Save" theme="primary" />
91
+
92
+ <Select placeholder="Choose..." onValueChange={(v) => console.log(v)}>
93
+ <Select.Trigger />
94
+ <Select.Content>
95
+ <Select.Option value="a">Option A</Select.Option>
96
+ <Select.Option value="b">Option B</Select.Option>
97
+ </Select.Content>
98
+ </Select>
71
99
  </Card.Body>
72
100
  </Card>
73
101
  );
74
102
  }
75
103
  ```
76
104
 
77
- See the [Storybook](https://storybook-tangible-ui.pages.dev) for full component documentation and examples.
105
+ ### Tree-shaking
106
+
107
+ Individual component imports are available if your bundler doesn't tree-shake the barrel export:
108
+
109
+ ```tsx
110
+ import { Button } from '@tangible/ui/components/Button';
111
+ import { Tooltip } from '@tangible/ui/components/Tooltip';
112
+ ```
113
+
114
+ ## Customisation
115
+
116
+ ### Token overrides
117
+
118
+ Components are styled via CSS custom properties. Override them on `.tui-interface` or any ancestor:
119
+
120
+ ```css
121
+ /* Global overrides */
122
+ .tui-interface {
123
+ --tui-radius-md: 2px;
124
+ --tui-focus-ring-color: hotpink;
125
+ }
126
+
127
+ /* Scoped to a specific context */
128
+ .my-sidebar .tui-interface {
129
+ --tui-button-radius: 0;
130
+ --tui-control-height-md: 32px;
131
+ }
132
+ ```
133
+
134
+ ### Component API tokens
135
+
136
+ Each component reads its own `--tui-{component}-*` tokens via fallback chains. These are never defined by TUI — only read. Set them from consuming code:
137
+
138
+ ```css
139
+ .compact-form {
140
+ --tui-accordion-padding: 8px;
141
+ --tui-select-trigger-radius: 2px;
142
+ --tui-modal-spacing: 24px;
143
+ }
144
+ ```
145
+
146
+ See each component's `styles.scss` header for its full token API.
147
+
148
+ ### Form control sizing
149
+
150
+ All form controls share a unified sizing system:
151
+
152
+ ```css
153
+ .my-context .tui-interface {
154
+ --tui-control-height-sm: 28px;
155
+ --tui-control-height-md: 32px;
156
+ --tui-control-height-lg: 40px;
157
+
158
+ /* Optional: decouple font size from size tier */
159
+ --tui-control-font-size-sm: 13px;
160
+ --tui-control-font-size-md: 13px;
161
+ --tui-control-font-size-lg: 14px;
162
+ }
163
+ ```
164
+
165
+ ### Icons
166
+
167
+ Four icon sets available via the registry: `system`, `cred`, `reaction`, `player`.
168
+
169
+ ```tsx
170
+ import { Icon } from '@tangible/ui';
171
+
172
+ <Icon name="system/check" />
173
+ <Icon name="reaction/clap-fill" size="lg" />
174
+ ```
78
175
 
79
176
  ## Development
80
177
 
@@ -83,7 +180,7 @@ npm install
83
180
  npm run storybook # Dev server on port 6006
84
181
  ```
85
182
 
86
- ## Commands
183
+ ### Commands
87
184
 
88
185
  ```bash
89
186
  npm run storybook # Dev server
@@ -97,12 +194,12 @@ npm run test:visual:update # Regenerate visual baselines
97
194
 
98
195
  ## Documentation
99
196
 
100
- - `CLAUDE.md` — Development guide (architecture, patterns, conventions)
101
- - `CONTEXT.md` — Project background and LMS requirements
102
- - `TIMELINE.md` — Development roadmap (Jan–Mar 2026)
197
+ - `CLAUDE.md` — Architecture, patterns, conventions, gotchas
198
+ - `CONTEXT.md` — Project background and design philosophy
199
+ - `TIMELINE.md` — Development roadmap
103
200
  - `TESTING.md` — Testing strategy and infrastructure
104
- - `AGENTS.md` — Quality gate agent configurations
201
+ - `CHANGELOG.md` — Release history
105
202
 
106
203
  ## Status
107
204
 
108
- Under active development for Course Builder (LMS) and Quiz modules. Component APIs are stabilising but may change before 1.0.
205
+ Under active development. Component APIs are stabilising but may change before 1.0.
@@ -233,7 +233,7 @@ function AccordionTrigger({ asChild = false, 'aria-label': ariaLabel, children,
233
233
  return element;
234
234
  }
235
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" })] }));
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: [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" })] }));
237
237
  // Wrap in heading if headingLevel is specified
238
238
  if (headingLevel) {
239
239
  const Heading = `h${headingLevel}`;
@@ -1,5 +1,5 @@
1
1
  import type { ComboboxProps, ComboboxContentProps, ComboboxOptionProps, ComboboxGroupProps, ComboboxLabelProps } from './types';
2
- declare function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, openOnFocus, filterMode, onQueryChange, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, children, }: ComboboxProps): import("react/jsx-runtime").JSX.Element;
2
+ declare function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled, placeholder, size, openOnFocus, filterMode, onQueryChange, clearable, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, children, }: ComboboxProps): import("react/jsx-runtime").JSX.Element;
3
3
  declare namespace ComboboxRoot {
4
4
  var displayName: string;
5
5
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
+ import { isDev } from '../../utils/is-dev.js';
3
4
  import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useRole, } from '@floating-ui/react';
4
5
  import { cx } from '../../utils/cx.js';
5
6
  import { toKey } from '../../utils/value-key.js';
@@ -11,7 +12,7 @@ import { ComboboxActionsContext, ComboboxStateContext, ComboboxContentContext, u
11
12
  // =============================================================================
12
13
  // Combobox Root
13
14
  // =============================================================================
14
- function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', openOnFocus = true, filterMode = 'always', onQueryChange, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, children, }) {
15
+ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inputValue: controlledInputValue, onInputChange, open: controlledOpen, defaultOpen, onOpenChange, disabled = false, placeholder = '', size = 'md', openOnFocus = true, filterMode = 'always', onQueryChange, clearable = true, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, inputClassName, children, }) {
15
16
  // Controlled/uncontrolled value (initialize from defaultValue)
16
17
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
17
18
  const isValueControlled = controlledValue !== undefined;
@@ -364,7 +365,7 @@ function ComboboxRoot({ value: controlledValue, defaultValue, onValueChange, inp
364
365
  return (_jsx(ComboboxActionsContext.Provider, { value: actionsValue, children: _jsx(ComboboxStateContext.Provider, { value: stateValue, children: _jsxs("div", { className: "tui-combobox", children: [_jsxs("div", { className: "tui-combobox__input-wrapper", children: [_jsx("input", { ref: (node) => {
365
366
  inputRef.current = node;
366
367
  refs.setReference(node);
367
- }, type: "text", id: inputId, className: cx('tui-combobox__input', size !== 'md' && `is-size-${size}`, inputClassName), role: "combobox", "aria-expanded": open, "aria-controls": listboxId, "aria-autocomplete": "list", "aria-activedescendant": activeOptionId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, disabled: disabled, placeholder: placeholder, value: inputValue, autoComplete: "off", ...referenceProps, onChange: handleInputChange }), inputValue && !disabled && (_jsx("span", { className: "tui-combobox__clear", onPointerDown: handleClear, "aria-hidden": "true", children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx("span", { className: "tui-combobox__icon", "aria-hidden": "true", children: _jsx(Icon, { name: "system/chevron-down", size: "sm" }) })] }), children] }) }) }));
368
+ }, type: "text", id: inputId, className: cx('tui-combobox__input', size !== 'md' && `is-size-${size}`, inputClassName), role: "combobox", "aria-expanded": open, "aria-controls": listboxId, "aria-autocomplete": "list", "aria-activedescendant": activeOptionId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, disabled: disabled, placeholder: placeholder, value: inputValue, autoComplete: "off", ...referenceProps, onChange: handleInputChange }), clearable && inputValue && !disabled && (_jsx("span", { className: "tui-combobox__clear", onPointerDown: handleClear, "aria-hidden": "true", children: _jsx(Icon, { name: "system/close", size: "sm" }) })), _jsx("span", { className: "tui-combobox__icon", "aria-hidden": "true", children: _jsx(Icon, { name: "system/chevron-down", size: "sm" }) })] }), children] }) }) }));
368
369
  }
369
370
  ComboboxRoot.displayName = 'Combobox';
370
371
  // =============================================================================
@@ -396,7 +397,7 @@ function ComboboxOptionComponent({ value: optionValue, disabled = false, textVal
396
397
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
397
398
  // Warn in dev if textValue couldn't be derived
398
399
  useEffect(() => {
399
- if (import.meta.env.DEV && !textValue) {
400
+ if (isDev() && !textValue) {
400
401
  console.warn(`Combobox.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
401
402
  }
402
403
  }, [textValue, optionValue]);
@@ -86,6 +86,11 @@ export type ComboboxProps = {
86
86
  * ID of element that labels this combobox.
87
87
  */
88
88
  'aria-labelledby'?: string;
89
+ /**
90
+ * Whether to show the clear button when a value is present.
91
+ * @default true
92
+ */
93
+ clearable?: boolean;
89
94
  /**
90
95
  * Class name applied directly to the `<input>` element.
91
96
  * Use for utilities like `tui-input-reset` that must target the input itself.
@@ -5,16 +5,18 @@ import { FieldContext, useFieldContext } from './FieldContext.js';
5
5
  export const Field = forwardRef(function Field({ error = false, required = false, disabled = false, inline = false, className, children, }, ref) {
6
6
  const baseId = useId();
7
7
  const controlId = `${baseId}-control`;
8
+ const labelId = `${baseId}-label`;
8
9
  const helperTextId = `${baseId}-helper`;
9
10
  const errorId = `${baseId}-error`;
10
11
  const contextValue = useMemo(() => ({
11
12
  controlId,
13
+ labelId,
12
14
  helperTextId,
13
15
  errorId,
14
16
  hasError: error,
15
17
  required,
16
18
  disabled,
17
- }), [controlId, helperTextId, errorId, error, required, disabled]);
19
+ }), [controlId, labelId, helperTextId, errorId, error, required, disabled]);
18
20
  const classes = cx('tui-field', error && 'is-error', disabled && 'is-disabled', inline && 'is-layout-inline', className);
19
21
  return (_jsx(FieldContext.Provider, { value: contextValue, children: _jsx("div", { ref: ref, className: classes, children: children }) }));
20
22
  });
@@ -22,16 +24,16 @@ export const Field = forwardRef(function Field({ error = false, required = false
22
24
  // Field.Label
23
25
  // =============================================================================
24
26
  function FieldLabel({ hidden = false, className, children, ...rest }) {
25
- const { controlId, required } = useFieldContext();
27
+ const { controlId, labelId, required } = useFieldContext();
26
28
  const classes = cx('tui-field__label', hidden && 'tui-visually-hidden', className);
27
- return (_jsxs("label", { htmlFor: controlId, className: classes, ...rest, children: [children, required && (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-field__required", "aria-hidden": "true", children: "*" }), _jsx("span", { className: "tui-visually-hidden", children: "required" })] }))] }));
29
+ return (_jsxs("label", { id: labelId, htmlFor: controlId, className: classes, ...rest, children: [children, required && (_jsxs(_Fragment, { children: [_jsx("span", { className: "tui-field__required", "aria-hidden": "true", children: "*" }), _jsx("span", { className: "tui-visually-hidden", children: "required" })] }))] }));
28
30
  }
29
31
  FieldLabel.displayName = 'Field.Label';
30
32
  // =============================================================================
31
33
  // Field.Control
32
34
  // =============================================================================
33
35
  function FieldControl({ children }) {
34
- const { controlId, helperTextId, errorId, hasError, required, disabled, } = useFieldContext();
36
+ const { controlId, labelId, helperTextId, errorId, hasError, required, disabled, } = useFieldContext();
35
37
  const child = Children.only(children);
36
38
  if (!isValidElement(child)) {
37
39
  throw new Error('Field.Control expects a single React element as its child');
@@ -48,10 +50,18 @@ function FieldControl({ children }) {
48
50
  describedByParts.push(errorId);
49
51
  }
50
52
  const describedBy = describedByParts.join(' ');
53
+ // Build aria-labelledby for non-labelable elements (<button>, <div>, etc.)
54
+ // <label htmlFor> only works with labelable elements (input, textarea, select,
55
+ // meter, output, progress). For everything else (Switch, future Slider, etc.)
56
+ // aria-labelledby provides the accessible name. For native inputs this is
57
+ // redundant with htmlFor but harmless — aria-labelledby takes priority in the
58
+ // accessible name algorithm and points at the same label text.
59
+ const labelledBy = childProps['aria-labelledby'] ?? labelId;
51
60
  // Clone child with a11y props
52
61
  // Note: aria-invalid and aria-required must be string "true", not boolean
53
62
  return cloneElement(child, {
54
63
  id: controlId,
64
+ 'aria-labelledby': labelledBy,
55
65
  'aria-describedby': describedBy,
56
66
  'aria-invalid': hasError ? 'true' : undefined,
57
67
  'aria-required': required ? 'true' : undefined,
@@ -1,6 +1,8 @@
1
1
  export type FieldContextValue = {
2
2
  /** ID for the form control element */
3
3
  controlId: string;
4
+ /** ID for the label element (for aria-labelledby on non-labelable controls) */
5
+ labelId: string;
4
6
  /** ID for helper text (for aria-describedby) */
5
7
  helperTextId: string;
6
8
  /** ID for error message (for aria-describedby) */
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
+ import { isDev } from '../../utils/is-dev.js';
4
5
  import { iconRegistry } from '../../icons/registry.js';
5
6
  /**
6
7
  * Icon component for SVG icons from the registry or emoji characters.
@@ -12,7 +13,7 @@ import { iconRegistry } from '../../icons/registry.js';
12
13
  export const Icon = React.forwardRef(({ name, emoji, label, size, className }, ref) => {
13
14
  const SvgIcon = name ? iconRegistry[name] : null;
14
15
  // Dev warning for invalid icon name
15
- if (import.meta.env.DEV && name && !SvgIcon) {
16
+ if (isDev() && name && !SvgIcon) {
16
17
  console.warn(`[Icon] Unknown icon name: "${name}". Check the registry.`);
17
18
  }
18
19
  // Decorative if no label provided
@@ -108,7 +108,7 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
108
108
  let target = null;
109
109
  if (initialFocusSelector) {
110
110
  target = dialog.querySelector(initialFocusSelector);
111
- if (!target && import.meta.env.DEV) {
111
+ if (!target && isDev()) {
112
112
  console.warn(`Modal: initialFocusSelector="${initialFocusSelector}" did not match any element. ` +
113
113
  `Falling back to first focusable element.`);
114
114
  }
@@ -118,7 +118,7 @@ function ModalRoot({ open, onClose, size = 'md', stickyHead, stickyFoot, 'aria-l
118
118
  }
119
119
  target.focus({ preventScroll: true });
120
120
  // Development warning for missing labelledBy target
121
- if (import.meta.env.DEV && labelledBy) {
121
+ if (isDev() && labelledBy) {
122
122
  const labelElement = document.getElementById(labelledBy);
123
123
  if (!labelElement) {
124
124
  console.warn(`Modal: aria-labelledby="${labelledBy}" references a non-existent element. ` +
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
2
+ import { forwardRef, useCallback, useEffect, useId, useRef, useState } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { isDev } from '../../utils/is-dev.js';
5
5
  import { Icon } from '../Icon/index.js';
@@ -64,6 +64,17 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
64
64
  fallback?.focus();
65
65
  }
66
66
  }, [mode, canMoveUp, canMoveDown]);
67
+ // Debounce the lock icon — prevents visual jitter when `locked` flashes
68
+ // briefly (e.g. during a save). Behaviour (disabled buttons) applies
69
+ // immediately; only the icon swap is delayed.
70
+ const [showLockIcon, setShowLockIcon] = useState(locked);
71
+ useEffect(() => {
72
+ if (locked) {
73
+ const id = setTimeout(() => setShowLockIcon(true), 150);
74
+ return () => clearTimeout(id);
75
+ }
76
+ setShowLockIcon(false);
77
+ }, [locked]);
67
78
  // Drag handle label precedence: dragHandleProps > labels.drag > default
68
79
  const resolvedDragLabel = dragHandleProps?.['aria-label'] ?? labels?.drag ?? 'Drag to reorder';
69
80
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -80,5 +91,5 @@ export const MoveHandle = forwardRef(function MoveHandle({ mode = 'full', size =
80
91
  const resolvedLockedDesc = locked
81
92
  ? (labels?.locked ?? 'This item is locked and cannot be reordered')
82
93
  : undefined;
83
- return (_jsxs("div", { ref: mergedRef, role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": locked ? lockedDescId : undefined, "aria-disabled": locked || undefined, className: cx('tui-move-handle', `is-size-${size}`, locked && 'is-locked', hasIndex && 'has-index', className), children: [locked && (_jsx("span", { id: lockedDescId, className: "tui-visually-hidden", children: resolvedLockedDesc })), onMoveUp && (_jsx("button", { type: "button", className: "tui-move-handle__up", "data-direction": "up", "aria-label": labels?.moveUp ?? 'Move up', disabled: locked || !canMoveUp, onClick: onMoveUp, children: _jsx(Icon, { name: "system/chevron-up" }) })), _jsx("div", { className: "tui-move-handle__center", children: locked ? (_jsx("span", { className: "tui-move-handle__lock", "aria-hidden": "true", children: _jsx(Icon, { name: "system/lock" }) })) : (_jsxs(_Fragment, { children: [hasIndex && (_jsx("span", { className: "tui-move-handle__index", "aria-hidden": "true", children: index })), _jsx("button", { type: "button", className: "tui-move-handle__handle", "data-role": "drag-handle", "aria-label": resolvedDragLabel, tabIndex: hasArrows ? -1 : 0, ...restDragProps, children: _jsx(Icon, { name: "system/handle-alt" }) })] })) }), onMoveDown && (_jsx("button", { type: "button", className: "tui-move-handle__down", "data-direction": "down", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
94
+ return (_jsxs("div", { ref: mergedRef, role: "group", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": locked ? lockedDescId : undefined, "aria-disabled": locked || undefined, className: cx('tui-move-handle', `is-size-${size}`, locked && 'is-locked', hasIndex && 'has-index', className), children: [locked && (_jsx("span", { id: lockedDescId, className: "tui-visually-hidden", children: resolvedLockedDesc })), onMoveUp && (_jsx("button", { type: "button", className: "tui-move-handle__up", "data-direction": "up", "aria-label": labels?.moveUp ?? 'Move up', disabled: locked || !canMoveUp, onClick: onMoveUp, children: _jsx(Icon, { name: "system/chevron-up" }) })), _jsx("div", { className: "tui-move-handle__center", children: showLockIcon ? (_jsx("span", { className: "tui-move-handle__lock", "aria-hidden": "true", children: _jsx(Icon, { name: "system/lock" }) })) : (_jsxs(_Fragment, { children: [hasIndex && (_jsx("span", { className: "tui-move-handle__index", "aria-hidden": "true", children: index })), _jsx("button", { type: "button", className: "tui-move-handle__handle", "data-role": "drag-handle", "aria-label": resolvedDragLabel, tabIndex: hasArrows ? -1 : 0, ...restDragProps, children: _jsx(Icon, { name: "system/handle-alt" }) })] })) }), onMoveDown && (_jsx("button", { type: "button", className: "tui-move-handle__down", "data-direction": "down", "aria-label": labels?.moveDown ?? 'Move down', disabled: locked || !canMoveDown, onClick: onMoveDown, children: _jsx(Icon, { name: "system/chevron-down" }) }))] }));
84
95
  });
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, cloneElement, isValidElement, } from 'react';
3
+ import { isDev } from '../../utils/is-dev.js';
3
4
  import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useTypeahead, useRole, useClick, } from '@floating-ui/react';
4
5
  import { cx } from '../../utils/cx.js';
5
6
  import { getPortalRootFor } from '../../utils/portal.js';
@@ -549,7 +550,7 @@ function MultiSelectOptionComponent({ value: optionValue, disabled = false, text
549
550
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
550
551
  // Warn in dev if textValue couldn't be derived
551
552
  useEffect(() => {
552
- if (import.meta.env.DEV && !textValue) {
553
+ if (isDev() && !textValue) {
553
554
  console.warn(`MultiSelect.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
554
555
  }
555
556
  }, [textValue, optionValue]);
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import React from 'react';
3
3
  import { useProgressSegments } from './useProgressSegments.js';
4
4
  import { cx } from '../../utils/cx.js';
5
+ import { isDev } from '../../utils/is-dev.js';
5
6
  // =============================================================================
6
7
  // COMPONENT
7
8
  // =============================================================================
@@ -23,7 +24,7 @@ export function Progress(props) {
23
24
  // Calculate percentages for standard mode
24
25
  const pct = Math.max(0, Math.min(100, (value / max) * 100));
25
26
  // Dev warning: inside position only supports labelStart (or children)
26
- if (import.meta.env.DEV && labelPosition === 'inside' && labelStart && labelEnd) {
27
+ if (isDev() && labelPosition === 'inside' && labelStart && labelEnd) {
27
28
  console.warn('Progress: labelPosition="inside" only supports a single label. ' +
28
29
  'labelEnd will be ignored. Use labelStart or children for inside content.');
29
30
  }
@@ -1,2 +1,6 @@
1
1
  import type { RadioProps } from './types';
2
+ /**
3
+ * Ref targets the inner `<button role="radio">`, not the outer wrapper `<div>`.
4
+ * This is intentional — roving tabindex and focus management operate on the button.
5
+ */
2
6
  export declare const Radio: import("react").ForwardRefExoticComponent<RadioProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useCallback, useEffect, useRef } from 'react';
2
+ import { forwardRef, useCallback, useEffect, useId, useRef } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
4
  import { composeRefs } from '../../utils/compose-refs.js';
5
5
  import { toKey } from '../../utils/value-key.js';
@@ -14,8 +14,14 @@ import { useRadioGroupContext } from './RadioGroupContext.js';
14
14
  // Arrow keys in the group move focus AND select.
15
15
  //
16
16
  // =============================================================================
17
- export const Radio = forwardRef(function Radio({ value, label, disabled = false, className }, externalRef) {
17
+ /**
18
+ * Ref targets the inner `<button role="radio">`, not the outer wrapper `<div>`.
19
+ * This is intentional — roving tabindex and focus management operate on the button.
20
+ */
21
+ export const Radio = forwardRef(function Radio({ value, label, description, disabled = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }, externalRef) {
18
22
  const { selectedValue, focusableValue, rootDisabled, registerItem, unregisterItem, onSelect, } = useRadioGroupContext();
23
+ const id = useId();
24
+ const descriptionId = `${id}-desc`;
19
25
  const isSelected = selectedValue !== undefined && toKey(selectedValue) === toKey(value);
20
26
  const isDisabled = rootDisabled || disabled;
21
27
  const isFocusable = focusableValue !== undefined && toKey(focusableValue) === toKey(value);
@@ -24,8 +30,8 @@ export const Radio = forwardRef(function Radio({ value, label, disabled = false,
24
30
  useEffect(() => {
25
31
  if (hasWarnedRef.current)
26
32
  return;
27
- if (isDev() && !label) {
28
- console.warn('Radio: Missing accessible name. Provide a label prop.');
33
+ if (isDev() && !label && !ariaLabel && !ariaLabelledBy) {
34
+ console.warn('Radio: Missing accessible name. Provide a label, aria-label, or aria-labelledby prop.');
29
35
  hasWarnedRef.current = true;
30
36
  }
31
37
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -45,6 +51,10 @@ export const Radio = forwardRef(function Radio({ value, label, disabled = false,
45
51
  return;
46
52
  onSelect(value);
47
53
  };
48
- return (_jsxs("button", { ref: composeRefs(callbackRef, externalRef), type: "button", role: "radio", className: cx('tui-radio', className), "aria-checked": isSelected, disabled: isDisabled, tabIndex: isFocusable ? 0 : -1, onClick: handleClick, children: [_jsx("span", { className: "tui-radio__indicator", "aria-hidden": "true" }), label && _jsx("span", { className: "tui-radio__label", children: label })] }));
54
+ const hasExpandedContent = !!(description || children);
55
+ return (_jsxs("div", { className: cx('tui-radio', hasExpandedContent && 'has-content', className), children: [_jsxs("button", { ref: composeRefs(callbackRef, externalRef), type: "button", role: "radio", className: "tui-radio__control", "aria-checked": isSelected, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": description ? descriptionId : undefined,
56
+ // Item-level disabled: native disabled (removes from focus cycle).
57
+ // Group-level disabled: aria-disabled (preserves AT group context).
58
+ disabled: disabled || undefined, "aria-disabled": rootDisabled || undefined, tabIndex: isFocusable ? 0 : -1, onClick: handleClick, children: [_jsx("span", { className: "tui-radio__indicator", "aria-hidden": "true" }), label && _jsx("span", { className: "tui-radio__label", children: label })] }), hasExpandedContent && (_jsxs("div", { className: "tui-radio__body", children: [description && (_jsx("p", { id: descriptionId, className: "tui-radio__description", children: description })), children] }))] }));
49
59
  });
50
60
  Radio.displayName = 'Radio';
@@ -1,2 +1,2 @@
1
1
  import type { RadioGroupProps } from './types';
2
- export declare function RadioGroup({ value: controlledValue, defaultValue, onValueChange, disabled, orientation, loop, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }: RadioGroupProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function RadioGroup({ value: controlledValue, defaultValue, onValueChange, disabled, orientation, loop, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, className, children, }: RadioGroupProps): import("react/jsx-runtime").JSX.Element;
@@ -17,7 +17,7 @@ import { isDev } from '../../utils/is-dev.js';
17
17
  // --tui-radio-accent Accent color for selected state
18
18
  //
19
19
  // =============================================================================
20
- export function RadioGroup({ value: controlledValue, defaultValue, onValueChange, disabled = false, orientation = 'vertical', loop = true, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, className, children, }) {
20
+ export function RadioGroup({ value: controlledValue, defaultValue, onValueChange, disabled = false, orientation = 'vertical', loop = true, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, className, children, }) {
21
21
  const [selectedValue, setSelectedValue] = useControllableState({
22
22
  value: controlledValue,
23
23
  defaultValue,
@@ -50,5 +50,5 @@ export function RadioGroup({ value: controlledValue, defaultValue, onValueChange
50
50
  unregisterItem,
51
51
  onSelect,
52
52
  }), [selectedValue, focusableValue, disabled, orientation, registerItem, unregisterItem, onSelect]);
53
- return (_jsx(RadioGroupContext.Provider, { value: contextValue, children: _jsx("div", { role: "radiogroup", className: cx('tui-radio-group', orientation === 'horizontal' && 'is-horizontal', className), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-disabled": disabled || undefined, "aria-orientation": orientation === 'horizontal' ? 'horizontal' : undefined, onKeyDown: handleKeyDown, children: children }) }));
53
+ return (_jsx(RadioGroupContext.Provider, { value: contextValue, children: _jsx("div", { role: "radiogroup", className: cx('tui-radio-group', orientation === 'horizontal' && 'is-horizontal', className), "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, "aria-disabled": disabled || undefined, "aria-orientation": orientation, onKeyDown: handleKeyDown, children: children }) }));
54
54
  }
@@ -10,14 +10,24 @@ export type RadioGroupProps = {
10
10
  loop?: boolean;
11
11
  'aria-label'?: string;
12
12
  'aria-labelledby'?: string;
13
+ 'aria-describedby'?: string;
14
+ 'aria-invalid'?: boolean | 'true' | 'false';
15
+ 'aria-required'?: boolean | 'true' | 'false';
13
16
  className?: string;
14
17
  children: ReactNode;
15
18
  };
16
19
  export type RadioProps = {
17
20
  value: OptionValue;
18
21
  label?: ReactNode;
22
+ /**
23
+ * Description text displayed below the label.
24
+ */
25
+ description?: string;
19
26
  disabled?: boolean;
27
+ 'aria-label'?: string;
28
+ 'aria-labelledby'?: string;
20
29
  className?: string;
30
+ children?: ReactNode;
21
31
  };
22
32
  export type RadioItemRecord = RovingItemRecord;
23
33
  export type RadioGroupContextValue = {
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, cloneElement, isValidElement, } from 'react';
3
+ import { isDev } from '../../utils/is-dev.js';
3
4
  import { useFloating, offset, flip, shift, size as sizeMiddleware, autoUpdate, FloatingPortal, useDismiss, useInteractions, useListNavigation, useTypeahead, useRole, useClick, } from '@floating-ui/react';
4
5
  import { cx } from '../../utils/cx.js';
5
6
  import { toKey } from '../../utils/value-key.js';
@@ -435,7 +436,7 @@ function SelectOptionComponent({ value: optionValue, disabled = false, textValue
435
436
  const textValue = explicitTextValue ?? (typeof children === 'string' ? children : '');
436
437
  // Warn in dev if textValue couldn't be derived
437
438
  useEffect(() => {
438
- if (import.meta.env.DEV && !textValue) {
439
+ if (isDev() && !textValue) {
439
440
  console.warn(`Select.Option with value="${optionValue}" has no textValue. Provide textValue prop when children is not a string.`);
440
441
  }
441
442
  }, [textValue, optionValue]);
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from 'react';
3
3
  import { cx } from '../../utils/cx.js';
4
+ import { isDev } from '../../utils/is-dev.js';
4
5
  import { StepListContext, useStepListContext } from './StepListContext.js';
5
6
  import { StepIndicator } from '../StepIndicator/index.js';
6
7
  // =============================================================================
@@ -9,7 +10,7 @@ import { StepIndicator } from '../StepIndicator/index.js';
9
10
  function StepListRoot(props) {
10
11
  const { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ariaCurrent = 'step', current, onSelect, children, className, } = props;
11
12
  // Dev warning for missing nav label
12
- if (import.meta.env.DEV && !ariaLabel && !ariaLabelledBy) {
13
+ if (isDev() && !ariaLabel && !ariaLabelledBy) {
13
14
  console.warn('StepList: Navigation landmark requires a label. ' +
14
15
  'Provide either `aria-label` or `aria-labelledby` prop for screen reader users.');
15
16
  }