@tangible/ui 0.0.4 → 0.0.6
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.d.ts +1 -1
- package/components/Accordion/Accordion.js +93 -22
- package/components/Accordion/index.d.ts +1 -1
- package/components/Accordion/types.d.ts +20 -3
- package/components/Combobox/Combobox.js +2 -1
- 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/Select/Select.js +2 -1
- package/components/StepList/StepList.js +2 -1
- 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 +107 -39
- package/styles/all.expanded.unlayered.css +107 -39
- 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/tui-manifest.json +23 -4
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.
|
|
@@ -10,7 +10,7 @@ declare function AccordionItem({ value, disabled, headingLevel, children, classN
|
|
|
10
10
|
declare namespace AccordionItem {
|
|
11
11
|
var displayName: string;
|
|
12
12
|
}
|
|
13
|
-
declare function AccordionTrigger({ children, className }: AccordionTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
declare function AccordionTrigger({ asChild, 'aria-label': ariaLabel, children, className }: AccordionTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
14
14
|
declare namespace AccordionTrigger {
|
|
15
15
|
var displayName: string;
|
|
16
16
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import React, { cloneElement, isValidElement, useCallback, useId, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
|
+
import { composeRefs } from '../../utils/compose-refs.js';
|
|
5
|
+
import { mergeProps } from '../../utils/merge-props.js';
|
|
4
6
|
import { Icon } from '../Icon/index.js';
|
|
5
7
|
import { AccordionContext, AccordionItemContext, useAccordionContext, useAccordionItemContext } from './AccordionContext.js';
|
|
6
8
|
function AccordionRoot(props) {
|
|
7
|
-
const { type, children, className } = props;
|
|
9
|
+
const { type, variant = 'card', children, className } = props;
|
|
8
10
|
// Track trigger refs for keyboard navigation
|
|
9
11
|
const triggersRef = useRef(new Map());
|
|
10
12
|
const registerTrigger = useCallback((value, element) => {
|
|
@@ -18,11 +20,11 @@ function AccordionRoot(props) {
|
|
|
18
20
|
const getTriggers = useCallback(() => triggersRef.current, []);
|
|
19
21
|
// State management differs by type
|
|
20
22
|
if (type === 'single') {
|
|
21
|
-
return (_jsx(AccordionSingle, { ...props, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
|
|
23
|
+
return (_jsx(AccordionSingle, { ...props, variant: variant, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
|
|
22
24
|
}
|
|
23
|
-
return (_jsx(AccordionMultiple, { ...props, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
|
|
25
|
+
return (_jsx(AccordionMultiple, { ...props, variant: variant, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
|
|
24
26
|
}
|
|
25
|
-
function AccordionSingle({ value: controlledValue, defaultValue, onValueChange, collapsible = false, children, className, registerTrigger, getTriggers, }) {
|
|
27
|
+
function AccordionSingle({ value: controlledValue, defaultValue, onValueChange, collapsible = false, variant, children, className, registerTrigger, getTriggers, }) {
|
|
26
28
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
27
29
|
const isControlled = controlledValue !== undefined;
|
|
28
30
|
const currentValue = isControlled ? controlledValue : internalValue;
|
|
@@ -37,6 +39,8 @@ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange,
|
|
|
37
39
|
// Opening new item
|
|
38
40
|
newValue = itemValue;
|
|
39
41
|
}
|
|
42
|
+
if (newValue === currentValue)
|
|
43
|
+
return;
|
|
40
44
|
if (!isControlled) {
|
|
41
45
|
setInternalValue(newValue);
|
|
42
46
|
}
|
|
@@ -50,9 +54,9 @@ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange,
|
|
|
50
54
|
registerTrigger,
|
|
51
55
|
getTriggers,
|
|
52
56
|
}), [collapsible, isOpen, toggle, registerTrigger, getTriggers]);
|
|
53
|
-
return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', className), "data-type": "single", children: children }) }));
|
|
57
|
+
return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', `is-variant-${variant}`, className), "data-type": "single", children: children }) }));
|
|
54
58
|
}
|
|
55
|
-
function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange, children, className, registerTrigger, getTriggers, }) {
|
|
59
|
+
function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange, variant, children, className, registerTrigger, getTriggers, }) {
|
|
56
60
|
const [internalValue, setInternalValue] = useState(defaultValue ?? []);
|
|
57
61
|
const isControlled = controlledValue !== undefined;
|
|
58
62
|
const currentValue = isControlled ? controlledValue : internalValue;
|
|
@@ -74,7 +78,7 @@ function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange
|
|
|
74
78
|
registerTrigger,
|
|
75
79
|
getTriggers,
|
|
76
80
|
}), [isOpen, toggle, registerTrigger, getTriggers]);
|
|
77
|
-
return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', className), "data-type": "multiple", children: children }) }));
|
|
81
|
+
return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', `is-variant-${variant}`, className), "data-type": "multiple", children: children }) }));
|
|
78
82
|
}
|
|
79
83
|
// =============================================================================
|
|
80
84
|
// Accordion Item
|
|
@@ -99,21 +103,49 @@ function AccordionItem({ value, disabled = false, headingLevel, children, classN
|
|
|
99
103
|
// =============================================================================
|
|
100
104
|
// Accordion Trigger
|
|
101
105
|
// =============================================================================
|
|
102
|
-
function AccordionTrigger({ children, className }) {
|
|
106
|
+
function AccordionTrigger({ asChild = false, 'aria-label': ariaLabel, children, className }) {
|
|
103
107
|
const { toggle, registerTrigger, getTriggers } = useAccordionContext();
|
|
104
108
|
const { value, disabled, headingLevel, triggerId, panelId, isOpen } = useAccordionItemContext();
|
|
105
|
-
const
|
|
109
|
+
const triggerRef = useRef(null);
|
|
106
110
|
// Register trigger for keyboard navigation
|
|
107
111
|
React.useEffect(() => {
|
|
108
|
-
registerTrigger(value,
|
|
112
|
+
registerTrigger(value, triggerRef.current);
|
|
109
113
|
return () => registerTrigger(value, null);
|
|
110
114
|
}, [value, registerTrigger]);
|
|
111
|
-
const handleClick = () => {
|
|
112
|
-
if (
|
|
113
|
-
|
|
115
|
+
const handleClick = (event) => {
|
|
116
|
+
if (disabled)
|
|
117
|
+
return;
|
|
118
|
+
// In asChild mode, the trigger is a container with nested interactive elements.
|
|
119
|
+
// Only toggle when the click is on the trigger itself or passive content (text,
|
|
120
|
+
// chips, icons) — not when it originates from a nested button, link, or input.
|
|
121
|
+
if (asChild) {
|
|
122
|
+
const target = event.target;
|
|
123
|
+
const currentTarget = event.currentTarget;
|
|
124
|
+
if (target !== currentTarget) {
|
|
125
|
+
const interactive = target.closest('button, a, input, select, textarea, [role="button"], [role="link"]');
|
|
126
|
+
if (interactive && interactive !== currentTarget && currentTarget.contains(interactive)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
114
130
|
}
|
|
131
|
+
toggle(value);
|
|
115
132
|
};
|
|
116
133
|
const handleKeyDown = (event) => {
|
|
134
|
+
// In asChild mode, only handle keyboard events when the trigger element
|
|
135
|
+
// itself has focus — not when focus is on a nested interactive child.
|
|
136
|
+
// (A native <button> trigger can't have focusable children, so this
|
|
137
|
+
// check is only needed for asChild.)
|
|
138
|
+
if (asChild && event.target !== event.currentTarget)
|
|
139
|
+
return;
|
|
140
|
+
// Enter/Space activation for asChild mode. Native <button> elements fire
|
|
141
|
+
// click on Enter/Space automatically; role="button" elements do not.
|
|
142
|
+
if (asChild && (event.key === 'Enter' || event.key === ' ')) {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
if (!disabled) {
|
|
145
|
+
toggle(value);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
117
149
|
const triggers = getTriggers();
|
|
118
150
|
// Sort triggers by DOM order (not Map insertion order) to handle
|
|
119
151
|
// conditional rendering, async mounting, or reordered items
|
|
@@ -124,14 +156,16 @@ function AccordionTrigger({ children, className }) {
|
|
|
124
156
|
return 1;
|
|
125
157
|
});
|
|
126
158
|
const currentIndex = sortedTriggers.findIndex(([v]) => v === value);
|
|
127
|
-
// Find next/prev non-disabled trigger
|
|
159
|
+
// Find next/prev non-disabled trigger — check both native disabled (button)
|
|
160
|
+
// and aria-disabled (asChild elements)
|
|
161
|
+
const isElementDisabled = (el) => el.disabled || el.getAttribute('aria-disabled') === 'true';
|
|
128
162
|
const findNextIndex = (start, direction) => {
|
|
129
163
|
let index = start;
|
|
130
164
|
const len = sortedTriggers.length;
|
|
131
165
|
for (let i = 0; i < len; i++) {
|
|
132
166
|
index = (index + direction + len) % len;
|
|
133
167
|
const [, triggerElement] = sortedTriggers[index];
|
|
134
|
-
if (triggerElement && !triggerElement
|
|
168
|
+
if (triggerElement && !isElementDisabled(triggerElement)) {
|
|
135
169
|
return index;
|
|
136
170
|
}
|
|
137
171
|
}
|
|
@@ -147,14 +181,24 @@ function AccordionTrigger({ children, className }) {
|
|
|
147
181
|
event.preventDefault();
|
|
148
182
|
targetIndex = findNextIndex(currentIndex, -1);
|
|
149
183
|
break;
|
|
150
|
-
case 'Home':
|
|
184
|
+
case 'Home': {
|
|
151
185
|
event.preventDefault();
|
|
152
|
-
|
|
186
|
+
const first = sortedTriggers.findIndex(([, el]) => !isElementDisabled(el));
|
|
187
|
+
if (first !== -1)
|
|
188
|
+
targetIndex = first;
|
|
153
189
|
break;
|
|
154
|
-
|
|
190
|
+
}
|
|
191
|
+
case 'End': {
|
|
155
192
|
event.preventDefault();
|
|
156
|
-
|
|
193
|
+
// Scan from end to find last non-disabled trigger
|
|
194
|
+
for (let i = sortedTriggers.length - 1; i >= 0; i--) {
|
|
195
|
+
if (!isElementDisabled(sortedTriggers[i][1])) {
|
|
196
|
+
targetIndex = i;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
157
200
|
break;
|
|
201
|
+
}
|
|
158
202
|
}
|
|
159
203
|
if (targetIndex !== null && targetIndex !== currentIndex) {
|
|
160
204
|
const [, targetElement] = sortedTriggers[targetIndex];
|
|
@@ -162,7 +206,34 @@ function AccordionTrigger({ children, className }) {
|
|
|
162
206
|
}
|
|
163
207
|
};
|
|
164
208
|
const state = isOpen ? 'open' : 'closed';
|
|
165
|
-
|
|
209
|
+
// asChild: merge trigger props onto the child element
|
|
210
|
+
if (asChild && isValidElement(children)) {
|
|
211
|
+
const triggerProps = {
|
|
212
|
+
id: triggerId,
|
|
213
|
+
role: 'button',
|
|
214
|
+
tabIndex: disabled ? -1 : 0,
|
|
215
|
+
'aria-expanded': isOpen,
|
|
216
|
+
'aria-controls': panelId,
|
|
217
|
+
'aria-disabled': disabled || undefined,
|
|
218
|
+
'aria-label': ariaLabel,
|
|
219
|
+
'data-state': state,
|
|
220
|
+
'data-disabled': disabled || undefined,
|
|
221
|
+
className: cx('tui-accordion__trigger', className),
|
|
222
|
+
onClick: handleClick,
|
|
223
|
+
onKeyDown: handleKeyDown,
|
|
224
|
+
};
|
|
225
|
+
const childRef = children.ref;
|
|
226
|
+
const merged = mergeProps(children.props, triggerProps);
|
|
227
|
+
merged.ref = composeRefs(triggerRef, childRef);
|
|
228
|
+
const element = cloneElement(children, merged);
|
|
229
|
+
if (headingLevel) {
|
|
230
|
+
const Heading = `h${headingLevel}`;
|
|
231
|
+
return _jsx(Heading, { className: "tui-accordion__heading", children: element });
|
|
232
|
+
}
|
|
233
|
+
return element;
|
|
234
|
+
}
|
|
235
|
+
// Default: render as button with built-in chevron indicator
|
|
236
|
+
const button = (_jsxs("button", { ref: triggerRef, type: "button", id: triggerId, className: cx('tui-accordion__trigger', className), "aria-expanded": isOpen, "aria-controls": panelId, "aria-label": ariaLabel, disabled: disabled, "data-state": state, "data-disabled": disabled || undefined, onClick: handleClick, onKeyDown: handleKeyDown, children: [children && _jsx("span", { className: "tui-accordion__trigger-content", children: children }), _jsx(Icon, { name: "system/chevron-down", size: "lg", className: "tui-accordion__indicator", "aria-hidden": "true" })] }));
|
|
166
237
|
// Wrap in heading if headingLevel is specified
|
|
167
238
|
if (headingLevel) {
|
|
168
239
|
const Heading = `h${headingLevel}`;
|
|
@@ -176,7 +247,7 @@ function AccordionTrigger({ children, className }) {
|
|
|
176
247
|
function AccordionPanel({ landmark = false, children, className }) {
|
|
177
248
|
const { triggerId, panelId, isOpen } = useAccordionItemContext();
|
|
178
249
|
const state = isOpen ? 'open' : 'closed';
|
|
179
|
-
return (_jsx("div", { id: panelId, role: landmark ? 'region' : undefined, "aria-labelledby": triggerId, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen,
|
|
250
|
+
return (_jsx("div", { id: panelId, role: landmark ? 'region' : undefined, "aria-labelledby": landmark ? triggerId : undefined, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen,
|
|
180
251
|
// Prevent keyboard focus into collapsed panels
|
|
181
252
|
inert: !isOpen || undefined, children: _jsx("div", { className: "tui-accordion__panel-content", children: children }) }));
|
|
182
253
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { Accordion } from './Accordion';
|
|
2
|
-
export type { AccordionProps, AccordionSingleProps, AccordionMultipleProps, AccordionItemProps, AccordionTriggerProps, AccordionPanelProps, } from './types';
|
|
2
|
+
export type { AccordionProps, AccordionSingleProps, AccordionMultipleProps, AccordionItemProps, AccordionTriggerProps, AccordionPanelProps, AccordionVariant, } from './types';
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
export type AccordionVariant = 'card' | 'flush';
|
|
2
3
|
type AccordionBaseProps = {
|
|
4
|
+
/**
|
|
5
|
+
* Visual treatment.
|
|
6
|
+
* - `card` (default): Bordered cards with gap between items.
|
|
7
|
+
* - `flush`: Items share borders, no gap, no radius — denser layout.
|
|
8
|
+
*/
|
|
9
|
+
variant?: AccordionVariant;
|
|
3
10
|
children: ReactNode;
|
|
4
11
|
className?: string;
|
|
5
12
|
};
|
|
@@ -40,7 +47,17 @@ export type AccordionItemProps = {
|
|
|
40
47
|
className?: string;
|
|
41
48
|
};
|
|
42
49
|
export type AccordionTriggerProps = {
|
|
43
|
-
|
|
50
|
+
/**
|
|
51
|
+
* When true, merges trigger props (aria attributes, event handlers, keyboard
|
|
52
|
+
* navigation) onto the child element instead of rendering a built-in button.
|
|
53
|
+
* The child must be a single React element that accepts ref and event handlers.
|
|
54
|
+
* The built-in chevron indicator is not rendered in asChild mode.
|
|
55
|
+
* @default false
|
|
56
|
+
*/
|
|
57
|
+
asChild?: boolean;
|
|
58
|
+
/** Accessible label for the trigger. Required when children is empty (e.g. icon-only triggers). */
|
|
59
|
+
'aria-label'?: string;
|
|
60
|
+
children?: ReactNode;
|
|
44
61
|
className?: string;
|
|
45
62
|
};
|
|
46
63
|
export type AccordionPanelProps = {
|
|
@@ -54,8 +71,8 @@ export type AccordionContextValue = {
|
|
|
54
71
|
collapsible: boolean;
|
|
55
72
|
isOpen: (value: string) => boolean;
|
|
56
73
|
toggle: (value: string) => void;
|
|
57
|
-
registerTrigger: (value: string, element:
|
|
58
|
-
getTriggers: () => Map<string,
|
|
74
|
+
registerTrigger: (value: string, element: HTMLElement | null) => void;
|
|
75
|
+
getTriggers: () => Map<string, HTMLElement>;
|
|
59
76
|
};
|
|
60
77
|
export type AccordionItemContextValue = {
|
|
61
78
|
value: string;
|
|
@@ -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';
|
|
@@ -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]);
|
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,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
|
}
|