@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 +124 -27
- package/components/Accordion/Accordion.js +1 -1
- package/components/Combobox/Combobox.d.ts +1 -1
- package/components/Combobox/Combobox.js +4 -3
- package/components/Combobox/types.d.ts +5 -0
- package/components/Field/Field.js +14 -4
- package/components/Field/FieldContext.d.ts +2 -0
- package/components/Icon/Icon.js +2 -1
- package/components/Modal/Modal.js +2 -2
- package/components/MoveHandle/MoveHandle.js +13 -2
- package/components/MultiSelect/MultiSelect.js +2 -1
- package/components/Progress/Progress.js +2 -1
- package/components/Radio/Radio.d.ts +4 -0
- package/components/Radio/Radio.js +15 -5
- package/components/Radio/RadioGroup.d.ts +1 -1
- package/components/Radio/RadioGroup.js +2 -2
- package/components/Radio/types.d.ts +10 -0
- package/components/Select/Select.js +2 -1
- package/components/StepList/StepList.js +2 -1
- package/components/Switch/Switch.js +28 -14
- package/components/Tabs/Tabs.js +2 -2
- package/components/Toolbar/Toolbar.js +2 -1
- package/components/Tooltip/Tooltip.js +2 -1
- package/package.json +7 -9
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +223 -109
- package/styles/all.expanded.unlayered.css +223 -109
- package/styles/all.unlayered.css +1 -1
- package/styles/components/input/index.scss +2 -2
- package/styles/index.scss +14 -0
- package/styles/system/_control.scss +6 -3
- package/styles/utilities/_index.scss +14 -4
- package/tui-manifest.json +39 -4
- package/utils/use-roving-group.js +9 -6
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# Tangible UI
|
|
2
2
|
|
|
3
|
-
Design system for Tangible WordPress plugins. React components
|
|
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,
|
|
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
|
-
|
|
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
|
-
###
|
|
55
|
+
### Set up the interface wrapper
|
|
37
56
|
|
|
38
|
-
|
|
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
|
|
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,
|
|
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="
|
|
62
|
-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
</
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` —
|
|
101
|
-
- `CONTEXT.md` — Project background and
|
|
102
|
-
- `TIMELINE.md` — Development roadmap
|
|
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
|
-
- `
|
|
201
|
+
- `CHANGELOG.md` — Release history
|
|
105
202
|
|
|
106
203
|
## Status
|
|
107
204
|
|
|
108
|
-
Under active development
|
|
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 (
|
|
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) */
|
package/components/Icon/Icon.js
CHANGED
|
@@ -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 (
|
|
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 &&
|
|
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 (
|
|
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:
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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 (
|
|
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 (
|
|
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
|
}
|