@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.
- package/fragments.json +1 -1
- package/package.json +9 -2
- package/src/components/Accordion/Accordion.test.tsx +171 -0
- package/src/components/Alert/Alert.test.tsx +127 -0
- package/src/components/AppShell/AppShell.test.tsx +80 -0
- package/src/components/Avatar/Avatar.test.tsx +40 -0
- package/src/components/Badge/Badge.test.tsx +58 -0
- package/src/components/Box/Box.test.tsx +43 -0
- package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
- package/src/components/Button/Button.test.tsx +53 -0
- package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
- package/src/components/Card/Card.test.tsx +71 -0
- package/src/components/Chart/Chart.test.tsx +123 -0
- package/src/components/Checkbox/Checkbox.test.tsx +63 -0
- package/src/components/Chip/Chip.module.scss +54 -1
- package/src/components/Chip/Chip.test.tsx +50 -0
- package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
- package/src/components/Collapsible/Collapsible.test.tsx +103 -0
- package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
- package/src/components/ColorPicker/index.tsx +4 -1
- package/src/components/Combobox/Combobox.test.tsx +202 -0
- package/src/components/ConversationList/ConversationList.test.tsx +79 -0
- package/src/components/Dialog/Dialog.test.tsx +277 -0
- package/src/components/EmptyState/EmptyState.test.tsx +67 -0
- package/src/components/Field/Field.test.tsx +65 -0
- package/src/components/Fieldset/Fieldset.test.tsx +48 -0
- package/src/components/Form/Form.test.tsx +41 -0
- package/src/components/Grid/Grid.test.tsx +65 -0
- package/src/components/Header/Header.test.tsx +83 -0
- package/src/components/Icon/Icon.test.tsx +38 -0
- package/src/components/Image/Image.test.tsx +39 -0
- package/src/components/Input/Input.test.tsx +72 -0
- package/src/components/Link/Link.test.tsx +37 -0
- package/src/components/List/List.test.tsx +57 -0
- package/src/components/Listbox/Listbox.module.scss +2 -1
- package/src/components/Listbox/Listbox.test.tsx +100 -0
- package/src/components/Listbox/index.tsx +26 -3
- package/src/components/Loading/Loading.test.tsx +38 -0
- package/src/components/Markdown/Markdown.test.tsx +41 -0
- package/src/components/Menu/Menu.test.tsx +336 -0
- package/src/components/Message/Message.test.tsx +75 -0
- package/src/components/Popover/Popover.test.tsx +105 -0
- package/src/components/Progress/Progress.test.tsx +58 -0
- package/src/components/Prompt/Prompt.test.tsx +89 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
- package/src/components/Select/Select.test.tsx +161 -0
- package/src/components/Separator/Separator.test.tsx +33 -0
- package/src/components/Sidebar/Sidebar.test.tsx +85 -0
- package/src/components/Skeleton/Skeleton.test.tsx +56 -0
- package/src/components/Slider/Slider.test.tsx +51 -0
- package/src/components/Stack/Stack.test.tsx +47 -0
- package/src/components/Table/Table.test.tsx +129 -0
- package/src/components/Tabs/Tabs.test.tsx +180 -0
- package/src/components/Text/Text.test.tsx +40 -0
- package/src/components/Textarea/Textarea.test.tsx +57 -0
- package/src/components/Theme/Theme.test.tsx +114 -0
- package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
- package/src/components/Toast/Toast.test.tsx +192 -0
- package/src/components/Toast/index.tsx +14 -4
- package/src/components/Toggle/Toggle.test.tsx +49 -0
- package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
- package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
- package/src/components/ToggleGroup/index.tsx +17 -2
- package/src/components/Tooltip/Tooltip.test.tsx +107 -0
- package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/test/setup.ts +74 -0
- package/src/test/utils.tsx +71 -0
- package/src/utils/a11y.test.tsx +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fragments-sdk/ui",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "Customizable UI components built on Base UI headless primitives",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,12 +48,18 @@
|
|
|
48
48
|
"shiki": "^3.0.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
+
"@testing-library/jest-dom": "^6.6.0",
|
|
52
|
+
"@testing-library/react": "^16.1.0",
|
|
53
|
+
"@testing-library/user-event": "^14.5.0",
|
|
51
54
|
"@types/react": "^19.0.0",
|
|
52
55
|
"@types/react-dom": "^19.0.0",
|
|
56
|
+
"jsdom": "^25.0.0",
|
|
53
57
|
"react": "^19.0.0",
|
|
54
58
|
"react-dom": "^19.0.0",
|
|
55
59
|
"sass": "^1.83.0",
|
|
56
60
|
"typescript": "^5.7.0",
|
|
61
|
+
"vitest": "^2.1.8",
|
|
62
|
+
"vitest-axe": "^0.1.0",
|
|
57
63
|
"@fragments-sdk/cli": "0.5.2"
|
|
58
64
|
},
|
|
59
65
|
"files": [
|
|
@@ -64,6 +70,7 @@
|
|
|
64
70
|
"dev": "fragments dev",
|
|
65
71
|
"build": "node ../../packages/cli/dist/bin.js build",
|
|
66
72
|
"lint": "eslint src",
|
|
67
|
-
"validate": "fragments validate"
|
|
73
|
+
"validate": "fragments validate",
|
|
74
|
+
"test": "vitest run"
|
|
68
75
|
}
|
|
69
76
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Accordion } from './index';
|
|
4
|
+
|
|
5
|
+
function renderAccordion(props: Partial<React.ComponentProps<typeof Accordion>> = {}) {
|
|
6
|
+
return render(
|
|
7
|
+
<Accordion {...props}>
|
|
8
|
+
<Accordion.Item value="one">
|
|
9
|
+
<Accordion.Trigger>Item One</Accordion.Trigger>
|
|
10
|
+
<Accordion.Content>Content One</Accordion.Content>
|
|
11
|
+
</Accordion.Item>
|
|
12
|
+
<Accordion.Item value="two">
|
|
13
|
+
<Accordion.Trigger>Item Two</Accordion.Trigger>
|
|
14
|
+
<Accordion.Content>Content Two</Accordion.Content>
|
|
15
|
+
</Accordion.Item>
|
|
16
|
+
<Accordion.Item value="three">
|
|
17
|
+
<Accordion.Trigger>Item Three</Accordion.Trigger>
|
|
18
|
+
<Accordion.Content>Content Three</Accordion.Content>
|
|
19
|
+
</Accordion.Item>
|
|
20
|
+
</Accordion>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('Accordion', () => {
|
|
25
|
+
it('renders all triggers', () => {
|
|
26
|
+
renderAccordion();
|
|
27
|
+
expect(screen.getByText('Item One')).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByText('Item Two')).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByText('Item Three')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('opens an item when its trigger is clicked', async () => {
|
|
33
|
+
const user = userEvent.setup();
|
|
34
|
+
renderAccordion();
|
|
35
|
+
|
|
36
|
+
const trigger = screen.getByRole('button', { name: /item one/i });
|
|
37
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
38
|
+
|
|
39
|
+
await user.click(trigger);
|
|
40
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
41
|
+
expect(screen.getByText('Content One')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('single type only allows one item open at a time', async () => {
|
|
45
|
+
const user = userEvent.setup();
|
|
46
|
+
renderAccordion({ type: 'single', collapsible: true });
|
|
47
|
+
|
|
48
|
+
const triggerOne = screen.getByRole('button', { name: /item one/i });
|
|
49
|
+
const triggerTwo = screen.getByRole('button', { name: /item two/i });
|
|
50
|
+
|
|
51
|
+
await user.click(triggerOne);
|
|
52
|
+
expect(triggerOne).toHaveAttribute('aria-expanded', 'true');
|
|
53
|
+
|
|
54
|
+
await user.click(triggerTwo);
|
|
55
|
+
expect(triggerTwo).toHaveAttribute('aria-expanded', 'true');
|
|
56
|
+
expect(triggerOne).toHaveAttribute('aria-expanded', 'false');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('multiple type allows multiple items open at once', async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
renderAccordion({ type: 'multiple' });
|
|
62
|
+
|
|
63
|
+
const triggerOne = screen.getByRole('button', { name: /item one/i });
|
|
64
|
+
const triggerTwo = screen.getByRole('button', { name: /item two/i });
|
|
65
|
+
|
|
66
|
+
await user.click(triggerOne);
|
|
67
|
+
await user.click(triggerTwo);
|
|
68
|
+
|
|
69
|
+
expect(triggerOne).toHaveAttribute('aria-expanded', 'true');
|
|
70
|
+
expect(triggerTwo).toHaveAttribute('aria-expanded', 'true');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('links trigger aria-controls to content id', async () => {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
renderAccordion({ defaultValue: 'one' });
|
|
76
|
+
|
|
77
|
+
const trigger = screen.getByRole('button', { name: /item one/i });
|
|
78
|
+
const contentId = trigger.getAttribute('aria-controls');
|
|
79
|
+
expect(contentId).toBeTruthy();
|
|
80
|
+
expect(document.getElementById(contentId!)).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('renders correct heading level', () => {
|
|
84
|
+
renderAccordion({ headingLevel: 4 });
|
|
85
|
+
const headings = document.querySelectorAll('h4');
|
|
86
|
+
expect(headings.length).toBe(3);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('defaults heading level to h3', () => {
|
|
90
|
+
renderAccordion();
|
|
91
|
+
const headings = document.querySelectorAll('h3');
|
|
92
|
+
expect(headings.length).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('disables an item when disabled prop is set', async () => {
|
|
96
|
+
const user = userEvent.setup();
|
|
97
|
+
render(
|
|
98
|
+
<Accordion>
|
|
99
|
+
<Accordion.Item value="one" disabled>
|
|
100
|
+
<Accordion.Trigger>Disabled Item</Accordion.Trigger>
|
|
101
|
+
<Accordion.Content>Hidden Content</Accordion.Content>
|
|
102
|
+
</Accordion.Item>
|
|
103
|
+
</Accordion>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const trigger = screen.getByRole('button', { name: /disabled item/i });
|
|
107
|
+
await user.click(trigger);
|
|
108
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('supports controlled value prop', async () => {
|
|
112
|
+
const onValueChange = vi.fn();
|
|
113
|
+
const { rerender } = render(
|
|
114
|
+
<Accordion value="one" onValueChange={onValueChange}>
|
|
115
|
+
<Accordion.Item value="one">
|
|
116
|
+
<Accordion.Trigger>Item One</Accordion.Trigger>
|
|
117
|
+
<Accordion.Content>Content One</Accordion.Content>
|
|
118
|
+
</Accordion.Item>
|
|
119
|
+
<Accordion.Item value="two">
|
|
120
|
+
<Accordion.Trigger>Item Two</Accordion.Trigger>
|
|
121
|
+
<Accordion.Content>Content Two</Accordion.Content>
|
|
122
|
+
</Accordion.Item>
|
|
123
|
+
</Accordion>
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const triggerOne = screen.getByRole('button', { name: /item one/i });
|
|
127
|
+
expect(triggerOne).toHaveAttribute('aria-expanded', 'true');
|
|
128
|
+
|
|
129
|
+
const user = userEvent.setup();
|
|
130
|
+
const triggerTwo = screen.getByRole('button', { name: /item two/i });
|
|
131
|
+
await user.click(triggerTwo);
|
|
132
|
+
expect(onValueChange).toHaveBeenCalledWith('two');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('supports defaultValue for uncontrolled usage', () => {
|
|
136
|
+
renderAccordion({ defaultValue: 'two' });
|
|
137
|
+
const trigger = screen.getByRole('button', { name: /item two/i });
|
|
138
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('collapsible prop allows full collapse in single type', async () => {
|
|
142
|
+
const user = userEvent.setup();
|
|
143
|
+
renderAccordion({ type: 'single', collapsible: true, defaultValue: 'one' });
|
|
144
|
+
|
|
145
|
+
const trigger = screen.getByRole('button', { name: /item one/i });
|
|
146
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
147
|
+
|
|
148
|
+
await user.click(trigger);
|
|
149
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('non-collapsible single type prevents full collapse', async () => {
|
|
153
|
+
const user = userEvent.setup();
|
|
154
|
+
renderAccordion({ type: 'single', collapsible: false, defaultValue: 'one' });
|
|
155
|
+
|
|
156
|
+
const trigger = screen.getByRole('button', { name: /item one/i });
|
|
157
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
158
|
+
|
|
159
|
+
await user.click(trigger);
|
|
160
|
+
// Should stay open because collapsible=false
|
|
161
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('has no accessibility violations', async () => {
|
|
165
|
+
const { container } = renderAccordion({ defaultValue: 'one' });
|
|
166
|
+
await expectNoA11yViolations(container, {
|
|
167
|
+
// Accordion root role="region" + content panel role="region" triggers this.
|
|
168
|
+
disabledRules: ['landmark-unique'],
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Alert } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Alert', () => {
|
|
6
|
+
it('renders with role="alert"', () => {
|
|
7
|
+
render(
|
|
8
|
+
<Alert>
|
|
9
|
+
<Alert.Title>Info</Alert.Title>
|
|
10
|
+
<Alert.Content>Details</Alert.Content>
|
|
11
|
+
</Alert>
|
|
12
|
+
);
|
|
13
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('applies severity variant class', () => {
|
|
17
|
+
const { rerender } = render(
|
|
18
|
+
<Alert severity="error">
|
|
19
|
+
<Alert.Title>Error</Alert.Title>
|
|
20
|
+
</Alert>
|
|
21
|
+
);
|
|
22
|
+
expect(screen.getByRole('alert')).toHaveClass('error');
|
|
23
|
+
|
|
24
|
+
rerender(
|
|
25
|
+
<Alert severity="success">
|
|
26
|
+
<Alert.Title>Success</Alert.Title>
|
|
27
|
+
</Alert>
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByRole('alert')).toHaveClass('success');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('links title and content via aria-labelledby and aria-describedby', () => {
|
|
33
|
+
render(
|
|
34
|
+
<Alert>
|
|
35
|
+
<Alert.Title>Title</Alert.Title>
|
|
36
|
+
<Alert.Content>Content</Alert.Content>
|
|
37
|
+
</Alert>
|
|
38
|
+
);
|
|
39
|
+
const alertEl = screen.getByRole('alert');
|
|
40
|
+
const titleId = alertEl.getAttribute('aria-labelledby');
|
|
41
|
+
const descId = alertEl.getAttribute('aria-describedby');
|
|
42
|
+
expect(titleId).toBeTruthy();
|
|
43
|
+
expect(descId).toBeTruthy();
|
|
44
|
+
expect(screen.getByText('Title')).toHaveAttribute('id', titleId);
|
|
45
|
+
expect(screen.getByText('Content')).toHaveAttribute('id', descId);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('dismisses when close button is clicked', async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
render(
|
|
51
|
+
<Alert>
|
|
52
|
+
<Alert.Title>Dismissable</Alert.Title>
|
|
53
|
+
<Alert.Close />
|
|
54
|
+
</Alert>
|
|
55
|
+
);
|
|
56
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
57
|
+
await user.click(screen.getByRole('button', { name: /dismiss alert/i }));
|
|
58
|
+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders default severity icon', () => {
|
|
62
|
+
render(
|
|
63
|
+
<Alert severity="success">
|
|
64
|
+
<Alert.Icon />
|
|
65
|
+
<Alert.Title>Done</Alert.Title>
|
|
66
|
+
</Alert>
|
|
67
|
+
);
|
|
68
|
+
// success icon character is checkmark
|
|
69
|
+
expect(screen.getByText('\u2713')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders compound sub-components', () => {
|
|
73
|
+
render(
|
|
74
|
+
<Alert>
|
|
75
|
+
<Alert.Icon />
|
|
76
|
+
<Alert.Body>
|
|
77
|
+
<Alert.Title>Title</Alert.Title>
|
|
78
|
+
<Alert.Content>Description</Alert.Content>
|
|
79
|
+
</Alert.Body>
|
|
80
|
+
<Alert.Actions>
|
|
81
|
+
<Alert.Action onClick={() => {}}>Retry</Alert.Action>
|
|
82
|
+
</Alert.Actions>
|
|
83
|
+
</Alert>
|
|
84
|
+
);
|
|
85
|
+
expect(screen.getByText('Title')).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByText('Description')).toBeInTheDocument();
|
|
87
|
+
expect(screen.getByText('Retry')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('fires action callback', async () => {
|
|
91
|
+
const handleAction = vi.fn();
|
|
92
|
+
const user = userEvent.setup();
|
|
93
|
+
render(
|
|
94
|
+
<Alert>
|
|
95
|
+
<Alert.Title>Alert</Alert.Title>
|
|
96
|
+
<Alert.Actions>
|
|
97
|
+
<Alert.Action onClick={handleAction}>Retry</Alert.Action>
|
|
98
|
+
</Alert.Actions>
|
|
99
|
+
</Alert>
|
|
100
|
+
);
|
|
101
|
+
await user.click(screen.getByText('Retry'));
|
|
102
|
+
expect(handleAction).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('defaults to info severity', () => {
|
|
106
|
+
render(
|
|
107
|
+
<Alert>
|
|
108
|
+
<Alert.Title>Default</Alert.Title>
|
|
109
|
+
</Alert>
|
|
110
|
+
);
|
|
111
|
+
expect(screen.getByRole('alert')).toHaveClass('info');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('has no accessibility violations', async () => {
|
|
115
|
+
const { container } = render(
|
|
116
|
+
<Alert severity="warning">
|
|
117
|
+
<Alert.Icon />
|
|
118
|
+
<Alert.Body>
|
|
119
|
+
<Alert.Title>Warning</Alert.Title>
|
|
120
|
+
<Alert.Content>Something happened</Alert.Content>
|
|
121
|
+
</Alert.Body>
|
|
122
|
+
<Alert.Close />
|
|
123
|
+
</Alert>
|
|
124
|
+
);
|
|
125
|
+
await expectNoA11yViolations(container);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { AppShell } from './index';
|
|
4
|
+
|
|
5
|
+
// Mock matchMedia for Sidebar/AppShell which use useIsMobile
|
|
6
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
7
|
+
writable: true,
|
|
8
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
9
|
+
matches: false,
|
|
10
|
+
media: query,
|
|
11
|
+
onchange: null,
|
|
12
|
+
addEventListener: vi.fn(),
|
|
13
|
+
removeEventListener: vi.fn(),
|
|
14
|
+
addListener: vi.fn(),
|
|
15
|
+
removeListener: vi.fn(),
|
|
16
|
+
dispatchEvent: vi.fn(),
|
|
17
|
+
})),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('AppShell', () => {
|
|
21
|
+
it('renders children in a layout container', () => {
|
|
22
|
+
render(
|
|
23
|
+
<AppShell>
|
|
24
|
+
<AppShell.Main>Main Content</AppShell.Main>
|
|
25
|
+
</AppShell>
|
|
26
|
+
);
|
|
27
|
+
expect(screen.getByText('Main Content')).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders the main region with role="main"', () => {
|
|
31
|
+
render(
|
|
32
|
+
<AppShell>
|
|
33
|
+
<AppShell.Main>Content</AppShell.Main>
|
|
34
|
+
</AppShell>
|
|
35
|
+
);
|
|
36
|
+
expect(screen.getByRole('main')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders header, sidebar, and main slots', () => {
|
|
40
|
+
render(
|
|
41
|
+
<AppShell>
|
|
42
|
+
<AppShell.Header>Header Content</AppShell.Header>
|
|
43
|
+
<AppShell.Sidebar>Sidebar Content</AppShell.Sidebar>
|
|
44
|
+
<AppShell.Main>Main Content</AppShell.Main>
|
|
45
|
+
</AppShell>
|
|
46
|
+
);
|
|
47
|
+
expect(screen.getByText('Header Content')).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText('Sidebar Content')).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText('Main Content')).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders aside slot when visible', () => {
|
|
53
|
+
render(
|
|
54
|
+
<AppShell>
|
|
55
|
+
<AppShell.Main>Main</AppShell.Main>
|
|
56
|
+
<AppShell.Aside visible>Aside Panel</AppShell.Aside>
|
|
57
|
+
</AppShell>
|
|
58
|
+
);
|
|
59
|
+
expect(screen.getByText('Aside Panel')).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('hides aside when visible is false', () => {
|
|
63
|
+
render(
|
|
64
|
+
<AppShell>
|
|
65
|
+
<AppShell.Main>Main</AppShell.Main>
|
|
66
|
+
<AppShell.Aside visible={false}>Hidden Aside</AppShell.Aside>
|
|
67
|
+
</AppShell>
|
|
68
|
+
);
|
|
69
|
+
expect(screen.queryByText('Hidden Aside')).not.toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('has no accessibility violations', async () => {
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<AppShell>
|
|
75
|
+
<AppShell.Main>Content</AppShell.Main>
|
|
76
|
+
</AppShell>
|
|
77
|
+
);
|
|
78
|
+
await expectNoA11yViolations(container);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Avatar } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Avatar', () => {
|
|
6
|
+
it('renders an image when src is provided', () => {
|
|
7
|
+
render(<Avatar src="https://example.com/photo.jpg" alt="Jane Doe" />);
|
|
8
|
+
const img = screen.getByRole('img');
|
|
9
|
+
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
|
10
|
+
expect(img).toHaveAttribute('alt', 'Jane Doe');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders initials from the name prop when no src', () => {
|
|
14
|
+
render(<Avatar name="John Smith" />);
|
|
15
|
+
expect(screen.getByText('JS')).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders explicit initials prop over name-derived initials', () => {
|
|
19
|
+
render(<Avatar name="John Smith" initials="AB" />);
|
|
20
|
+
expect(screen.getByText('AB')).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders Avatar.Group and limits visible avatars with max', () => {
|
|
24
|
+
render(
|
|
25
|
+
<Avatar.Group max={2}>
|
|
26
|
+
<Avatar name="Alice" />
|
|
27
|
+
<Avatar name="Bob" />
|
|
28
|
+
<Avatar name="Charlie" />
|
|
29
|
+
<Avatar name="Diana" />
|
|
30
|
+
</Avatar.Group>
|
|
31
|
+
);
|
|
32
|
+
// 2 visible + 1 overflow indicator
|
|
33
|
+
expect(screen.getByText('+2')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('has no accessibility violations', async () => {
|
|
37
|
+
const { container } = render(<Avatar name="Jane Doe" />);
|
|
38
|
+
await expectNoA11yViolations(container);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Badge } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Badge', () => {
|
|
6
|
+
it('renders with children', () => {
|
|
7
|
+
render(<Badge>New</Badge>);
|
|
8
|
+
expect(screen.getByText('New')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('applies variant classes', () => {
|
|
12
|
+
const { container } = render(<Badge variant="success">OK</Badge>);
|
|
13
|
+
const badge = container.firstChild as HTMLElement;
|
|
14
|
+
expect(badge).toHaveClass('success');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('applies size classes', () => {
|
|
18
|
+
const { container } = render(<Badge size="sm">Small</Badge>);
|
|
19
|
+
const badge = container.firstChild as HTMLElement;
|
|
20
|
+
expect(badge).toHaveClass('sm');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders dot with aria-hidden', () => {
|
|
24
|
+
const { container } = render(<Badge dot>Status</Badge>);
|
|
25
|
+
const dot = container.querySelector('.dot');
|
|
26
|
+
expect(dot).toBeInTheDocument();
|
|
27
|
+
expect(dot).toHaveAttribute('aria-hidden', 'true');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders icon with aria-hidden', () => {
|
|
31
|
+
const { container } = render(<Badge icon={<svg data-testid="icon" />}>Info</Badge>);
|
|
32
|
+
const iconWrapper = container.querySelector('.icon');
|
|
33
|
+
expect(iconWrapper).toHaveAttribute('aria-hidden', 'true');
|
|
34
|
+
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders remove button with aria-label', async () => {
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
const onRemove = vi.fn();
|
|
40
|
+
render(<Badge onRemove={onRemove}>Tag</Badge>);
|
|
41
|
+
const removeBtn = screen.getByRole('button', { name: 'Remove Tag' });
|
|
42
|
+
expect(removeBtn).toBeInTheDocument();
|
|
43
|
+
await user.click(removeBtn);
|
|
44
|
+
expect(onRemove).toHaveBeenCalledOnce();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('sets role="status" and aria-label for status variants', () => {
|
|
48
|
+
const { container } = render(<Badge variant="error">Failed</Badge>);
|
|
49
|
+
const badge = container.firstChild as HTMLElement;
|
|
50
|
+
expect(badge).toHaveAttribute('role', 'status');
|
|
51
|
+
expect(badge).toHaveAttribute('aria-label', 'error: Failed');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('has no accessibility violations', async () => {
|
|
55
|
+
const { container } = render(<Badge>Accessible</Badge>);
|
|
56
|
+
await expectNoA11yViolations(container);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Box } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Box', () => {
|
|
6
|
+
it('renders a div by default', () => {
|
|
7
|
+
render(<Box>Content</Box>);
|
|
8
|
+
const el = screen.getByText('Content');
|
|
9
|
+
expect(el.tagName).toBe('DIV');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('renders as a different element via "as" prop', () => {
|
|
13
|
+
render(<Box as="section">Content</Box>);
|
|
14
|
+
const el = screen.getByText('Content');
|
|
15
|
+
expect(el.tagName).toBe('SECTION');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('forwards className and ref', () => {
|
|
19
|
+
const ref = vi.fn();
|
|
20
|
+
const { container } = render(<Box ref={ref} className="custom">Content</Box>);
|
|
21
|
+
expect(ref).toHaveBeenCalled();
|
|
22
|
+
expect(container.firstChild).toHaveClass('custom');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('applies padding and background classes', () => {
|
|
26
|
+
const { container } = render(<Box padding="lg" background="elevated">Content</Box>);
|
|
27
|
+
const el = container.firstChild as HTMLElement;
|
|
28
|
+
expect(el).toHaveClass('p-lg');
|
|
29
|
+
expect(el).toHaveClass('bg-elevated');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('sets width/height as inline styles', () => {
|
|
33
|
+
const { container } = render(<Box width={300} height="50%">Content</Box>);
|
|
34
|
+
const el = container.firstChild as HTMLElement;
|
|
35
|
+
expect(el.style.width).toBe('300px');
|
|
36
|
+
expect(el.style.height).toBe('50%');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('has no accessibility violations', async () => {
|
|
40
|
+
const { container } = render(<Box>Accessible</Box>);
|
|
41
|
+
await expectNoA11yViolations(container);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Breadcrumbs } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Breadcrumbs', () => {
|
|
6
|
+
it('renders a nav landmark with aria-label "Breadcrumb"', () => {
|
|
7
|
+
render(
|
|
8
|
+
<Breadcrumbs>
|
|
9
|
+
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
|
10
|
+
<Breadcrumbs.Item current>Page</Breadcrumbs.Item>
|
|
11
|
+
</Breadcrumbs>
|
|
12
|
+
);
|
|
13
|
+
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('marks current page with aria-current="page"', () => {
|
|
17
|
+
render(
|
|
18
|
+
<Breadcrumbs>
|
|
19
|
+
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
|
20
|
+
<Breadcrumbs.Item current>Current</Breadcrumbs.Item>
|
|
21
|
+
</Breadcrumbs>
|
|
22
|
+
);
|
|
23
|
+
expect(screen.getByText('Current').closest('[aria-current="page"]')).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders separator between items', () => {
|
|
27
|
+
render(
|
|
28
|
+
<Breadcrumbs separator=">">
|
|
29
|
+
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
|
30
|
+
<Breadcrumbs.Item current>Page</Breadcrumbs.Item>
|
|
31
|
+
</Breadcrumbs>
|
|
32
|
+
);
|
|
33
|
+
expect(screen.getByText('>')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('renders items as links when href is provided', () => {
|
|
37
|
+
render(
|
|
38
|
+
<Breadcrumbs>
|
|
39
|
+
<Breadcrumbs.Item href="/about">About</Breadcrumbs.Item>
|
|
40
|
+
<Breadcrumbs.Item current>Contact</Breadcrumbs.Item>
|
|
41
|
+
</Breadcrumbs>
|
|
42
|
+
);
|
|
43
|
+
expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('collapses middle items when maxItems is set', async () => {
|
|
47
|
+
const user = userEvent.setup();
|
|
48
|
+
render(
|
|
49
|
+
<Breadcrumbs maxItems={2}>
|
|
50
|
+
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
|
51
|
+
<Breadcrumbs.Item href="/a">A</Breadcrumbs.Item>
|
|
52
|
+
<Breadcrumbs.Item href="/b">B</Breadcrumbs.Item>
|
|
53
|
+
<Breadcrumbs.Item current>C</Breadcrumbs.Item>
|
|
54
|
+
</Breadcrumbs>
|
|
55
|
+
);
|
|
56
|
+
// Middle items should be collapsed with an ellipsis button
|
|
57
|
+
expect(screen.getByRole('button', { name: /show collapsed/i })).toBeInTheDocument();
|
|
58
|
+
expect(screen.queryByText('A')).not.toBeInTheDocument();
|
|
59
|
+
|
|
60
|
+
// Expand collapsed items
|
|
61
|
+
await user.click(screen.getByRole('button', { name: /show collapsed/i }));
|
|
62
|
+
expect(screen.getByText('A')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('has no accessibility violations', async () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<Breadcrumbs>
|
|
68
|
+
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
|
69
|
+
<Breadcrumbs.Item href="/products">Products</Breadcrumbs.Item>
|
|
70
|
+
<Breadcrumbs.Item current>Widget</Breadcrumbs.Item>
|
|
71
|
+
</Breadcrumbs>
|
|
72
|
+
);
|
|
73
|
+
await expectNoA11yViolations(container);
|
|
74
|
+
});
|
|
75
|
+
});
|