@fragments-sdk/ui 0.7.4 → 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 (68) hide show
  1. package/fragments.json +1 -1
  2. package/package.json +9 -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.test.tsx +80 -0
  6. package/src/components/Avatar/Avatar.test.tsx +40 -0
  7. package/src/components/Badge/Badge.test.tsx +58 -0
  8. package/src/components/Box/Box.test.tsx +43 -0
  9. package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
  10. package/src/components/Button/Button.test.tsx +53 -0
  11. package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
  12. package/src/components/Card/Card.test.tsx +71 -0
  13. package/src/components/Chart/Chart.test.tsx +123 -0
  14. package/src/components/Checkbox/Checkbox.test.tsx +63 -0
  15. package/src/components/Chip/Chip.module.scss +54 -1
  16. package/src/components/Chip/Chip.test.tsx +50 -0
  17. package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
  18. package/src/components/Collapsible/Collapsible.test.tsx +103 -0
  19. package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
  20. package/src/components/ColorPicker/index.tsx +4 -1
  21. package/src/components/Combobox/Combobox.test.tsx +202 -0
  22. package/src/components/ConversationList/ConversationList.test.tsx +79 -0
  23. package/src/components/Dialog/Dialog.test.tsx +277 -0
  24. package/src/components/EmptyState/EmptyState.test.tsx +67 -0
  25. package/src/components/Field/Field.test.tsx +65 -0
  26. package/src/components/Fieldset/Fieldset.test.tsx +48 -0
  27. package/src/components/Form/Form.test.tsx +41 -0
  28. package/src/components/Grid/Grid.test.tsx +65 -0
  29. package/src/components/Header/Header.test.tsx +83 -0
  30. package/src/components/Icon/Icon.test.tsx +38 -0
  31. package/src/components/Image/Image.test.tsx +39 -0
  32. package/src/components/Input/Input.test.tsx +72 -0
  33. package/src/components/Link/Link.test.tsx +37 -0
  34. package/src/components/List/List.test.tsx +57 -0
  35. package/src/components/Listbox/Listbox.module.scss +2 -1
  36. package/src/components/Listbox/Listbox.test.tsx +100 -0
  37. package/src/components/Listbox/index.tsx +26 -3
  38. package/src/components/Loading/Loading.test.tsx +38 -0
  39. package/src/components/Markdown/Markdown.test.tsx +41 -0
  40. package/src/components/Menu/Menu.test.tsx +336 -0
  41. package/src/components/Message/Message.test.tsx +75 -0
  42. package/src/components/Popover/Popover.test.tsx +105 -0
  43. package/src/components/Progress/Progress.test.tsx +58 -0
  44. package/src/components/Prompt/Prompt.test.tsx +89 -0
  45. package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
  46. package/src/components/Select/Select.test.tsx +161 -0
  47. package/src/components/Separator/Separator.test.tsx +33 -0
  48. package/src/components/Sidebar/Sidebar.test.tsx +85 -0
  49. package/src/components/Skeleton/Skeleton.test.tsx +56 -0
  50. package/src/components/Slider/Slider.test.tsx +51 -0
  51. package/src/components/Stack/Stack.test.tsx +47 -0
  52. package/src/components/Table/Table.test.tsx +129 -0
  53. package/src/components/Tabs/Tabs.test.tsx +180 -0
  54. package/src/components/Text/Text.test.tsx +40 -0
  55. package/src/components/Textarea/Textarea.test.tsx +57 -0
  56. package/src/components/Theme/Theme.test.tsx +114 -0
  57. package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
  58. package/src/components/Toast/Toast.test.tsx +192 -0
  59. package/src/components/Toast/index.tsx +14 -4
  60. package/src/components/Toggle/Toggle.test.tsx +49 -0
  61. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
  62. package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
  63. package/src/components/ToggleGroup/index.tsx +17 -2
  64. package/src/components/Tooltip/Tooltip.test.tsx +107 -0
  65. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  66. package/src/test/setup.ts +74 -0
  67. package/src/test/utils.tsx +71 -0
  68. package/src/utils/a11y.test.tsx +79 -0
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
3
+ import { Field } from './index';
4
+
5
+ describe('Field', () => {
6
+ it('renders children inside the field root', () => {
7
+ render(
8
+ <Field>
9
+ <Field.Label>Username</Field.Label>
10
+ <Field.Control><input /></Field.Control>
11
+ </Field>
12
+ );
13
+ expect(screen.getByText('Username')).toBeInTheDocument();
14
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
15
+ });
16
+
17
+ it('associates label with control', () => {
18
+ render(
19
+ <Field>
20
+ <Field.Label>Username</Field.Label>
21
+ <Field.Control><input /></Field.Control>
22
+ </Field>
23
+ );
24
+ expect(screen.getByRole('textbox')).toHaveAccessibleName('Username');
25
+ });
26
+
27
+ it('renders description text', () => {
28
+ render(
29
+ <Field>
30
+ <Field.Label>Email</Field.Label>
31
+ <Field.Control><input /></Field.Control>
32
+ <Field.Description>We will never share your email.</Field.Description>
33
+ </Field>
34
+ );
35
+ expect(screen.getByText('We will never share your email.')).toBeInTheDocument();
36
+ });
37
+
38
+ it('sets aria-invalid on control when invalid', () => {
39
+ render(
40
+ <Field invalid>
41
+ <Field.Label>Email</Field.Label>
42
+ <Field.Control><input /></Field.Control>
43
+ </Field>
44
+ );
45
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
46
+ });
47
+
48
+ it('exposes compound component pattern', () => {
49
+ expect(Field.Label).toBeDefined();
50
+ expect(Field.Control).toBeDefined();
51
+ expect(Field.Description).toBeDefined();
52
+ expect(Field.Error).toBeDefined();
53
+ expect(Field.Validity).toBeDefined();
54
+ });
55
+
56
+ it('has no accessibility violations', async () => {
57
+ const { container } = render(
58
+ <Field>
59
+ <Field.Label>Accessible field</Field.Label>
60
+ <Field.Control><input /></Field.Control>
61
+ </Field>
62
+ );
63
+ await expectNoA11yViolations(container);
64
+ });
65
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
3
+ import { Fieldset } from './index';
4
+
5
+ describe('Fieldset', () => {
6
+ it('renders a fieldset element', () => {
7
+ render(
8
+ <Fieldset>
9
+ <Fieldset.Legend>Personal Info</Fieldset.Legend>
10
+ <input aria-label="Name" />
11
+ </Fieldset>
12
+ );
13
+ // Base UI renders a fieldset element
14
+ const fieldset = screen.getByRole('group');
15
+ expect(fieldset).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders a legend', () => {
19
+ render(
20
+ <Fieldset>
21
+ <Fieldset.Legend>Contact Details</Fieldset.Legend>
22
+ </Fieldset>
23
+ );
24
+ expect(screen.getByText('Contact Details')).toBeInTheDocument();
25
+ });
26
+
27
+ it('passes disabled prop to fieldset element', () => {
28
+ const { container } = render(
29
+ <Fieldset disabled>
30
+ <Fieldset.Legend>Settings</Fieldset.Legend>
31
+ <input aria-label="Option" />
32
+ </Fieldset>
33
+ );
34
+ const fieldset = container.querySelector('fieldset');
35
+ expect(fieldset).toBeInTheDocument();
36
+ expect(fieldset).toHaveAttribute('data-disabled', '');
37
+ });
38
+
39
+ it('has no accessibility violations', async () => {
40
+ const { container } = render(
41
+ <Fieldset>
42
+ <Fieldset.Legend>Accessible fieldset</Fieldset.Legend>
43
+ <input aria-label="Field" />
44
+ </Fieldset>
45
+ );
46
+ await expectNoA11yViolations(container);
47
+ });
48
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Form } from './index';
4
+
5
+ describe('Form', () => {
6
+ it('renders a form element', () => {
7
+ const { container } = render(<Form>content</Form>);
8
+ expect(container.querySelector('form')).toBeInTheDocument();
9
+ });
10
+
11
+ it('calls onFormSubmit when submitted', async () => {
12
+ const handleSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
13
+ const user = userEvent.setup();
14
+ render(
15
+ <Form onFormSubmit={handleSubmit} aria-label="Test form">
16
+ <button type="submit">Submit</button>
17
+ </Form>
18
+ );
19
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
20
+ expect(handleSubmit).toHaveBeenCalledTimes(1);
21
+ });
22
+
23
+ it('renders children', () => {
24
+ render(
25
+ <Form aria-label="Test form">
26
+ <span>Child content</span>
27
+ </Form>
28
+ );
29
+ expect(screen.getByText('Child content')).toBeInTheDocument();
30
+ });
31
+
32
+ it('has no accessibility violations', async () => {
33
+ const { container } = render(
34
+ <Form aria-label="Accessible form">
35
+ <label htmlFor="f">Name</label>
36
+ <input id="f" />
37
+ </Form>
38
+ );
39
+ await expectNoA11yViolations(container);
40
+ });
41
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
3
+ import { Grid } from './index';
4
+
5
+ describe('Grid', () => {
6
+ it('renders children', () => {
7
+ render(
8
+ <Grid>
9
+ <div>Item 1</div>
10
+ <div>Item 2</div>
11
+ </Grid>
12
+ );
13
+ expect(screen.getByText('Item 1')).toBeInTheDocument();
14
+ expect(screen.getByText('Item 2')).toBeInTheDocument();
15
+ });
16
+
17
+ it('applies fixed column class', () => {
18
+ const { container } = render(<Grid columns={3}>Content</Grid>);
19
+ expect(container.firstChild).toHaveClass('columns3');
20
+ });
21
+
22
+ it('applies responsive column CSS variables', () => {
23
+ const { container } = render(
24
+ <Grid columns={{ base: 1, md: 2, lg: 3 }}>Content</Grid>
25
+ );
26
+ const el = container.firstChild as HTMLElement;
27
+ expect(el).toHaveClass('columnsResponsive');
28
+ expect(el.style.getPropertyValue('--fui-grid-cols')).toBe('1');
29
+ expect(el.style.getPropertyValue('--fui-grid-cols-md')).toBe('2');
30
+ expect(el.style.getPropertyValue('--fui-grid-cols-lg')).toBe('3');
31
+ });
32
+
33
+ it('applies gap classes', () => {
34
+ const { container } = render(<Grid gap="lg">Content</Grid>);
35
+ expect(container.firstChild).toHaveClass('gapLg');
36
+ });
37
+
38
+ it('forwards ref', () => {
39
+ const ref = vi.fn();
40
+ render(<Grid ref={ref}>Content</Grid>);
41
+ expect(ref).toHaveBeenCalled();
42
+ });
43
+
44
+ it('renders Grid.Item with colSpan', () => {
45
+ const { container } = render(
46
+ <Grid columns={3}>
47
+ <Grid.Item colSpan={2}>Wide</Grid.Item>
48
+ <Grid.Item>Normal</Grid.Item>
49
+ </Grid>
50
+ );
51
+ const wideItem = screen.getByText('Wide').parentElement ?? screen.getByText('Wide');
52
+ // The item that contains "Wide" should have colSpan class
53
+ expect(container.querySelector('.colSpan2')).toBeInTheDocument();
54
+ });
55
+
56
+ it('has no accessibility violations', async () => {
57
+ const { container } = render(
58
+ <Grid columns={2}>
59
+ <Grid.Item>A</Grid.Item>
60
+ <Grid.Item>B</Grid.Item>
61
+ </Grid>
62
+ );
63
+ await expectNoA11yViolations(container);
64
+ });
65
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
3
+ import { Header } from './index';
4
+
5
+ describe('Header', () => {
6
+ it('renders as a banner landmark (header element)', () => {
7
+ render(
8
+ <Header>
9
+ <Header.Brand>Logo</Header.Brand>
10
+ </Header>
11
+ );
12
+ expect(screen.getByRole('banner')).toBeInTheDocument();
13
+ });
14
+
15
+ it('renders Brand slot content', () => {
16
+ render(
17
+ <Header>
18
+ <Header.Brand>My App</Header.Brand>
19
+ </Header>
20
+ );
21
+ expect(screen.getByText('My App')).toBeInTheDocument();
22
+ });
23
+
24
+ it('renders Brand as a link when href is provided', () => {
25
+ render(
26
+ <Header>
27
+ <Header.Brand href="/">Home</Header.Brand>
28
+ </Header>
29
+ );
30
+ const link = screen.getByRole('link', { name: 'Home' });
31
+ expect(link).toHaveAttribute('href', '/');
32
+ });
33
+
34
+ it('renders navigation with accessible label', () => {
35
+ render(
36
+ <Header>
37
+ <Header.Nav aria-label="Primary navigation">
38
+ <Header.NavItem href="/about">About</Header.NavItem>
39
+ <Header.NavItem href="/contact">Contact</Header.NavItem>
40
+ </Header.Nav>
41
+ </Header>
42
+ );
43
+ expect(screen.getByRole('navigation', { name: 'Primary navigation' })).toBeInTheDocument();
44
+ expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument();
45
+ expect(screen.getByRole('link', { name: 'Contact' })).toBeInTheDocument();
46
+ });
47
+
48
+ it('marks active NavItem with aria-current="page"', () => {
49
+ render(
50
+ <Header>
51
+ <Header.Nav>
52
+ <Header.NavItem href="/about" active>About</Header.NavItem>
53
+ <Header.NavItem href="/contact">Contact</Header.NavItem>
54
+ </Header.Nav>
55
+ </Header>
56
+ );
57
+ expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('aria-current', 'page');
58
+ expect(screen.getByRole('link', { name: 'Contact' })).not.toHaveAttribute('aria-current');
59
+ });
60
+
61
+ it('renders Actions slot', () => {
62
+ render(
63
+ <Header>
64
+ <Header.Actions>
65
+ <button>Sign In</button>
66
+ </Header.Actions>
67
+ </Header>
68
+ );
69
+ expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
70
+ });
71
+
72
+ it('has no accessibility violations', async () => {
73
+ const { container } = render(
74
+ <Header>
75
+ <Header.Brand>Logo</Header.Brand>
76
+ <Header.Nav aria-label="Main">
77
+ <Header.NavItem href="/home">Home</Header.NavItem>
78
+ </Header.Nav>
79
+ </Header>
80
+ );
81
+ await expectNoA11yViolations(container);
82
+ });
83
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, expectNoA11yViolations } from '../../test/utils';
3
+ import { Icon } from './index';
4
+
5
+ // Mock Phosphor icon component
6
+ function MockIcon(props: { size?: number; weight?: string }) {
7
+ return <svg data-testid="mock-icon" data-size={props.size} data-weight={props.weight} />;
8
+ }
9
+
10
+ describe('Icon', () => {
11
+ it('renders the icon component inside a span', () => {
12
+ const { container } = render(<Icon icon={MockIcon} />);
13
+ const wrapper = container.firstChild as HTMLElement;
14
+ expect(wrapper.tagName).toBe('SPAN');
15
+ expect(wrapper.querySelector('svg')).toBeInTheDocument();
16
+ });
17
+
18
+ it('passes the correct pixel size to the icon', () => {
19
+ const { container } = render(<Icon icon={MockIcon} size="lg" />);
20
+ const svg = container.querySelector('[data-testid="mock-icon"]');
21
+ expect(svg).toHaveAttribute('data-size', '24');
22
+ });
23
+
24
+ it('applies variant color class', () => {
25
+ const { container } = render(<Icon icon={MockIcon} variant="error" />);
26
+ expect(container.firstChild).toHaveClass('error');
27
+ });
28
+
29
+ it('does not add aria-hidden by default (wrapping span is presentational)', () => {
30
+ const { container } = render(<Icon icon={MockIcon} aria-hidden="true" />);
31
+ expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
32
+ });
33
+
34
+ it('has no accessibility violations', async () => {
35
+ const { container } = render(<Icon icon={MockIcon} aria-hidden="true" />);
36
+ await expectNoA11yViolations(container);
37
+ });
38
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, act, expectNoA11yViolations } from '../../test/utils';
3
+ import { Image } from './index';
4
+
5
+ describe('Image', () => {
6
+ it('renders an img element with src and alt', () => {
7
+ render(<Image src="/photo.jpg" alt="A photo" />);
8
+ const img = screen.getByRole('img', { name: 'A photo' });
9
+ expect(img).toHaveAttribute('src', '/photo.jpg');
10
+ });
11
+
12
+ it('shows fallback while loading', () => {
13
+ render(<Image src="/photo.jpg" alt="Photo" fallback={<span>Loading...</span>} />);
14
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
15
+ // Image should be present but hidden (opacity 0)
16
+ const img = screen.getByRole('img');
17
+ expect(img.style.opacity).toBe('0');
18
+ });
19
+
20
+ it('shows fallback on error', () => {
21
+ render(<Image src="/broken.jpg" alt="Broken" fallback={<span>Error</span>} />);
22
+ const img = screen.getByRole('img');
23
+ // Simulate error
24
+ act(() => {
25
+ img.dispatchEvent(new Event('error', { bubbles: false }));
26
+ });
27
+ expect(screen.getByText('Error')).toBeInTheDocument();
28
+ });
29
+
30
+ it('applies aspect ratio class', () => {
31
+ const { container } = render(<Image src="/photo.jpg" alt="Photo" aspectRatio="16:9" />);
32
+ expect(container.firstChild).toHaveClass('aspect-16-9');
33
+ });
34
+
35
+ it('has no accessibility violations', async () => {
36
+ const { container } = render(<Image src="/photo.jpg" alt="Accessible photo" />);
37
+ await expectNoA11yViolations(container);
38
+ });
39
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Input } from './index';
4
+
5
+ describe('Input', () => {
6
+ it('renders a textbox', () => {
7
+ render(<Input aria-label="Name" />);
8
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
9
+ });
10
+
11
+ it('renders label via Field.Label when label prop is provided', () => {
12
+ render(<Input label="Email" />);
13
+ const input = screen.getByRole('textbox');
14
+ expect(screen.getByText('Email')).toBeInTheDocument();
15
+ // Field.Label associates via htmlFor — input should be labelled
16
+ expect(input).toHaveAccessibleName('Email');
17
+ });
18
+
19
+ it('associates helperText via aria-describedby', () => {
20
+ render(<Input label="Password" helperText="Must be 8+ characters" />);
21
+ const input = screen.getByRole('textbox');
22
+ expect(input).toHaveAccessibleDescription('Must be 8+ characters');
23
+ });
24
+
25
+ it('sets aria-invalid when error is true', () => {
26
+ render(<Input label="Email" error />);
27
+ const input = screen.getByRole('textbox');
28
+ expect(input).toHaveAttribute('aria-invalid', 'true');
29
+ });
30
+
31
+ it('disables the input when disabled prop is true', () => {
32
+ render(<Input label="Name" disabled />);
33
+ expect(screen.getByRole('textbox')).toBeDisabled();
34
+ });
35
+
36
+ it('renders a controlled value', () => {
37
+ render(<Input label="Name" value="hello" onChange={() => {}} />);
38
+ expect(screen.getByRole('textbox')).toHaveValue('hello');
39
+ });
40
+
41
+ it('renders shortcut as a kbd element', () => {
42
+ const { container } = render(<Input aria-label="Search" shortcut="⌘K" />);
43
+ const kbd = container.querySelector('kbd');
44
+ expect(kbd).toBeInTheDocument();
45
+ expect(kbd).toHaveTextContent('⌘K');
46
+ });
47
+
48
+ it('applies size class', () => {
49
+ render(<Input aria-label="Name" size="sm" />);
50
+ const input = screen.getByRole('textbox');
51
+ expect(input.className).toContain('sm');
52
+ });
53
+
54
+ it('forwards ref to the input element', () => {
55
+ const ref = vi.fn<(el: HTMLInputElement | null) => void>();
56
+ render(<Input aria-label="Name" ref={ref} />);
57
+ expect(ref).toHaveBeenCalledWith(expect.any(HTMLInputElement));
58
+ });
59
+
60
+ it('calls onChange with the string value', async () => {
61
+ const handleChange = vi.fn();
62
+ const user = userEvent.setup();
63
+ render(<Input label="Name" onChange={handleChange} />);
64
+ await user.type(screen.getByRole('textbox'), 'a');
65
+ expect(handleChange).toHaveBeenCalledWith('a');
66
+ });
67
+
68
+ it('has no accessibility violations', async () => {
69
+ const { container } = render(<Input label="Accessible input" />);
70
+ await expectNoA11yViolations(container);
71
+ });
72
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
3
+ import { Link } from './index';
4
+
5
+ describe('Link', () => {
6
+ it('renders an anchor element', () => {
7
+ render(<Link href="/page">Go</Link>);
8
+ const link = screen.getByRole('link', { name: 'Go' });
9
+ expect(link.tagName).toBe('A');
10
+ expect(link).toHaveAttribute('href', '/page');
11
+ });
12
+
13
+ it('applies variant and underline classes', () => {
14
+ render(<Link href="#" variant="subtle" underline="always">Styled</Link>);
15
+ const link = screen.getByRole('link');
16
+ expect(link).toHaveClass('subtle');
17
+ expect(link).toHaveClass('underline-always');
18
+ });
19
+
20
+ it('adds external link attributes', () => {
21
+ render(<Link href="https://example.com" external>External</Link>);
22
+ const link = screen.getByRole('link');
23
+ expect(link).toHaveAttribute('target', '_blank');
24
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
25
+ });
26
+
27
+ it('forwards ref', () => {
28
+ const ref = vi.fn();
29
+ render(<Link ref={ref} href="#">Ref</Link>);
30
+ expect(ref).toHaveBeenCalled();
31
+ });
32
+
33
+ it('has no accessibility violations', async () => {
34
+ const { container } = render(<Link href="/page">Accessible link</Link>);
35
+ await expectNoA11yViolations(container);
36
+ });
37
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
3
+ import { List } from './index';
4
+
5
+ describe('List', () => {
6
+ it('renders as <ul> by default', () => {
7
+ render(
8
+ <List>
9
+ <List.Item>Item 1</List.Item>
10
+ <List.Item>Item 2</List.Item>
11
+ </List>
12
+ );
13
+ expect(screen.getByRole('list').tagName).toBe('UL');
14
+ });
15
+
16
+ it('renders as <ol> when as="ol"', () => {
17
+ render(
18
+ <List as="ol">
19
+ <List.Item>First</List.Item>
20
+ </List>
21
+ );
22
+ expect(screen.getByRole('list').tagName).toBe('OL');
23
+ });
24
+
25
+ it('renders list items', () => {
26
+ render(
27
+ <List>
28
+ <List.Item>Alpha</List.Item>
29
+ <List.Item>Beta</List.Item>
30
+ <List.Item>Gamma</List.Item>
31
+ </List>
32
+ );
33
+ const items = screen.getAllByRole('listitem');
34
+ expect(items).toHaveLength(3);
35
+ expect(items[0]).toHaveTextContent('Alpha');
36
+ });
37
+
38
+ it('renders icon items when variant is "icon"', () => {
39
+ render(
40
+ <List variant="icon">
41
+ <List.Item icon={<span data-testid="star">*</span>}>Starred</List.Item>
42
+ </List>
43
+ );
44
+ expect(screen.getByTestId('star')).toBeInTheDocument();
45
+ expect(screen.getByText('Starred')).toBeInTheDocument();
46
+ });
47
+
48
+ it('has no accessibility violations', async () => {
49
+ const { container } = render(
50
+ <List>
51
+ <List.Item>Item A</List.Item>
52
+ <List.Item>Item B</List.Item>
53
+ </List>
54
+ );
55
+ await expectNoA11yViolations(container);
56
+ });
57
+ });
@@ -100,7 +100,8 @@
100
100
  border-width: 2px;
101
101
  }
102
102
 
103
- .itemSelected {
103
+ .itemSelected,
104
+ .itemActive {
104
105
  outline: 2px solid var(--fui-color-accent);
105
106
  outline-offset: -2px;
106
107
  }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Listbox } from './index';
4
+
5
+ describe('Listbox', () => {
6
+ it('renders with listbox role', () => {
7
+ render(
8
+ <Listbox aria-label="Fruits">
9
+ <Listbox.Item>Apple</Listbox.Item>
10
+ <Listbox.Item>Banana</Listbox.Item>
11
+ </Listbox>
12
+ );
13
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
14
+ });
15
+
16
+ it('renders items with option role', () => {
17
+ render(
18
+ <Listbox aria-label="Fruits">
19
+ <Listbox.Item>Apple</Listbox.Item>
20
+ <Listbox.Item>Banana</Listbox.Item>
21
+ </Listbox>
22
+ );
23
+ const options = screen.getAllByRole('option');
24
+ expect(options).toHaveLength(2);
25
+ expect(options[0]).toHaveTextContent('Apple');
26
+ });
27
+
28
+ it('reflects selected state with aria-selected', () => {
29
+ render(
30
+ <Listbox aria-label="Fruits">
31
+ <Listbox.Item selected>Apple</Listbox.Item>
32
+ <Listbox.Item>Banana</Listbox.Item>
33
+ </Listbox>
34
+ );
35
+ expect(screen.getByText('Apple').closest('[role="option"]')).toHaveAttribute('aria-selected', 'true');
36
+ expect(screen.getByText('Banana').closest('[role="option"]')).toHaveAttribute('aria-selected', 'false');
37
+ });
38
+
39
+ it('marks disabled items with aria-disabled', () => {
40
+ render(
41
+ <Listbox aria-label="Fruits">
42
+ <Listbox.Item disabled>Apple</Listbox.Item>
43
+ <Listbox.Item>Banana</Listbox.Item>
44
+ </Listbox>
45
+ );
46
+ expect(screen.getByText('Apple').closest('[role="option"]')).toHaveAttribute('aria-disabled', 'true');
47
+ });
48
+
49
+ it('calls onClick when an item is clicked', async () => {
50
+ const user = userEvent.setup();
51
+ const handleClick = vi.fn();
52
+ render(
53
+ <Listbox aria-label="Fruits">
54
+ <Listbox.Item onClick={handleClick}>Apple</Listbox.Item>
55
+ </Listbox>
56
+ );
57
+ await user.click(screen.getByRole('option'));
58
+ expect(handleClick).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it('does not call onClick on disabled items', async () => {
62
+ const user = userEvent.setup();
63
+ const handleClick = vi.fn();
64
+ render(
65
+ <Listbox aria-label="Fruits">
66
+ <Listbox.Item disabled onClick={handleClick}>Apple</Listbox.Item>
67
+ </Listbox>
68
+ );
69
+ await user.click(screen.getByRole('option'));
70
+ expect(handleClick).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it('renders groups with a label', () => {
74
+ render(
75
+ <Listbox aria-label="Food">
76
+ <Listbox.Group label="Fruits">
77
+ <Listbox.Item>Apple</Listbox.Item>
78
+ </Listbox.Group>
79
+ <Listbox.Group label="Vegetables">
80
+ <Listbox.Item>Carrot</Listbox.Item>
81
+ </Listbox.Group>
82
+ </Listbox>
83
+ );
84
+ expect(screen.getByText('Fruits')).toBeInTheDocument();
85
+ expect(screen.getByText('Vegetables')).toBeInTheDocument();
86
+ const groups = screen.getAllByRole('group');
87
+ expect(groups).toHaveLength(2);
88
+ });
89
+
90
+ it('has no accessibility violations', async () => {
91
+ const { container } = render(
92
+ <Listbox aria-label="Fruits">
93
+ <Listbox.Item>Apple</Listbox.Item>
94
+ <Listbox.Item selected>Banana</Listbox.Item>
95
+ <Listbox.Item disabled>Cherry</Listbox.Item>
96
+ </Listbox>
97
+ );
98
+ await expectNoA11yViolations(container);
99
+ });
100
+ });