@fragments-sdk/ui 0.7.3 → 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 +13 -2
- package/src/components/Accordion/Accordion.test.tsx +171 -0
- package/src/components/Alert/Alert.test.tsx +127 -0
- package/src/components/AppShell/AppShell.module.scss +5 -5
- package/src/components/AppShell/AppShell.test.tsx +80 -0
- package/src/components/Avatar/Avatar.test.tsx +40 -0
- package/src/components/Avatar/index.tsx +11 -1
- 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.module.scss +3 -3
- 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.module.scss +5 -0
- package/src/components/Chart/Chart.test.tsx +123 -0
- package/src/components/Chart/index.tsx +34 -0
- package/src/components/Checkbox/Checkbox.test.tsx +63 -0
- package/src/components/Checkbox/index.tsx +29 -4
- package/src/components/Chip/Chip.module.scss +73 -7
- package/src/components/Chip/Chip.test.tsx +50 -0
- package/src/components/Chip/index.tsx +36 -26
- package/src/components/CodeBlock/CodeBlock.module.scss +6 -6
- package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
- package/src/components/CodeBlock/index.tsx +4 -1
- 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.module.scss +4 -4
- package/src/components/ConversationList/ConversationList.test.tsx +79 -0
- package/src/components/Dialog/Dialog.test.tsx +277 -0
- package/src/components/Dialog/index.tsx +14 -6
- package/src/components/EmptyState/EmptyState.module.scss +3 -3
- 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.module.scss +3 -3
- package/src/components/Grid/Grid.test.tsx +65 -0
- package/src/components/Header/Header.module.scss +4 -4
- 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/Input/index.tsx +15 -1
- 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 +16 -5
- package/src/components/Listbox/Listbox.test.tsx +100 -0
- package/src/components/Listbox/index.tsx +186 -13
- package/src/components/Loading/Loading.test.tsx +38 -0
- package/src/components/Markdown/Markdown.module.scss +3 -3
- package/src/components/Markdown/Markdown.test.tsx +41 -0
- package/src/components/Menu/Menu.module.scss +3 -3
- package/src/components/Menu/Menu.test.tsx +336 -0
- package/src/components/Message/Message.module.scss +9 -3
- package/src/components/Message/Message.test.tsx +75 -0
- package/src/components/Popover/Popover.test.tsx +105 -0
- package/src/components/Popover/index.tsx +4 -1
- package/src/components/Progress/Progress.test.tsx +58 -0
- package/src/components/Prompt/Prompt.module.scss +6 -5
- package/src/components/Prompt/Prompt.test.tsx +89 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
- package/src/components/RadioGroup/index.tsx +37 -4
- package/src/components/Select/Select.test.tsx +161 -0
- package/src/components/Select/index.tsx +3 -2
- package/src/components/Separator/Separator.test.tsx +33 -0
- package/src/components/Sidebar/Sidebar.module.scss +32 -22
- package/src/components/Sidebar/Sidebar.test.tsx +85 -0
- package/src/components/Sidebar/index.tsx +31 -9
- package/src/components/Skeleton/Skeleton.test.tsx +56 -0
- package/src/components/Slider/Slider.test.tsx +51 -0
- package/src/components/Slider/index.tsx +13 -3
- package/src/components/Stack/Stack.test.tsx +47 -0
- package/src/components/Table/Table.module.scss +20 -11
- package/src/components/Table/Table.test.tsx +129 -0
- package/src/components/Table/index.tsx +52 -30
- 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/Textarea/index.tsx +22 -2
- 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 +124 -20
- 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 +70 -1
- package/src/components/Tooltip/Tooltip.module.scss +1 -1
- 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/tokens/_computed.scss +2 -0
- package/src/tokens/_density.scss +4 -0
- package/src/tokens/_derive.scss +16 -16
- package/src/tokens/_variables.scss +8 -2
- package/src/utils/a11y.test.tsx +79 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Combobox } from './index';
|
|
4
|
+
|
|
5
|
+
function renderCombobox(props: { onValueChange?: (v: string | string[] | null) => void; multiple?: boolean; placeholder?: string } = {}) {
|
|
6
|
+
return render(
|
|
7
|
+
<Combobox
|
|
8
|
+
placeholder={props.placeholder ?? 'Search...'}
|
|
9
|
+
onValueChange={props.onValueChange}
|
|
10
|
+
multiple={props.multiple}
|
|
11
|
+
>
|
|
12
|
+
<Combobox.Input />
|
|
13
|
+
<Combobox.Content>
|
|
14
|
+
<Combobox.Item value="react">React</Combobox.Item>
|
|
15
|
+
<Combobox.Item value="vue">Vue</Combobox.Item>
|
|
16
|
+
<Combobox.Item value="angular">Angular</Combobox.Item>
|
|
17
|
+
<Combobox.Empty>No results found</Combobox.Empty>
|
|
18
|
+
</Combobox.Content>
|
|
19
|
+
</Combobox>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Combobox', () => {
|
|
24
|
+
it('renders an input and a trigger button', () => {
|
|
25
|
+
renderCombobox();
|
|
26
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shows placeholder text', () => {
|
|
30
|
+
renderCombobox({ placeholder: 'Type to search' });
|
|
31
|
+
expect(screen.getByPlaceholderText('Type to search')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('opens dropdown and shows options when trigger is clicked', async () => {
|
|
35
|
+
const user = userEvent.setup();
|
|
36
|
+
renderCombobox();
|
|
37
|
+
// Click the combobox input to open
|
|
38
|
+
await user.click(screen.getByRole('combobox'));
|
|
39
|
+
expect(await screen.findByRole('option', { name: 'React' })).toBeInTheDocument();
|
|
40
|
+
expect(await screen.findByRole('option', { name: 'Vue' })).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('selects an option on click', async () => {
|
|
44
|
+
const user = userEvent.setup();
|
|
45
|
+
const onChange = vi.fn();
|
|
46
|
+
renderCombobox({ onValueChange: onChange });
|
|
47
|
+
|
|
48
|
+
await user.click(screen.getByRole('combobox'));
|
|
49
|
+
const option = await screen.findByRole('option', { name: 'React' });
|
|
50
|
+
await user.click(option);
|
|
51
|
+
expect(onChange).toHaveBeenCalledWith('react');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('filters options based on input text', async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
renderCombobox();
|
|
57
|
+
|
|
58
|
+
const input = screen.getByRole('combobox');
|
|
59
|
+
await user.click(input);
|
|
60
|
+
await user.type(input, 'rea');
|
|
61
|
+
// React should be visible, Vue/Angular may be filtered out
|
|
62
|
+
expect(await screen.findByRole('option', { name: 'React' })).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders groups and group labels', async () => {
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
render(
|
|
68
|
+
<Combobox placeholder="Search">
|
|
69
|
+
<Combobox.Input />
|
|
70
|
+
<Combobox.Content>
|
|
71
|
+
<Combobox.Group>
|
|
72
|
+
<Combobox.GroupLabel>Frameworks</Combobox.GroupLabel>
|
|
73
|
+
<Combobox.Item value="react">React</Combobox.Item>
|
|
74
|
+
</Combobox.Group>
|
|
75
|
+
</Combobox.Content>
|
|
76
|
+
</Combobox>
|
|
77
|
+
);
|
|
78
|
+
await user.click(screen.getByRole('combobox'));
|
|
79
|
+
expect(await screen.findByText('Frameworks')).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('supports multiple selection mode', async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
const onChange = vi.fn();
|
|
85
|
+
renderCombobox({ multiple: true, onValueChange: onChange });
|
|
86
|
+
|
|
87
|
+
await user.click(screen.getByRole('combobox'));
|
|
88
|
+
const option = await screen.findByRole('option', { name: 'React' });
|
|
89
|
+
await user.click(option);
|
|
90
|
+
expect(onChange).toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('has no accessibility violations', async () => {
|
|
94
|
+
const { container } = render(
|
|
95
|
+
<Combobox placeholder="Search...">
|
|
96
|
+
<Combobox.Input aria-label="Search frameworks" />
|
|
97
|
+
<Combobox.Content>
|
|
98
|
+
<Combobox.Item value="react">React</Combobox.Item>
|
|
99
|
+
</Combobox.Content>
|
|
100
|
+
</Combobox>
|
|
101
|
+
);
|
|
102
|
+
// The internal Base UI trigger button lacks aria-label by design (it's a
|
|
103
|
+
// decorative chevron next to the labeled input). Disable button-name for
|
|
104
|
+
// this component-level test.
|
|
105
|
+
await expectNoA11yViolations(container, {
|
|
106
|
+
disabledRules: ['button-name'],
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('keyboard & focus', () => {
|
|
111
|
+
it('ArrowDown opens the dropdown from input', async () => {
|
|
112
|
+
const user = userEvent.setup();
|
|
113
|
+
renderCombobox();
|
|
114
|
+
|
|
115
|
+
const input = screen.getByRole('combobox');
|
|
116
|
+
await user.click(input);
|
|
117
|
+
// Close it first, then reopen with keyboard
|
|
118
|
+
await user.keyboard('{Escape}');
|
|
119
|
+
await user.keyboard('{ArrowDown}');
|
|
120
|
+
expect(await screen.findByRole('option', { name: 'React' })).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('options are rendered in listbox when open', async () => {
|
|
124
|
+
const user = userEvent.setup();
|
|
125
|
+
renderCombobox();
|
|
126
|
+
|
|
127
|
+
await user.click(screen.getByRole('combobox'));
|
|
128
|
+
expect(await screen.findByRole('option', { name: 'React' })).toBeInTheDocument();
|
|
129
|
+
expect(screen.getByRole('option', { name: 'Vue' })).toBeInTheDocument();
|
|
130
|
+
expect(screen.getByRole('option', { name: 'Angular' })).toBeInTheDocument();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('input has aria-expanded when dropdown is open', async () => {
|
|
134
|
+
const user = userEvent.setup();
|
|
135
|
+
renderCombobox();
|
|
136
|
+
|
|
137
|
+
const input = screen.getByRole('combobox');
|
|
138
|
+
await user.click(input);
|
|
139
|
+
await screen.findByRole('option', { name: 'React' });
|
|
140
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('clicking an option selects it', async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
const onChange = vi.fn();
|
|
146
|
+
renderCombobox({ onValueChange: onChange });
|
|
147
|
+
|
|
148
|
+
await user.click(screen.getByRole('combobox'));
|
|
149
|
+
const option = await screen.findByRole('option', { name: 'Vue' });
|
|
150
|
+
await user.click(option);
|
|
151
|
+
expect(onChange).toHaveBeenCalledWith('vue');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('Escape closes the dropdown', async () => {
|
|
155
|
+
const user = userEvent.setup();
|
|
156
|
+
renderCombobox();
|
|
157
|
+
|
|
158
|
+
await user.click(screen.getByRole('combobox'));
|
|
159
|
+
await screen.findByRole('option', { name: 'React' });
|
|
160
|
+
|
|
161
|
+
await user.keyboard('{Escape}');
|
|
162
|
+
expect(screen.queryByRole('option')).not.toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('focus stays on input after Escape', async () => {
|
|
166
|
+
const user = userEvent.setup();
|
|
167
|
+
renderCombobox();
|
|
168
|
+
|
|
169
|
+
const input = screen.getByRole('combobox');
|
|
170
|
+
await user.click(input);
|
|
171
|
+
await screen.findByRole('option', { name: 'React' });
|
|
172
|
+
|
|
173
|
+
await user.keyboard('{Escape}');
|
|
174
|
+
expect(input).toHaveFocus();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('typing filters results to show matching option', async () => {
|
|
178
|
+
const user = userEvent.setup();
|
|
179
|
+
renderCombobox();
|
|
180
|
+
|
|
181
|
+
const input = screen.getByRole('combobox');
|
|
182
|
+
await user.click(input);
|
|
183
|
+
await user.type(input, 'rea');
|
|
184
|
+
|
|
185
|
+
// React should be visible after filtering
|
|
186
|
+
expect(await screen.findByRole('option', { name: 'React' })).toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('Escape after filtering closes the dropdown', async () => {
|
|
190
|
+
const user = userEvent.setup();
|
|
191
|
+
renderCombobox();
|
|
192
|
+
|
|
193
|
+
const input = screen.getByRole('combobox');
|
|
194
|
+
await user.click(input);
|
|
195
|
+
await user.type(input, 'vue');
|
|
196
|
+
await screen.findByRole('option', { name: 'Vue' });
|
|
197
|
+
|
|
198
|
+
await user.keyboard('{Escape}');
|
|
199
|
+
expect(screen.queryByRole('option')).not.toBeInTheDocument();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
display: flex;
|
|
24
24
|
flex-direction: column;
|
|
25
25
|
gap: var(--fui-space-2, $fui-space-2);
|
|
26
|
-
padding: var(--fui-
|
|
26
|
+
padding: var(--fui-padding-container-sm);
|
|
27
27
|
min-height: 100%;
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
align-items: center;
|
|
37
37
|
justify-content: center;
|
|
38
38
|
gap: var(--fui-space-2, $fui-space-2);
|
|
39
|
-
padding: var(--fui-
|
|
39
|
+
padding: var(--fui-padding-container-sm);
|
|
40
40
|
color: var(--fui-text-secondary, $fui-text-secondary);
|
|
41
41
|
font-size: var(--fui-font-size-xs, $fui-font-size-xs);
|
|
42
42
|
}
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
display: flex;
|
|
93
93
|
align-items: center;
|
|
94
94
|
gap: var(--fui-space-2, $fui-space-2);
|
|
95
|
-
padding: var(--fui-
|
|
95
|
+
padding: var(--fui-padding-inline-sm);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
.typingAvatar {
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
display: flex;
|
|
117
117
|
align-items: center;
|
|
118
118
|
gap: 4px;
|
|
119
|
-
padding: var(--fui-
|
|
119
|
+
padding: var(--fui-padding-inline-sm) var(--fui-padding-inline-md);
|
|
120
120
|
background-color: var(--fui-bg-secondary, $fui-bg-secondary);
|
|
121
121
|
border-radius: var(--fui-radius-lg, $fui-radius-lg);
|
|
122
122
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { ConversationList } from './index';
|
|
4
|
+
|
|
5
|
+
// jsdom does not implement scrollTo
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
Element.prototype.scrollTo = vi.fn();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('ConversationList', () => {
|
|
11
|
+
it('renders children as messages', () => {
|
|
12
|
+
render(
|
|
13
|
+
<ConversationList>
|
|
14
|
+
<div>Message 1</div>
|
|
15
|
+
<div>Message 2</div>
|
|
16
|
+
</ConversationList>
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText('Message 1')).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText('Message 2')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders empty state when no children', () => {
|
|
23
|
+
render(
|
|
24
|
+
<ConversationList emptyState={<div>No messages yet</div>}>
|
|
25
|
+
{null}
|
|
26
|
+
</ConversationList>
|
|
27
|
+
);
|
|
28
|
+
expect(screen.getByText('No messages yet')).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders DateSeparator with role="separator"', () => {
|
|
32
|
+
const date = new Date(2025, 0, 15);
|
|
33
|
+
render(
|
|
34
|
+
<ConversationList>
|
|
35
|
+
<ConversationList.DateSeparator date={date} />
|
|
36
|
+
<div>Message</div>
|
|
37
|
+
</ConversationList>
|
|
38
|
+
);
|
|
39
|
+
expect(screen.getByRole('separator')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders DateSeparator with custom format function', () => {
|
|
43
|
+
const date = new Date(2025, 0, 15);
|
|
44
|
+
render(
|
|
45
|
+
<ConversationList>
|
|
46
|
+
<ConversationList.DateSeparator date={date} format={() => 'Custom Date'} />
|
|
47
|
+
</ConversationList>
|
|
48
|
+
);
|
|
49
|
+
expect(screen.getByText('Custom Date')).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders TypingIndicator with accessible label', () => {
|
|
53
|
+
render(
|
|
54
|
+
<ConversationList>
|
|
55
|
+
<ConversationList.TypingIndicator name="Claude" />
|
|
56
|
+
</ConversationList>
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByLabelText('Claude is typing')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('shows loading history spinner when loadingHistory is true', () => {
|
|
62
|
+
render(
|
|
63
|
+
<ConversationList loadingHistory>
|
|
64
|
+
<div>Message</div>
|
|
65
|
+
</ConversationList>
|
|
66
|
+
);
|
|
67
|
+
expect(screen.getByText('Loading history...')).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('has no accessibility violations', async () => {
|
|
71
|
+
const { container } = render(
|
|
72
|
+
<ConversationList>
|
|
73
|
+
<div>Message 1</div>
|
|
74
|
+
<div>Message 2</div>
|
|
75
|
+
</ConversationList>
|
|
76
|
+
);
|
|
77
|
+
await expectNoA11yViolations(container);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -135,11 +135,11 @@ function DialogContent({
|
|
|
135
135
|
return (
|
|
136
136
|
<BaseDialog.Portal>
|
|
137
137
|
<BaseDialog.Backdrop className={styles.backdrop} />
|
|
138
|
-
<
|
|
139
|
-
<BaseDialog.Popup {...htmlProps} className={popupClasses}>
|
|
138
|
+
<BaseDialog.Viewport className={styles.positioner}>
|
|
139
|
+
<BaseDialog.Popup initialFocus {...htmlProps} className={popupClasses}>
|
|
140
140
|
{children}
|
|
141
141
|
</BaseDialog.Popup>
|
|
142
|
-
</
|
|
142
|
+
</BaseDialog.Viewport>
|
|
143
143
|
</BaseDialog.Portal>
|
|
144
144
|
);
|
|
145
145
|
}
|
|
@@ -177,7 +177,11 @@ function DialogClose({ children, asChild, className }: DialogCloseProps) {
|
|
|
177
177
|
// If no children, render the default X close button
|
|
178
178
|
if (!children) {
|
|
179
179
|
return (
|
|
180
|
-
<BaseDialog.Close
|
|
180
|
+
<BaseDialog.Close
|
|
181
|
+
data-dialog-close
|
|
182
|
+
aria-label="Close dialog"
|
|
183
|
+
className={[styles.close, className].filter(Boolean).join(' ')}
|
|
184
|
+
>
|
|
181
185
|
<CloseIcon />
|
|
182
186
|
</BaseDialog.Close>
|
|
183
187
|
);
|
|
@@ -185,14 +189,18 @@ function DialogClose({ children, asChild, className }: DialogCloseProps) {
|
|
|
185
189
|
|
|
186
190
|
if (asChild) {
|
|
187
191
|
return (
|
|
188
|
-
<BaseDialog.Close
|
|
192
|
+
<BaseDialog.Close
|
|
193
|
+
data-dialog-close
|
|
194
|
+
className={className}
|
|
195
|
+
render={children as React.ReactElement}
|
|
196
|
+
>
|
|
189
197
|
{null}
|
|
190
198
|
</BaseDialog.Close>
|
|
191
199
|
);
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
return (
|
|
195
|
-
<BaseDialog.Close className={className}>
|
|
203
|
+
<BaseDialog.Close data-dialog-close className={className}>
|
|
196
204
|
{children}
|
|
197
205
|
</BaseDialog.Close>
|
|
198
206
|
);
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
// Size variants
|
|
15
15
|
.sm {
|
|
16
|
-
padding: var(--fui-
|
|
16
|
+
padding: var(--fui-padding-container-lg) var(--fui-padding-container-md);
|
|
17
17
|
|
|
18
18
|
.title {
|
|
19
19
|
font-size: var(--fui-font-size-sm, $fui-font-size-sm);
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
.md {
|
|
37
|
-
padding: var(--fui-space-10, $fui-space-10) var(--fui-
|
|
37
|
+
padding: var(--fui-space-10, $fui-space-10) var(--fui-padding-container-lg);
|
|
38
38
|
|
|
39
39
|
.title {
|
|
40
40
|
font-size: var(--fui-font-size-base, $fui-font-size-base);
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
.lg {
|
|
58
|
-
padding: var(--fui-space-12, $fui-space-12) var(--fui-
|
|
58
|
+
padding: var(--fui-space-12, $fui-space-12) var(--fui-padding-container-xl);
|
|
59
59
|
|
|
60
60
|
.title {
|
|
61
61
|
font-size: var(--fui-font-size-lg, $fui-font-size-lg);
|