@cosxai/ui 1.0.0-alpha.0 → 1.0.0-alpha.1
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 +20 -0
- package/package.json +27 -3
- package/src/brand.css +13 -0
- package/src/components/Badge.tsx +68 -0
- package/src/components/Button.tsx +178 -0
- package/src/components/Card.tsx +42 -0
- package/src/components/Checkbox.tsx +67 -0
- package/src/components/Dialog.tsx +111 -0
- package/src/components/Field.tsx +66 -0
- package/src/components/Figure.tsx +30 -0
- package/src/components/Icon.tsx +32 -0
- package/src/components/IconButton.tsx +55 -0
- package/src/components/Input.tsx +74 -0
- package/src/components/Logo.tsx +62 -0
- package/src/components/Marker.tsx +26 -0
- package/src/components/MetaLabel.tsx +38 -0
- package/src/components/Radio.tsx +72 -0
- package/src/components/Select.tsx +351 -0
- package/src/components/Spinner.tsx +20 -0
- package/src/components/Switch.tsx +67 -0
- package/src/components/Table.tsx +196 -0
- package/src/components/Tabs.tsx +124 -0
- package/src/components/Tag.tsx +35 -0
- package/src/components/Textarea.tsx +46 -0
- package/src/components/ThemeProvider.tsx +80 -0
- package/src/components/Toast.tsx +159 -0
- package/src/components/Tooltip.tsx +57 -0
- package/src/components/core.test.tsx +242 -0
- package/src/components/forms-control.ts +36 -0
- package/src/components/forms.test.tsx +280 -0
- package/src/components/logo-art.ts +4 -0
- package/src/components/overlays.test.tsx +245 -0
- package/src/components/useButtonAction.ts +57 -0
- package/src/index.ts +29 -2
- package/src/modes.css +10 -0
- package/src/styles.css +2 -0
- package/src/theme.css +11 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { Share2, Star } from 'lucide-react';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { a11y } from '../../test/a11y';
|
|
7
|
+
import { Badge } from './Badge';
|
|
8
|
+
import { Button, formatCount } from './Button';
|
|
9
|
+
import { Card } from './Card';
|
|
10
|
+
import { Figure } from './Figure';
|
|
11
|
+
import { IconButton } from './IconButton';
|
|
12
|
+
import { Logo } from './Logo';
|
|
13
|
+
import { Marker } from './Marker';
|
|
14
|
+
import { MetaLabel } from './MetaLabel';
|
|
15
|
+
import { Tag } from './Tag';
|
|
16
|
+
import { ThemeProvider, useTheme } from './ThemeProvider';
|
|
17
|
+
import { useButtonAction } from './useButtonAction';
|
|
18
|
+
|
|
19
|
+
describe('Button', () => {
|
|
20
|
+
it('is a type=button that clicks', async () => {
|
|
21
|
+
const onClick = vi.fn();
|
|
22
|
+
render(<Button onClick={onClick}>Publish register</Button>);
|
|
23
|
+
const b = screen.getByRole('button', { name: 'Publish register' });
|
|
24
|
+
expect(b).toHaveAttribute('type', 'button');
|
|
25
|
+
await userEvent.click(b);
|
|
26
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('disabled with a reason stays focusable, explains itself, ignores clicks', async () => {
|
|
30
|
+
const onClick = vi.fn();
|
|
31
|
+
render(
|
|
32
|
+
<Button disabled disabledReason="Downloads are off for this share" onClick={onClick}>
|
|
33
|
+
Share
|
|
34
|
+
</Button>,
|
|
35
|
+
);
|
|
36
|
+
const b = screen.getByRole('button', { name: 'Share' });
|
|
37
|
+
expect(b).not.toBeDisabled();
|
|
38
|
+
expect(b).toHaveAttribute('aria-disabled', 'true');
|
|
39
|
+
expect(b).toHaveAttribute('title', 'Downloads are off for this share');
|
|
40
|
+
await userEvent.click(b);
|
|
41
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('busy: aria-busy, keeps its name, ignores clicks', async () => {
|
|
45
|
+
const onClick = vi.fn();
|
|
46
|
+
render(
|
|
47
|
+
<Button state="busy" onClick={onClick}>
|
|
48
|
+
Publish
|
|
49
|
+
</Button>,
|
|
50
|
+
);
|
|
51
|
+
const b = screen.getByRole('button', { name: 'Publish' });
|
|
52
|
+
expect(b).toHaveAttribute('aria-busy', 'true');
|
|
53
|
+
await userEvent.click(b);
|
|
54
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('done shows the confirmation in place', () => {
|
|
58
|
+
render(
|
|
59
|
+
<Button state="done" doneLabel="Published">
|
|
60
|
+
Publish
|
|
61
|
+
</Button>,
|
|
62
|
+
);
|
|
63
|
+
expect(screen.getByRole('button')).toHaveTextContent('Published');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('count: hidden at zero, 99+ above 99', () => {
|
|
67
|
+
expect(formatCount(0)).toBeNull();
|
|
68
|
+
expect(formatCount(12)).toBe('12');
|
|
69
|
+
expect(formatCount(140)).toBe('99+');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('primary flips to the yellow on an ink ground', () => {
|
|
73
|
+
render(<Button ground="ink">See what we do</Button>);
|
|
74
|
+
expect(screen.getByRole('button').className).toMatch(/bg-yellow-accent/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('asChild renders the link with the button look', () => {
|
|
78
|
+
render(
|
|
79
|
+
<Button asChild variant="secondary">
|
|
80
|
+
<a href="/x">Open</a>
|
|
81
|
+
</Button>,
|
|
82
|
+
);
|
|
83
|
+
expect(screen.getByRole('link', { name: 'Open' }).className).toMatch(/rounded-md/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('passes axe', async () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<div>
|
|
89
|
+
<Button>Publish</Button>
|
|
90
|
+
<Button variant="secondary" count={12}>
|
|
91
|
+
Comments
|
|
92
|
+
</Button>
|
|
93
|
+
<Button disabled disabledReason="Still converting to PDF">
|
|
94
|
+
Download
|
|
95
|
+
</Button>
|
|
96
|
+
</div>,
|
|
97
|
+
);
|
|
98
|
+
expect(await a11y(container)).toHaveNoViolations();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('useButtonAction', () => {
|
|
103
|
+
it('busy → done → idle', async () => {
|
|
104
|
+
vi.useFakeTimers();
|
|
105
|
+
let resolve!: () => void;
|
|
106
|
+
const { result } = renderHook(() => useButtonAction(() => new Promise<void>((r) => (resolve = r))));
|
|
107
|
+
let p!: Promise<void>;
|
|
108
|
+
act(() => {
|
|
109
|
+
p = result.current.run();
|
|
110
|
+
});
|
|
111
|
+
expect(result.current.state).toBe('busy');
|
|
112
|
+
await act(async () => {
|
|
113
|
+
resolve();
|
|
114
|
+
await p;
|
|
115
|
+
});
|
|
116
|
+
expect(result.current.state).toBe('done');
|
|
117
|
+
act(() => vi.advanceTimersByTime(1600));
|
|
118
|
+
expect(result.current.state).toBe('idle');
|
|
119
|
+
vi.useRealTimers();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('a failure returns to idle with the error, and runs are not doubled while busy', async () => {
|
|
123
|
+
const action = vi.fn().mockRejectedValue(new Error("Couldn't reach the mail server."));
|
|
124
|
+
const { result } = renderHook(() => useButtonAction(action));
|
|
125
|
+
await act(async () => {
|
|
126
|
+
await Promise.all([result.current.run(), result.current.run()]);
|
|
127
|
+
});
|
|
128
|
+
expect(action).toHaveBeenCalledOnce();
|
|
129
|
+
expect(result.current.state).toBe('idle');
|
|
130
|
+
expect(String(result.current.error)).toMatch(/mail server/);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('IconButton', () => {
|
|
135
|
+
it('has a label, and the count joins it', () => {
|
|
136
|
+
render(<IconButton icon={Star} label="Comments" count={120} />);
|
|
137
|
+
expect(screen.getByRole('button', { name: 'Comments (99+)' })).toBeInTheDocument();
|
|
138
|
+
});
|
|
139
|
+
it('passes axe', async () => {
|
|
140
|
+
const { container } = render(<IconButton icon={Share2} label="Share" variant="outline" />);
|
|
141
|
+
expect(await a11y(container)).toHaveNoViolations();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('Badge', () => {
|
|
146
|
+
it('defaults the appearance by status', () => {
|
|
147
|
+
const { container } = render(
|
|
148
|
+
<>
|
|
149
|
+
<Badge status="attention">Awaiting you</Badge>
|
|
150
|
+
<Badge status="progress">In progress</Badge>
|
|
151
|
+
<Badge status="complete">Delivered</Badge>
|
|
152
|
+
</>,
|
|
153
|
+
);
|
|
154
|
+
const [a, p, c] = container.querySelectorAll('[data-status]');
|
|
155
|
+
expect(a!.className).toMatch(/bg-brand-mark/);
|
|
156
|
+
expect(p!.className).toMatch(/border-fg/);
|
|
157
|
+
expect(c!.querySelector('[aria-hidden]')!.className).toMatch(/bg-fg\b/);
|
|
158
|
+
});
|
|
159
|
+
it('unknown status falls back to neutral', () => {
|
|
160
|
+
const { container } = render(<Badge status="quarantined-ish">Draft</Badge>);
|
|
161
|
+
expect(container.querySelector('[data-status]')).toHaveAttribute('data-status', 'neutral');
|
|
162
|
+
});
|
|
163
|
+
it('compact keeps the wording for screen readers and on hover', () => {
|
|
164
|
+
render(
|
|
165
|
+
<Badge status="error" compact>
|
|
166
|
+
Overdue
|
|
167
|
+
</Badge>,
|
|
168
|
+
);
|
|
169
|
+
const text = screen.getByText('Overdue');
|
|
170
|
+
expect(text).toHaveClass('sr-only');
|
|
171
|
+
expect(text.parentElement).toHaveAttribute('title', 'Overdue');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('Tag', () => {
|
|
176
|
+
it('removes', async () => {
|
|
177
|
+
const onRemove = vi.fn();
|
|
178
|
+
render(
|
|
179
|
+
<Tag onRemove={onRemove} removeLabel="Remove PDF">
|
|
180
|
+
PDF
|
|
181
|
+
</Tag>,
|
|
182
|
+
);
|
|
183
|
+
await userEvent.click(screen.getByRole('button', { name: 'Remove PDF' }));
|
|
184
|
+
expect(onRemove).toHaveBeenCalledOnce();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('Logo, Card, Figure, Marker, MetaLabel', () => {
|
|
189
|
+
it('logo has an accessible name and never paints itself yellow', () => {
|
|
190
|
+
render(<Logo />);
|
|
191
|
+
const svg = screen.getByRole('img', { name: 'COSX' });
|
|
192
|
+
expect(svg).toHaveAttribute('fill', 'currentColor');
|
|
193
|
+
});
|
|
194
|
+
it('card outline defaults by ground', () => {
|
|
195
|
+
const { container } = render(
|
|
196
|
+
<>
|
|
197
|
+
<Card>a</Card>
|
|
198
|
+
<Card ground="sunk">b</Card>
|
|
199
|
+
</>,
|
|
200
|
+
);
|
|
201
|
+
const [page, sunk] = container.querySelectorAll('div');
|
|
202
|
+
expect(page!.className).toMatch(/border-rule/);
|
|
203
|
+
expect(sunk!.className).not.toMatch(/border-rule/);
|
|
204
|
+
});
|
|
205
|
+
it('figure, marker and label render', async () => {
|
|
206
|
+
const { container } = render(
|
|
207
|
+
<div>
|
|
208
|
+
<Figure value="117" label="Organisations" source="Verified September 2026" />
|
|
209
|
+
<h2>
|
|
210
|
+
Of 47 organisations, <Marker>20 name no regulator</Marker>
|
|
211
|
+
</h2>
|
|
212
|
+
<MetaLabel rule="yellow">03 / Who we serve</MetaLabel>
|
|
213
|
+
</div>,
|
|
214
|
+
);
|
|
215
|
+
expect(container.querySelector('.marker')).toHaveTextContent('20 name no regulator');
|
|
216
|
+
expect(await a11y(container)).toHaveNoViolations();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('ThemeProvider', () => {
|
|
221
|
+
it('sets data-mode="ink" when ink is chosen and clears it for light', () => {
|
|
222
|
+
function Switch() {
|
|
223
|
+
const { setMode, resolved } = useTheme();
|
|
224
|
+
return (
|
|
225
|
+
<>
|
|
226
|
+
<span>{resolved}</span>
|
|
227
|
+
<button onClick={() => setMode('ink')}>ink</button>
|
|
228
|
+
<button onClick={() => setMode('light')}>light</button>
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
render(
|
|
233
|
+
<ThemeProvider storageKey={null}>
|
|
234
|
+
<Switch />
|
|
235
|
+
</ThemeProvider>,
|
|
236
|
+
);
|
|
237
|
+
act(() => screen.getByText('ink', { selector: 'button' }).click());
|
|
238
|
+
expect(document.documentElement).toHaveAttribute('data-mode', 'ink');
|
|
239
|
+
act(() => screen.getByText('light', { selector: 'button' }).click());
|
|
240
|
+
expect(document.documentElement).not.toHaveAttribute('data-mode');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared frame of text controls (Input, Textarea, Select trigger):
|
|
5
|
+
* sunk to linen, a --rule edge, ink edge + focus ring on focus, the one
|
|
6
|
+
* red on error, well + grey text when disabled. Single-line heights are
|
|
7
|
+
* fixed at 32 · 38 · 44 (Textarea overrides with h-auto).
|
|
8
|
+
*/
|
|
9
|
+
export const controlFrame = cva(
|
|
10
|
+
[
|
|
11
|
+
'flex w-full items-center gap-2 rounded-md border font-sans text-fg',
|
|
12
|
+
'transition-[border-color,background-color] duration-[120ms] ease-standard',
|
|
13
|
+
],
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
variant: {
|
|
17
|
+
default: 'border-rule bg-sunk focus-within:border-fg focus-within:shadow-(--focus-ring)',
|
|
18
|
+
borderless:
|
|
19
|
+
'border-transparent bg-transparent hover:bg-hover focus-within:border-fg focus-within:bg-sunk focus-within:shadow-(--focus-ring)',
|
|
20
|
+
},
|
|
21
|
+
size: {
|
|
22
|
+
sm: 'h-8 px-2.5 text-[13px]',
|
|
23
|
+
md: 'h-[38px] px-2.5 text-[13px]',
|
|
24
|
+
lg: 'h-11 px-3 text-[15px]',
|
|
25
|
+
},
|
|
26
|
+
invalid: { true: 'border-error focus-within:border-error', false: '' },
|
|
27
|
+
disabled: { true: 'cursor-not-allowed border-rule-soft bg-well text-fg-secondary', false: '' },
|
|
28
|
+
readOnly: { true: 'border-rule-soft', false: '' },
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: { variant: 'default', size: 'md', invalid: false, disabled: false, readOnly: false },
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** The bare native element inside the frame. */
|
|
35
|
+
export const controlInner =
|
|
36
|
+
'min-w-0 flex-1 border-none bg-transparent p-0 font-sans text-inherit outline-none placeholder:text-fg-secondary disabled:cursor-not-allowed';
|
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Generated by scripts/gen-logo.mjs from the COSX Design System 3.0
|
|
2
|
+
// logo artwork. Do not edit — regenerate.
|
|
3
|
+
export const WORDMARK = {"viewBox":"32 113 225 62","paths":["M242.79,170.43L228.93,152.02L215.07,170.43L204.43,170.43L223.66,145.02L204.29,119.29L214.99,119.29L229.06,137.89L243.14,119.29L253.58,119.29L234.34,144.85L253.7,170.43L242.79,170.43Z","M121.49,172.05a27.37,27.37,0,0,1-25.91-18.53l-1.17-3.42L84.33,161.86l-.49.58-.62.71A27.39,27.39,0,1,1,82,125l-5.92,5.89A18.25,18.25,0,0,0,63,125.36c-10.31,0-18.71,8.65-18.71,19.28s8.4,19.28,18.71,19.28A18.6,18.6,0,0,0,77.22,157c2.12-2.47,4.1-5.09,6-7.63,1-1.27,2-2.69,3.08-4l12.09-15.45.07-.09a25,25,0,0,1,2.91-3.48l.36-.38a27.07,27.07,0,0,1,19.74-8.69,27.38,27.38,0,1,1,0,54.76Zm0-46.69c-10.32,0-18.71,8.65-18.71,19.28s8.39,19.28,18.71,19.28,18.71-8.65,18.71-19.28S131.81,125.36,121.49,125.36Z","M177.23,171.65a34.27,34.27,0,0,1-22.3-8.3l3.65-7.11c5.58,4.82,12.57,7.68,18.81,7.68,6.85,0,10.93-2.65,10.93-7.08,0-5.71-6.22-7.48-12.82-9.35-8.16-2.31-17.41-4.94-17.41-15.42,0-8.94,7.83-14.72,19.94-14.72a36.71,36.71,0,0,1,19,5.31L193.51,130c-5.34-3.06-11.36-4.87-16.2-4.87-6,0-9.57,2.29-9.57,6.14,0,5.56,6.13,7.36,12.63,9.28,8.23,2.43,17.56,5.18,17.52,15.87C197.89,165.51,189.58,171.65,177.23,171.65Z"]} as const;
|
|
4
|
+
export const LOOP = {"viewBox":"0 0 289.34 289.34","paths":["M193.32,99.1a45.06,45.06,0,0,0-32.85,14.46l-.59.64A41.46,41.46,0,0,0,155,120l-.1.16-20.13,25.7c-1.74,2.22-3.53,4.58-5.12,6.69-3.2,4.22-6.5,8.59-10,12.7-6.31,7.39-14.71,11.46-23.63,11.46-17.17,0-31.13-14.39-31.13-32.08h0c0-17.69,14-32.08,31.13-32.08a30.37,30.37,0,0,1,21.8,9.19l9.85-9.81a45.55,45.55,0,1,0,2,63.51l1-1.19.83-1,16.76-19.56,1.95,5.69a45.57,45.57,0,1,0,43.12-60.3Zm0,77.6c-17.17,0-31.14-14.39-31.14-32.08s14-32.08,31.14-32.08,31.13,14.39,31.13,32.08S210.49,176.7,193.32,176.7Z"]} as const;
|