@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.
Files changed (101) hide show
  1. package/fragments.json +1 -1
  2. package/package.json +13 -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.module.scss +5 -5
  6. package/src/components/AppShell/AppShell.test.tsx +80 -0
  7. package/src/components/Avatar/Avatar.test.tsx +40 -0
  8. package/src/components/Avatar/index.tsx +11 -1
  9. package/src/components/Badge/Badge.test.tsx +58 -0
  10. package/src/components/Box/Box.test.tsx +43 -0
  11. package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
  12. package/src/components/Button/Button.module.scss +3 -3
  13. package/src/components/Button/Button.test.tsx +53 -0
  14. package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
  15. package/src/components/Card/Card.test.tsx +71 -0
  16. package/src/components/Chart/Chart.module.scss +5 -0
  17. package/src/components/Chart/Chart.test.tsx +123 -0
  18. package/src/components/Chart/index.tsx +34 -0
  19. package/src/components/Checkbox/Checkbox.test.tsx +63 -0
  20. package/src/components/Checkbox/index.tsx +29 -4
  21. package/src/components/Chip/Chip.module.scss +73 -7
  22. package/src/components/Chip/Chip.test.tsx +50 -0
  23. package/src/components/Chip/index.tsx +36 -26
  24. package/src/components/CodeBlock/CodeBlock.module.scss +6 -6
  25. package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
  26. package/src/components/CodeBlock/index.tsx +4 -1
  27. package/src/components/Collapsible/Collapsible.test.tsx +103 -0
  28. package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
  29. package/src/components/ColorPicker/index.tsx +4 -1
  30. package/src/components/Combobox/Combobox.test.tsx +202 -0
  31. package/src/components/ConversationList/ConversationList.module.scss +4 -4
  32. package/src/components/ConversationList/ConversationList.test.tsx +79 -0
  33. package/src/components/Dialog/Dialog.test.tsx +277 -0
  34. package/src/components/Dialog/index.tsx +14 -6
  35. package/src/components/EmptyState/EmptyState.module.scss +3 -3
  36. package/src/components/EmptyState/EmptyState.test.tsx +67 -0
  37. package/src/components/Field/Field.test.tsx +65 -0
  38. package/src/components/Fieldset/Fieldset.test.tsx +48 -0
  39. package/src/components/Form/Form.test.tsx +41 -0
  40. package/src/components/Grid/Grid.module.scss +3 -3
  41. package/src/components/Grid/Grid.test.tsx +65 -0
  42. package/src/components/Header/Header.module.scss +4 -4
  43. package/src/components/Header/Header.test.tsx +83 -0
  44. package/src/components/Icon/Icon.test.tsx +38 -0
  45. package/src/components/Image/Image.test.tsx +39 -0
  46. package/src/components/Input/Input.test.tsx +72 -0
  47. package/src/components/Input/index.tsx +15 -1
  48. package/src/components/Link/Link.test.tsx +37 -0
  49. package/src/components/List/List.test.tsx +57 -0
  50. package/src/components/Listbox/Listbox.module.scss +16 -5
  51. package/src/components/Listbox/Listbox.test.tsx +100 -0
  52. package/src/components/Listbox/index.tsx +186 -13
  53. package/src/components/Loading/Loading.test.tsx +38 -0
  54. package/src/components/Markdown/Markdown.module.scss +3 -3
  55. package/src/components/Markdown/Markdown.test.tsx +41 -0
  56. package/src/components/Menu/Menu.module.scss +3 -3
  57. package/src/components/Menu/Menu.test.tsx +336 -0
  58. package/src/components/Message/Message.module.scss +9 -3
  59. package/src/components/Message/Message.test.tsx +75 -0
  60. package/src/components/Popover/Popover.test.tsx +105 -0
  61. package/src/components/Popover/index.tsx +4 -1
  62. package/src/components/Progress/Progress.test.tsx +58 -0
  63. package/src/components/Prompt/Prompt.module.scss +6 -5
  64. package/src/components/Prompt/Prompt.test.tsx +89 -0
  65. package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
  66. package/src/components/RadioGroup/index.tsx +37 -4
  67. package/src/components/Select/Select.test.tsx +161 -0
  68. package/src/components/Select/index.tsx +3 -2
  69. package/src/components/Separator/Separator.test.tsx +33 -0
  70. package/src/components/Sidebar/Sidebar.module.scss +32 -22
  71. package/src/components/Sidebar/Sidebar.test.tsx +85 -0
  72. package/src/components/Sidebar/index.tsx +31 -9
  73. package/src/components/Skeleton/Skeleton.test.tsx +56 -0
  74. package/src/components/Slider/Slider.test.tsx +51 -0
  75. package/src/components/Slider/index.tsx +13 -3
  76. package/src/components/Stack/Stack.test.tsx +47 -0
  77. package/src/components/Table/Table.module.scss +20 -11
  78. package/src/components/Table/Table.test.tsx +129 -0
  79. package/src/components/Table/index.tsx +52 -30
  80. package/src/components/Tabs/Tabs.test.tsx +180 -0
  81. package/src/components/Text/Text.test.tsx +40 -0
  82. package/src/components/Textarea/Textarea.test.tsx +57 -0
  83. package/src/components/Textarea/index.tsx +22 -2
  84. package/src/components/Theme/Theme.test.tsx +114 -0
  85. package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
  86. package/src/components/Toast/Toast.test.tsx +192 -0
  87. package/src/components/Toast/index.tsx +124 -20
  88. package/src/components/Toggle/Toggle.test.tsx +49 -0
  89. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
  90. package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
  91. package/src/components/ToggleGroup/index.tsx +70 -1
  92. package/src/components/Tooltip/Tooltip.module.scss +1 -1
  93. package/src/components/Tooltip/Tooltip.test.tsx +107 -0
  94. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  95. package/src/test/setup.ts +74 -0
  96. package/src/test/utils.tsx +71 -0
  97. package/src/tokens/_computed.scss +2 -0
  98. package/src/tokens/_density.scss +4 -0
  99. package/src/tokens/_derive.scss +16 -16
  100. package/src/tokens/_variables.scss +8 -2
  101. 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-space-3, $fui-space-3);
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-space-3, $fui-space-3);
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-space-2, $fui-space-2);
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-space-2, $fui-space-2) var(--fui-space-3, $fui-space-3);
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
- <div className={styles.positioner}>
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
- </div>
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 className={[styles.close, className].filter(Boolean).join(' ')}>
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 className={className} render={children as React.ReactElement}>
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-space-6, $fui-space-6) var(--fui-space-4, $fui-space-4);
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-space-6, $fui-space-6);
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-space-8, $fui-space-8);
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);