@tangible/ui 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -13
- package/components/Accordion/Accordion.d.ts +2 -2
- package/components/Accordion/Accordion.js +94 -23
- package/components/Accordion/index.d.ts +1 -1
- package/components/Accordion/types.d.ts +28 -4
- package/components/Avatar/Avatar.js +16 -7
- package/components/Avatar/AvatarGroup.js +7 -5
- package/components/Avatar/types.d.ts +11 -0
- package/components/Button/Button.js +10 -3
- package/components/Button/types.d.ts +9 -1
- package/components/Card/Card.js +26 -13
- package/components/Checkbox/Checkbox.d.ts +1 -1
- package/components/Chip/Chip.d.ts +37 -1
- package/components/Chip/Chip.js +10 -8
- package/components/ChipGroup/ChipGroup.js +5 -4
- package/components/ChipGroup/types.d.ts +3 -0
- package/components/Dropdown/Dropdown.d.ts +19 -1
- package/components/Dropdown/Dropdown.js +84 -28
- package/components/Dropdown/index.d.ts +2 -2
- package/components/Dropdown/index.js +1 -1
- package/components/Dropdown/types.d.ts +15 -0
- package/components/IconButton/IconButton.js +5 -4
- package/components/IconButton/index.d.ts +1 -1
- package/components/IconButton/types.d.ts +24 -4
- package/components/Modal/Modal.d.ts +16 -2
- package/components/Modal/Modal.js +45 -20
- package/components/MoveHandle/MoveHandle.js +3 -3
- package/components/MoveHandle/types.d.ts +12 -2
- package/components/Notice/Notice.js +32 -19
- package/components/Select/Select.js +6 -2
- package/components/Sidebar/Sidebar.d.ts +6 -1
- package/components/Sidebar/Sidebar.js +65 -11
- package/components/Sidebar/index.d.ts +1 -1
- package/components/Sidebar/types.d.ts +39 -14
- package/components/Tabs/Tabs.d.ts +1 -1
- package/components/Tabs/Tabs.js +12 -3
- package/components/Tabs/types.d.ts +20 -5
- package/components/TextInput/TextInput.js +10 -2
- package/components/Tooltip/Tooltip.d.ts +2 -2
- package/components/Tooltip/Tooltip.js +61 -40
- package/components/Tooltip/index.d.ts +1 -1
- package/components/Tooltip/types.d.ts +28 -1
- package/components/index.d.ts +2 -2
- package/components/index.js +1 -1
- package/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +354 -64
- package/styles/all.expanded.unlayered.css +354 -64
- package/styles/all.unlayered.css +1 -1
- package/styles/system/_tokens.scss +3 -0
- package/tui-manifest.json +291 -66
- package/utils/focus-trap.js +8 -1
package/README.md
CHANGED
|
@@ -6,10 +6,12 @@ Design system for Tangible WordPress plugins. React components + CSS tokens + ut
|
|
|
6
6
|
|
|
7
7
|
## Components
|
|
8
8
|
|
|
9
|
-
- **Primitives:** Button, Chip, Icon, IconButton, Progress, Rating, Tooltip
|
|
10
|
-
- **Layout:** Accordion, Card, Modal, Notice,
|
|
11
|
-
- **
|
|
12
|
-
- **Form
|
|
9
|
+
- **Primitives:** Button, Chip, ChipGroup, Icon, IconButton, Progress, Rating, Tooltip
|
|
10
|
+
- **Layout:** Accordion, Card, Modal, Notice, Sidebar, Tabs, Toolbar
|
|
11
|
+
- **Data:** DataTable, StepList, StepIndicator, Pager
|
|
12
|
+
- **Form Controls:** Select, MultiSelect, Combobox, TextInput, Textarea, Checkbox, Switch, Radio
|
|
13
|
+
- **Composites:** Avatar, Dropdown, MoveHandle, OverlapStack, SegmentedControl, Field
|
|
14
|
+
- **CSS-only Inputs:** Text, textarea, select, checkbox, radio, toggle, file
|
|
13
15
|
|
|
14
16
|
## Getting Started
|
|
15
17
|
|
|
@@ -26,9 +28,9 @@ npm install @tangible/ui
|
|
|
26
28
|
import '@tangible/ui/styles';
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
Or
|
|
30
|
-
```
|
|
31
|
-
|
|
31
|
+
Or for WordPress contexts (no CSS layers):
|
|
32
|
+
```tsx
|
|
33
|
+
import '@tangible/ui/styles/unlayered';
|
|
32
34
|
```
|
|
33
35
|
|
|
34
36
|
### Wrap your app
|
|
@@ -45,6 +47,8 @@ function App() {
|
|
|
45
47
|
}
|
|
46
48
|
```
|
|
47
49
|
|
|
50
|
+
Dark mode via `data-theme="dark"` on the wrapper.
|
|
51
|
+
|
|
48
52
|
### Use components
|
|
49
53
|
|
|
50
54
|
```tsx
|
|
@@ -82,11 +86,13 @@ npm run storybook # Dev server on port 6006
|
|
|
82
86
|
## Commands
|
|
83
87
|
|
|
84
88
|
```bash
|
|
85
|
-
npm run storybook
|
|
86
|
-
npm run build:lib
|
|
87
|
-
npm run lint
|
|
88
|
-
npm run test
|
|
89
|
-
npm run test:storybook
|
|
89
|
+
npm run storybook # Dev server
|
|
90
|
+
npm run build:lib # Build library (outputs to publish/)
|
|
91
|
+
npm run lint # ESLint
|
|
92
|
+
npm run test # Unit tests (vitest, jsdom)
|
|
93
|
+
npm run test:storybook # Story + a11y tests (vitest, Playwright chromium)
|
|
94
|
+
npm run test:visual # Visual regression (Playwright)
|
|
95
|
+
npm run test:visual:update # Regenerate visual baselines
|
|
90
96
|
```
|
|
91
97
|
|
|
92
98
|
## Documentation
|
|
@@ -94,7 +100,9 @@ npm run test:storybook # Story tests (requires Playwright)
|
|
|
94
100
|
- `CLAUDE.md` — Development guide (architecture, patterns, conventions)
|
|
95
101
|
- `CONTEXT.md` — Project background and LMS requirements
|
|
96
102
|
- `TIMELINE.md` — Development roadmap (Jan–Mar 2026)
|
|
103
|
+
- `TESTING.md` — Testing strategy and infrastructure
|
|
104
|
+
- `AGENTS.md` — Quality gate agent configurations
|
|
97
105
|
|
|
98
106
|
## Status
|
|
99
107
|
|
|
100
|
-
Under active development for Course
|
|
108
|
+
Under active development for Course Builder (LMS) and Quiz modules. Component APIs are stabilising but may change before 1.0.
|
|
@@ -10,11 +10,11 @@ declare function AccordionItem({ value, disabled, headingLevel, children, classN
|
|
|
10
10
|
declare namespace AccordionItem {
|
|
11
11
|
var displayName: string;
|
|
12
12
|
}
|
|
13
|
-
declare function AccordionTrigger({ children, className }: AccordionTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
13
|
+
declare function AccordionTrigger({ asChild, 'aria-label': ariaLabel, children, className }: AccordionTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
14
14
|
declare namespace AccordionTrigger {
|
|
15
15
|
var displayName: string;
|
|
16
16
|
}
|
|
17
|
-
declare function AccordionPanel({ children, className }: AccordionPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
declare function AccordionPanel({ landmark, children, className }: AccordionPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
18
18
|
declare namespace AccordionPanel {
|
|
19
19
|
var displayName: string;
|
|
20
20
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import React, { cloneElement, isValidElement, useCallback, useId, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
|
+
import { composeRefs } from '../../utils/compose-refs.js';
|
|
5
|
+
import { mergeProps } from '../../utils/merge-props.js';
|
|
4
6
|
import { Icon } from '../Icon/index.js';
|
|
5
7
|
import { AccordionContext, AccordionItemContext, useAccordionContext, useAccordionItemContext } from './AccordionContext.js';
|
|
6
8
|
function AccordionRoot(props) {
|
|
7
|
-
const { type, children, className } = props;
|
|
9
|
+
const { type, variant = 'card', children, className } = props;
|
|
8
10
|
// Track trigger refs for keyboard navigation
|
|
9
11
|
const triggersRef = useRef(new Map());
|
|
10
12
|
const registerTrigger = useCallback((value, element) => {
|
|
@@ -18,11 +20,11 @@ function AccordionRoot(props) {
|
|
|
18
20
|
const getTriggers = useCallback(() => triggersRef.current, []);
|
|
19
21
|
// State management differs by type
|
|
20
22
|
if (type === 'single') {
|
|
21
|
-
return (_jsx(AccordionSingle, { ...props, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
|
|
23
|
+
return (_jsx(AccordionSingle, { ...props, variant: variant, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
|
|
22
24
|
}
|
|
23
|
-
return (_jsx(AccordionMultiple, { ...props, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
|
|
25
|
+
return (_jsx(AccordionMultiple, { ...props, variant: variant, registerTrigger: registerTrigger, getTriggers: getTriggers, className: className, children: children }));
|
|
24
26
|
}
|
|
25
|
-
function AccordionSingle({ value: controlledValue, defaultValue, onValueChange, collapsible = false, children, className, registerTrigger, getTriggers, }) {
|
|
27
|
+
function AccordionSingle({ value: controlledValue, defaultValue, onValueChange, collapsible = false, variant, children, className, registerTrigger, getTriggers, }) {
|
|
26
28
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
27
29
|
const isControlled = controlledValue !== undefined;
|
|
28
30
|
const currentValue = isControlled ? controlledValue : internalValue;
|
|
@@ -37,6 +39,8 @@ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange,
|
|
|
37
39
|
// Opening new item
|
|
38
40
|
newValue = itemValue;
|
|
39
41
|
}
|
|
42
|
+
if (newValue === currentValue)
|
|
43
|
+
return;
|
|
40
44
|
if (!isControlled) {
|
|
41
45
|
setInternalValue(newValue);
|
|
42
46
|
}
|
|
@@ -50,9 +54,9 @@ function AccordionSingle({ value: controlledValue, defaultValue, onValueChange,
|
|
|
50
54
|
registerTrigger,
|
|
51
55
|
getTriggers,
|
|
52
56
|
}), [collapsible, isOpen, toggle, registerTrigger, getTriggers]);
|
|
53
|
-
return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', className), "data-type": "single", children: children }) }));
|
|
57
|
+
return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', `is-variant-${variant}`, className), "data-type": "single", children: children }) }));
|
|
54
58
|
}
|
|
55
|
-
function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange, children, className, registerTrigger, getTriggers, }) {
|
|
59
|
+
function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange, variant, children, className, registerTrigger, getTriggers, }) {
|
|
56
60
|
const [internalValue, setInternalValue] = useState(defaultValue ?? []);
|
|
57
61
|
const isControlled = controlledValue !== undefined;
|
|
58
62
|
const currentValue = isControlled ? controlledValue : internalValue;
|
|
@@ -74,7 +78,7 @@ function AccordionMultiple({ value: controlledValue, defaultValue, onValueChange
|
|
|
74
78
|
registerTrigger,
|
|
75
79
|
getTriggers,
|
|
76
80
|
}), [isOpen, toggle, registerTrigger, getTriggers]);
|
|
77
|
-
return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', className), "data-type": "multiple", children: children }) }));
|
|
81
|
+
return (_jsx(AccordionContext.Provider, { value: contextValue, children: _jsx("div", { className: cx('tui-accordion', `is-variant-${variant}`, className), "data-type": "multiple", children: children }) }));
|
|
78
82
|
}
|
|
79
83
|
// =============================================================================
|
|
80
84
|
// Accordion Item
|
|
@@ -99,21 +103,49 @@ function AccordionItem({ value, disabled = false, headingLevel, children, classN
|
|
|
99
103
|
// =============================================================================
|
|
100
104
|
// Accordion Trigger
|
|
101
105
|
// =============================================================================
|
|
102
|
-
function AccordionTrigger({ children, className }) {
|
|
106
|
+
function AccordionTrigger({ asChild = false, 'aria-label': ariaLabel, children, className }) {
|
|
103
107
|
const { toggle, registerTrigger, getTriggers } = useAccordionContext();
|
|
104
108
|
const { value, disabled, headingLevel, triggerId, panelId, isOpen } = useAccordionItemContext();
|
|
105
|
-
const
|
|
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: [_jsx("span", { className: "tui-accordion__trigger-content", children: children }), _jsx(Icon, { name: "system/chevron-down", size: "lg", className: "tui-accordion__indicator", "aria-hidden": "true" })] }));
|
|
166
237
|
// Wrap in heading if headingLevel is specified
|
|
167
238
|
if (headingLevel) {
|
|
168
239
|
const Heading = `h${headingLevel}`;
|
|
@@ -173,10 +244,10 @@ function AccordionTrigger({ children, className }) {
|
|
|
173
244
|
// =============================================================================
|
|
174
245
|
// Accordion Panel
|
|
175
246
|
// =============================================================================
|
|
176
|
-
function AccordionPanel({ children, className }) {
|
|
247
|
+
function AccordionPanel({ landmark = false, children, className }) {
|
|
177
248
|
const { triggerId, panelId, isOpen } = useAccordionItemContext();
|
|
178
249
|
const state = isOpen ? 'open' : 'closed';
|
|
179
|
-
return (_jsx("div", { id: panelId, role:
|
|
250
|
+
return (_jsx("div", { id: panelId, role: landmark ? 'region' : undefined, "aria-labelledby": landmark ? triggerId : undefined, className: cx('tui-accordion__panel', className), "data-state": state, "aria-hidden": !isOpen,
|
|
180
251
|
// Prevent keyboard focus into collapsed panels
|
|
181
252
|
inert: !isOpen || undefined, children: _jsx("div", { className: "tui-accordion__panel-content", children: children }) }));
|
|
182
253
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { Accordion } from './Accordion';
|
|
2
|
-
export type { AccordionProps, AccordionSingleProps, AccordionMultipleProps, AccordionItemProps, AccordionTriggerProps, AccordionPanelProps, } from './types';
|
|
2
|
+
export type { AccordionProps, AccordionSingleProps, AccordionMultipleProps, AccordionItemProps, AccordionTriggerProps, AccordionPanelProps, AccordionVariant, } from './types';
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
export type AccordionVariant = 'card' | 'flush';
|
|
2
3
|
type AccordionBaseProps = {
|
|
4
|
+
/**
|
|
5
|
+
* Visual treatment.
|
|
6
|
+
* - `card` (default): Bordered cards with gap between items.
|
|
7
|
+
* - `flush`: Items share borders, no gap, no radius — denser layout.
|
|
8
|
+
*/
|
|
9
|
+
variant?: AccordionVariant;
|
|
3
10
|
children: ReactNode;
|
|
4
11
|
className?: string;
|
|
5
12
|
};
|
|
@@ -29,16 +36,33 @@ export type AccordionItemProps = {
|
|
|
29
36
|
value: string;
|
|
30
37
|
/** Prevent interaction */
|
|
31
38
|
disabled?: boolean;
|
|
32
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Wrap trigger in heading element (h2–h6).
|
|
41
|
+
* Omitting this reduces discoverability for screen reader users who
|
|
42
|
+
* navigate by headings (NVDA/JAWS `H` key). Only omit when the
|
|
43
|
+
* accordion is already inside an appropriate heading context.
|
|
44
|
+
*/
|
|
33
45
|
headingLevel?: 2 | 3 | 4 | 5 | 6;
|
|
34
46
|
children: ReactNode;
|
|
35
47
|
className?: string;
|
|
36
48
|
};
|
|
37
49
|
export type AccordionTriggerProps = {
|
|
38
|
-
|
|
50
|
+
/**
|
|
51
|
+
* When true, merges trigger props (aria attributes, event handlers, keyboard
|
|
52
|
+
* navigation) onto the child element instead of rendering a built-in button.
|
|
53
|
+
* The child must be a single React element that accepts ref and event handlers.
|
|
54
|
+
* The built-in chevron indicator is not rendered in asChild mode.
|
|
55
|
+
* @default false
|
|
56
|
+
*/
|
|
57
|
+
asChild?: boolean;
|
|
58
|
+
/** Accessible label for the trigger. Required when children is empty (e.g. icon-only triggers). */
|
|
59
|
+
'aria-label'?: string;
|
|
60
|
+
children?: ReactNode;
|
|
39
61
|
className?: string;
|
|
40
62
|
};
|
|
41
63
|
export type AccordionPanelProps = {
|
|
64
|
+
/** Render panel as a landmark region (role="region"). Default false to avoid landmark pollution with many panels. */
|
|
65
|
+
landmark?: boolean;
|
|
42
66
|
children: ReactNode;
|
|
43
67
|
className?: string;
|
|
44
68
|
};
|
|
@@ -47,8 +71,8 @@ export type AccordionContextValue = {
|
|
|
47
71
|
collapsible: boolean;
|
|
48
72
|
isOpen: (value: string) => boolean;
|
|
49
73
|
toggle: (value: string) => void;
|
|
50
|
-
registerTrigger: (value: string, element:
|
|
51
|
-
getTriggers: () => Map<string,
|
|
74
|
+
registerTrigger: (value: string, element: HTMLElement | null) => void;
|
|
75
|
+
getTriggers: () => Map<string, HTMLElement>;
|
|
52
76
|
};
|
|
53
77
|
export type AccordionItemContextValue = {
|
|
54
78
|
value: string;
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import React, { useState, useMemo } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { Icon } from '../Icon/index.js';
|
|
5
|
+
import { Tooltip } from '../Tooltip/index.js';
|
|
5
6
|
import { AVATAR_COLORS } from './types.js';
|
|
6
7
|
/**
|
|
7
8
|
* Generate initials from a name.
|
|
@@ -45,7 +46,7 @@ function getColorFromName(name, colors) {
|
|
|
45
46
|
* - Shows placeholder icon if neither `src` nor `name` provided
|
|
46
47
|
* - Colors for initials are derived from the name hash for consistency
|
|
47
48
|
*/
|
|
48
|
-
export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circle', color, indicator, indicatorLabel, indicatorPosition = 'bottom-right', className, }, ref) => {
|
|
49
|
+
export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circle', color, indicator, indicatorLabel, indicatorPosition = 'bottom-right', tooltip, className, }, ref) => {
|
|
49
50
|
const [imgError, setImgError] = useState(false);
|
|
50
51
|
// Reset error state when src changes
|
|
51
52
|
React.useEffect(() => {
|
|
@@ -56,12 +57,20 @@ export const Avatar = React.forwardRef(({ src, name, size = 'md', shape = 'circl
|
|
|
56
57
|
const showImage = src && !imgError;
|
|
57
58
|
const showInitials = !showImage && initials;
|
|
58
59
|
const showPlaceholder = !showImage && !initials;
|
|
59
|
-
//
|
|
60
|
-
const isDecorative =
|
|
61
|
-
|
|
60
|
+
// Avatars with no name and no indicator label have no meaningful identity
|
|
61
|
+
const isDecorative = !name && !indicatorLabel;
|
|
62
|
+
const showTooltip = tooltip && !!name;
|
|
63
|
+
const avatarElement = (_jsxs("span", { ref: ref, className: cx('tui-avatar', `is-size-${size}`, `is-shape-${shape}`, !showImage && `is-color-${derivedColor}`, className), ...(isDecorative
|
|
62
64
|
? { 'aria-hidden': true }
|
|
63
|
-
: {
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
: {
|
|
66
|
+
role: 'img',
|
|
67
|
+
'aria-label': name && indicatorLabel
|
|
68
|
+
? `${name}, ${indicatorLabel}`
|
|
69
|
+
: name || indicatorLabel,
|
|
70
|
+
}), children: [_jsxs("span", { className: "tui-avatar__content", children: [showImage && (_jsx("img", { src: src, alt: "", className: "tui-avatar__image", onError: () => setImgError(true) })), showInitials && (_jsx("span", { className: "tui-avatar__initials", "aria-hidden": "true", children: initials })), showPlaceholder && (_jsx(Icon, { name: "system/user-circle-outline", className: "tui-avatar__placeholder" }))] }), indicator && (_jsx("span", { className: cx('tui-avatar__indicator', `is-position-${indicatorPosition}`), "aria-hidden": "true", children: indicator }))] }));
|
|
71
|
+
if (showTooltip) {
|
|
72
|
+
return (_jsxs(Tooltip, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: avatarElement }), _jsx(Tooltip.Content, { "aria-hidden": "true", children: name })] }));
|
|
73
|
+
}
|
|
74
|
+
return avatarElement;
|
|
66
75
|
});
|
|
67
76
|
Avatar.displayName = 'Avatar';
|
|
@@ -10,7 +10,7 @@ import { OverlapStack } from '../OverlapStack/index.js';
|
|
|
10
10
|
* - Non-overlap mode uses flex with gap
|
|
11
11
|
* - Override overlap amount via `--tui-avatar-group-overlap` CSS property
|
|
12
12
|
*/
|
|
13
|
-
export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true, children, className }, ref) => {
|
|
13
|
+
export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true, groupLabel: groupLabelFn, children, className }, ref) => {
|
|
14
14
|
const childArray = Children.toArray(children).filter(isValidElement);
|
|
15
15
|
const total = childArray.length;
|
|
16
16
|
// Clone children to inject size/shape props if provided at group level
|
|
@@ -26,9 +26,11 @@ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true,
|
|
|
26
26
|
// Descriptive label for the group
|
|
27
27
|
const hasOverflow = max !== undefined && total > max;
|
|
28
28
|
const visibleCount = hasOverflow ? max : total;
|
|
29
|
-
const groupLabel =
|
|
30
|
-
?
|
|
31
|
-
:
|
|
29
|
+
const groupLabel = groupLabelFn
|
|
30
|
+
? groupLabelFn(total, visibleCount)
|
|
31
|
+
: hasOverflow
|
|
32
|
+
? `${total} users, showing ${visibleCount}`
|
|
33
|
+
: `${total} users`;
|
|
32
34
|
// Non-overlap mode: simple flex layout
|
|
33
35
|
if (!overlap) {
|
|
34
36
|
return (_jsx("div", { ref: ref, className: cx('tui-avatar-group', className), role: "group", "aria-label": groupLabel, children: clonedChildren }));
|
|
@@ -41,5 +43,5 @@ export const AvatarGroup = React.forwardRef(({ max, size, shape, overlap = true,
|
|
|
41
43
|
});
|
|
42
44
|
AvatarGroup.displayName = 'AvatarGroup';
|
|
43
45
|
function AvatarOverflow({ count, size, shape }) {
|
|
44
|
-
return (_jsx("span", { className: cx('tui-avatar', 'tui-avatar--overflow', size && `is-size-${size}`, shape && `is-shape-${shape}`),
|
|
46
|
+
return (_jsx("span", { className: cx('tui-avatar', 'tui-avatar--overflow', size && `is-size-${size}`, shape && `is-shape-${shape}`), "aria-hidden": "true", children: _jsx("span", { className: "tui-avatar__content", children: _jsxs("span", { className: "tui-avatar__initials", children: ["+", count] }) }) }));
|
|
45
47
|
}
|
|
@@ -25,6 +25,12 @@ export type AvatarProps = {
|
|
|
25
25
|
indicatorLabel?: string;
|
|
26
26
|
/** Position of the indicator */
|
|
27
27
|
indicatorPosition?: IndicatorPosition;
|
|
28
|
+
/**
|
|
29
|
+
* When true, wraps the avatar with a Tooltip showing the `name`.
|
|
30
|
+
* Helps sighted users discover the user's name on hover/focus.
|
|
31
|
+
* Has no effect when `name` is not provided.
|
|
32
|
+
*/
|
|
33
|
+
tooltip?: boolean;
|
|
28
34
|
/** Additional CSS class */
|
|
29
35
|
className?: string;
|
|
30
36
|
};
|
|
@@ -37,6 +43,11 @@ export type AvatarGroupProps = {
|
|
|
37
43
|
shape?: AvatarShape;
|
|
38
44
|
/** Whether avatars overlap (default: true) */
|
|
39
45
|
overlap?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Custom label function for i18n. Receives total and visible counts.
|
|
48
|
+
* Default: "N users" or "N users, showing M".
|
|
49
|
+
*/
|
|
50
|
+
groupLabel?: (total: number, visible: number) => string;
|
|
40
51
|
/** Children (Avatar components) */
|
|
41
52
|
children: React.ReactNode;
|
|
42
53
|
/** Additional CSS class */
|
|
@@ -3,9 +3,16 @@ import React, { forwardRef } from 'react';
|
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { Icon } from '../Icon/index.js';
|
|
5
5
|
import { getSafeRel } from '../../utils/polymorphic.js';
|
|
6
|
-
export const Button = forwardRef(({ label, children, size = 'md', theme = 'primary', variant = 'solid', fullWidth, disabled = false, loading = false, leftIconName, rightIconName, leftIcon, rightIcon, iconSize
|
|
6
|
+
export const Button = forwardRef(({ label, children, size = 'md', theme = 'primary', variant = 'solid', fullWidth, disabled = false, loading = false, loadingLabel: loadingLabelProp, leftIconName, rightIconName, leftIcon, rightIcon, iconSize: iconSizeProp, className, target, rel, onClick, style, ...rest }, ref) => {
|
|
7
7
|
const isLink = typeof rest.href === 'string';
|
|
8
8
|
const isDisabled = disabled || loading;
|
|
9
|
+
// Auto-scale icon size with button size when not explicitly set
|
|
10
|
+
const iconSizeMap = { xs: 'xs', sm: 'xs', md: 'sm', lg: 'md' };
|
|
11
|
+
const iconSize = iconSizeProp ?? iconSizeMap[size];
|
|
12
|
+
// Compose loading state into aria-label for screen readers
|
|
13
|
+
const loadingLabel = loading
|
|
14
|
+
? (loadingLabelProp ?? (typeof label === 'string' ? `${label}, loading` : undefined))
|
|
15
|
+
: undefined;
|
|
9
16
|
// Normalize destructive → danger for CSS class
|
|
10
17
|
const themeClass = theme === 'destructive' ? 'danger' : theme;
|
|
11
18
|
const classes = cx('tui-button', `is-size-${size}`, `is-theme-${themeClass}`, variant !== 'solid' && `is-style-${variant}`, fullWidth && 'is-width-full', isDisabled && 'is-disabled', className);
|
|
@@ -25,9 +32,9 @@ export const Button = forwardRef(({ label, children, size = 'md', theme = 'prima
|
|
|
25
32
|
}
|
|
26
33
|
onClick?.(e);
|
|
27
34
|
};
|
|
28
|
-
return (
|
|
35
|
+
return (_jsxs("a", { ref: ref, href: isDisabled ? undefined : href, className: classes, "aria-label": loadingLabel, "aria-disabled": isDisabled || undefined, "aria-busy": loading || undefined, tabIndex: isDisabled ? -1 : tabIndex, onClick: handleClick, "data-loading": loading || undefined, target: target, rel: safeRel, style: style, ...anchorRest, children: [content, target === '_blank' && (_jsx("span", { className: "tui-visually-hidden", children: " (opens in new tab)" }))] }));
|
|
29
36
|
}
|
|
30
37
|
const buttonRest = rest;
|
|
31
|
-
return (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes,
|
|
38
|
+
return (_jsx("button", { ref: ref, type: buttonRest.type ?? 'button', className: classes, "aria-label": loadingLabel, disabled: isDisabled, "aria-busy": loading || undefined, onClick: onClick, "data-loading": loading || undefined, style: style, ...buttonRest, children: content }));
|
|
32
39
|
});
|
|
33
40
|
Button.displayName = 'Button';
|
|
@@ -6,7 +6,9 @@ export type { Size };
|
|
|
6
6
|
* - `'primary'`: Primary action (default)
|
|
7
7
|
* - `'secondary'`: Secondary/neutral action
|
|
8
8
|
* - `'danger'`: Destructive action
|
|
9
|
-
* - `'destructive'`: Alias for danger
|
|
9
|
+
* - `'destructive'`: Alias for `'danger'` — mapped internally before
|
|
10
|
+
* class generation. Provided for readability in consumer code where
|
|
11
|
+
* "destructive" better describes the intent (e.g. `theme="destructive"`).
|
|
10
12
|
*/
|
|
11
13
|
export type Theme = ThemeIntent | 'destructive';
|
|
12
14
|
/**
|
|
@@ -60,6 +62,12 @@ type CommonProps = {
|
|
|
60
62
|
* @default false
|
|
61
63
|
*/
|
|
62
64
|
loading?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Accessible label override during loading state. For i18n support.
|
|
67
|
+
* When loading, this replaces the auto-composed label.
|
|
68
|
+
* @default `${label}, loading` (English)
|
|
69
|
+
*/
|
|
70
|
+
loadingLabel?: string;
|
|
63
71
|
/**
|
|
64
72
|
* Link target (for anchor variant).
|
|
65
73
|
*/
|
package/components/Card/Card.js
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import React, { forwardRef, useRef, useEffect } from 'react';
|
|
2
|
+
import React, { forwardRef, useRef, useEffect, createContext, useContext } from 'react';
|
|
3
3
|
import { cx } from '../../utils/cx.js';
|
|
4
4
|
import { isDev } from '../../utils/is-dev.js';
|
|
5
|
+
const CardDisabledContext = createContext(false);
|
|
5
6
|
export const Card = forwardRef(function Card({ as = 'article', inline, elevated, interactive, disabled, className, style, onClick, children, ...rest }, ref) {
|
|
6
7
|
const Tag = as;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const type = child.type;
|
|
11
|
-
return child.type === CardLink || type.displayName === 'Card.Link';
|
|
12
|
-
});
|
|
13
|
-
// Dev warning: suggest Card.Link for interactive cards
|
|
8
|
+
// Dev warning: suggest Card.Link for interactive cards.
|
|
9
|
+
// The recursive children walk is gated behind isDev() to avoid
|
|
10
|
+
// unnecessary work in production on every render.
|
|
14
11
|
const warnedRef = useRef(false);
|
|
15
12
|
useEffect(() => {
|
|
16
|
-
if (isDev()
|
|
13
|
+
if (!isDev() || warnedRef.current || !(interactive || onClick))
|
|
14
|
+
return;
|
|
15
|
+
const hasCardLinkChild = (function checkChildren(nodes) {
|
|
16
|
+
return React.Children.toArray(nodes).some((child) => {
|
|
17
|
+
if (!React.isValidElement(child))
|
|
18
|
+
return false;
|
|
19
|
+
const type = child.type;
|
|
20
|
+
if (child.type === CardLink || type.displayName === 'Card.Link')
|
|
21
|
+
return true;
|
|
22
|
+
const childProps = child.props;
|
|
23
|
+
if (childProps.children)
|
|
24
|
+
return checkChildren(childProps.children);
|
|
25
|
+
return false;
|
|
26
|
+
});
|
|
27
|
+
})(children);
|
|
28
|
+
if (!hasCardLinkChild) {
|
|
17
29
|
warnedRef.current = true;
|
|
18
30
|
console.warn('[TUI Card] Interactive cards should use <Card.Link> for accessible click targets. ' +
|
|
19
31
|
'`interactive` and `onClick` provide visual hover styles but no keyboard/screen-reader semantics.');
|
|
20
32
|
}
|
|
21
|
-
}
|
|
33
|
+
});
|
|
22
34
|
const classes = cx('tui-card', inline && 'is-layout-inline', elevated && 'is-style-elevated', interactive && 'has-interaction', className);
|
|
23
|
-
return (_jsx(Tag, { ref: ref, className: classes, style: style, "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onClick, ...rest, children: _jsx("div", { className: "tui-card__inner", children: children }) }));
|
|
35
|
+
return (_jsx(Tag, { ref: ref, className: classes, style: style, "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onClick, ...rest, children: _jsx(CardDisabledContext.Provider, { value: !!disabled, children: _jsx("div", { className: "tui-card__inner", children: children }) }) }));
|
|
24
36
|
});
|
|
25
37
|
function CardHead({ className, children, ...rest }) {
|
|
26
38
|
return (_jsx("div", { className: cx('tui-card__head', className), ...rest, children: children }));
|
|
@@ -34,8 +46,9 @@ function CardFoot({ className, children, ...rest }) {
|
|
|
34
46
|
return (_jsx("div", { className: cx('tui-card__foot', className), ...rest, children: children }));
|
|
35
47
|
}
|
|
36
48
|
CardFoot.displayName = 'Card.Foot';
|
|
37
|
-
function CardLink({ className, children, rel, target, ...rest }) {
|
|
38
|
-
|
|
49
|
+
function CardLink({ className, children, rel, target, href, ...rest }) {
|
|
50
|
+
const isDisabled = useContext(CardDisabledContext);
|
|
51
|
+
return (_jsx("a", { className: cx('tui-stretched-link', className), rel: target === '_blank' ? ['noopener', 'noreferrer', rel].filter(Boolean).join(' ') : rel, target: isDisabled ? undefined : target, href: isDisabled ? undefined : href, role: isDisabled ? 'link' : undefined, "aria-disabled": isDisabled || undefined, tabIndex: isDisabled ? -1 : undefined, ...rest, children: children }));
|
|
39
52
|
}
|
|
40
53
|
CardLink.displayName = 'Card.Link';
|
|
41
54
|
Card.Head = CardHead;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<import("react").InputHTMLAttributes<HTMLInputElement>, "role" | "type" | "
|
|
1
|
+
export declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<import("react").InputHTMLAttributes<HTMLInputElement>, "role" | "type" | "onChange" | "defaultChecked" | "checked"> & {
|
|
2
2
|
checked?: boolean;
|
|
3
3
|
defaultChecked?: boolean;
|
|
4
4
|
onCheckedChange?: (checked: boolean) => void;
|