@cosxai/ui 1.0.0-alpha.1 → 1.0.0-alpha.2
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/CHANGELOG.md +22 -0
- package/package.json +6 -1
- package/src/components/ActionBar.tsx +607 -0
- package/src/components/ActivityTimeline.tsx +77 -0
- package/src/components/AsyncSelect.tsx +205 -0
- package/src/components/Breadcrumb.tsx +129 -0
- package/src/components/Button.tsx +17 -2
- package/src/components/ChoiceCards.tsx +68 -0
- package/src/components/CodeInput.tsx +150 -0
- package/src/components/CommandPalette.tsx +228 -0
- package/src/components/ConfirmDialog.tsx +294 -0
- package/src/components/CopyField.tsx +104 -0
- package/src/components/DateInput.tsx +366 -0
- package/src/components/Dialog.tsx +27 -3
- package/src/components/Dropzone.tsx +187 -0
- package/src/components/FileCard.tsx +233 -0
- package/src/components/FileRow.tsx +159 -0
- package/src/components/FolderTree.tsx +377 -0
- package/src/components/ListToolbar.tsx +177 -0
- package/src/components/MentionInput.tsx +233 -0
- package/src/components/Menu.tsx +109 -0
- package/src/components/PageHeader.tsx +62 -0
- package/src/components/PageState.tsx +84 -0
- package/src/components/Progress.tsx +97 -0
- package/src/components/PulseDot.tsx +31 -0
- package/src/components/RecipientsInput.tsx +252 -0
- package/src/components/SearchField.tsx +137 -0
- package/src/components/SegmentedControl.tsx +95 -0
- package/src/components/SidePanel.tsx +130 -0
- package/src/components/Skeleton.tsx +73 -0
- package/src/components/StageProgress.tsx +51 -0
- package/src/components/Steps.tsx +58 -0
- package/src/components/SyncStatus.tsx +56 -0
- package/src/components/UploadCheck.tsx +118 -0
- package/src/components/UploadList.tsx +176 -0
- package/src/components/VirtualList.tsx +90 -0
- package/src/components/WindowDrop.tsx +131 -0
- package/src/components/inputs-aria.ts +5 -0
- package/src/components/inputs-date.ts +92 -0
- package/src/components/inputs-listbox.tsx +154 -0
- package/src/components/inputs-search.ts +60 -0
- package/src/components/status-files.ts +124 -0
- package/src/components/status-motion.ts +30 -0
- package/src/components/useSelection.ts +142 -0
- package/src/index.ts +38 -0
- package/src/components/core.test.tsx +0 -242
- package/src/components/forms.test.tsx +0 -280
- package/src/components/overlays.test.tsx +0 -245
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
import { render, screen, within } from '@testing-library/react';
|
|
2
|
-
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
-
|
|
6
|
-
import { axe } from 'vitest-axe';
|
|
7
|
-
|
|
8
|
-
import { a11y } from '../../test/a11y';
|
|
9
|
-
import { Checkbox } from './Checkbox';
|
|
10
|
-
import { Field } from './Field';
|
|
11
|
-
import { Input } from './Input';
|
|
12
|
-
import { RadioGroup } from './Radio';
|
|
13
|
-
import { Select, type SelectOption } from './Select';
|
|
14
|
-
import { Switch } from './Switch';
|
|
15
|
-
import { Textarea } from './Textarea';
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
describe('Field, Input, Textarea', () => {
|
|
19
|
-
it('ties label, hint and error to the input', () => {
|
|
20
|
-
render(<Input label="Email" hint="We send the code here" />);
|
|
21
|
-
const input = screen.getByLabelText('Email');
|
|
22
|
-
expect(input).toHaveAccessibleDescription('We send the code here');
|
|
23
|
-
expect(input).not.toHaveAttribute('aria-invalid');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('an error replaces the hint and sets aria-invalid', () => {
|
|
27
|
-
render(<Input label="Code" hint="6 digits" error="That code has expired. Send a new one." />);
|
|
28
|
-
const input = screen.getByLabelText('Code');
|
|
29
|
-
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
30
|
-
expect(input).toHaveAccessibleDescription('That code has expired. Send a new one.');
|
|
31
|
-
expect(screen.queryByText('6 digits')).toBeNull();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('a Field wraps any control, including a Textarea', () => {
|
|
35
|
-
render(
|
|
36
|
-
<Field label="Note" error="Say what changed.">
|
|
37
|
-
<Textarea />
|
|
38
|
-
</Field>,
|
|
39
|
-
);
|
|
40
|
-
const ta = screen.getByLabelText('Note');
|
|
41
|
-
expect(ta.tagName).toBe('TEXTAREA');
|
|
42
|
-
expect(ta).toHaveAttribute('aria-invalid', 'true');
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('keeps prefix and suffix inside the frame and types', async () => {
|
|
46
|
-
const onChange = vi.fn();
|
|
47
|
-
render(<Input label="Amount" prefix="£" suffix="GBP" onChange={onChange} />);
|
|
48
|
-
await userEvent.type(screen.getByLabelText('Amount'), '12');
|
|
49
|
-
expect(onChange).toHaveBeenCalledTimes(2);
|
|
50
|
-
expect(screen.getByText('£')).toBeInTheDocument();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('disabled and read-only are native', () => {
|
|
54
|
-
render(
|
|
55
|
-
<>
|
|
56
|
-
<Input label="A" disabled />
|
|
57
|
-
<Input label="B" readOnly value="x" />
|
|
58
|
-
</>,
|
|
59
|
-
);
|
|
60
|
-
expect(screen.getByLabelText('A')).toBeDisabled();
|
|
61
|
-
expect(screen.getByLabelText('B')).toHaveAttribute('readonly');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('passes axe', async () => {
|
|
65
|
-
const { container } = render(
|
|
66
|
-
<div>
|
|
67
|
-
<Input label="Email" hint="Work address" />
|
|
68
|
-
<Input label="Code" error="That code has expired." />
|
|
69
|
-
<Textarea label="Note" />
|
|
70
|
-
</div>,
|
|
71
|
-
);
|
|
72
|
-
expect(await a11y(container)).toHaveNoViolations();
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const PEOPLE: SelectOption[] = [
|
|
77
|
-
{ value: 'hv', label: 'Harbour Ventures', meta: '3 projects', group: 'Customers' },
|
|
78
|
-
{ value: 'hw', label: 'Hardwick Family Office', group: 'Customers' },
|
|
79
|
-
{ value: 'kb', label: 'Kowloon Bay', meta: 'Archived', group: 'Customers', disabled: true },
|
|
80
|
-
{ value: 'lw', label: 'Li Wei', group: 'Staff' },
|
|
81
|
-
{ value: 'am', label: 'Anna Kowalski', group: 'Staff' },
|
|
82
|
-
];
|
|
83
|
-
|
|
84
|
-
function Controlled(props: { options?: SelectOption[] | string[]; searchable?: boolean }) {
|
|
85
|
-
const [v, setV] = useState<string | undefined>(undefined);
|
|
86
|
-
return (
|
|
87
|
-
<Field label="Customer">
|
|
88
|
-
<Select options={props.options ?? PEOPLE} value={v} onChange={setV} searchable={props.searchable} />
|
|
89
|
-
</Field>
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
describe('Select', () => {
|
|
94
|
-
it('is a labelled combobox that opens a listbox with options', async () => {
|
|
95
|
-
render(<Controlled />);
|
|
96
|
-
const trigger = screen.getByRole('combobox', { name: 'Customer' });
|
|
97
|
-
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
98
|
-
await userEvent.click(trigger);
|
|
99
|
-
const list = await screen.findByRole('listbox');
|
|
100
|
-
expect(within(list).getAllByRole('option')).toHaveLength(5);
|
|
101
|
-
expect(within(list).getByRole('group', { name: 'Staff' })).toBeInTheDocument();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('keyboard: arrows skip disabled rows, Home/End jump, Enter chooses', async () => {
|
|
105
|
-
const user = userEvent.setup();
|
|
106
|
-
render(<Controlled />);
|
|
107
|
-
const trigger = screen.getByRole('combobox', { name: 'Customer' });
|
|
108
|
-
trigger.focus();
|
|
109
|
-
await user.keyboard('{ArrowDown}');
|
|
110
|
-
const list = await screen.findByRole('listbox');
|
|
111
|
-
expect(list).toHaveAttribute('aria-activedescendant', screen.getByRole('option', { name: 'Harbour Ventures, 3 projects' }).id);
|
|
112
|
-
await user.keyboard('{ArrowDown}{ArrowDown}');
|
|
113
|
-
// Kowloon Bay (disabled) is skipped
|
|
114
|
-
expect(list).toHaveAttribute('aria-activedescendant', screen.getByRole('option', { name: 'Li Wei' }).id);
|
|
115
|
-
await user.keyboard('{End}');
|
|
116
|
-
expect(list).toHaveAttribute('aria-activedescendant', screen.getByRole('option', { name: 'Anna Kowalski' }).id);
|
|
117
|
-
await user.keyboard('{Home}{Enter}');
|
|
118
|
-
expect(trigger).toHaveTextContent('Harbour Ventures');
|
|
119
|
-
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('a letter jumps to it (closed and open)', async () => {
|
|
123
|
-
const user = userEvent.setup();
|
|
124
|
-
render(<Controlled />);
|
|
125
|
-
const trigger = screen.getByRole('combobox', { name: 'Customer' });
|
|
126
|
-
trigger.focus();
|
|
127
|
-
await user.keyboard('l');
|
|
128
|
-
const list = await screen.findByRole('listbox');
|
|
129
|
-
expect(list).toHaveAttribute('aria-activedescendant', screen.getByRole('option', { name: 'Li Wei' }).id);
|
|
130
|
-
await user.keyboard('i'); // fast typing matches a prefix: "li"
|
|
131
|
-
expect(list).toHaveAttribute('aria-activedescendant', screen.getByRole('option', { name: 'Li Wei' }).id);
|
|
132
|
-
const later = Date.now() + 1000; // a pause starts a new search
|
|
133
|
-
const spy = vi.spyOn(Date, 'now').mockReturnValue(later);
|
|
134
|
-
await user.keyboard('a');
|
|
135
|
-
expect(list).toHaveAttribute('aria-activedescendant', screen.getByRole('option', { name: 'Anna Kowalski' }).id);
|
|
136
|
-
spy.mockRestore();
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('Escape closes without choosing', async () => {
|
|
140
|
-
const user = userEvent.setup();
|
|
141
|
-
render(<Controlled />);
|
|
142
|
-
const trigger = screen.getByRole('combobox', { name: 'Customer' });
|
|
143
|
-
await user.click(trigger);
|
|
144
|
-
await screen.findByRole('listbox');
|
|
145
|
-
await user.keyboard('{Escape}');
|
|
146
|
-
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
147
|
-
expect(trigger).toHaveTextContent('Choose one');
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('disabled rows stay visible and cannot be chosen', async () => {
|
|
151
|
-
const user = userEvent.setup();
|
|
152
|
-
render(<Controlled />);
|
|
153
|
-
await user.click(screen.getByRole('combobox', { name: 'Customer' }));
|
|
154
|
-
const row = await screen.findByRole('option', { name: 'Kowloon Bay, Archived' });
|
|
155
|
-
expect(row).toHaveAttribute('aria-disabled', 'true');
|
|
156
|
-
await user.click(row);
|
|
157
|
-
expect(screen.getByRole('combobox', { name: 'Customer' })).toHaveTextContent('Choose one');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('turns the filter on above 8 options and filters', async () => {
|
|
161
|
-
const user = userEvent.setup();
|
|
162
|
-
const many = ['Passport', 'Visa', 'BRP', 'Payslip', 'P60', 'Bank statement', 'Tenancy', 'Council tax', 'Utility bill'];
|
|
163
|
-
render(<Controlled options={many} />);
|
|
164
|
-
await user.click(screen.getByRole('combobox', { name: 'Customer' }));
|
|
165
|
-
const filter = await screen.findByRole('combobox', { name: 'Filter' });
|
|
166
|
-
expect(filter).toHaveFocus();
|
|
167
|
-
await user.type(filter, 'pa');
|
|
168
|
-
expect(within(screen.getByRole('listbox')).getAllByRole('option').map((o) => o.textContent)).toEqual(['Passport', 'Payslip']);
|
|
169
|
-
await user.keyboard('{ArrowDown}{Enter}');
|
|
170
|
-
expect(screen.getByRole('combobox', { name: 'Customer' })).toHaveTextContent('Passport');
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('shows the empty text when nothing matches', async () => {
|
|
174
|
-
const user = userEvent.setup();
|
|
175
|
-
render(<Controlled searchable />);
|
|
176
|
-
await user.click(screen.getByRole('combobox', { name: 'Customer' }));
|
|
177
|
-
await user.type(await screen.findByRole('combobox', { name: 'Filter' }), 'zzz');
|
|
178
|
-
expect(screen.getByText('Nothing matches that.')).toBeInTheDocument();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('is invalid and described inside a Field with an error', () => {
|
|
182
|
-
render(
|
|
183
|
-
<Field label="Customer" error="Choose who this is for.">
|
|
184
|
-
<Select options={PEOPLE} />
|
|
185
|
-
</Field>,
|
|
186
|
-
);
|
|
187
|
-
const trigger = screen.getByRole('combobox', { name: 'Customer' });
|
|
188
|
-
expect(trigger).toHaveAttribute('aria-invalid', 'true');
|
|
189
|
-
expect(trigger).toHaveAccessibleDescription('Choose who this is for.');
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('passes axe (closed and open)', async () => {
|
|
193
|
-
const user = userEvent.setup();
|
|
194
|
-
const { container } = render(<Controlled />);
|
|
195
|
-
expect(await a11y(container)).toHaveNoViolations();
|
|
196
|
-
await user.click(screen.getByRole('combobox', { name: 'Customer' }));
|
|
197
|
-
await screen.findByRole('listbox');
|
|
198
|
-
// the open menu lives in a portal on <body>; 'region' is a page-level rule
|
|
199
|
-
const open = await axe(document.body, { rules: { 'color-contrast': { enabled: false }, region: { enabled: false } } });
|
|
200
|
-
expect(open).toHaveNoViolations();
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe('Checkbox', () => {
|
|
205
|
-
it('the label toggles; space toggles', async () => {
|
|
206
|
-
const user = userEvent.setup();
|
|
207
|
-
const onChange = vi.fn();
|
|
208
|
-
render(<Checkbox label="Can comment" description="Recipients can leave comments" onChange={onChange} />);
|
|
209
|
-
const box = screen.getByRole('checkbox', { name: 'Can comment' });
|
|
210
|
-
expect(box).toHaveAccessibleDescription('Recipients can leave comments');
|
|
211
|
-
await user.click(screen.getByText('Can comment'));
|
|
212
|
-
expect(onChange).toHaveBeenLastCalledWith(true);
|
|
213
|
-
box.focus();
|
|
214
|
-
await user.keyboard(' ');
|
|
215
|
-
expect(onChange).toHaveBeenCalledTimes(2);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('shows the mixed state', () => {
|
|
219
|
-
render(<Checkbox label="All documents" checked="indeterminate" />);
|
|
220
|
-
expect(screen.getByRole('checkbox', { name: 'All documents' })).toHaveAttribute('aria-checked', 'mixed');
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('disabled explains why and does not toggle', async () => {
|
|
224
|
-
const onChange = vi.fn();
|
|
225
|
-
render(<Checkbox label="Can forward" description="Turned off by the upstream share" disabled onChange={onChange} />);
|
|
226
|
-
const box = screen.getByRole('checkbox', { name: 'Can forward' });
|
|
227
|
-
expect(box).toBeDisabled();
|
|
228
|
-
expect(box).toHaveAccessibleDescription('Turned off by the upstream share');
|
|
229
|
-
await userEvent.click(screen.getByText('Can forward'));
|
|
230
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
describe('RadioGroup', () => {
|
|
235
|
-
it('arrow keys move and choose', async () => {
|
|
236
|
-
const user = userEvent.setup();
|
|
237
|
-
function R() {
|
|
238
|
-
const [v, setV] = useState('view');
|
|
239
|
-
return <RadioGroup aria-label="Access" options={[{ value: 'view', label: 'View' }, { value: 'comment', label: 'Comment' }, { value: 'edit', label: 'Edit' }]} value={v} onChange={setV} />;
|
|
240
|
-
}
|
|
241
|
-
render(<R />);
|
|
242
|
-
const view = screen.getByRole('radio', { name: 'View' });
|
|
243
|
-
expect(view).toBeChecked();
|
|
244
|
-
view.focus();
|
|
245
|
-
// Radix moves focus on a timer and selects while the arrow is held —
|
|
246
|
-
// hold it like a real key press.
|
|
247
|
-
await user.keyboard('{ArrowDown>}');
|
|
248
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
249
|
-
await user.keyboard('{/ArrowDown}');
|
|
250
|
-
expect(screen.getByRole('radio', { name: 'Comment' })).toBeChecked();
|
|
251
|
-
expect(screen.getByRole('radio', { name: 'Comment' })).toHaveFocus();
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
describe('Switch', () => {
|
|
256
|
-
it('is a switch; space and the label flip it at once', async () => {
|
|
257
|
-
const user = userEvent.setup();
|
|
258
|
-
const onChange = vi.fn();
|
|
259
|
-
render(<Switch label="Require two-step verification" description="Including customers" onChange={onChange} />);
|
|
260
|
-
const sw = screen.getByRole('switch', { name: 'Require two-step verification' });
|
|
261
|
-
expect(sw).toHaveAttribute('aria-checked', 'false');
|
|
262
|
-
expect(sw).toHaveAccessibleDescription('Including customers');
|
|
263
|
-
sw.focus();
|
|
264
|
-
await user.keyboard(' ');
|
|
265
|
-
expect(onChange).toHaveBeenLastCalledWith(true);
|
|
266
|
-
await user.click(screen.getByText('Require two-step verification'));
|
|
267
|
-
expect(onChange).toHaveBeenCalledTimes(2);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('checkbox, radio and switch pass axe', async () => {
|
|
271
|
-
const { container } = render(
|
|
272
|
-
<div>
|
|
273
|
-
<Checkbox label="Can comment" description="Recipients can leave comments" checked />
|
|
274
|
-
<RadioGroup aria-label="Access" options={['View', 'Comment']} value="View" />
|
|
275
|
-
<Switch label="Organise new documents" checked />
|
|
276
|
-
</div>,
|
|
277
|
-
);
|
|
278
|
-
expect(await a11y(container)).toHaveNoViolations();
|
|
279
|
-
});
|
|
280
|
-
});
|
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
import { act, render, screen, within } from '@testing-library/react';
|
|
2
|
-
import userEvent from '@testing-library/user-event';
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
|
|
6
|
-
import { a11y } from '../../test/a11y';
|
|
7
|
-
import { Button } from './Button';
|
|
8
|
-
import { Dialog, DialogClose, DialogContent, DialogTrigger } from './Dialog';
|
|
9
|
-
import { Table, type TableSort } from './Table';
|
|
10
|
-
import { TabPanel, Tabs } from './Tabs';
|
|
11
|
-
import { Toaster, toast } from './Toast';
|
|
12
|
-
import { Tooltip } from './Tooltip';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
describe('Tabs', () => {
|
|
16
|
-
const items = [
|
|
17
|
-
{ value: 'overview', label: 'Overview' },
|
|
18
|
-
{ value: 'documents', label: 'Documents', count: 3 },
|
|
19
|
-
{ value: 'parties', label: 'Parties', count: 0 },
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
it('arrow keys move and aria-selected follows; onChange reports the value', async () => {
|
|
23
|
-
const onChange = vi.fn();
|
|
24
|
-
render(
|
|
25
|
-
<Tabs items={items} defaultValue="overview" onChange={onChange} label="Project">
|
|
26
|
-
<TabPanel value="overview">Overview panel</TabPanel>
|
|
27
|
-
<TabPanel value="documents">Documents panel</TabPanel>
|
|
28
|
-
<TabPanel value="parties">Parties panel</TabPanel>
|
|
29
|
-
</Tabs>,
|
|
30
|
-
);
|
|
31
|
-
const tabs = screen.getAllByRole('tab');
|
|
32
|
-
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
|
|
33
|
-
await userEvent.click(tabs[0]!);
|
|
34
|
-
await userEvent.keyboard('{ArrowRight}');
|
|
35
|
-
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
|
|
36
|
-
expect(onChange).toHaveBeenLastCalledWith('documents');
|
|
37
|
-
expect(screen.getByRole('tabpanel')).toHaveTextContent('Documents panel');
|
|
38
|
-
await userEvent.keyboard('{End}');
|
|
39
|
-
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('count shows as a badge, hidden at zero', () => {
|
|
43
|
-
render(<Tabs items={items} defaultValue="overview" label="Project" />);
|
|
44
|
-
expect(screen.getByRole('tab', { name: 'Documents 3' })).toBeInTheDocument();
|
|
45
|
-
expect(screen.getByRole('tab', { name: 'Parties' })).toBeInTheDocument();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('passes axe (both variants)', async () => {
|
|
49
|
-
const { container } = render(
|
|
50
|
-
<div>
|
|
51
|
-
<Tabs items={items} defaultValue="overview" label="Underline">
|
|
52
|
-
<TabPanel value="overview">a</TabPanel>
|
|
53
|
-
</Tabs>
|
|
54
|
-
<Tabs variant="pills" items={items} defaultValue="documents" label="Pills">
|
|
55
|
-
<TabPanel value="documents">b</TabPanel>
|
|
56
|
-
</Tabs>
|
|
57
|
-
</div>,
|
|
58
|
-
);
|
|
59
|
-
expect(await a11y(container)).toHaveNoViolations();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('Dialog', () => {
|
|
64
|
-
function Revoke() {
|
|
65
|
-
return (
|
|
66
|
-
<Dialog>
|
|
67
|
-
<DialogTrigger asChild>
|
|
68
|
-
<Button variant="secondary">Revoke share</Button>
|
|
69
|
-
</DialogTrigger>
|
|
70
|
-
<DialogContent
|
|
71
|
-
size="sm"
|
|
72
|
-
title="Revoke this share?"
|
|
73
|
-
description="Anna loses access now."
|
|
74
|
-
footer={
|
|
75
|
-
<>
|
|
76
|
-
<DialogClose asChild>
|
|
77
|
-
<Button variant="secondary">Cancel</Button>
|
|
78
|
-
</DialogClose>
|
|
79
|
-
<Button variant="danger">Revoke share</Button>
|
|
80
|
-
</>
|
|
81
|
-
}
|
|
82
|
-
/>
|
|
83
|
-
</Dialog>
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
it('is labelled by its title and described by its consequence', async () => {
|
|
88
|
-
render(<Revoke />);
|
|
89
|
-
await userEvent.click(screen.getByRole('button', { name: 'Revoke share' }));
|
|
90
|
-
const dialog = screen.getByRole('dialog', { name: 'Revoke this share?' });
|
|
91
|
-
expect(dialog).toHaveAccessibleDescription('Anna loses access now.');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('traps focus, closes on Esc and returns focus to the trigger', async () => {
|
|
95
|
-
render(<Revoke />);
|
|
96
|
-
const trigger = screen.getByRole('button', { name: 'Revoke share' });
|
|
97
|
-
await userEvent.click(trigger);
|
|
98
|
-
const dialog = screen.getByRole('dialog');
|
|
99
|
-
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
100
|
-
for (let i = 0; i < 6; i++) {
|
|
101
|
-
await userEvent.tab();
|
|
102
|
-
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
103
|
-
}
|
|
104
|
-
await userEvent.keyboard('{Escape}');
|
|
105
|
-
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
106
|
-
expect(trigger).toHaveFocus();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('passes axe while open', async () => {
|
|
110
|
-
render(<Revoke />);
|
|
111
|
-
await userEvent.click(screen.getByRole('button', { name: 'Revoke share' }));
|
|
112
|
-
expect(await a11y(document.body)).toHaveNoViolations();
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('Toast', () => {
|
|
117
|
-
afterEach(() => {
|
|
118
|
-
act(() => toast.reset());
|
|
119
|
-
vi.useRealTimers();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('is announced in a live region and leaves after 4s (8s with an action)', () => {
|
|
123
|
-
vi.useFakeTimers();
|
|
124
|
-
render(<Toaster />);
|
|
125
|
-
act(() => {
|
|
126
|
-
toast({ title: 'Link copied.' });
|
|
127
|
-
toast({ title: '3 files moved to Archive.', status: 'complete', action: { label: 'Undo', onClick: () => {} } });
|
|
128
|
-
});
|
|
129
|
-
// Radix announces toasts through a status live region.
|
|
130
|
-
expect(document.querySelector('[role="status"][aria-live]')).not.toBeNull();
|
|
131
|
-
expect(screen.getByText('Link copied.')).toBeInTheDocument();
|
|
132
|
-
act(() => vi.advanceTimersByTime(4100));
|
|
133
|
-
expect(screen.queryByText('Link copied.')).not.toBeInTheDocument();
|
|
134
|
-
expect(screen.getByText('3 files moved to Archive.')).toBeInTheDocument();
|
|
135
|
-
act(() => vi.advanceTimersByTime(4000));
|
|
136
|
-
expect(screen.queryByText('3 files moved to Archive.')).not.toBeInTheDocument();
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('shows the same error once per 30 seconds and at most three at once', () => {
|
|
140
|
-
render(<Toaster />);
|
|
141
|
-
act(() => {
|
|
142
|
-
expect(toast({ title: "Couldn't rename the folder.", status: 'error' })).not.toBeNull();
|
|
143
|
-
expect(toast({ title: "Couldn't rename the folder.", status: 'error' })).toBeNull();
|
|
144
|
-
toast({ title: 'One.' });
|
|
145
|
-
toast({ title: 'Two.' });
|
|
146
|
-
toast({ title: 'Three.' });
|
|
147
|
-
});
|
|
148
|
-
expect(screen.queryByText("Couldn't rename the folder.")).not.toBeInTheDocument();
|
|
149
|
-
expect(screen.getByText('Three.')).toBeInTheDocument();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('never takes focus', () => {
|
|
153
|
-
render(
|
|
154
|
-
<>
|
|
155
|
-
<button>Work</button>
|
|
156
|
-
<Toaster />
|
|
157
|
-
</>,
|
|
158
|
-
);
|
|
159
|
-
screen.getByRole('button', { name: 'Work' }).focus();
|
|
160
|
-
act(() => void toast({ title: 'Link copied.' }));
|
|
161
|
-
expect(screen.getByRole('button', { name: 'Work' })).toHaveFocus();
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
describe('Tooltip', () => {
|
|
166
|
-
it('opens on keyboard focus as well as hover', async () => {
|
|
167
|
-
render(
|
|
168
|
-
<Tooltip content="Downloads are off for this share">
|
|
169
|
-
<button>Share</button>
|
|
170
|
-
</Tooltip>,
|
|
171
|
-
);
|
|
172
|
-
await userEvent.tab();
|
|
173
|
-
expect(screen.getByRole('button', { name: 'Share' })).toHaveFocus();
|
|
174
|
-
expect(await screen.findByRole('tooltip')).toHaveTextContent('Downloads are off for this share');
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('opens on hover', async () => {
|
|
178
|
-
render(
|
|
179
|
-
<Tooltip content="Star" delay={0}>
|
|
180
|
-
<button>☆</button>
|
|
181
|
-
</Tooltip>,
|
|
182
|
-
);
|
|
183
|
-
await userEvent.hover(screen.getByRole('button'));
|
|
184
|
-
expect(await screen.findByRole('tooltip')).toHaveTextContent('Star');
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
describe('Table', () => {
|
|
189
|
-
type Row = { id: string; status?: 'attention' | 'error'; task: string; docs: number };
|
|
190
|
-
const rows: Row[] = [
|
|
191
|
-
{ id: 'T-119', task: 'Translate the term sheet', docs: 1 },
|
|
192
|
-
{ id: 'T-122', status: 'error', task: 'Prepare the data room index', docs: 20 },
|
|
193
|
-
{ id: 'T-131', status: 'attention', task: 'Sign the engagement letter', docs: 2 },
|
|
194
|
-
];
|
|
195
|
-
const columns = [
|
|
196
|
-
{ key: 'task', label: 'Task', sortable: true },
|
|
197
|
-
{ key: 'docs', label: 'Docs', align: 'right' as const, sortable: true },
|
|
198
|
-
];
|
|
199
|
-
|
|
200
|
-
function Sorted({ onRowClick }: { onRowClick: (r: Row) => void }) {
|
|
201
|
-
const [sort, setSort] = useState<TableSort>({ key: 'task', direction: 'asc' });
|
|
202
|
-
return <Table caption="Review queue" columns={columns} rows={rows} sort={sort} onSortChange={setSort} onRowClick={onRowClick} />;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
it('sorting sets aria-sort on the header', async () => {
|
|
206
|
-
render(<Sorted onRowClick={() => {}} />);
|
|
207
|
-
const [task, docs] = screen.getAllByRole('columnheader');
|
|
208
|
-
expect(task).toHaveAttribute('aria-sort', 'ascending');
|
|
209
|
-
expect(docs).toHaveAttribute('aria-sort', 'none');
|
|
210
|
-
await userEvent.click(within(docs!).getByRole('button', { name: 'Docs' }));
|
|
211
|
-
expect(docs).toHaveAttribute('aria-sort', 'ascending');
|
|
212
|
-
await userEvent.click(within(docs!).getByRole('button', { name: 'Docs' }));
|
|
213
|
-
expect(docs).toHaveAttribute('aria-sort', 'descending');
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('rows open on click and on Enter from the keyboard', async () => {
|
|
217
|
-
const onRowClick = vi.fn();
|
|
218
|
-
render(<Sorted onRowClick={onRowClick} />);
|
|
219
|
-
const row = screen.getByRole('row', { name: /Prepare the data room index/ });
|
|
220
|
-
await userEvent.click(row);
|
|
221
|
-
expect(onRowClick).toHaveBeenLastCalledWith(rows[1]);
|
|
222
|
-
row.focus();
|
|
223
|
-
await userEvent.keyboard('{Enter}');
|
|
224
|
-
expect(onRowClick).toHaveBeenCalledTimes(2);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('rows needing a person take a wash; the title can be a real link', () => {
|
|
228
|
-
render(<Table columns={columns} rows={rows} rowHref={(r) => `/tasks/${r.id}`} />);
|
|
229
|
-
expect(screen.getByRole('row', { name: /data room/ }).className).toMatch(/bg-error-wash/);
|
|
230
|
-
expect(screen.getByRole('row', { name: /engagement letter/ }).className).toMatch(/bg-attention-wash/);
|
|
231
|
-
expect(screen.getByRole('link', { name: 'Sign the engagement letter' })).toHaveAttribute('href', '/tasks/T-131');
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('empty and loading', () => {
|
|
235
|
-
const { rerender } = render(<Table columns={columns} rows={[]} empty="No tasks yet." />);
|
|
236
|
-
expect(screen.getByText('No tasks yet.')).toBeInTheDocument();
|
|
237
|
-
rerender(<Table columns={columns} rows={[]} loading />);
|
|
238
|
-
expect(screen.getAllByRole('columnheader')).toHaveLength(2);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('passes axe', async () => {
|
|
242
|
-
const { container } = render(<Sorted onRowClick={() => {}} />);
|
|
243
|
-
expect(await a11y(container)).toHaveNoViolations();
|
|
244
|
-
});
|
|
245
|
-
});
|