@fragments-sdk/ui 0.12.0 → 0.13.0
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/dist/components/Accordion/index.cjs +11 -4
- package/dist/components/Accordion/index.cjs.map +1 -1
- package/dist/components/Accordion/index.d.ts +3 -3
- package/dist/components/Accordion/index.d.ts.map +1 -1
- package/dist/components/Accordion/index.js +11 -4
- package/dist/components/Accordion/index.js.map +1 -1
- package/dist/components/Collapsible/index.cjs +45 -10
- package/dist/components/Collapsible/index.cjs.map +1 -1
- package/dist/components/Collapsible/index.d.ts +6 -12
- package/dist/components/Collapsible/index.d.ts.map +1 -1
- package/dist/components/Collapsible/index.js +45 -10
- package/dist/components/Collapsible/index.js.map +1 -1
- package/dist/components/Combobox/index.cjs +18 -9
- package/dist/components/Combobox/index.cjs.map +1 -1
- package/dist/components/Combobox/index.d.ts +8 -12
- package/dist/components/Combobox/index.d.ts.map +1 -1
- package/dist/components/Combobox/index.js +18 -9
- package/dist/components/Combobox/index.js.map +1 -1
- package/dist/components/Command/index.cjs +54 -21
- package/dist/components/Command/index.cjs.map +1 -1
- package/dist/components/Command/index.d.ts +2 -2
- package/dist/components/Command/index.d.ts.map +1 -1
- package/dist/components/Command/index.js +54 -21
- package/dist/components/Command/index.js.map +1 -1
- package/dist/components/DataTable/index.cjs +13 -1
- package/dist/components/DataTable/index.cjs.map +1 -1
- package/dist/components/DataTable/index.d.ts.map +1 -1
- package/dist/components/DataTable/index.js +13 -1
- package/dist/components/DataTable/index.js.map +1 -1
- package/dist/components/DatePicker/index.d.ts +2 -3
- package/dist/components/DatePicker/index.d.ts.map +1 -1
- package/dist/components/Dialog/index.cjs +12 -9
- package/dist/components/Dialog/index.cjs.map +1 -1
- package/dist/components/Dialog/index.d.ts +8 -12
- package/dist/components/Dialog/index.d.ts.map +1 -1
- package/dist/components/Dialog/index.js +12 -9
- package/dist/components/Dialog/index.js.map +1 -1
- package/dist/components/Drawer/index.cjs +12 -9
- package/dist/components/Drawer/index.cjs.map +1 -1
- package/dist/components/Drawer/index.d.ts +8 -12
- package/dist/components/Drawer/index.d.ts.map +1 -1
- package/dist/components/Drawer/index.js +12 -9
- package/dist/components/Drawer/index.js.map +1 -1
- package/dist/components/Menu/index.cjs +30 -16
- package/dist/components/Menu/index.cjs.map +1 -1
- package/dist/components/Menu/index.d.ts +17 -25
- package/dist/components/Menu/index.d.ts.map +1 -1
- package/dist/components/Menu/index.js +30 -16
- package/dist/components/Menu/index.js.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenuContext.cjs.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenuContext.d.ts +1 -0
- package/dist/components/NavigationMenu/NavigationMenuContext.d.ts.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenuContext.js.map +1 -1
- package/dist/components/NavigationMenu/index.cjs +43 -11
- package/dist/components/NavigationMenu/index.cjs.map +1 -1
- package/dist/components/NavigationMenu/index.d.ts.map +1 -1
- package/dist/components/NavigationMenu/index.js +43 -11
- package/dist/components/NavigationMenu/index.js.map +1 -1
- package/dist/components/NavigationMenu/useNavigationMenu.cjs +2 -0
- package/dist/components/NavigationMenu/useNavigationMenu.cjs.map +1 -1
- package/dist/components/NavigationMenu/useNavigationMenu.d.ts +1 -0
- package/dist/components/NavigationMenu/useNavigationMenu.d.ts.map +1 -1
- package/dist/components/NavigationMenu/useNavigationMenu.js +2 -0
- package/dist/components/NavigationMenu/useNavigationMenu.js.map +1 -1
- package/dist/components/Popover/index.cjs +11 -10
- package/dist/components/Popover/index.cjs.map +1 -1
- package/dist/components/Popover/index.d.ts +8 -12
- package/dist/components/Popover/index.d.ts.map +1 -1
- package/dist/components/Popover/index.js +11 -10
- package/dist/components/Popover/index.js.map +1 -1
- package/dist/components/Select/index.cjs +7 -6
- package/dist/components/Select/index.cjs.map +1 -1
- package/dist/components/Select/index.d.ts +6 -9
- package/dist/components/Select/index.d.ts.map +1 -1
- package/dist/components/Select/index.js +7 -6
- package/dist/components/Select/index.js.map +1 -1
- package/dist/components/Sidebar/index.cjs +71 -24
- package/dist/components/Sidebar/index.cjs.map +1 -1
- package/dist/components/Sidebar/index.d.ts +21 -33
- package/dist/components/Sidebar/index.d.ts.map +1 -1
- package/dist/components/Sidebar/index.js +71 -24
- package/dist/components/Sidebar/index.js.map +1 -1
- package/dist/components/Tooltip/index.cjs +12 -6
- package/dist/components/Tooltip/index.cjs.map +1 -1
- package/dist/components/Tooltip/index.d.ts.map +1 -1
- package/dist/components/Tooltip/index.js +12 -6
- package/dist/components/Tooltip/index.js.map +1 -1
- package/dist/datepicker.cjs +24 -10
- package/dist/datepicker.cjs.map +1 -1
- package/dist/datepicker.js +24 -10
- package/dist/datepicker.js.map +1 -1
- package/fragments.json +1 -1
- package/package.json +2 -2
- package/src/components/Accordion/Accordion.test.tsx +33 -0
- package/src/components/Accordion/index.tsx +10 -3
- package/src/components/Collapsible/Collapsible.test.tsx +41 -0
- package/src/components/Collapsible/index.tsx +53 -16
- package/src/components/Combobox/Combobox.test.tsx +55 -0
- package/src/components/Combobox/index.tsx +23 -17
- package/src/components/Command/Command.test.tsx +93 -0
- package/src/components/Command/index.tsx +61 -18
- package/src/components/DataTable/DataTable.test.tsx +11 -2
- package/src/components/DataTable/index.tsx +22 -2
- package/src/components/DatePicker/DatePicker.test.tsx +79 -0
- package/src/components/DatePicker/index.tsx +29 -14
- package/src/components/Dialog/Dialog.test.tsx +23 -0
- package/src/components/Dialog/index.tsx +15 -16
- package/src/components/Drawer/Drawer.test.tsx +27 -0
- package/src/components/Drawer/index.tsx +15 -16
- package/src/components/Menu/index.tsx +35 -30
- package/src/components/NavigationMenu/NavigationMenu.fragment.tsx +1 -1
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +40 -4
- package/src/components/NavigationMenu/NavigationMenuContext.ts +3 -0
- package/src/components/NavigationMenu/index.tsx +49 -13
- package/src/components/NavigationMenu/useNavigationMenu.ts +4 -0
- package/src/components/Popover/Popover.test.tsx +23 -0
- package/src/components/Popover/index.tsx +15 -18
- package/src/components/Select/Select.test.tsx +41 -0
- package/src/components/Select/index.tsx +10 -12
- package/src/components/Sidebar/Sidebar.test.tsx +83 -4
- package/src/components/Sidebar/index.tsx +87 -45
- package/src/components/Tooltip/Tooltip.test.tsx +17 -0
- package/src/components/Tooltip/index.tsx +46 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fragments-sdk/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Customizable UI components built on Base UI headless primitives",
|
|
6
6
|
"author": "Conan McNicholl",
|
|
@@ -230,7 +230,7 @@
|
|
|
230
230
|
"vite": "^6.0.0",
|
|
231
231
|
"vitest": "^2.1.8",
|
|
232
232
|
"vitest-axe": "^0.1.0",
|
|
233
|
-
"@fragments-sdk/cli": "0.10.
|
|
233
|
+
"@fragments-sdk/cli": "0.10.1"
|
|
234
234
|
},
|
|
235
235
|
"files": [
|
|
236
236
|
"src",
|
|
@@ -148,6 +148,22 @@ describe('Accordion', () => {
|
|
|
148
148
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
it('emits undefined when a single collapsible accordion fully closes', async () => {
|
|
152
|
+
const user = userEvent.setup();
|
|
153
|
+
const onValueChange = vi.fn();
|
|
154
|
+
|
|
155
|
+
renderAccordion({
|
|
156
|
+
type: 'single',
|
|
157
|
+
collapsible: true,
|
|
158
|
+
defaultValue: 'one',
|
|
159
|
+
onValueChange,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await user.click(screen.getByRole('button', { name: /item one/i }));
|
|
163
|
+
|
|
164
|
+
expect(onValueChange).toHaveBeenCalledWith(undefined);
|
|
165
|
+
});
|
|
166
|
+
|
|
151
167
|
it('non-collapsible single type prevents full collapse', async () => {
|
|
152
168
|
const user = userEvent.setup();
|
|
153
169
|
renderAccordion({ type: 'single', collapsible: false, defaultValue: 'one' });
|
|
@@ -160,6 +176,23 @@ describe('Accordion', () => {
|
|
|
160
176
|
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
161
177
|
});
|
|
162
178
|
|
|
179
|
+
it('forwards html props to trigger and content', async () => {
|
|
180
|
+
const user = userEvent.setup();
|
|
181
|
+
render(
|
|
182
|
+
<Accordion>
|
|
183
|
+
<Accordion.Item value="one">
|
|
184
|
+
<Accordion.Trigger data-testid="trigger" data-track="accordion-trigger">Item One</Accordion.Trigger>
|
|
185
|
+
<Accordion.Content data-testid="content" aria-label="Accordion panel">Content One</Accordion.Content>
|
|
186
|
+
</Accordion.Item>
|
|
187
|
+
</Accordion>
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await user.click(screen.getByTestId('trigger'));
|
|
191
|
+
|
|
192
|
+
expect(screen.getByTestId('trigger')).toHaveAttribute('data-track', 'accordion-trigger');
|
|
193
|
+
expect(screen.getByTestId('content')).toHaveAttribute('aria-label', 'Accordion panel');
|
|
194
|
+
});
|
|
195
|
+
|
|
163
196
|
it('has no accessibility violations', async () => {
|
|
164
197
|
const { container } = renderAccordion({ defaultValue: 'one' });
|
|
165
198
|
await expectNoA11yViolations(container, {
|
|
@@ -19,7 +19,7 @@ export interface AccordionProps extends Omit<React.HTMLAttributes<HTMLDivElement
|
|
|
19
19
|
/** Default value for uncontrolled usage */
|
|
20
20
|
defaultValue?: AccordionValue;
|
|
21
21
|
/** Callback when value changes */
|
|
22
|
-
onValueChange?: (value: AccordionValue) => void;
|
|
22
|
+
onValueChange?: (value: AccordionValue | undefined) => void;
|
|
23
23
|
/** Whether items can be fully collapsed (only for type="single") */
|
|
24
24
|
collapsible?: boolean;
|
|
25
25
|
/**
|
|
@@ -137,7 +137,7 @@ function AccordionRoot({
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
if (onValueChange) {
|
|
140
|
-
onValueChange(type === 'single' ?
|
|
140
|
+
onValueChange(type === 'single' ? newItems[0] : newItems);
|
|
141
141
|
}
|
|
142
142
|
}, [type, currentOpenItems, collapsible, controlledOpenItems, onValueChange]);
|
|
143
143
|
|
|
@@ -186,11 +186,15 @@ function AccordionItem({
|
|
|
186
186
|
function AccordionTrigger({
|
|
187
187
|
children,
|
|
188
188
|
className,
|
|
189
|
+
onClick,
|
|
190
|
+
...htmlProps
|
|
189
191
|
}: AccordionTriggerProps) {
|
|
190
192
|
const { toggle, headingLevel } = useAccordionContext();
|
|
191
193
|
const { value, isOpen, disabled, triggerId, contentId } = useAccordionItemContext();
|
|
192
194
|
|
|
193
|
-
const handleClick = () => {
|
|
195
|
+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
196
|
+
onClick?.(event);
|
|
197
|
+
if (event.defaultPrevented) return;
|
|
194
198
|
if (!disabled) {
|
|
195
199
|
toggle(value);
|
|
196
200
|
}
|
|
@@ -204,6 +208,7 @@ function AccordionTrigger({
|
|
|
204
208
|
return (
|
|
205
209
|
<HeadingTag className={styles.heading}>
|
|
206
210
|
<BaseCollapsible.Trigger
|
|
211
|
+
{...htmlProps}
|
|
207
212
|
id={triggerId}
|
|
208
213
|
className={classes}
|
|
209
214
|
onClick={handleClick}
|
|
@@ -237,6 +242,7 @@ function AccordionTrigger({
|
|
|
237
242
|
function AccordionContent({
|
|
238
243
|
children,
|
|
239
244
|
className,
|
|
245
|
+
...htmlProps
|
|
240
246
|
}: AccordionContentProps) {
|
|
241
247
|
const { isOpen, triggerId, contentId } = useAccordionItemContext();
|
|
242
248
|
|
|
@@ -244,6 +250,7 @@ function AccordionContent({
|
|
|
244
250
|
|
|
245
251
|
return (
|
|
246
252
|
<BaseCollapsible.Panel
|
|
253
|
+
{...htmlProps}
|
|
247
254
|
id={contentId}
|
|
248
255
|
className={classes}
|
|
249
256
|
data-state={isOpen ? 'open' : 'closed'}
|
|
@@ -96,6 +96,47 @@ describe('Collapsible', () => {
|
|
|
96
96
|
expect(screen.queryByText('Collapsible content here')).not.toBeInTheDocument();
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
it('composes child handlers when trigger uses asChild', async () => {
|
|
100
|
+
const user = userEvent.setup();
|
|
101
|
+
const childClick = vi.fn();
|
|
102
|
+
const childKeyDown = vi.fn();
|
|
103
|
+
|
|
104
|
+
render(
|
|
105
|
+
<Collapsible>
|
|
106
|
+
<Collapsible.Trigger asChild>
|
|
107
|
+
<button onClick={childClick} onKeyDown={childKeyDown}>Toggle</button>
|
|
108
|
+
</Collapsible.Trigger>
|
|
109
|
+
<Collapsible.Content>Collapsible content here</Collapsible.Content>
|
|
110
|
+
</Collapsible>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const trigger = screen.getByRole('button', { name: /toggle/i });
|
|
114
|
+
await user.click(trigger);
|
|
115
|
+
expect(childClick).toHaveBeenCalled();
|
|
116
|
+
expect(screen.getByText('Collapsible content here')).toBeInTheDocument();
|
|
117
|
+
|
|
118
|
+
await user.keyboard('{Enter}');
|
|
119
|
+
expect(childKeyDown).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('forwards html props to root, trigger, and content', async () => {
|
|
123
|
+
const user = userEvent.setup();
|
|
124
|
+
render(
|
|
125
|
+
<Collapsible data-testid="root" data-track="collapsible-root">
|
|
126
|
+
<Collapsible.Trigger data-testid="trigger" aria-label="Toggle section">Toggle</Collapsible.Trigger>
|
|
127
|
+
<Collapsible.Content data-testid="content" data-panel="details">
|
|
128
|
+
Collapsible content here
|
|
129
|
+
</Collapsible.Content>
|
|
130
|
+
</Collapsible>
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await user.click(screen.getByTestId('trigger'));
|
|
134
|
+
|
|
135
|
+
expect(screen.getByTestId('root')).toHaveAttribute('data-track', 'collapsible-root');
|
|
136
|
+
expect(screen.getByTestId('trigger')).toHaveAttribute('aria-label', 'Toggle section');
|
|
137
|
+
expect(screen.getByTestId('content')).toHaveAttribute('data-panel', 'details');
|
|
138
|
+
});
|
|
139
|
+
|
|
99
140
|
it('has no accessibility violations', async () => {
|
|
100
141
|
const { container } = renderCollapsible({ defaultOpen: true });
|
|
101
142
|
await expectNoA11yViolations(container);
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
import React, { useState, useCallback, useId, createContext, useContext } from 'react';
|
|
4
4
|
import styles from './Collapsible.module.scss';
|
|
5
5
|
|
|
6
|
+
function composeEventHandlers<E extends { defaultPrevented: boolean }>(
|
|
7
|
+
userHandler: ((event: E) => void) | undefined,
|
|
8
|
+
internalHandler: (event: E) => void
|
|
9
|
+
) {
|
|
10
|
+
return (event: E) => {
|
|
11
|
+
userHandler?.(event);
|
|
12
|
+
if (event.defaultPrevented) return;
|
|
13
|
+
internalHandler(event);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
// Context for sharing state between compound components
|
|
7
18
|
interface CollapsibleContextValue {
|
|
8
19
|
isOpen: boolean;
|
|
@@ -23,7 +34,7 @@ function useCollapsibleContext() {
|
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
// Root component
|
|
26
|
-
export interface CollapsibleRootProps {
|
|
37
|
+
export interface CollapsibleRootProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
27
38
|
children: React.ReactNode;
|
|
28
39
|
/** Whether the collapsible is initially open */
|
|
29
40
|
defaultOpen?: boolean;
|
|
@@ -33,8 +44,6 @@ export interface CollapsibleRootProps {
|
|
|
33
44
|
onOpenChange?: (open: boolean) => void;
|
|
34
45
|
/** Whether the collapsible is disabled */
|
|
35
46
|
disabled?: boolean;
|
|
36
|
-
/** Additional class name */
|
|
37
|
-
className?: string;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
function CollapsibleRoot({
|
|
@@ -44,6 +53,7 @@ function CollapsibleRoot({
|
|
|
44
53
|
onOpenChange,
|
|
45
54
|
disabled = false,
|
|
46
55
|
className,
|
|
56
|
+
...htmlProps
|
|
47
57
|
}: CollapsibleRootProps) {
|
|
48
58
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
49
59
|
const isControlled = controlledOpen !== undefined;
|
|
@@ -78,6 +88,7 @@ function CollapsibleRoot({
|
|
|
78
88
|
return (
|
|
79
89
|
<CollapsibleContext.Provider value={contextValue}>
|
|
80
90
|
<div
|
|
91
|
+
{...htmlProps}
|
|
81
92
|
className={`${styles.root} ${isOpen ? styles.open : ''} ${disabled ? styles.disabled : ''} ${className || ''}`}
|
|
82
93
|
data-state={isOpen ? 'open' : 'closed'}
|
|
83
94
|
data-disabled={disabled || undefined}
|
|
@@ -89,10 +100,8 @@ function CollapsibleRoot({
|
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
// Trigger component
|
|
92
|
-
export interface CollapsibleTriggerProps {
|
|
103
|
+
export interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
93
104
|
children: React.ReactNode;
|
|
94
|
-
/** Additional class name */
|
|
95
|
-
className?: string;
|
|
96
105
|
/** Show chevron indicator */
|
|
97
106
|
showChevron?: boolean;
|
|
98
107
|
/** Chevron position */
|
|
@@ -107,6 +116,9 @@ function CollapsibleTrigger({
|
|
|
107
116
|
showChevron = true,
|
|
108
117
|
chevronPosition = 'end',
|
|
109
118
|
asChild = false,
|
|
119
|
+
onClick,
|
|
120
|
+
onKeyDown,
|
|
121
|
+
...htmlProps
|
|
110
122
|
}: CollapsibleTriggerProps) {
|
|
111
123
|
const { isOpen, toggle, contentId, triggerId, disabled } = useCollapsibleContext();
|
|
112
124
|
|
|
@@ -117,6 +129,10 @@ function CollapsibleTrigger({
|
|
|
117
129
|
}
|
|
118
130
|
};
|
|
119
131
|
|
|
132
|
+
const handleClick = () => {
|
|
133
|
+
toggle();
|
|
134
|
+
};
|
|
135
|
+
|
|
120
136
|
const chevronIcon = showChevron && (
|
|
121
137
|
<svg
|
|
122
138
|
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
|
|
@@ -137,26 +153,47 @@ function CollapsibleTrigger({
|
|
|
137
153
|
);
|
|
138
154
|
|
|
139
155
|
if (asChild && React.isValidElement(children)) {
|
|
156
|
+
const childProps = children.props as {
|
|
157
|
+
className?: string;
|
|
158
|
+
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
|
159
|
+
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
|
|
160
|
+
};
|
|
161
|
+
|
|
140
162
|
return React.cloneElement(children as React.ReactElement<any>, {
|
|
141
|
-
|
|
163
|
+
...htmlProps,
|
|
164
|
+
id: (htmlProps.id as string | undefined) ?? triggerId,
|
|
142
165
|
'aria-expanded': isOpen,
|
|
143
166
|
'aria-controls': contentId,
|
|
144
167
|
'aria-disabled': disabled || undefined,
|
|
145
|
-
onClick:
|
|
146
|
-
|
|
168
|
+
onClick: composeEventHandlers(
|
|
169
|
+
(event: React.MouseEvent<HTMLElement>) => {
|
|
170
|
+
childProps.onClick?.(event);
|
|
171
|
+
onClick?.(event as unknown as React.MouseEvent<HTMLButtonElement>);
|
|
172
|
+
},
|
|
173
|
+
() => handleClick()
|
|
174
|
+
),
|
|
175
|
+
onKeyDown: composeEventHandlers(
|
|
176
|
+
(event: React.KeyboardEvent<HTMLElement>) => {
|
|
177
|
+
childProps.onKeyDown?.(event);
|
|
178
|
+
onKeyDown?.(event as unknown as React.KeyboardEvent<HTMLButtonElement>);
|
|
179
|
+
},
|
|
180
|
+
(event) => handleKeyDown(event as unknown as React.KeyboardEvent)
|
|
181
|
+
),
|
|
182
|
+
className: [className, childProps.className].filter(Boolean).join(' '),
|
|
147
183
|
});
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
return (
|
|
151
187
|
<button
|
|
188
|
+
{...htmlProps}
|
|
152
189
|
type="button"
|
|
153
|
-
id={triggerId}
|
|
190
|
+
id={(htmlProps.id as string | undefined) ?? triggerId}
|
|
154
191
|
className={`${styles.trigger} ${className || ''}`}
|
|
155
192
|
aria-expanded={isOpen}
|
|
156
193
|
aria-controls={contentId}
|
|
157
194
|
aria-disabled={disabled || undefined}
|
|
158
|
-
onClick={
|
|
159
|
-
onKeyDown={handleKeyDown}
|
|
195
|
+
onClick={composeEventHandlers(onClick, handleClick)}
|
|
196
|
+
onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
|
|
160
197
|
disabled={disabled}
|
|
161
198
|
>
|
|
162
199
|
{chevronPosition === 'start' && chevronIcon}
|
|
@@ -167,10 +204,8 @@ function CollapsibleTrigger({
|
|
|
167
204
|
}
|
|
168
205
|
|
|
169
206
|
// Content component
|
|
170
|
-
export interface CollapsibleContentProps {
|
|
207
|
+
export interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
171
208
|
children: React.ReactNode;
|
|
172
|
-
/** Additional class name */
|
|
173
|
-
className?: string;
|
|
174
209
|
/** Force mount content even when closed (useful for animations) */
|
|
175
210
|
forceMount?: boolean;
|
|
176
211
|
}
|
|
@@ -179,6 +214,7 @@ function CollapsibleContent({
|
|
|
179
214
|
children,
|
|
180
215
|
className,
|
|
181
216
|
forceMount = false,
|
|
217
|
+
...htmlProps
|
|
182
218
|
}: CollapsibleContentProps) {
|
|
183
219
|
const { isOpen, contentId, triggerId } = useCollapsibleContext();
|
|
184
220
|
|
|
@@ -189,7 +225,8 @@ function CollapsibleContent({
|
|
|
189
225
|
|
|
190
226
|
return (
|
|
191
227
|
<div
|
|
192
|
-
|
|
228
|
+
{...htmlProps}
|
|
229
|
+
id={(htmlProps.id as string | undefined) ?? contentId}
|
|
193
230
|
role="region"
|
|
194
231
|
aria-labelledby={triggerId}
|
|
195
232
|
className={`${styles.content} ${isOpen ? styles.contentOpen : styles.contentClosed} ${className || ''}`}
|
|
@@ -79,6 +79,26 @@ describe('Combobox', () => {
|
|
|
79
79
|
expect(await screen.findByText('Frameworks')).toBeInTheDocument();
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it('forwards html props to item and labels', async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
render(
|
|
85
|
+
<Combobox placeholder="Search">
|
|
86
|
+
<Combobox.Input />
|
|
87
|
+
<Combobox.Content>
|
|
88
|
+
<Combobox.Group id="framework-group">
|
|
89
|
+
<Combobox.GroupLabel id="framework-group-label">Frameworks</Combobox.GroupLabel>
|
|
90
|
+
<Combobox.Item id="react-option" value="react">React</Combobox.Item>
|
|
91
|
+
</Combobox.Group>
|
|
92
|
+
</Combobox.Content>
|
|
93
|
+
</Combobox>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await user.click(screen.getByRole('combobox'));
|
|
97
|
+
expect(await screen.findByRole('option', { name: 'React' })).toHaveAttribute('id', 'react-option');
|
|
98
|
+
expect(screen.getByText('Frameworks')).toHaveAttribute('id', 'framework-group-label');
|
|
99
|
+
expect(screen.getByText('Frameworks').closest('#framework-group')).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
82
102
|
it('supports multiple selection mode', async () => {
|
|
83
103
|
const user = userEvent.setup();
|
|
84
104
|
const onChange = vi.fn();
|
|
@@ -90,6 +110,22 @@ describe('Combobox', () => {
|
|
|
90
110
|
expect(onChange).toHaveBeenCalled();
|
|
91
111
|
});
|
|
92
112
|
|
|
113
|
+
it('uses text content for non-string item labels', async () => {
|
|
114
|
+
render(
|
|
115
|
+
<Combobox multiple value={['react']} defaultOpen>
|
|
116
|
+
<Combobox.Input />
|
|
117
|
+
<Combobox.Content>
|
|
118
|
+
<Combobox.Item value="react">
|
|
119
|
+
<span>React</span>
|
|
120
|
+
</Combobox.Item>
|
|
121
|
+
</Combobox.Content>
|
|
122
|
+
</Combobox>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect((await screen.findAllByText('React')).length).toBeGreaterThan(0);
|
|
126
|
+
expect(screen.queryByText('[object Object]')).not.toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
93
129
|
it('has no accessibility violations', async () => {
|
|
94
130
|
const { container } = render(
|
|
95
131
|
<Combobox placeholder="Search...">
|
|
@@ -199,4 +235,23 @@ describe('Combobox', () => {
|
|
|
199
235
|
expect(screen.queryByRole('option')).not.toBeInTheDocument();
|
|
200
236
|
});
|
|
201
237
|
});
|
|
238
|
+
|
|
239
|
+
it('updates chip label when selected option is removed', async () => {
|
|
240
|
+
const renderDemo = (showReact: boolean) => (
|
|
241
|
+
<Combobox multiple value={['react']} defaultOpen>
|
|
242
|
+
<Combobox.Input />
|
|
243
|
+
<Combobox.Content>
|
|
244
|
+
{showReact && <Combobox.Item value="react">React</Combobox.Item>}
|
|
245
|
+
<Combobox.Item value="vue">Vue</Combobox.Item>
|
|
246
|
+
</Combobox.Content>
|
|
247
|
+
</Combobox>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const { rerender } = render(renderDemo(true));
|
|
251
|
+
|
|
252
|
+
await screen.findAllByText('React');
|
|
253
|
+
rerender(renderDemo(false));
|
|
254
|
+
|
|
255
|
+
expect(await screen.findByText('react')).toBeInTheDocument();
|
|
256
|
+
});
|
|
202
257
|
});
|
|
@@ -46,26 +46,22 @@ export interface ComboboxContentProps extends React.HTMLAttributes<HTMLDivElemen
|
|
|
46
46
|
maxVisibleItems?: number;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export interface ComboboxItemProps {
|
|
49
|
+
export interface ComboboxItemProps extends Omit<React.HTMLAttributes<HTMLElement>, 'children'> {
|
|
50
50
|
children: React.ReactNode;
|
|
51
51
|
value: string;
|
|
52
52
|
disabled?: boolean;
|
|
53
|
-
className?: string;
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
export interface ComboboxEmptyProps {
|
|
55
|
+
export interface ComboboxEmptyProps extends React.HTMLAttributes<HTMLElement> {
|
|
57
56
|
children: React.ReactNode;
|
|
58
|
-
className?: string;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
export interface ComboboxGroupProps {
|
|
59
|
+
export interface ComboboxGroupProps extends React.HTMLAttributes<HTMLElement> {
|
|
62
60
|
children: React.ReactNode;
|
|
63
|
-
className?: string;
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
export interface ComboboxGroupLabelProps {
|
|
63
|
+
export interface ComboboxGroupLabelProps extends React.HTMLAttributes<HTMLElement> {
|
|
67
64
|
children: React.ReactNode;
|
|
68
|
-
className?: string;
|
|
69
65
|
}
|
|
70
66
|
|
|
71
67
|
// ============================================
|
|
@@ -150,6 +146,15 @@ const ComboboxContext = React.createContext<ComboboxContextValue>({
|
|
|
150
146
|
incrementItemsVersion: () => {},
|
|
151
147
|
});
|
|
152
148
|
|
|
149
|
+
function getNodeText(node: React.ReactNode): string {
|
|
150
|
+
if (node == null || typeof node === 'boolean') return '';
|
|
151
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
152
|
+
if (Array.isArray(node)) return node.map(getNodeText).join('');
|
|
153
|
+
if (React.isValidElement(node))
|
|
154
|
+
return getNodeText((node.props as { children?: React.ReactNode }).children);
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
|
|
153
158
|
// ============================================
|
|
154
159
|
// Components
|
|
155
160
|
// ============================================
|
|
@@ -337,24 +342,25 @@ function ComboboxContent({
|
|
|
337
342
|
);
|
|
338
343
|
}
|
|
339
344
|
|
|
340
|
-
function ComboboxItem({ children, value, disabled, className }: ComboboxItemProps) {
|
|
345
|
+
function ComboboxItem({ children, value, disabled, className, ...htmlProps }: ComboboxItemProps) {
|
|
341
346
|
const { itemsRef, incrementItemsVersion } = React.useContext(ComboboxContext);
|
|
342
347
|
const classes = [styles.item, className].filter(Boolean).join(' ');
|
|
343
348
|
|
|
344
349
|
// Register this item's label in the registry so the input can display it
|
|
345
|
-
const label =
|
|
350
|
+
const label = React.useMemo(() => getNodeText(children).trim() || value, [children, value]);
|
|
346
351
|
React.useEffect(() => {
|
|
347
352
|
const items = itemsRef.current;
|
|
348
353
|
items.set(value, label);
|
|
349
354
|
incrementItemsVersion();
|
|
350
355
|
return () => {
|
|
351
356
|
items.delete(value);
|
|
357
|
+
incrementItemsVersion();
|
|
352
358
|
};
|
|
353
359
|
// itemsRef is a stable ref, incrementItemsVersion is a stable callback
|
|
354
360
|
}, [itemsRef, incrementItemsVersion, value, label]);
|
|
355
361
|
|
|
356
362
|
return (
|
|
357
|
-
<BaseCombobox.Item value={value} disabled={disabled} className={classes}>
|
|
363
|
+
<BaseCombobox.Item {...htmlProps} value={value} disabled={disabled} className={classes}>
|
|
358
364
|
{children}
|
|
359
365
|
<BaseCombobox.ItemIndicator className={styles.itemIndicator}>
|
|
360
366
|
<CheckIcon />
|
|
@@ -363,19 +369,19 @@ function ComboboxItem({ children, value, disabled, className }: ComboboxItemProp
|
|
|
363
369
|
);
|
|
364
370
|
}
|
|
365
371
|
|
|
366
|
-
function ComboboxEmpty({ children, className }: ComboboxEmptyProps) {
|
|
372
|
+
function ComboboxEmpty({ children, className, ...htmlProps }: ComboboxEmptyProps) {
|
|
367
373
|
const classes = [styles.empty, className].filter(Boolean).join(' ');
|
|
368
|
-
return <BaseCombobox.Empty className={classes}>{children}</BaseCombobox.Empty>;
|
|
374
|
+
return <BaseCombobox.Empty {...htmlProps} className={classes}>{children}</BaseCombobox.Empty>;
|
|
369
375
|
}
|
|
370
376
|
|
|
371
|
-
function ComboboxGroup({ children, className }: ComboboxGroupProps) {
|
|
377
|
+
function ComboboxGroup({ children, className, ...htmlProps }: ComboboxGroupProps) {
|
|
372
378
|
const classes = [styles.group, className].filter(Boolean).join(' ');
|
|
373
|
-
return <BaseCombobox.Group className={classes}>{children}</BaseCombobox.Group>;
|
|
379
|
+
return <BaseCombobox.Group {...htmlProps} className={classes}>{children}</BaseCombobox.Group>;
|
|
374
380
|
}
|
|
375
381
|
|
|
376
|
-
function ComboboxGroupLabel({ children, className }: ComboboxGroupLabelProps) {
|
|
382
|
+
function ComboboxGroupLabel({ children, className, ...htmlProps }: ComboboxGroupLabelProps) {
|
|
377
383
|
const classes = [styles.groupLabel, className].filter(Boolean).join(' ');
|
|
378
|
-
return <BaseCombobox.GroupLabel className={classes}>{children}</BaseCombobox.GroupLabel>;
|
|
384
|
+
return <BaseCombobox.GroupLabel {...htmlProps} className={classes}>{children}</BaseCombobox.GroupLabel>;
|
|
379
385
|
}
|
|
380
386
|
|
|
381
387
|
// ============================================
|
|
@@ -355,6 +355,99 @@ describe('Command', () => {
|
|
|
355
355
|
});
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
+
it('uses unique list ids for multiple command instances', () => {
|
|
359
|
+
render(
|
|
360
|
+
<>
|
|
361
|
+
<Command>
|
|
362
|
+
<Command.Input placeholder="First search" />
|
|
363
|
+
<Command.List>
|
|
364
|
+
<Command.Item onItemSelect={() => {}}>One</Command.Item>
|
|
365
|
+
</Command.List>
|
|
366
|
+
</Command>
|
|
367
|
+
<Command>
|
|
368
|
+
<Command.Input placeholder="Second search" />
|
|
369
|
+
<Command.List>
|
|
370
|
+
<Command.Item onItemSelect={() => {}}>Two</Command.Item>
|
|
371
|
+
</Command.List>
|
|
372
|
+
</Command>
|
|
373
|
+
</>
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const inputs = [
|
|
377
|
+
screen.getByPlaceholderText('First search'),
|
|
378
|
+
screen.getByPlaceholderText('Second search'),
|
|
379
|
+
];
|
|
380
|
+
const listIds = inputs.map((input) => input.getAttribute('aria-controls'));
|
|
381
|
+
|
|
382
|
+
expect(listIds[0]).toBeTruthy();
|
|
383
|
+
expect(listIds[1]).toBeTruthy();
|
|
384
|
+
expect(listIds[0]).not.toBe(listIds[1]);
|
|
385
|
+
expect(document.getElementById(listIds[0]!)).toBeInTheDocument();
|
|
386
|
+
expect(document.getElementById(listIds[1]!)).toBeInTheDocument();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('filters rich-label items using extracted text content', async () => {
|
|
390
|
+
const user = userEvent.setup();
|
|
391
|
+
render(
|
|
392
|
+
<Command>
|
|
393
|
+
<Command.Input placeholder="Search..." />
|
|
394
|
+
<Command.List>
|
|
395
|
+
<Command.Item onItemSelect={() => {}}>
|
|
396
|
+
<span>Open</span> File
|
|
397
|
+
</Command.Item>
|
|
398
|
+
<Command.Item onItemSelect={() => {}}>Save</Command.Item>
|
|
399
|
+
</Command.List>
|
|
400
|
+
</Command>
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
await user.type(screen.getByPlaceholderText('Search...'), 'open');
|
|
404
|
+
|
|
405
|
+
await waitFor(() => {
|
|
406
|
+
expect(screen.getByText('Save')).not.toBeVisible();
|
|
407
|
+
expect(screen.getByText('Open')).toBeVisible();
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('composes item and group html props without dropping handlers/styles', async () => {
|
|
412
|
+
const user = userEvent.setup();
|
|
413
|
+
const itemClick = vi.fn();
|
|
414
|
+
const itemKeyDown = vi.fn();
|
|
415
|
+
const itemMouseEnter = vi.fn();
|
|
416
|
+
|
|
417
|
+
render(
|
|
418
|
+
<Command>
|
|
419
|
+
<Command.Input placeholder="Search..." />
|
|
420
|
+
<Command.List>
|
|
421
|
+
<Command.Group heading="Files" data-testid="group" style={{ opacity: 0.5 }}>
|
|
422
|
+
<Command.Item
|
|
423
|
+
onItemSelect={() => {}}
|
|
424
|
+
data-testid="item"
|
|
425
|
+
onClick={itemClick}
|
|
426
|
+
onKeyDown={itemKeyDown}
|
|
427
|
+
onMouseEnter={itemMouseEnter}
|
|
428
|
+
tabIndex={0}
|
|
429
|
+
style={{ color: 'rgb(255, 0, 0)' }}
|
|
430
|
+
>
|
|
431
|
+
Open File
|
|
432
|
+
</Command.Item>
|
|
433
|
+
</Command.Group>
|
|
434
|
+
</Command.List>
|
|
435
|
+
</Command>
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const item = screen.getByTestId('item');
|
|
439
|
+
await user.hover(item);
|
|
440
|
+
await user.click(item);
|
|
441
|
+
item.focus();
|
|
442
|
+
await user.keyboard('{Enter}');
|
|
443
|
+
|
|
444
|
+
expect(itemMouseEnter).toHaveBeenCalled();
|
|
445
|
+
expect(itemClick).toHaveBeenCalled();
|
|
446
|
+
expect(itemKeyDown).toHaveBeenCalled();
|
|
447
|
+
expect(item).toHaveStyle({ color: 'rgb(255, 0, 0)' });
|
|
448
|
+
expect(screen.getByTestId('group')).toHaveStyle({ opacity: '0.5' });
|
|
449
|
+
});
|
|
450
|
+
|
|
358
451
|
it('has no accessibility violations', async () => {
|
|
359
452
|
const { container } = renderCommand();
|
|
360
453
|
|