@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.
Files changed (68) hide show
  1. package/fragments.json +1 -1
  2. package/package.json +9 -2
  3. package/src/components/Accordion/Accordion.test.tsx +171 -0
  4. package/src/components/Alert/Alert.test.tsx +127 -0
  5. package/src/components/AppShell/AppShell.test.tsx +80 -0
  6. package/src/components/Avatar/Avatar.test.tsx +40 -0
  7. package/src/components/Badge/Badge.test.tsx +58 -0
  8. package/src/components/Box/Box.test.tsx +43 -0
  9. package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
  10. package/src/components/Button/Button.test.tsx +53 -0
  11. package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
  12. package/src/components/Card/Card.test.tsx +71 -0
  13. package/src/components/Chart/Chart.test.tsx +123 -0
  14. package/src/components/Checkbox/Checkbox.test.tsx +63 -0
  15. package/src/components/Chip/Chip.module.scss +54 -1
  16. package/src/components/Chip/Chip.test.tsx +50 -0
  17. package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
  18. package/src/components/Collapsible/Collapsible.test.tsx +103 -0
  19. package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
  20. package/src/components/ColorPicker/index.tsx +4 -1
  21. package/src/components/Combobox/Combobox.test.tsx +202 -0
  22. package/src/components/ConversationList/ConversationList.test.tsx +79 -0
  23. package/src/components/Dialog/Dialog.test.tsx +277 -0
  24. package/src/components/EmptyState/EmptyState.test.tsx +67 -0
  25. package/src/components/Field/Field.test.tsx +65 -0
  26. package/src/components/Fieldset/Fieldset.test.tsx +48 -0
  27. package/src/components/Form/Form.test.tsx +41 -0
  28. package/src/components/Grid/Grid.test.tsx +65 -0
  29. package/src/components/Header/Header.test.tsx +83 -0
  30. package/src/components/Icon/Icon.test.tsx +38 -0
  31. package/src/components/Image/Image.test.tsx +39 -0
  32. package/src/components/Input/Input.test.tsx +72 -0
  33. package/src/components/Link/Link.test.tsx +37 -0
  34. package/src/components/List/List.test.tsx +57 -0
  35. package/src/components/Listbox/Listbox.module.scss +2 -1
  36. package/src/components/Listbox/Listbox.test.tsx +100 -0
  37. package/src/components/Listbox/index.tsx +26 -3
  38. package/src/components/Loading/Loading.test.tsx +38 -0
  39. package/src/components/Markdown/Markdown.test.tsx +41 -0
  40. package/src/components/Menu/Menu.test.tsx +336 -0
  41. package/src/components/Message/Message.test.tsx +75 -0
  42. package/src/components/Popover/Popover.test.tsx +105 -0
  43. package/src/components/Progress/Progress.test.tsx +58 -0
  44. package/src/components/Prompt/Prompt.test.tsx +89 -0
  45. package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
  46. package/src/components/Select/Select.test.tsx +161 -0
  47. package/src/components/Separator/Separator.test.tsx +33 -0
  48. package/src/components/Sidebar/Sidebar.test.tsx +85 -0
  49. package/src/components/Skeleton/Skeleton.test.tsx +56 -0
  50. package/src/components/Slider/Slider.test.tsx +51 -0
  51. package/src/components/Stack/Stack.test.tsx +47 -0
  52. package/src/components/Table/Table.test.tsx +129 -0
  53. package/src/components/Tabs/Tabs.test.tsx +180 -0
  54. package/src/components/Text/Text.test.tsx +40 -0
  55. package/src/components/Textarea/Textarea.test.tsx +57 -0
  56. package/src/components/Theme/Theme.test.tsx +114 -0
  57. package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
  58. package/src/components/Toast/Toast.test.tsx +192 -0
  59. package/src/components/Toast/index.tsx +14 -4
  60. package/src/components/Toggle/Toggle.test.tsx +49 -0
  61. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
  62. package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
  63. package/src/components/ToggleGroup/index.tsx +17 -2
  64. package/src/components/Tooltip/Tooltip.test.tsx +107 -0
  65. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  66. package/src/test/setup.ts +74 -0
  67. package/src/test/utils.tsx +71 -0
  68. 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={onResume}
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) => clearTimeout(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) => clearTimeout(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={isSelected ? 0 : -1}
194
+ tabIndex={isTabbableSelected || isFirstFallback ? 0 : -1}
180
195
  disabled={disabled}
181
196
  onClick={handleClick}
182
197
  onKeyDown={handleKeyDown}