@tangible/ui 0.0.4 → 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/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/package.json +1 -1
- package/styles/all.css +1 -1
- package/styles/all.expanded.css +44 -7
- package/styles/all.expanded.unlayered.css +44 -7
- package/styles/all.unlayered.css +1 -1
- package/tui-manifest.json +15 -4
|
@@ -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: [_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;
|