@fragments-sdk/ui 0.7.4 → 0.7.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/fragments.json +1 -1
- package/package.json +9 -2
- package/src/components/Accordion/Accordion.test.tsx +171 -0
- package/src/components/Alert/Alert.test.tsx +127 -0
- package/src/components/AppShell/AppShell.test.tsx +80 -0
- package/src/components/Avatar/Avatar.test.tsx +40 -0
- package/src/components/Badge/Badge.test.tsx +58 -0
- package/src/components/Box/Box.test.tsx +43 -0
- package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
- package/src/components/Button/Button.test.tsx +53 -0
- package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
- package/src/components/Card/Card.test.tsx +71 -0
- package/src/components/Chart/Chart.test.tsx +123 -0
- package/src/components/Checkbox/Checkbox.test.tsx +63 -0
- package/src/components/Chip/Chip.module.scss +54 -1
- package/src/components/Chip/Chip.test.tsx +50 -0
- package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
- package/src/components/Collapsible/Collapsible.test.tsx +103 -0
- package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
- package/src/components/ColorPicker/index.tsx +4 -1
- package/src/components/Combobox/Combobox.test.tsx +202 -0
- package/src/components/ConversationList/ConversationList.test.tsx +79 -0
- package/src/components/Dialog/Dialog.test.tsx +277 -0
- package/src/components/EmptyState/EmptyState.test.tsx +67 -0
- package/src/components/Field/Field.test.tsx +65 -0
- package/src/components/Fieldset/Fieldset.test.tsx +48 -0
- package/src/components/Form/Form.test.tsx +41 -0
- package/src/components/Grid/Grid.test.tsx +65 -0
- package/src/components/Header/Header.test.tsx +83 -0
- package/src/components/Icon/Icon.test.tsx +38 -0
- package/src/components/Image/Image.test.tsx +39 -0
- package/src/components/Input/Input.test.tsx +72 -0
- package/src/components/Link/Link.test.tsx +37 -0
- package/src/components/List/List.test.tsx +57 -0
- package/src/components/Listbox/Listbox.module.scss +2 -1
- package/src/components/Listbox/Listbox.test.tsx +100 -0
- package/src/components/Listbox/index.tsx +26 -3
- package/src/components/Loading/Loading.test.tsx +38 -0
- package/src/components/Markdown/Markdown.test.tsx +41 -0
- package/src/components/Menu/Menu.test.tsx +336 -0
- package/src/components/Message/Message.test.tsx +75 -0
- package/src/components/Popover/Popover.test.tsx +105 -0
- package/src/components/Progress/Progress.test.tsx +58 -0
- package/src/components/Prompt/Prompt.test.tsx +89 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
- package/src/components/Select/Select.test.tsx +161 -0
- package/src/components/Separator/Separator.test.tsx +33 -0
- package/src/components/Sidebar/Sidebar.test.tsx +85 -0
- package/src/components/Skeleton/Skeleton.test.tsx +56 -0
- package/src/components/Slider/Slider.test.tsx +51 -0
- package/src/components/Stack/Stack.test.tsx +47 -0
- package/src/components/Table/Table.test.tsx +129 -0
- package/src/components/Tabs/Tabs.test.tsx +180 -0
- package/src/components/Text/Text.test.tsx +40 -0
- package/src/components/Textarea/Textarea.test.tsx +57 -0
- package/src/components/Theme/Theme.test.tsx +114 -0
- package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
- package/src/components/Toast/Toast.test.tsx +192 -0
- package/src/components/Toast/index.tsx +14 -4
- package/src/components/Toggle/Toggle.test.tsx +49 -0
- package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
- package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
- package/src/components/ToggleGroup/index.tsx +17 -2
- package/src/components/Tooltip/Tooltip.test.tsx +107 -0
- package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/test/setup.ts +74 -0
- package/src/test/utils.tsx +71 -0
- package/src/utils/a11y.test.tsx +79 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, act, waitFor, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Toast, useToast, ToastProvider } from './index';
|
|
4
|
+
|
|
5
|
+
// Helper component to trigger toasts via the hook
|
|
6
|
+
function ToastTrigger({
|
|
7
|
+
variant,
|
|
8
|
+
title = 'Test Toast',
|
|
9
|
+
description,
|
|
10
|
+
duration,
|
|
11
|
+
action,
|
|
12
|
+
}: {
|
|
13
|
+
variant?: 'default' | 'success' | 'error' | 'warning' | 'info';
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
duration?: number;
|
|
17
|
+
action?: { label: string; onClick: () => void };
|
|
18
|
+
}) {
|
|
19
|
+
const { toast } = useToast();
|
|
20
|
+
return (
|
|
21
|
+
<button
|
|
22
|
+
onClick={() => toast({ title, description, variant, duration, action })}
|
|
23
|
+
>
|
|
24
|
+
Show Toast
|
|
25
|
+
</button>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderWithProvider(
|
|
30
|
+
ui: React.ReactElement,
|
|
31
|
+
providerProps: Partial<React.ComponentProps<typeof ToastProvider>> = {}
|
|
32
|
+
) {
|
|
33
|
+
return render(
|
|
34
|
+
<ToastProvider {...providerProps}>
|
|
35
|
+
{ui}
|
|
36
|
+
</ToastProvider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Toast', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.useRealTimers();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders a toast when triggered via useToast hook', async () => {
|
|
50
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
51
|
+
renderWithProvider(<ToastTrigger title="Hello Toast" />);
|
|
52
|
+
|
|
53
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
54
|
+
|
|
55
|
+
await waitFor(() => {
|
|
56
|
+
expect(screen.getByText('Hello Toast')).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('renders title and description', async () => {
|
|
61
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
62
|
+
renderWithProvider(
|
|
63
|
+
<ToastTrigger title="Title" description="Description text" />
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
67
|
+
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(screen.getByText('Title')).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText('Description text')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('uses role="alert" for error variant', async () => {
|
|
75
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
76
|
+
renderWithProvider(<ToastTrigger variant="error" title="Error!" />);
|
|
77
|
+
|
|
78
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
79
|
+
|
|
80
|
+
await waitFor(() => {
|
|
81
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('uses role="alert" for warning variant', async () => {
|
|
86
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
87
|
+
renderWithProvider(<ToastTrigger variant="warning" title="Warning!" />);
|
|
88
|
+
|
|
89
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('uses role="status" for default/success/info variants', async () => {
|
|
97
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
98
|
+
renderWithProvider(<ToastTrigger variant="success" title="Success!" />);
|
|
99
|
+
|
|
100
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('dismiss button has aria-label', async () => {
|
|
108
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
109
|
+
renderWithProvider(<ToastTrigger title="Dismissable" />);
|
|
110
|
+
|
|
111
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(screen.getByRole('button', { name: /dismiss notification/i })).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('dismisses when dismiss button is clicked', async () => {
|
|
119
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
120
|
+
renderWithProvider(<ToastTrigger title="To Dismiss" duration={0} />);
|
|
121
|
+
|
|
122
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(screen.getByText('To Dismiss')).toBeInTheDocument();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await user.click(screen.getByRole('button', { name: /dismiss notification/i }));
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(screen.queryByText('To Dismiss')).not.toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('auto-dismisses after duration', async () => {
|
|
136
|
+
renderWithProvider(
|
|
137
|
+
<ToastTrigger title="Auto Dismiss" duration={3000} />,
|
|
138
|
+
{ duration: 3000 }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
142
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
143
|
+
|
|
144
|
+
await waitFor(() => {
|
|
145
|
+
expect(screen.getByText('Auto Dismiss')).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
vi.advanceTimersByTime(4000);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(screen.queryByText('Auto Dismiss')).not.toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('renders action button when action is provided', async () => {
|
|
158
|
+
const onClick = vi.fn();
|
|
159
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
160
|
+
renderWithProvider(
|
|
161
|
+
<ToastTrigger
|
|
162
|
+
title="With Action"
|
|
163
|
+
duration={0}
|
|
164
|
+
action={{ label: 'Undo', onClick }}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
169
|
+
|
|
170
|
+
await waitFor(() => {
|
|
171
|
+
expect(screen.getByRole('button', { name: /undo/i })).toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await user.click(screen.getByRole('button', { name: /undo/i }));
|
|
175
|
+
expect(onClick).toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('has no accessibility violations', async () => {
|
|
179
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
180
|
+
const { container } = renderWithProvider(
|
|
181
|
+
<ToastTrigger title="Accessible Toast" variant="info" />
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
await user.click(screen.getByRole('button', { name: /show toast/i }));
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(screen.getByText('Accessible Toast')).toBeInTheDocument();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await expectNoA11yViolations(container);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -201,7 +201,13 @@ function ToastItem({
|
|
|
201
201
|
aria-labelledby={titleId}
|
|
202
202
|
aria-describedby={descId}
|
|
203
203
|
onMouseEnter={onPause}
|
|
204
|
-
onMouseLeave={
|
|
204
|
+
onMouseLeave={() => {
|
|
205
|
+
requestAnimationFrame(() => {
|
|
206
|
+
if (!toastRef.current?.contains(document.activeElement)) {
|
|
207
|
+
onResume?.();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}}
|
|
205
211
|
onFocusCapture={onPause}
|
|
206
212
|
onBlurCapture={() => {
|
|
207
213
|
requestAnimationFrame(() => {
|
|
@@ -268,7 +274,7 @@ function ToastContainer({
|
|
|
268
274
|
// Always render the container for screen reader live region to work properly
|
|
269
275
|
// The live region must exist before announcements are made
|
|
270
276
|
return (
|
|
271
|
-
<div className={containerClasses} aria-label="Notifications">
|
|
277
|
+
<div className={containerClasses} role="region" aria-label="Notifications">
|
|
272
278
|
{toasts.map((toast) => (
|
|
273
279
|
<ToastItem
|
|
274
280
|
key={toast.id}
|
|
@@ -380,7 +386,9 @@ export function ToastProvider({
|
|
|
380
386
|
}, [scheduleRemoval]);
|
|
381
387
|
|
|
382
388
|
const clearToasts = React.useCallback(() => {
|
|
383
|
-
timeoutRef.current.forEach((timeout) =>
|
|
389
|
+
timeoutRef.current.forEach((timeout) => {
|
|
390
|
+
clearTimeout(timeout);
|
|
391
|
+
});
|
|
384
392
|
timeoutRef.current.clear();
|
|
385
393
|
remainingRef.current.clear();
|
|
386
394
|
startTimeRef.current.clear();
|
|
@@ -389,7 +397,9 @@ export function ToastProvider({
|
|
|
389
397
|
|
|
390
398
|
React.useEffect(
|
|
391
399
|
() => () => {
|
|
392
|
-
timeoutRef.current.forEach((timeout) =>
|
|
400
|
+
timeoutRef.current.forEach((timeout) => {
|
|
401
|
+
clearTimeout(timeout);
|
|
402
|
+
});
|
|
393
403
|
timeoutRef.current.clear();
|
|
394
404
|
remainingRef.current.clear();
|
|
395
405
|
startTimeRef.current.clear();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Toggle } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Toggle', () => {
|
|
6
|
+
it('renders a switch role', () => {
|
|
7
|
+
render(<Toggle aria-label="Dark mode" />);
|
|
8
|
+
expect(screen.getByRole('switch')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('is unchecked by default', () => {
|
|
12
|
+
render(<Toggle aria-label="Dark mode" />);
|
|
13
|
+
expect(screen.getByRole('switch')).not.toBeChecked();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('renders as checked when checked prop is true', () => {
|
|
17
|
+
render(<Toggle aria-label="Dark mode" checked onChange={() => {}} />);
|
|
18
|
+
expect(screen.getByRole('switch')).toBeChecked();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders label text', () => {
|
|
22
|
+
render(<Toggle label="Dark mode" />);
|
|
23
|
+
expect(screen.getByText('Dark mode')).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders description text', () => {
|
|
27
|
+
render(<Toggle label="Notifications" description="Enable push alerts" />);
|
|
28
|
+
expect(screen.getByText('Enable push alerts')).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('disables the switch', () => {
|
|
32
|
+
render(<Toggle aria-label="Dark mode" disabled />);
|
|
33
|
+
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('calls onChange with the new value on click', async () => {
|
|
37
|
+
const handleChange = vi.fn();
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
render(<Toggle aria-label="Dark mode" onChange={handleChange} />);
|
|
40
|
+
await user.click(screen.getByRole('switch'));
|
|
41
|
+
expect(handleChange).toHaveBeenCalled();
|
|
42
|
+
expect(handleChange.mock.calls[0][0]).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('has no accessibility violations', async () => {
|
|
46
|
+
const { container } = render(<Toggle aria-label="Accessible toggle" />);
|
|
47
|
+
await expectNoA11yViolations(container);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -2,6 +2,96 @@ import React from 'react';
|
|
|
2
2
|
import { defineSegment } from '@fragments/core';
|
|
3
3
|
import { ToggleGroup } from '.';
|
|
4
4
|
|
|
5
|
+
function DefaultExample() {
|
|
6
|
+
const [value, setValue] = React.useState('left');
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<ToggleGroup value={value} onChange={setValue}>
|
|
10
|
+
<ToggleGroup.Item value="left">Left</ToggleGroup.Item>
|
|
11
|
+
<ToggleGroup.Item value="center">Center</ToggleGroup.Item>
|
|
12
|
+
<ToggleGroup.Item value="right">Right</ToggleGroup.Item>
|
|
13
|
+
</ToggleGroup>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function PillsExample() {
|
|
18
|
+
const [value, setValue] = React.useState('all');
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<ToggleGroup value={value} onChange={setValue} variant="pills">
|
|
22
|
+
<ToggleGroup.Item value="all">All</ToggleGroup.Item>
|
|
23
|
+
<ToggleGroup.Item value="active">Active</ToggleGroup.Item>
|
|
24
|
+
<ToggleGroup.Item value="completed">Completed</ToggleGroup.Item>
|
|
25
|
+
</ToggleGroup>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function OutlineExample() {
|
|
30
|
+
const [value, setValue] = React.useState('day');
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<ToggleGroup value={value} onChange={setValue} variant="outline">
|
|
34
|
+
<ToggleGroup.Item value="day">Day</ToggleGroup.Item>
|
|
35
|
+
<ToggleGroup.Item value="week">Week</ToggleGroup.Item>
|
|
36
|
+
<ToggleGroup.Item value="month">Month</ToggleGroup.Item>
|
|
37
|
+
</ToggleGroup>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function SizesExample() {
|
|
42
|
+
const [value1, setValue1] = React.useState('a');
|
|
43
|
+
const [value2, setValue2] = React.useState('a');
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
47
|
+
<ToggleGroup value={value1} onChange={setValue1} size="sm">
|
|
48
|
+
<ToggleGroup.Item value="a">Small</ToggleGroup.Item>
|
|
49
|
+
<ToggleGroup.Item value="b">Size</ToggleGroup.Item>
|
|
50
|
+
</ToggleGroup>
|
|
51
|
+
<ToggleGroup value={value2} onChange={setValue2} size="md">
|
|
52
|
+
<ToggleGroup.Item value="a">Medium</ToggleGroup.Item>
|
|
53
|
+
<ToggleGroup.Item value="b">Size</ToggleGroup.Item>
|
|
54
|
+
</ToggleGroup>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ViewSwitcherExample() {
|
|
60
|
+
const [view, setView] = React.useState('grid');
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<ToggleGroup value={view} onChange={setView} size="sm">
|
|
64
|
+
<ToggleGroup.Item value="grid">
|
|
65
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
66
|
+
<rect x="3" y="3" width="7" height="7" />
|
|
67
|
+
<rect x="14" y="3" width="7" height="7" />
|
|
68
|
+
<rect x="3" y="14" width="7" height="7" />
|
|
69
|
+
<rect x="14" y="14" width="7" height="7" />
|
|
70
|
+
</svg>
|
|
71
|
+
</ToggleGroup.Item>
|
|
72
|
+
<ToggleGroup.Item value="list">
|
|
73
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
74
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
75
|
+
<line x1="3" y1="12" x2="21" y2="12" />
|
|
76
|
+
<line x1="3" y1="18" x2="21" y2="18" />
|
|
77
|
+
</svg>
|
|
78
|
+
</ToggleGroup.Item>
|
|
79
|
+
</ToggleGroup>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function DisabledItemExample() {
|
|
84
|
+
const [value, setValue] = React.useState('basic');
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<ToggleGroup value={value} onChange={setValue}>
|
|
88
|
+
<ToggleGroup.Item value="basic">Basic</ToggleGroup.Item>
|
|
89
|
+
<ToggleGroup.Item value="pro">Pro</ToggleGroup.Item>
|
|
90
|
+
<ToggleGroup.Item value="enterprise" disabled>Enterprise</ToggleGroup.Item>
|
|
91
|
+
</ToggleGroup>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
5
95
|
export default defineSegment({
|
|
6
96
|
component: ToggleGroup,
|
|
7
97
|
|
|
@@ -104,104 +194,32 @@ export default defineSegment({
|
|
|
104
194
|
{
|
|
105
195
|
name: 'Default',
|
|
106
196
|
description: 'Basic toggle group',
|
|
107
|
-
render: () =>
|
|
108
|
-
const [value, setValue] = React.useState('left');
|
|
109
|
-
return (
|
|
110
|
-
<ToggleGroup value={value} onChange={setValue}>
|
|
111
|
-
<ToggleGroup.Item value="left">Left</ToggleGroup.Item>
|
|
112
|
-
<ToggleGroup.Item value="center">Center</ToggleGroup.Item>
|
|
113
|
-
<ToggleGroup.Item value="right">Right</ToggleGroup.Item>
|
|
114
|
-
</ToggleGroup>
|
|
115
|
-
);
|
|
116
|
-
},
|
|
197
|
+
render: () => <DefaultExample />,
|
|
117
198
|
},
|
|
118
199
|
{
|
|
119
200
|
name: 'Pills Variant',
|
|
120
201
|
description: 'Pill-shaped toggle buttons',
|
|
121
|
-
render: () =>
|
|
122
|
-
const [value, setValue] = React.useState('all');
|
|
123
|
-
return (
|
|
124
|
-
<ToggleGroup value={value} onChange={setValue} variant="pills">
|
|
125
|
-
<ToggleGroup.Item value="all">All</ToggleGroup.Item>
|
|
126
|
-
<ToggleGroup.Item value="active">Active</ToggleGroup.Item>
|
|
127
|
-
<ToggleGroup.Item value="completed">Completed</ToggleGroup.Item>
|
|
128
|
-
</ToggleGroup>
|
|
129
|
-
);
|
|
130
|
-
},
|
|
202
|
+
render: () => <PillsExample />,
|
|
131
203
|
},
|
|
132
204
|
{
|
|
133
205
|
name: 'Outline Variant',
|
|
134
206
|
description: 'Outlined toggle buttons',
|
|
135
|
-
render: () =>
|
|
136
|
-
const [value, setValue] = React.useState('day');
|
|
137
|
-
return (
|
|
138
|
-
<ToggleGroup value={value} onChange={setValue} variant="outline">
|
|
139
|
-
<ToggleGroup.Item value="day">Day</ToggleGroup.Item>
|
|
140
|
-
<ToggleGroup.Item value="week">Week</ToggleGroup.Item>
|
|
141
|
-
<ToggleGroup.Item value="month">Month</ToggleGroup.Item>
|
|
142
|
-
</ToggleGroup>
|
|
143
|
-
);
|
|
144
|
-
},
|
|
207
|
+
render: () => <OutlineExample />,
|
|
145
208
|
},
|
|
146
209
|
{
|
|
147
210
|
name: 'Sizes',
|
|
148
211
|
description: 'Different size variants',
|
|
149
|
-
render: () =>
|
|
150
|
-
const [value1, setValue1] = React.useState('a');
|
|
151
|
-
const [value2, setValue2] = React.useState('a');
|
|
152
|
-
return (
|
|
153
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
154
|
-
<ToggleGroup value={value1} onChange={setValue1} size="sm">
|
|
155
|
-
<ToggleGroup.Item value="a">Small</ToggleGroup.Item>
|
|
156
|
-
<ToggleGroup.Item value="b">Size</ToggleGroup.Item>
|
|
157
|
-
</ToggleGroup>
|
|
158
|
-
<ToggleGroup value={value2} onChange={setValue2} size="md">
|
|
159
|
-
<ToggleGroup.Item value="a">Medium</ToggleGroup.Item>
|
|
160
|
-
<ToggleGroup.Item value="b">Size</ToggleGroup.Item>
|
|
161
|
-
</ToggleGroup>
|
|
162
|
-
</div>
|
|
163
|
-
);
|
|
164
|
-
},
|
|
212
|
+
render: () => <SizesExample />,
|
|
165
213
|
},
|
|
166
214
|
{
|
|
167
215
|
name: 'View Switcher',
|
|
168
216
|
description: 'Common pattern for switching between views',
|
|
169
|
-
render: () =>
|
|
170
|
-
const [view, setView] = React.useState('grid');
|
|
171
|
-
return (
|
|
172
|
-
<ToggleGroup value={view} onChange={setView} size="sm">
|
|
173
|
-
<ToggleGroup.Item value="grid">
|
|
174
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
175
|
-
<rect x="3" y="3" width="7" height="7" />
|
|
176
|
-
<rect x="14" y="3" width="7" height="7" />
|
|
177
|
-
<rect x="3" y="14" width="7" height="7" />
|
|
178
|
-
<rect x="14" y="14" width="7" height="7" />
|
|
179
|
-
</svg>
|
|
180
|
-
</ToggleGroup.Item>
|
|
181
|
-
<ToggleGroup.Item value="list">
|
|
182
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
183
|
-
<line x1="3" y1="6" x2="21" y2="6" />
|
|
184
|
-
<line x1="3" y1="12" x2="21" y2="12" />
|
|
185
|
-
<line x1="3" y1="18" x2="21" y2="18" />
|
|
186
|
-
</svg>
|
|
187
|
-
</ToggleGroup.Item>
|
|
188
|
-
</ToggleGroup>
|
|
189
|
-
);
|
|
190
|
-
},
|
|
217
|
+
render: () => <ViewSwitcherExample />,
|
|
191
218
|
},
|
|
192
219
|
{
|
|
193
220
|
name: 'With Disabled Item',
|
|
194
221
|
description: 'Toggle group with a disabled option',
|
|
195
|
-
render: () =>
|
|
196
|
-
const [value, setValue] = React.useState('basic');
|
|
197
|
-
return (
|
|
198
|
-
<ToggleGroup value={value} onChange={setValue}>
|
|
199
|
-
<ToggleGroup.Item value="basic">Basic</ToggleGroup.Item>
|
|
200
|
-
<ToggleGroup.Item value="pro">Pro</ToggleGroup.Item>
|
|
201
|
-
<ToggleGroup.Item value="enterprise" disabled>Enterprise</ToggleGroup.Item>
|
|
202
|
-
</ToggleGroup>
|
|
203
|
-
);
|
|
204
|
-
},
|
|
222
|
+
render: () => <DisabledItemExample />,
|
|
205
223
|
},
|
|
206
224
|
],
|
|
207
225
|
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { ToggleGroup } from './index';
|
|
4
|
+
|
|
5
|
+
function renderToggleGroup(
|
|
6
|
+
props: Partial<React.ComponentProps<typeof ToggleGroup>> = {}
|
|
7
|
+
) {
|
|
8
|
+
const defaultProps = {
|
|
9
|
+
value: 'a',
|
|
10
|
+
onChange: vi.fn(),
|
|
11
|
+
...props,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
onChange: defaultProps.onChange,
|
|
16
|
+
...render(
|
|
17
|
+
<ToggleGroup {...defaultProps}>
|
|
18
|
+
<ToggleGroup.Item value="a">Option A</ToggleGroup.Item>
|
|
19
|
+
<ToggleGroup.Item value="b">Option B</ToggleGroup.Item>
|
|
20
|
+
<ToggleGroup.Item value="c" disabled>Option C</ToggleGroup.Item>
|
|
21
|
+
</ToggleGroup>
|
|
22
|
+
),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('ToggleGroup', () => {
|
|
27
|
+
it('renders with radiogroup role', () => {
|
|
28
|
+
renderToggleGroup();
|
|
29
|
+
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders items with radio role', () => {
|
|
33
|
+
renderToggleGroup();
|
|
34
|
+
expect(screen.getAllByRole('radio')).toHaveLength(3);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('marks selected item with aria-checked', () => {
|
|
38
|
+
renderToggleGroup({ value: 'b' });
|
|
39
|
+
const optionB = screen.getByRole('radio', { name: /option b/i });
|
|
40
|
+
expect(optionB).toHaveAttribute('aria-checked', 'true');
|
|
41
|
+
|
|
42
|
+
const optionA = screen.getByRole('radio', { name: /option a/i });
|
|
43
|
+
expect(optionA).toHaveAttribute('aria-checked', 'false');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('calls onChange when an item is clicked', async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
const { onChange } = renderToggleGroup();
|
|
49
|
+
|
|
50
|
+
await user.click(screen.getByRole('radio', { name: /option b/i }));
|
|
51
|
+
expect(onChange).toHaveBeenCalledWith('b');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not call onChange for disabled items', async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
const { onChange } = renderToggleGroup();
|
|
57
|
+
|
|
58
|
+
const disabledItem = screen.getByRole('radio', { name: /option c/i });
|
|
59
|
+
expect(disabledItem).toBeDisabled();
|
|
60
|
+
|
|
61
|
+
await user.click(disabledItem);
|
|
62
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('applies variant class', () => {
|
|
66
|
+
renderToggleGroup({ variant: 'pills' });
|
|
67
|
+
const group = screen.getByRole('radiogroup');
|
|
68
|
+
expect(group.className).toContain('pills');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('applies size class', () => {
|
|
72
|
+
renderToggleGroup({ size: 'sm' });
|
|
73
|
+
const group = screen.getByRole('radiogroup');
|
|
74
|
+
expect(group.className).toContain('size-sm');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('manages tabIndex correctly for selected item', () => {
|
|
78
|
+
renderToggleGroup({ value: 'a' });
|
|
79
|
+
const optionA = screen.getByRole('radio', { name: /option a/i });
|
|
80
|
+
const optionB = screen.getByRole('radio', { name: /option b/i });
|
|
81
|
+
|
|
82
|
+
expect(optionA).toHaveAttribute('tabindex', '0');
|
|
83
|
+
expect(optionB).toHaveAttribute('tabindex', '-1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('has no accessibility violations', async () => {
|
|
87
|
+
const { container } = renderToggleGroup();
|
|
88
|
+
await expectNoA11yViolations(container);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -39,6 +39,8 @@ interface ToggleGroupContextValue {
|
|
|
39
39
|
onChange: (value: string) => void;
|
|
40
40
|
variant: 'default' | 'pills' | 'outline';
|
|
41
41
|
size: 'sm' | 'md';
|
|
42
|
+
hasFocusableSelection: boolean;
|
|
43
|
+
firstEnabledValue: string | null;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(
|
|
@@ -77,11 +79,22 @@ function ToggleGroupRoot({
|
|
|
77
79
|
.filter(Boolean)
|
|
78
80
|
.join(' ');
|
|
79
81
|
|
|
82
|
+
const childItems = React.Children.toArray(children).filter(
|
|
83
|
+
(child): child is React.ReactElement<ToggleGroupItemProps> =>
|
|
84
|
+
React.isValidElement<ToggleGroupItemProps>(child)
|
|
85
|
+
);
|
|
86
|
+
const firstEnabledValue = childItems.find((item) => !item.props.disabled)?.props.value ?? null;
|
|
87
|
+
const hasFocusableSelection = childItems.some(
|
|
88
|
+
(item) => !item.props.disabled && item.props.value === value
|
|
89
|
+
);
|
|
90
|
+
|
|
80
91
|
const contextValue: ToggleGroupContextValue = {
|
|
81
92
|
value,
|
|
82
93
|
onChange,
|
|
83
94
|
variant,
|
|
84
95
|
size,
|
|
96
|
+
hasFocusableSelection,
|
|
97
|
+
firstEnabledValue,
|
|
85
98
|
};
|
|
86
99
|
|
|
87
100
|
return (
|
|
@@ -103,6 +116,8 @@ function ToggleGroupItem({
|
|
|
103
116
|
}: ToggleGroupItemProps) {
|
|
104
117
|
const context = useToggleGroupContext();
|
|
105
118
|
const isSelected = context.value === value;
|
|
119
|
+
const isTabbableSelected = isSelected && !disabled;
|
|
120
|
+
const isFirstFallback = !context.hasFocusableSelection && !disabled && context.firstEnabledValue === value;
|
|
106
121
|
|
|
107
122
|
const classes = [
|
|
108
123
|
styles.item,
|
|
@@ -163,7 +178,7 @@ function ToggleGroupItem({
|
|
|
163
178
|
|
|
164
179
|
const target = radios[nextIndex];
|
|
165
180
|
const nextValue = target.dataset.value;
|
|
166
|
-
if (nextValue) {
|
|
181
|
+
if (nextValue != null) {
|
|
167
182
|
context.onChange(nextValue);
|
|
168
183
|
}
|
|
169
184
|
target.focus();
|
|
@@ -176,7 +191,7 @@ function ToggleGroupItem({
|
|
|
176
191
|
data-value={value}
|
|
177
192
|
role="radio"
|
|
178
193
|
aria-checked={isSelected}
|
|
179
|
-
tabIndex={
|
|
194
|
+
tabIndex={isTabbableSelected || isFirstFallback ? 0 : -1}
|
|
180
195
|
disabled={disabled}
|
|
181
196
|
onClick={handleClick}
|
|
182
197
|
onKeyDown={handleKeyDown}
|