@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,53 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Button } from './index';
4
+
5
+ describe('Button', () => {
6
+ it('renders with children', () => {
7
+ render(<Button>Click me</Button>);
8
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
9
+ });
10
+
11
+ it('calls onClick when clicked', async () => {
12
+ const user = userEvent.setup();
13
+ const onClick = vi.fn();
14
+ render(<Button onClick={onClick}>Click</Button>);
15
+ await user.click(screen.getByRole('button'));
16
+ expect(onClick).toHaveBeenCalledOnce();
17
+ });
18
+
19
+ it('applies variant classes', () => {
20
+ const { rerender } = render(<Button variant="primary">Btn</Button>);
21
+ expect(screen.getByRole('button')).toHaveClass('primary');
22
+
23
+ rerender(<Button variant="danger">Btn</Button>);
24
+ expect(screen.getByRole('button')).toHaveClass('danger');
25
+ });
26
+
27
+ it('applies size classes', () => {
28
+ render(<Button size="lg">Btn</Button>);
29
+ expect(screen.getByRole('button')).toHaveClass('lg');
30
+ });
31
+
32
+ it('renders as an anchor when as="a"', () => {
33
+ render(<Button as="a" href="/test">Link</Button>);
34
+ const link = screen.getByRole('link', { name: 'Link' });
35
+ expect(link).toHaveAttribute('href', '/test');
36
+ });
37
+
38
+ it('supports disabled state', () => {
39
+ render(<Button disabled>Disabled</Button>);
40
+ expect(screen.getByRole('button')).toBeDisabled();
41
+ });
42
+
43
+ it('forwards ref', () => {
44
+ const ref = vi.fn();
45
+ render(<Button ref={ref}>Ref</Button>);
46
+ expect(ref).toHaveBeenCalled();
47
+ });
48
+
49
+ it('has no accessibility violations', async () => {
50
+ const { container } = render(<Button>Accessible</Button>);
51
+ await expectNoA11yViolations(container);
52
+ });
53
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
3
+ import { ButtonGroup } from './index';
4
+
5
+ describe('ButtonGroup', () => {
6
+ it('renders children buttons', () => {
7
+ render(
8
+ <ButtonGroup>
9
+ <button>Save</button>
10
+ <button>Cancel</button>
11
+ </ButtonGroup>
12
+ );
13
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
14
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
15
+ });
16
+
17
+ it('applies gap class', () => {
18
+ const { container } = render(
19
+ <ButtonGroup gap="md">
20
+ <button>A</button>
21
+ </ButtonGroup>
22
+ );
23
+ expect(container.firstElementChild).toHaveClass('gap-md');
24
+ });
25
+
26
+ it('applies wrap class when wrap is true', () => {
27
+ const { container } = render(
28
+ <ButtonGroup wrap>
29
+ <button>A</button>
30
+ </ButtonGroup>
31
+ );
32
+ expect(container.firstElementChild).toHaveClass('wrap');
33
+ });
34
+
35
+ it('has no accessibility violations', async () => {
36
+ const { container } = render(
37
+ <ButtonGroup>
38
+ <button>OK</button>
39
+ <button>Cancel</button>
40
+ </ButtonGroup>
41
+ );
42
+ await expectNoA11yViolations(container);
43
+ });
44
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Card } from './index';
4
+
5
+ describe('Card', () => {
6
+ it('renders as <article> by default', () => {
7
+ render(<Card>Content</Card>);
8
+ expect(screen.getByRole('article')).toBeInTheDocument();
9
+ });
10
+
11
+ it('renders as <button> when onClick is provided', () => {
12
+ render(<Card onClick={() => {}}>Click me</Card>);
13
+ expect(screen.getByRole('button')).toBeInTheDocument();
14
+ expect(screen.queryByRole('article')).not.toBeInTheDocument();
15
+ });
16
+
17
+ it('applies variant classes', () => {
18
+ const { rerender } = render(<Card variant="outlined">Content</Card>);
19
+ expect(screen.getByRole('article')).toHaveClass('outlined');
20
+
21
+ rerender(<Card variant="elevated">Content</Card>);
22
+ expect(screen.getByRole('article')).toHaveClass('elevated');
23
+ });
24
+
25
+ it('applies padding classes', () => {
26
+ render(<Card padding="lg">Content</Card>);
27
+ expect(screen.getByRole('article')).toHaveClass('paddingLg');
28
+ });
29
+
30
+ it('fires onClick callback', async () => {
31
+ const handleClick = vi.fn();
32
+ const user = userEvent.setup();
33
+ render(<Card onClick={handleClick}>Click me</Card>);
34
+ await user.click(screen.getByRole('button'));
35
+ expect(handleClick).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it('renders compound sub-components', () => {
39
+ render(
40
+ <Card>
41
+ <Card.Header>Header</Card.Header>
42
+ <Card.Title>Title</Card.Title>
43
+ <Card.Description>Description</Card.Description>
44
+ <Card.Body>Body</Card.Body>
45
+ <Card.Footer>Footer</Card.Footer>
46
+ </Card>
47
+ );
48
+ expect(screen.getByText('Header')).toHaveClass('header');
49
+ expect(screen.getByText('Title').tagName).toBe('H3');
50
+ expect(screen.getByText('Description').tagName).toBe('P');
51
+ expect(screen.getByText('Body')).toHaveClass('body');
52
+ expect(screen.getByText('Footer')).toHaveClass('footer');
53
+ });
54
+
55
+ it('adds interactive class when onClick is provided', () => {
56
+ render(<Card onClick={() => {}}>Content</Card>);
57
+ expect(screen.getByRole('button')).toHaveClass('interactive');
58
+ });
59
+
60
+ it('has no accessibility violations', async () => {
61
+ const { container } = render(
62
+ <Card>
63
+ <Card.Header>
64
+ <Card.Title>Card Title</Card.Title>
65
+ </Card.Header>
66
+ <Card.Body>Card body content</Card.Body>
67
+ </Card>
68
+ );
69
+ await expectNoA11yViolations(container);
70
+ });
71
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import * as React from 'react';
3
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
4
+ import {
5
+ ChartContainer,
6
+ ChartTooltipContent,
7
+ ChartLegendContent,
8
+ useChartConfig,
9
+ type ChartConfig,
10
+ } from './index';
11
+
12
+ // Mock recharts to avoid SVG rendering issues in jsdom
13
+ vi.mock('recharts', () => ({
14
+ Tooltip: ({ content, ...props }: any) => <div data-testid="recharts-tooltip" {...props} />,
15
+ Legend: ({ content, ...props }: any) => <div data-testid="recharts-legend" {...props} />,
16
+ }));
17
+
18
+ const config: ChartConfig = {
19
+ revenue: { label: 'Revenue', color: '#3b82f6' },
20
+ expenses: { label: 'Expenses', color: '#ef4444' },
21
+ };
22
+
23
+ describe('ChartContainer', () => {
24
+ it('renders a container with role="img"', () => {
25
+ render(
26
+ <ChartContainer config={config}>
27
+ <div>chart</div>
28
+ </ChartContainer>
29
+ );
30
+ expect(screen.getByRole('img')).toBeInTheDocument();
31
+ });
32
+
33
+ it('applies default aria-label "Chart"', () => {
34
+ render(
35
+ <ChartContainer config={config}>
36
+ <div>chart</div>
37
+ </ChartContainer>
38
+ );
39
+ expect(screen.getByRole('img')).toHaveAttribute('aria-label', 'Chart');
40
+ });
41
+
42
+ it('renders summary text for screen readers', () => {
43
+ render(
44
+ <ChartContainer config={config} summary="Revenue vs Expenses over time">
45
+ <div>chart</div>
46
+ </ChartContainer>
47
+ );
48
+ expect(screen.getByText('Revenue vs Expenses over time')).toBeInTheDocument();
49
+ });
50
+
51
+ it('provides config context via useChartConfig', () => {
52
+ function Consumer() {
53
+ const ctx = useChartConfig();
54
+ return <span>{ctx.revenue.label}</span>;
55
+ }
56
+ render(
57
+ <ChartContainer config={config}>
58
+ <Consumer />
59
+ </ChartContainer>
60
+ );
61
+ expect(screen.getByText('Revenue')).toBeInTheDocument();
62
+ });
63
+
64
+ it('throws when useChartConfig is used outside ChartContainer', () => {
65
+ function Consumer() {
66
+ useChartConfig();
67
+ return null;
68
+ }
69
+ expect(() => render(<Consumer />)).toThrow('useChartConfig must be used within a <ChartContainer>');
70
+ });
71
+ });
72
+
73
+ describe('ChartTooltipContent', () => {
74
+ it('renders nothing when not active', () => {
75
+ const { container } = render(
76
+ <ChartTooltipContent active={false} payload={[]} />
77
+ );
78
+ expect(container.innerHTML).toBe('');
79
+ });
80
+
81
+ it('renders payload items when active', () => {
82
+ render(
83
+ <ChartTooltipContent
84
+ active
85
+ payload={[{ name: 'revenue', value: 100, dataKey: 'revenue', color: '#3b82f6' }]}
86
+ label="Jan"
87
+ />
88
+ );
89
+ expect(screen.getByText('Jan')).toBeInTheDocument();
90
+ expect(screen.getByText('100')).toBeInTheDocument();
91
+ });
92
+ });
93
+
94
+ describe('ChartLegendContent', () => {
95
+ it('renders legend items from payload', () => {
96
+ render(
97
+ <ChartLegendContent
98
+ payload={[
99
+ { value: 'revenue', dataKey: 'revenue', color: '#3b82f6' },
100
+ { value: 'expenses', dataKey: 'expenses', color: '#ef4444' },
101
+ ]}
102
+ />
103
+ );
104
+ expect(screen.getByText('revenue')).toBeInTheDocument();
105
+ expect(screen.getByText('expenses')).toBeInTheDocument();
106
+ });
107
+
108
+ it('renders nothing with empty payload', () => {
109
+ const { container } = render(<ChartLegendContent payload={[]} />);
110
+ expect(container.innerHTML).toBe('');
111
+ });
112
+ });
113
+
114
+ describe('Chart accessibility', () => {
115
+ it('has no accessibility violations', async () => {
116
+ const { container } = render(
117
+ <ChartContainer config={config} aria-label="Revenue chart" summary="Shows revenue data">
118
+ <div>chart content</div>
119
+ </ChartContainer>
120
+ );
121
+ await expectNoA11yViolations(container);
122
+ });
123
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Checkbox } from './index';
4
+
5
+ describe('Checkbox', () => {
6
+ it('renders a checkbox role', () => {
7
+ render(<Checkbox aria-label="Accept" />);
8
+ expect(screen.getByRole('checkbox')).toBeInTheDocument();
9
+ });
10
+
11
+ it('toggles checked state on click', async () => {
12
+ const user = userEvent.setup();
13
+ render(<Checkbox aria-label="Accept" defaultChecked={false} />);
14
+ const checkbox = screen.getByRole('checkbox');
15
+ expect(checkbox).not.toBeChecked();
16
+ await user.click(checkbox);
17
+ expect(checkbox).toBeChecked();
18
+ });
19
+
20
+ it('renders as checked when checked prop is true', () => {
21
+ render(<Checkbox aria-label="Accept" checked onCheckedChange={() => {}} />);
22
+ expect(screen.getByRole('checkbox')).toBeChecked();
23
+ });
24
+
25
+ it('supports indeterminate state via aria-checked=mixed', () => {
26
+ render(<Checkbox aria-label="Select all" indeterminate />);
27
+ expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'mixed');
28
+ });
29
+
30
+ it('renders label text', () => {
31
+ render(<Checkbox label="I agree" />);
32
+ expect(screen.getByText('I agree')).toBeInTheDocument();
33
+ });
34
+
35
+ it('renders description text', () => {
36
+ render(<Checkbox label="Subscribe" description="Get weekly updates" />);
37
+ expect(screen.getByText('Get weekly updates')).toBeInTheDocument();
38
+ });
39
+
40
+ it('disables the checkbox', () => {
41
+ render(<Checkbox aria-label="Accept" disabled />);
42
+ expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true');
43
+ });
44
+
45
+ it('sets required attribute', () => {
46
+ render(<Checkbox aria-label="Accept" required />);
47
+ expect(screen.getByRole('checkbox')).toBeRequired();
48
+ });
49
+
50
+ it('calls onCheckedChange with the new value on click', async () => {
51
+ const handleChange = vi.fn();
52
+ const user = userEvent.setup();
53
+ render(<Checkbox aria-label="Accept" onCheckedChange={handleChange} />);
54
+ await user.click(screen.getByRole('checkbox'));
55
+ expect(handleChange).toHaveBeenCalled();
56
+ expect(handleChange.mock.calls[0][0]).toBe(true);
57
+ });
58
+
59
+ it('has no accessibility violations', async () => {
60
+ const { container } = render(<Checkbox label="Accessible checkbox" />);
61
+ await expectNoA11yViolations(container);
62
+ });
63
+ });
@@ -166,7 +166,7 @@
166
166
  border-bottom-right-radius: var(--fui-radius-full, $fui-radius-full);
167
167
  border-top-left-radius: 0;
168
168
  border-bottom-left-radius: 0;
169
- background-color: var(--fui-bg-tertiary, $fui-bg-tertiary);
169
+ background-color: transparent;
170
170
  border: 1px solid transparent;
171
171
  border-left-color: var(--fui-border, $fui-border);
172
172
 
@@ -179,6 +179,59 @@
179
179
  }
180
180
  }
181
181
 
182
+ // Match remove button visuals to chip variant/state so the compound control
183
+ // remains visually coherent across seeds/themes.
184
+ .filled + .remove {
185
+ background-color: var(--fui-bg-tertiary, $fui-bg-tertiary);
186
+
187
+ &:hover:not(:disabled) {
188
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
189
+ }
190
+
191
+ &:active:not(:disabled) {
192
+ background-color: var(--fui-bg-active, $fui-bg-active);
193
+ }
194
+ }
195
+
196
+ .outlined + .remove {
197
+ background-color: transparent;
198
+
199
+ &:hover:not(:disabled) {
200
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
201
+ }
202
+
203
+ &:active:not(:disabled) {
204
+ background-color: var(--fui-bg-active, $fui-bg-active);
205
+ }
206
+ }
207
+
208
+ .soft + .remove {
209
+ background-color: var(--fui-color-info-bg, $fui-color-info-bg);
210
+ color: var(--fui-color-info, $fui-color-info);
211
+
212
+ &:hover:not(:disabled) {
213
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
214
+ }
215
+
216
+ &:active:not(:disabled) {
217
+ background-color: var(--fui-bg-active, $fui-bg-active);
218
+ }
219
+ }
220
+
221
+ .selected + .remove {
222
+ background-color: var(--fui-color-accent, $fui-color-accent);
223
+ color: var(--fui-text-inverse, $fui-text-inverse);
224
+ border-left-color: transparent;
225
+
226
+ &:hover:not(:disabled) {
227
+ background-color: var(--fui-color-accent-hover, $fui-color-accent-hover);
228
+ }
229
+
230
+ &:active:not(:disabled) {
231
+ background-color: var(--fui-color-accent-active, $fui-color-accent-active);
232
+ }
233
+ }
234
+
182
235
  // Chip.Group
183
236
  .group {
184
237
  display: flex;
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Chip } from './index';
4
+
5
+ describe('Chip', () => {
6
+ it('renders with correct text', () => {
7
+ render(<Chip>Tag</Chip>);
8
+ expect(screen.getByRole('button', { name: 'Tag' })).toBeInTheDocument();
9
+ });
10
+
11
+ it('applies variant classes', () => {
12
+ render(<Chip variant="outlined">Outlined</Chip>);
13
+ expect(screen.getByRole('button', { name: 'Outlined' })).toHaveClass('outlined');
14
+ });
15
+
16
+ it('sets aria-pressed for selected state', () => {
17
+ const { rerender } = render(<Chip selected>Active</Chip>);
18
+ expect(screen.getByRole('button', { name: 'Active' })).toHaveAttribute('aria-pressed', 'true');
19
+
20
+ rerender(<Chip selected={false}>Active</Chip>);
21
+ expect(screen.getByRole('button', { name: 'Active' })).toHaveAttribute('aria-pressed', 'false');
22
+ });
23
+
24
+ it('renders remove button with aria-label when onRemove is provided', () => {
25
+ const handleRemove = vi.fn();
26
+ render(<Chip onRemove={handleRemove}>Removable</Chip>);
27
+ expect(screen.getByRole('button', { name: /remove removable/i })).toBeInTheDocument();
28
+ });
29
+
30
+ it('fires onRemove when remove button is clicked', async () => {
31
+ const handleRemove = vi.fn();
32
+ const user = userEvent.setup();
33
+ render(<Chip onRemove={handleRemove}>Delete me</Chip>);
34
+ await user.click(screen.getByRole('button', { name: /remove delete me/i }));
35
+ expect(handleRemove).toHaveBeenCalledTimes(1);
36
+ });
37
+
38
+ it('fires onClick callback', async () => {
39
+ const handleClick = vi.fn();
40
+ const user = userEvent.setup();
41
+ render(<Chip onClick={handleClick}>Clickable</Chip>);
42
+ await user.click(screen.getByRole('button', { name: 'Clickable' }));
43
+ expect(handleClick).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ it('has no accessibility violations', async () => {
47
+ const { container } = render(<Chip>Accessible chip</Chip>);
48
+ await expectNoA11yViolations(container);
49
+ });
50
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, waitFor, expectNoA11yViolations } from '../../test/utils';
3
+ import { CodeBlock } from './index';
4
+
5
+ // Mock shiki to avoid loading real syntax highlighting in tests
6
+ vi.mock('shiki', () => ({
7
+ codeToHtml: vi.fn(() => Promise.resolve('<pre class="shiki"><code>highlighted code</code></pre>')),
8
+ }));
9
+
10
+ describe('CodeBlock', () => {
11
+ it('renders pre and code elements', async () => {
12
+ const { container } = render(<CodeBlock code="const x = 1;" />);
13
+ // Initially shows loading state with pre/code
14
+ expect(container.querySelector('pre')).toBeInTheDocument();
15
+ expect(container.querySelector('code')).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders a copy button by default', () => {
19
+ render(<CodeBlock code="const x = 1;" />);
20
+ expect(screen.getByRole('button', { name: /copy code/i })).toBeInTheDocument();
21
+ });
22
+
23
+ it('hides copy button when showCopy is false', () => {
24
+ render(<CodeBlock code="const x = 1;" showCopy={false} />);
25
+ expect(screen.queryByRole('button', { name: /copy/i })).not.toBeInTheDocument();
26
+ });
27
+
28
+ it('shows language-highlighted content after shiki resolves', async () => {
29
+ render(<CodeBlock code="const x = 1;" language="typescript" />);
30
+ await waitFor(() => {
31
+ expect(screen.getByText('highlighted code')).toBeInTheDocument();
32
+ });
33
+ });
34
+
35
+ it('renders title and caption when provided', () => {
36
+ render(<CodeBlock code="x = 1" title="Example" caption="A simple example" />);
37
+ expect(screen.getByText('Example')).toBeInTheDocument();
38
+ expect(screen.getByText('A simple example')).toBeInTheDocument();
39
+ });
40
+
41
+ it('renders filename in header', () => {
42
+ render(<CodeBlock code="x = 1" filename="app.ts" />);
43
+ expect(screen.getByText('app.ts')).toBeInTheDocument();
44
+ });
45
+
46
+ it('copies code to clipboard on copy button click', async () => {
47
+ const user = userEvent.setup();
48
+ const writeText = vi.fn().mockResolvedValue(undefined);
49
+ Object.defineProperty(navigator, 'clipboard', {
50
+ value: { writeText },
51
+ writable: true,
52
+ configurable: true,
53
+ });
54
+
55
+ render(<CodeBlock code="const x = 1;" />);
56
+ await user.click(screen.getByRole('button', { name: /copy code/i }));
57
+ expect(writeText).toHaveBeenCalledWith('const x = 1;');
58
+ });
59
+
60
+ it('supports collapsible mode', async () => {
61
+ const user = userEvent.setup();
62
+ const longCode = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n');
63
+ render(<CodeBlock code={longCode} collapsible defaultCollapsed collapsedLines={5} />);
64
+ // Should show expand button
65
+ const expandBtn = screen.getByRole('button', { name: /expand code/i });
66
+ expect(expandBtn).toBeInTheDocument();
67
+ expect(expandBtn).toHaveAttribute('aria-expanded', 'false');
68
+
69
+ await user.click(expandBtn);
70
+ const collapseBtn = screen.getByRole('button', { name: /collapse code/i });
71
+ expect(collapseBtn).toHaveAttribute('aria-expanded', 'true');
72
+ });
73
+
74
+ it('has no accessibility violations', async () => {
75
+ const { container } = render(<CodeBlock code="const x = 1;" />);
76
+ await expectNoA11yViolations(container);
77
+ });
78
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Collapsible } from './index';
4
+
5
+ function renderCollapsible(props: Partial<React.ComponentProps<typeof Collapsible>> = {}) {
6
+ return render(
7
+ <Collapsible {...props}>
8
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
9
+ <Collapsible.Content>Collapsible content here</Collapsible.Content>
10
+ </Collapsible>
11
+ );
12
+ }
13
+
14
+ describe('Collapsible', () => {
15
+ it('renders the trigger', () => {
16
+ renderCollapsible();
17
+ expect(screen.getByRole('button', { name: /toggle/i })).toBeInTheDocument();
18
+ });
19
+
20
+ it('opens content when trigger is clicked', async () => {
21
+ const user = userEvent.setup();
22
+ renderCollapsible();
23
+
24
+ expect(screen.queryByText('Collapsible content here')).not.toBeInTheDocument();
25
+
26
+ await user.click(screen.getByRole('button', { name: /toggle/i }));
27
+ expect(screen.getByText('Collapsible content here')).toBeInTheDocument();
28
+ });
29
+
30
+ it('closes content when trigger is clicked again', async () => {
31
+ const user = userEvent.setup();
32
+ renderCollapsible({ defaultOpen: true });
33
+
34
+ expect(screen.getByText('Collapsible content here')).toBeInTheDocument();
35
+
36
+ await user.click(screen.getByRole('button', { name: /toggle/i }));
37
+ expect(screen.queryByText('Collapsible content here')).not.toBeInTheDocument();
38
+ });
39
+
40
+ it('sets aria-expanded on the trigger', async () => {
41
+ const user = userEvent.setup();
42
+ renderCollapsible();
43
+
44
+ const trigger = screen.getByRole('button', { name: /toggle/i });
45
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
46
+
47
+ await user.click(trigger);
48
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
49
+ });
50
+
51
+ it('links trigger aria-controls to content id', () => {
52
+ renderCollapsible({ defaultOpen: true });
53
+ const trigger = screen.getByRole('button', { name: /toggle/i });
54
+ const contentId = trigger.getAttribute('aria-controls');
55
+ expect(contentId).toBeTruthy();
56
+ expect(document.getElementById(contentId!)).toBeInTheDocument();
57
+ });
58
+
59
+ it('supports controlled open prop', () => {
60
+ const { rerender } = render(
61
+ <Collapsible open={false}>
62
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
63
+ <Collapsible.Content>Content</Collapsible.Content>
64
+ </Collapsible>
65
+ );
66
+
67
+ expect(screen.queryByText('Content')).not.toBeInTheDocument();
68
+
69
+ rerender(
70
+ <Collapsible open={true}>
71
+ <Collapsible.Trigger>Toggle</Collapsible.Trigger>
72
+ <Collapsible.Content>Content</Collapsible.Content>
73
+ </Collapsible>
74
+ );
75
+
76
+ expect(screen.getByText('Content')).toBeInTheDocument();
77
+ });
78
+
79
+ it('fires onOpenChange callback', async () => {
80
+ const onOpenChange = vi.fn();
81
+ const user = userEvent.setup();
82
+ renderCollapsible({ onOpenChange });
83
+
84
+ await user.click(screen.getByRole('button', { name: /toggle/i }));
85
+ expect(onOpenChange).toHaveBeenCalledWith(true);
86
+ });
87
+
88
+ it('does not toggle when disabled', async () => {
89
+ const user = userEvent.setup();
90
+ renderCollapsible({ disabled: true });
91
+
92
+ const trigger = screen.getByRole('button', { name: /toggle/i });
93
+ expect(trigger).toBeDisabled();
94
+
95
+ await user.click(trigger);
96
+ expect(screen.queryByText('Collapsible content here')).not.toBeInTheDocument();
97
+ });
98
+
99
+ it('has no accessibility violations', async () => {
100
+ const { container } = renderCollapsible({ defaultOpen: true });
101
+ await expectNoA11yViolations(container);
102
+ });
103
+ });