@fragments-sdk/ui 0.7.4 → 0.8.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/README.md +58 -25
- package/fragments.json +1 -1
- package/package.json +22 -5
- package/src/blocks/AppShell.block.ts +2 -2
- package/src/blocks/InsetDashboardLayout.block.ts +1 -1
- package/src/blocks/LoginForm.block.ts +14 -7
- package/src/components/Accordion/Accordion.fragment.tsx +8 -2
- package/src/components/Accordion/Accordion.test.tsx +171 -0
- package/src/components/Alert/Alert.module.scss +4 -4
- package/src/components/Alert/Alert.test.tsx +127 -0
- package/src/components/AppShell/AppShell.fragment.tsx +1 -1
- package/src/components/AppShell/AppShell.test.tsx +80 -0
- package/src/components/AppShell/index.tsx +2 -0
- package/src/components/Avatar/Avatar.fragment.tsx +5 -1
- package/src/components/Avatar/Avatar.module.scss +1 -1
- package/src/components/Avatar/Avatar.test.tsx +40 -0
- package/src/components/Avatar/index.tsx +37 -1
- package/src/components/Badge/Badge.fragment.tsx +3 -3
- package/src/components/Badge/Badge.module.scss +4 -4
- package/src/components/Badge/Badge.test.tsx +58 -0
- package/src/components/Badge/index.tsx +5 -1
- package/src/components/Box/Box.test.tsx +43 -0
- package/src/components/Box/index.tsx +5 -1
- package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
- package/src/components/Button/Button.fragment.tsx +17 -16
- package/src/components/Button/Button.test.tsx +53 -0
- package/src/components/Button/index.tsx +5 -1
- package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
- package/src/components/ButtonGroup/index.tsx +5 -1
- package/src/components/Card/Card.fragment.tsx +5 -5
- package/src/components/Card/Card.test.tsx +71 -0
- package/src/components/Chart/Chart.fragment.tsx +9 -1
- package/src/components/Chart/Chart.test.tsx +123 -0
- package/src/components/Chart/index.tsx +22 -4
- package/src/components/Checkbox/Checkbox.test.tsx +63 -0
- package/src/components/Checkbox/index.tsx +5 -1
- package/src/components/Chip/Chip.fragment.tsx +0 -5
- package/src/components/Chip/Chip.module.scss +55 -2
- package/src/components/Chip/Chip.test.tsx +50 -0
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +9 -3
- package/src/components/CodeBlock/CodeBlock.module.scss +1 -1
- 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 +9 -2
- package/src/components/Combobox/Combobox.fragment.tsx +15 -7
- package/src/components/Combobox/Combobox.test.tsx +202 -0
- package/src/components/ConversationList/ConversationList.fragment.tsx +3 -3
- package/src/components/ConversationList/ConversationList.module.scss +1 -1
- package/src/components/ConversationList/ConversationList.test.tsx +79 -0
- package/src/components/DatePicker/DatePicker.fragment.tsx +245 -0
- package/src/components/DatePicker/DatePicker.module.scss +394 -0
- package/src/components/DatePicker/DatePicker.test.tsx +264 -0
- package/src/components/DatePicker/index.tsx +535 -0
- package/src/components/Dialog/Dialog.test.tsx +277 -0
- package/src/components/EmptyState/EmptyState.test.tsx +67 -0
- package/src/components/Field/Field.fragment.tsx +5 -4
- package/src/components/Field/Field.test.tsx +65 -0
- package/src/components/Fieldset/Fieldset.fragment.tsx +5 -4
- package/src/components/Fieldset/Fieldset.test.tsx +48 -0
- package/src/components/Form/Form.fragment.tsx +9 -3
- package/src/components/Form/Form.test.tsx +41 -0
- package/src/components/Form/index.tsx +5 -1
- package/src/components/Grid/Grid.fragment.tsx +4 -0
- package/src/components/Grid/Grid.test.tsx +65 -0
- package/src/components/Header/Header.fragment.tsx +36 -13
- package/src/components/Header/Header.module.scss +114 -1
- package/src/components/Header/Header.test.tsx +188 -0
- package/src/components/Header/index.tsx +100 -31
- package/src/components/Icon/Icon.fragment.tsx +6 -1
- package/src/components/Icon/Icon.test.tsx +38 -0
- package/src/components/Icon/index.tsx +5 -1
- package/src/components/Image/Image.fragment.tsx +2 -2
- package/src/components/Image/Image.test.tsx +39 -0
- package/src/components/Image/index.tsx +5 -1
- package/src/components/Input/Input.fragment.tsx +21 -3
- package/src/components/Input/Input.module.scss +1 -1
- package/src/components/Input/Input.test.tsx +72 -0
- package/src/components/Input/index.tsx +5 -1
- package/src/components/Link/Link.fragment.tsx +0 -4
- package/src/components/Link/Link.test.tsx +37 -0
- package/src/components/Link/index.tsx +5 -1
- package/src/components/List/List.test.tsx +57 -0
- package/src/components/Listbox/Listbox.fragment.tsx +0 -12
- 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.module.scss +6 -3
- package/src/components/Markdown/Markdown.test.tsx +41 -0
- package/src/components/Markdown/index.tsx +5 -1
- package/src/components/Menu/Menu.test.tsx +336 -0
- package/src/components/Message/Message.fragment.tsx +8 -6
- package/src/components/Message/Message.module.scss +1 -1
- package/src/components/Message/Message.test.tsx +75 -0
- package/src/components/Popover/Popover.test.tsx +105 -0
- package/src/components/Progress/Progress.fragment.tsx +14 -0
- package/src/components/Progress/Progress.test.tsx +58 -0
- package/src/components/Progress/index.tsx +9 -2
- package/src/components/Prompt/Prompt.fragment.tsx +11 -0
- package/src/components/Prompt/Prompt.test.tsx +89 -0
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +5 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
- package/src/components/ScrollArea/ScrollArea.fragment.tsx +185 -0
- package/src/components/ScrollArea/ScrollArea.module.scss +136 -0
- package/src/components/ScrollArea/ScrollArea.test.tsx +38 -0
- package/src/components/ScrollArea/index.tsx +121 -0
- package/src/components/Select/Select.fragment.tsx +13 -5
- package/src/components/Select/Select.test.tsx +161 -0
- package/src/components/Separator/Separator.test.tsx +33 -0
- package/src/components/Separator/index.tsx +5 -1
- package/src/components/Sidebar/Sidebar.fragment.tsx +64 -11
- package/src/components/Sidebar/Sidebar.module.scss +68 -16
- package/src/components/Sidebar/Sidebar.test.tsx +114 -0
- package/src/components/Sidebar/index.tsx +69 -45
- package/src/components/Skeleton/Skeleton.fragment.tsx +5 -0
- package/src/components/Skeleton/Skeleton.test.tsx +56 -0
- package/src/components/Slider/Slider.test.tsx +51 -0
- package/src/components/Slider/index.tsx +5 -1
- package/src/components/Stack/Stack.fragment.tsx +2 -2
- package/src/components/Stack/Stack.test.tsx +47 -0
- package/src/components/Stack/index.tsx +5 -1
- package/src/components/Table/Table.fragment.tsx +29 -0
- package/src/components/Table/Table.test.tsx +129 -0
- package/src/components/Table/index.tsx +6 -1
- package/src/components/TableOfContents/TableOfContents.fragment.tsx +149 -0
- package/src/components/TableOfContents/TableOfContents.module.scss +71 -0
- package/src/components/TableOfContents/TableOfContents.test.tsx +126 -0
- package/src/components/TableOfContents/index.tsx +105 -0
- package/src/components/Tabs/Tabs.test.tsx +180 -0
- package/src/components/Text/Text.test.tsx +40 -0
- package/src/components/Text/index.tsx +5 -1
- package/src/components/Textarea/Textarea.fragment.tsx +8 -0
- package/src/components/Textarea/Textarea.test.tsx +57 -0
- package/src/components/Textarea/index.tsx +5 -1
- package/src/components/Theme/Theme.test.tsx +114 -0
- package/src/components/Theme/index.tsx +7 -0
- package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +3 -2
- package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
- package/src/components/Toast/Toast.fragment.tsx +12 -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/Toggle/index.tsx +5 -1
- 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.fragment.tsx +18 -0
- package/src/components/Tooltip/Tooltip.test.tsx +107 -0
- package/src/components/Tooltip/index.tsx +6 -1
- package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/components/VisuallyHidden/index.tsx +5 -1
- package/src/components/compound-pattern.test.ts +40 -0
- package/src/index.ts +29 -0
- package/src/recipes/AppShell.recipe.ts +2 -2
- package/src/recipes/LoginForm.recipe.ts +14 -7
- package/src/test/setup.ts +74 -0
- package/src/test/utils.tsx +71 -0
- package/src/tokens/_computed.scss +12 -0
- package/src/tokens/_derive.scss +71 -0
- package/src/tokens/_variables.scss +22 -0
- package/src/utils/a11y.test.tsx +79 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, waitFor, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Dialog } from './index';
|
|
4
|
+
|
|
5
|
+
function renderDialog(props: Partial<React.ComponentProps<typeof Dialog>> = {}) {
|
|
6
|
+
return render(
|
|
7
|
+
<Dialog {...props}>
|
|
8
|
+
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
|
|
9
|
+
<Dialog.Content>
|
|
10
|
+
<Dialog.Header>
|
|
11
|
+
<Dialog.Title>Dialog Title</Dialog.Title>
|
|
12
|
+
<Dialog.Close />
|
|
13
|
+
</Dialog.Header>
|
|
14
|
+
<Dialog.Body>
|
|
15
|
+
<Dialog.Description>Dialog description text</Dialog.Description>
|
|
16
|
+
<p>Body content</p>
|
|
17
|
+
</Dialog.Body>
|
|
18
|
+
<Dialog.Footer>
|
|
19
|
+
<Dialog.Close asChild>
|
|
20
|
+
<button>Cancel</button>
|
|
21
|
+
</Dialog.Close>
|
|
22
|
+
</Dialog.Footer>
|
|
23
|
+
</Dialog.Content>
|
|
24
|
+
</Dialog>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Dialog', () => {
|
|
29
|
+
it('opens when trigger is clicked', async () => {
|
|
30
|
+
const user = userEvent.setup();
|
|
31
|
+
renderDialog();
|
|
32
|
+
|
|
33
|
+
expect(screen.queryByText('Dialog Title')).not.toBeInTheDocument();
|
|
34
|
+
|
|
35
|
+
await user.click(screen.getByRole('button', { name: /open dialog/i }));
|
|
36
|
+
await waitFor(() => {
|
|
37
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('closes when close button is clicked', async () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
renderDialog({ defaultOpen: true });
|
|
44
|
+
|
|
45
|
+
await waitFor(() => {
|
|
46
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const closeButton = screen.getByRole('button', { name: /close dialog/i });
|
|
50
|
+
await user.click(closeButton);
|
|
51
|
+
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(screen.queryByText('Dialog Title')).not.toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('close button has aria-label', async () => {
|
|
58
|
+
renderDialog({ defaultOpen: true });
|
|
59
|
+
|
|
60
|
+
await waitFor(() => {
|
|
61
|
+
expect(screen.getByRole('button', { name: /close dialog/i })).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders title and description', async () => {
|
|
66
|
+
renderDialog({ defaultOpen: true });
|
|
67
|
+
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText('Dialog description text')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('renders compound sub-components (Header, Body, Footer)', async () => {
|
|
75
|
+
renderDialog({ defaultOpen: true });
|
|
76
|
+
|
|
77
|
+
await waitFor(() => {
|
|
78
|
+
expect(screen.getByText('Body content')).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('supports size variant prop', async () => {
|
|
84
|
+
render(
|
|
85
|
+
<Dialog defaultOpen>
|
|
86
|
+
<Dialog.Trigger>Open</Dialog.Trigger>
|
|
87
|
+
<Dialog.Content size="lg">
|
|
88
|
+
<Dialog.Title>Large Dialog</Dialog.Title>
|
|
89
|
+
</Dialog.Content>
|
|
90
|
+
</Dialog>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(screen.getByText('Large Dialog')).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('renders as modal by default', async () => {
|
|
99
|
+
renderDialog({ defaultOpen: true });
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('has no accessibility violations when open', async () => {
|
|
107
|
+
const { container } = renderDialog({ defaultOpen: true });
|
|
108
|
+
|
|
109
|
+
await waitFor(() => {
|
|
110
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await expectNoA11yViolations(container, {
|
|
114
|
+
// Base UI focus guard spans have role="button" without labels.
|
|
115
|
+
disabledRules: ['aria-command-name'],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('keyboard & focus', () => {
|
|
120
|
+
it('Escape closes dialog (WCAG 2.1.1)', async () => {
|
|
121
|
+
const user = userEvent.setup();
|
|
122
|
+
renderDialog({ defaultOpen: true });
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await user.keyboard('{Escape}');
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(screen.queryByText('Dialog Title')).not.toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('focus moves into dialog on open (WCAG 2.4.3)', async () => {
|
|
136
|
+
const user = userEvent.setup();
|
|
137
|
+
renderDialog();
|
|
138
|
+
|
|
139
|
+
await user.click(screen.getByRole('button', { name: /open dialog/i }));
|
|
140
|
+
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await waitFor(() => {
|
|
146
|
+
const dialog = screen.getByRole('dialog');
|
|
147
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('focus returns to trigger on Escape (WCAG 2.4.3)', async () => {
|
|
152
|
+
const user = userEvent.setup();
|
|
153
|
+
renderDialog();
|
|
154
|
+
|
|
155
|
+
const trigger = screen.getByRole('button', { name: /open dialog/i });
|
|
156
|
+
await user.click(trigger);
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await user.keyboard('{Escape}');
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(screen.queryByText('Dialog Title')).not.toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await waitFor(() => {
|
|
169
|
+
expect(trigger).toHaveFocus();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('focus returns to trigger on Close button click (WCAG 2.4.3)', async () => {
|
|
174
|
+
const user = userEvent.setup();
|
|
175
|
+
renderDialog();
|
|
176
|
+
|
|
177
|
+
const trigger = screen.getByRole('button', { name: /open dialog/i });
|
|
178
|
+
await user.click(trigger);
|
|
179
|
+
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const closeButton = screen.getByRole('button', { name: /close dialog/i });
|
|
185
|
+
await user.click(closeButton);
|
|
186
|
+
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(screen.queryByText('Dialog Title')).not.toBeInTheDocument();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await waitFor(() => {
|
|
192
|
+
expect(trigger).toHaveFocus();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('Tab cycles within dialog (focus trap) (WCAG 2.1.2)', async () => {
|
|
197
|
+
const user = userEvent.setup();
|
|
198
|
+
renderDialog({ defaultOpen: true });
|
|
199
|
+
|
|
200
|
+
await waitFor(() => {
|
|
201
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const dialog = screen.getByRole('dialog');
|
|
205
|
+
const closeButton = screen.getByRole('button', { name: /close dialog/i });
|
|
206
|
+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
|
207
|
+
|
|
208
|
+
// Tab through focusable elements — focus should stay inside dialog
|
|
209
|
+
await user.tab();
|
|
210
|
+
await user.tab();
|
|
211
|
+
await user.tab();
|
|
212
|
+
|
|
213
|
+
await waitFor(() => {
|
|
214
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Verify that close and cancel buttons are reachable
|
|
218
|
+
// Focus one of them directly and confirm containment
|
|
219
|
+
closeButton.focus();
|
|
220
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
221
|
+
|
|
222
|
+
cancelButton.focus();
|
|
223
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('Shift+Tab cycles backward (WCAG 2.1.2)', async () => {
|
|
227
|
+
const user = userEvent.setup();
|
|
228
|
+
renderDialog({ defaultOpen: true });
|
|
229
|
+
|
|
230
|
+
await waitFor(() => {
|
|
231
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const dialog = screen.getByRole('dialog');
|
|
235
|
+
|
|
236
|
+
// Shift+Tab backward multiple times — focus should remain trapped
|
|
237
|
+
await user.keyboard('{Shift>}{Tab}{/Shift}');
|
|
238
|
+
await user.keyboard('{Shift>}{Tab}{/Shift}');
|
|
239
|
+
await user.keyboard('{Shift>}{Tab}{/Shift}');
|
|
240
|
+
|
|
241
|
+
await waitFor(() => {
|
|
242
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('role="dialog" is present when open (WCAG 4.1.2)', async () => {
|
|
247
|
+
renderDialog({ defaultOpen: true });
|
|
248
|
+
|
|
249
|
+
await waitFor(() => {
|
|
250
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('Cancel button closes dialog and returns focus (WCAG 2.4.3)', async () => {
|
|
255
|
+
const user = userEvent.setup();
|
|
256
|
+
renderDialog();
|
|
257
|
+
|
|
258
|
+
const trigger = screen.getByRole('button', { name: /open dialog/i });
|
|
259
|
+
await user.click(trigger);
|
|
260
|
+
|
|
261
|
+
await waitFor(() => {
|
|
262
|
+
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
|
266
|
+
await user.click(cancelButton);
|
|
267
|
+
|
|
268
|
+
await waitFor(() => {
|
|
269
|
+
expect(screen.queryByText('Dialog Title')).not.toBeInTheDocument();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await waitFor(() => {
|
|
273
|
+
expect(trigger).toHaveFocus();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { EmptyState } from './index';
|
|
4
|
+
|
|
5
|
+
describe('EmptyState', () => {
|
|
6
|
+
it('renders children', () => {
|
|
7
|
+
render(
|
|
8
|
+
<EmptyState>
|
|
9
|
+
<EmptyState.Title>No results</EmptyState.Title>
|
|
10
|
+
</EmptyState>
|
|
11
|
+
);
|
|
12
|
+
expect(screen.getByText('No results')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('renders all compound sub-components', () => {
|
|
16
|
+
render(
|
|
17
|
+
<EmptyState>
|
|
18
|
+
<EmptyState.Icon>ICON</EmptyState.Icon>
|
|
19
|
+
<EmptyState.Title>Empty</EmptyState.Title>
|
|
20
|
+
<EmptyState.Description>Nothing to show</EmptyState.Description>
|
|
21
|
+
<EmptyState.Actions><button>Add item</button></EmptyState.Actions>
|
|
22
|
+
</EmptyState>
|
|
23
|
+
);
|
|
24
|
+
expect(screen.getByText('ICON')).toHaveClass('icon');
|
|
25
|
+
expect(screen.getByText('Empty').tagName).toBe('H3');
|
|
26
|
+
expect(screen.getByText('Nothing to show').tagName).toBe('P');
|
|
27
|
+
expect(screen.getByRole('button', { name: 'Add item' })).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('applies size class', () => {
|
|
31
|
+
const { container } = render(
|
|
32
|
+
<EmptyState size="lg">
|
|
33
|
+
<EmptyState.Title>Large</EmptyState.Title>
|
|
34
|
+
</EmptyState>
|
|
35
|
+
);
|
|
36
|
+
expect(container.firstElementChild).toHaveClass('lg');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('applies custom className', () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<EmptyState className="custom">
|
|
42
|
+
<EmptyState.Title>Custom</EmptyState.Title>
|
|
43
|
+
</EmptyState>
|
|
44
|
+
);
|
|
45
|
+
expect(container.firstElementChild).toHaveClass('custom');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('defaults to md size', () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<EmptyState>
|
|
51
|
+
<EmptyState.Title>Default</EmptyState.Title>
|
|
52
|
+
</EmptyState>
|
|
53
|
+
);
|
|
54
|
+
expect(container.firstElementChild).toHaveClass('md');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('has no accessibility violations', async () => {
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<EmptyState>
|
|
60
|
+
<EmptyState.Icon>ICON</EmptyState.Icon>
|
|
61
|
+
<EmptyState.Title>No items found</EmptyState.Title>
|
|
62
|
+
<EmptyState.Description>Try adjusting your search</EmptyState.Description>
|
|
63
|
+
</EmptyState>
|
|
64
|
+
);
|
|
65
|
+
await expectNoA11yViolations(container);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -42,6 +42,11 @@ export default defineSegment({
|
|
|
42
42
|
},
|
|
43
43
|
|
|
44
44
|
props: {
|
|
45
|
+
children: {
|
|
46
|
+
type: 'node',
|
|
47
|
+
description: 'Field content (Label, Control, Description, Error)',
|
|
48
|
+
required: true,
|
|
49
|
+
},
|
|
45
50
|
name: {
|
|
46
51
|
type: 'string',
|
|
47
52
|
description: 'Field name, used for error distribution from Form',
|
|
@@ -67,10 +72,6 @@ export default defineSegment({
|
|
|
67
72
|
type: 'number',
|
|
68
73
|
description: 'Debounce time in ms for onChange validation',
|
|
69
74
|
},
|
|
70
|
-
className: {
|
|
71
|
-
type: 'string',
|
|
72
|
-
description: 'Additional CSS class',
|
|
73
|
-
},
|
|
74
75
|
},
|
|
75
76
|
|
|
76
77
|
relations: [
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Field } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Field', () => {
|
|
6
|
+
it('renders children inside the field root', () => {
|
|
7
|
+
render(
|
|
8
|
+
<Field>
|
|
9
|
+
<Field.Label>Username</Field.Label>
|
|
10
|
+
<Field.Control><input /></Field.Control>
|
|
11
|
+
</Field>
|
|
12
|
+
);
|
|
13
|
+
expect(screen.getByText('Username')).toBeInTheDocument();
|
|
14
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('associates label with control', () => {
|
|
18
|
+
render(
|
|
19
|
+
<Field>
|
|
20
|
+
<Field.Label>Username</Field.Label>
|
|
21
|
+
<Field.Control><input /></Field.Control>
|
|
22
|
+
</Field>
|
|
23
|
+
);
|
|
24
|
+
expect(screen.getByRole('textbox')).toHaveAccessibleName('Username');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders description text', () => {
|
|
28
|
+
render(
|
|
29
|
+
<Field>
|
|
30
|
+
<Field.Label>Email</Field.Label>
|
|
31
|
+
<Field.Control><input /></Field.Control>
|
|
32
|
+
<Field.Description>We will never share your email.</Field.Description>
|
|
33
|
+
</Field>
|
|
34
|
+
);
|
|
35
|
+
expect(screen.getByText('We will never share your email.')).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('sets aria-invalid on control when invalid', () => {
|
|
39
|
+
render(
|
|
40
|
+
<Field invalid>
|
|
41
|
+
<Field.Label>Email</Field.Label>
|
|
42
|
+
<Field.Control><input /></Field.Control>
|
|
43
|
+
</Field>
|
|
44
|
+
);
|
|
45
|
+
expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('exposes compound component pattern', () => {
|
|
49
|
+
expect(Field.Label).toBeDefined();
|
|
50
|
+
expect(Field.Control).toBeDefined();
|
|
51
|
+
expect(Field.Description).toBeDefined();
|
|
52
|
+
expect(Field.Error).toBeDefined();
|
|
53
|
+
expect(Field.Validity).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('has no accessibility violations', async () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<Field>
|
|
59
|
+
<Field.Label>Accessible field</Field.Label>
|
|
60
|
+
<Field.Control><input /></Field.Control>
|
|
61
|
+
</Field>
|
|
62
|
+
);
|
|
63
|
+
await expectNoA11yViolations(container);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -43,14 +43,15 @@ export default defineSegment({
|
|
|
43
43
|
},
|
|
44
44
|
|
|
45
45
|
props: {
|
|
46
|
+
children: {
|
|
47
|
+
type: 'node',
|
|
48
|
+
description: 'Fieldset content including Fieldset.Legend and form fields',
|
|
49
|
+
required: true,
|
|
50
|
+
},
|
|
46
51
|
disabled: {
|
|
47
52
|
type: 'boolean',
|
|
48
53
|
description: 'Disables all fields within the fieldset',
|
|
49
54
|
},
|
|
50
|
-
className: {
|
|
51
|
-
type: 'string',
|
|
52
|
-
description: 'Additional CSS class',
|
|
53
|
-
},
|
|
54
55
|
},
|
|
55
56
|
|
|
56
57
|
relations: [
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Fieldset } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Fieldset', () => {
|
|
6
|
+
it('renders a fieldset element', () => {
|
|
7
|
+
render(
|
|
8
|
+
<Fieldset>
|
|
9
|
+
<Fieldset.Legend>Personal Info</Fieldset.Legend>
|
|
10
|
+
<input aria-label="Name" />
|
|
11
|
+
</Fieldset>
|
|
12
|
+
);
|
|
13
|
+
// Base UI renders a fieldset element
|
|
14
|
+
const fieldset = screen.getByRole('group');
|
|
15
|
+
expect(fieldset).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders a legend', () => {
|
|
19
|
+
render(
|
|
20
|
+
<Fieldset>
|
|
21
|
+
<Fieldset.Legend>Contact Details</Fieldset.Legend>
|
|
22
|
+
</Fieldset>
|
|
23
|
+
);
|
|
24
|
+
expect(screen.getByText('Contact Details')).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('passes disabled prop to fieldset element', () => {
|
|
28
|
+
const { container } = render(
|
|
29
|
+
<Fieldset disabled>
|
|
30
|
+
<Fieldset.Legend>Settings</Fieldset.Legend>
|
|
31
|
+
<input aria-label="Option" />
|
|
32
|
+
</Fieldset>
|
|
33
|
+
);
|
|
34
|
+
const fieldset = container.querySelector('fieldset');
|
|
35
|
+
expect(fieldset).toBeInTheDocument();
|
|
36
|
+
expect(fieldset).toHaveAttribute('data-disabled', '');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('has no accessibility violations', async () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<Fieldset>
|
|
42
|
+
<Fieldset.Legend>Accessible fieldset</Fieldset.Legend>
|
|
43
|
+
<input aria-label="Field" />
|
|
44
|
+
</Fieldset>
|
|
45
|
+
);
|
|
46
|
+
await expectNoA11yViolations(container);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -48,6 +48,11 @@ export default defineSegment({
|
|
|
48
48
|
},
|
|
49
49
|
|
|
50
50
|
props: {
|
|
51
|
+
children: {
|
|
52
|
+
type: 'node',
|
|
53
|
+
description: 'Form content',
|
|
54
|
+
required: true,
|
|
55
|
+
},
|
|
51
56
|
errors: {
|
|
52
57
|
type: 'object',
|
|
53
58
|
description: 'Server-side errors keyed by field name',
|
|
@@ -60,9 +65,10 @@ export default defineSegment({
|
|
|
60
65
|
type: 'function',
|
|
61
66
|
description: 'Called with field name when errors should be cleared',
|
|
62
67
|
},
|
|
63
|
-
|
|
64
|
-
type: '
|
|
65
|
-
description: '
|
|
68
|
+
validationMode: {
|
|
69
|
+
type: 'enum',
|
|
70
|
+
description: 'When field validation should run',
|
|
71
|
+
values: ['onSubmit', 'onBlur', 'onChange'],
|
|
66
72
|
},
|
|
67
73
|
},
|
|
68
74
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Form } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Form', () => {
|
|
6
|
+
it('renders a form element', () => {
|
|
7
|
+
const { container } = render(<Form>content</Form>);
|
|
8
|
+
expect(container.querySelector('form')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('calls onFormSubmit when submitted', async () => {
|
|
12
|
+
const handleSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
|
|
13
|
+
const user = userEvent.setup();
|
|
14
|
+
render(
|
|
15
|
+
<Form onFormSubmit={handleSubmit} aria-label="Test form">
|
|
16
|
+
<button type="submit">Submit</button>
|
|
17
|
+
</Form>
|
|
18
|
+
);
|
|
19
|
+
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
|
20
|
+
expect(handleSubmit).toHaveBeenCalledTimes(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders children', () => {
|
|
24
|
+
render(
|
|
25
|
+
<Form aria-label="Test form">
|
|
26
|
+
<span>Child content</span>
|
|
27
|
+
</Form>
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByText('Child content')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('has no accessibility violations', async () => {
|
|
33
|
+
const { container } = render(
|
|
34
|
+
<Form aria-label="Accessible form">
|
|
35
|
+
<label htmlFor="f">Name</label>
|
|
36
|
+
<input id="f" />
|
|
37
|
+
</Form>
|
|
38
|
+
);
|
|
39
|
+
await expectNoA11yViolations(container);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -19,7 +19,7 @@ export interface FormProps extends Omit<React.HTMLAttributes<HTMLFormElement>, '
|
|
|
19
19
|
// Component
|
|
20
20
|
// ============================================
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
function FormRoot({
|
|
23
23
|
children,
|
|
24
24
|
errors,
|
|
25
25
|
onFormSubmit,
|
|
@@ -51,3 +51,7 @@ export function Form({
|
|
|
51
51
|
</BaseForm>
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
export const Form = Object.assign(FormRoot, {
|
|
56
|
+
Root: FormRoot,
|
|
57
|
+
});
|
|
@@ -39,6 +39,10 @@ export default defineSegment({
|
|
|
39
39
|
},
|
|
40
40
|
|
|
41
41
|
props: {
|
|
42
|
+
children: {
|
|
43
|
+
type: 'node',
|
|
44
|
+
description: 'Grid items and content',
|
|
45
|
+
},
|
|
42
46
|
columns: {
|
|
43
47
|
type: 'union',
|
|
44
48
|
description: 'Number of columns: a number (1-12), a responsive object { base, sm, md, lg, xl }, or "auto" for auto-fill',
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Grid } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Grid', () => {
|
|
6
|
+
it('renders children', () => {
|
|
7
|
+
render(
|
|
8
|
+
<Grid>
|
|
9
|
+
<div>Item 1</div>
|
|
10
|
+
<div>Item 2</div>
|
|
11
|
+
</Grid>
|
|
12
|
+
);
|
|
13
|
+
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
14
|
+
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('applies fixed column class', () => {
|
|
18
|
+
const { container } = render(<Grid columns={3}>Content</Grid>);
|
|
19
|
+
expect(container.firstChild).toHaveClass('columns3');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('applies responsive column CSS variables', () => {
|
|
23
|
+
const { container } = render(
|
|
24
|
+
<Grid columns={{ base: 1, md: 2, lg: 3 }}>Content</Grid>
|
|
25
|
+
);
|
|
26
|
+
const el = container.firstChild as HTMLElement;
|
|
27
|
+
expect(el).toHaveClass('columnsResponsive');
|
|
28
|
+
expect(el.style.getPropertyValue('--fui-grid-cols')).toBe('1');
|
|
29
|
+
expect(el.style.getPropertyValue('--fui-grid-cols-md')).toBe('2');
|
|
30
|
+
expect(el.style.getPropertyValue('--fui-grid-cols-lg')).toBe('3');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('applies gap classes', () => {
|
|
34
|
+
const { container } = render(<Grid gap="lg">Content</Grid>);
|
|
35
|
+
expect(container.firstChild).toHaveClass('gapLg');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('forwards ref', () => {
|
|
39
|
+
const ref = vi.fn();
|
|
40
|
+
render(<Grid ref={ref}>Content</Grid>);
|
|
41
|
+
expect(ref).toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('renders Grid.Item with colSpan', () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<Grid columns={3}>
|
|
47
|
+
<Grid.Item colSpan={2}>Wide</Grid.Item>
|
|
48
|
+
<Grid.Item>Normal</Grid.Item>
|
|
49
|
+
</Grid>
|
|
50
|
+
);
|
|
51
|
+
const wideItem = screen.getByText('Wide').parentElement ?? screen.getByText('Wide');
|
|
52
|
+
// The item that contains "Wide" should have colSpan class
|
|
53
|
+
expect(container.querySelector('.colSpan2')).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('has no accessibility violations', async () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<Grid columns={2}>
|
|
59
|
+
<Grid.Item>A</Grid.Item>
|
|
60
|
+
<Grid.Item>B</Grid.Item>
|
|
61
|
+
</Grid>
|
|
62
|
+
);
|
|
63
|
+
await expectNoA11yViolations(container);
|
|
64
|
+
});
|
|
65
|
+
});
|