@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.
@@ -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 buttonRef = useRef(null);
109
+ const triggerRef = useRef(null);
106
110
  // Register trigger for keyboard navigation
107
111
  React.useEffect(() => {
108
- registerTrigger(value, buttonRef.current);
112
+ registerTrigger(value, triggerRef.current);
109
113
  return () => registerTrigger(value, null);
110
114
  }, [value, registerTrigger]);
111
- const handleClick = () => {
112
- if (!disabled) {
113
- toggle(value);
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.disabled) {
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
- targetIndex = findNextIndex(sortedTriggers.length - 1, 1); // Start from end, go forward
186
+ const first = sortedTriggers.findIndex(([, el]) => !isElementDisabled(el));
187
+ if (first !== -1)
188
+ targetIndex = first;
153
189
  break;
154
- case 'End':
190
+ }
191
+ case 'End': {
155
192
  event.preventDefault();
156
- targetIndex = findNextIndex(0, -1); // Start from beginning, go backward
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
- const button = (_jsxs("button", { ref: buttonRef, type: "button", id: triggerId, className: cx('tui-accordion__trigger', className), "aria-expanded": isOpen, "aria-controls": panelId, 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" })] }));
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
- children: ReactNode;
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: HTMLButtonElement | null) => void;
58
- getTriggers: () => Map<string, HTMLButtonElement>;
74
+ registerTrigger: (value: string, element: HTMLElement | null) => void;
75
+ getTriggers: () => Map<string, HTMLElement>;
59
76
  };
60
77
  export type AccordionItemContextValue = {
61
78
  value: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangible/ui",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Tangible Design System",
5
5
  "type": "module",
6
6
  "main": "./components/index.js",