@fragments-sdk/ui 0.7.3 → 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 (101) hide show
  1. package/fragments.json +1 -1
  2. package/package.json +13 -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.module.scss +5 -5
  6. package/src/components/AppShell/AppShell.test.tsx +80 -0
  7. package/src/components/Avatar/Avatar.test.tsx +40 -0
  8. package/src/components/Avatar/index.tsx +11 -1
  9. package/src/components/Badge/Badge.test.tsx +58 -0
  10. package/src/components/Box/Box.test.tsx +43 -0
  11. package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
  12. package/src/components/Button/Button.module.scss +3 -3
  13. package/src/components/Button/Button.test.tsx +53 -0
  14. package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
  15. package/src/components/Card/Card.test.tsx +71 -0
  16. package/src/components/Chart/Chart.module.scss +5 -0
  17. package/src/components/Chart/Chart.test.tsx +123 -0
  18. package/src/components/Chart/index.tsx +34 -0
  19. package/src/components/Checkbox/Checkbox.test.tsx +63 -0
  20. package/src/components/Checkbox/index.tsx +29 -4
  21. package/src/components/Chip/Chip.module.scss +73 -7
  22. package/src/components/Chip/Chip.test.tsx +50 -0
  23. package/src/components/Chip/index.tsx +36 -26
  24. package/src/components/CodeBlock/CodeBlock.module.scss +6 -6
  25. package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
  26. package/src/components/CodeBlock/index.tsx +4 -1
  27. package/src/components/Collapsible/Collapsible.test.tsx +103 -0
  28. package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
  29. package/src/components/ColorPicker/index.tsx +4 -1
  30. package/src/components/Combobox/Combobox.test.tsx +202 -0
  31. package/src/components/ConversationList/ConversationList.module.scss +4 -4
  32. package/src/components/ConversationList/ConversationList.test.tsx +79 -0
  33. package/src/components/Dialog/Dialog.test.tsx +277 -0
  34. package/src/components/Dialog/index.tsx +14 -6
  35. package/src/components/EmptyState/EmptyState.module.scss +3 -3
  36. package/src/components/EmptyState/EmptyState.test.tsx +67 -0
  37. package/src/components/Field/Field.test.tsx +65 -0
  38. package/src/components/Fieldset/Fieldset.test.tsx +48 -0
  39. package/src/components/Form/Form.test.tsx +41 -0
  40. package/src/components/Grid/Grid.module.scss +3 -3
  41. package/src/components/Grid/Grid.test.tsx +65 -0
  42. package/src/components/Header/Header.module.scss +4 -4
  43. package/src/components/Header/Header.test.tsx +83 -0
  44. package/src/components/Icon/Icon.test.tsx +38 -0
  45. package/src/components/Image/Image.test.tsx +39 -0
  46. package/src/components/Input/Input.test.tsx +72 -0
  47. package/src/components/Input/index.tsx +15 -1
  48. package/src/components/Link/Link.test.tsx +37 -0
  49. package/src/components/List/List.test.tsx +57 -0
  50. package/src/components/Listbox/Listbox.module.scss +16 -5
  51. package/src/components/Listbox/Listbox.test.tsx +100 -0
  52. package/src/components/Listbox/index.tsx +186 -13
  53. package/src/components/Loading/Loading.test.tsx +38 -0
  54. package/src/components/Markdown/Markdown.module.scss +3 -3
  55. package/src/components/Markdown/Markdown.test.tsx +41 -0
  56. package/src/components/Menu/Menu.module.scss +3 -3
  57. package/src/components/Menu/Menu.test.tsx +336 -0
  58. package/src/components/Message/Message.module.scss +9 -3
  59. package/src/components/Message/Message.test.tsx +75 -0
  60. package/src/components/Popover/Popover.test.tsx +105 -0
  61. package/src/components/Popover/index.tsx +4 -1
  62. package/src/components/Progress/Progress.test.tsx +58 -0
  63. package/src/components/Prompt/Prompt.module.scss +6 -5
  64. package/src/components/Prompt/Prompt.test.tsx +89 -0
  65. package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
  66. package/src/components/RadioGroup/index.tsx +37 -4
  67. package/src/components/Select/Select.test.tsx +161 -0
  68. package/src/components/Select/index.tsx +3 -2
  69. package/src/components/Separator/Separator.test.tsx +33 -0
  70. package/src/components/Sidebar/Sidebar.module.scss +32 -22
  71. package/src/components/Sidebar/Sidebar.test.tsx +85 -0
  72. package/src/components/Sidebar/index.tsx +31 -9
  73. package/src/components/Skeleton/Skeleton.test.tsx +56 -0
  74. package/src/components/Slider/Slider.test.tsx +51 -0
  75. package/src/components/Slider/index.tsx +13 -3
  76. package/src/components/Stack/Stack.test.tsx +47 -0
  77. package/src/components/Table/Table.module.scss +20 -11
  78. package/src/components/Table/Table.test.tsx +129 -0
  79. package/src/components/Table/index.tsx +52 -30
  80. package/src/components/Tabs/Tabs.test.tsx +180 -0
  81. package/src/components/Text/Text.test.tsx +40 -0
  82. package/src/components/Textarea/Textarea.test.tsx +57 -0
  83. package/src/components/Textarea/index.tsx +22 -2
  84. package/src/components/Theme/Theme.test.tsx +114 -0
  85. package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
  86. package/src/components/Toast/Toast.test.tsx +192 -0
  87. package/src/components/Toast/index.tsx +124 -20
  88. package/src/components/Toggle/Toggle.test.tsx +49 -0
  89. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
  90. package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
  91. package/src/components/ToggleGroup/index.tsx +70 -1
  92. package/src/components/Tooltip/Tooltip.module.scss +1 -1
  93. package/src/components/Tooltip/Tooltip.test.tsx +107 -0
  94. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  95. package/src/test/setup.ts +74 -0
  96. package/src/test/utils.tsx +71 -0
  97. package/src/tokens/_computed.scss +2 -0
  98. package/src/tokens/_density.scss +4 -0
  99. package/src/tokens/_derive.scss +16 -16
  100. package/src/tokens/_variables.scss +8 -2
  101. package/src/utils/a11y.test.tsx +79 -0
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, expectNoA11yViolations } from '../../test/utils';
3
+ import { EmptyState } from './index';
4
+
5
+ describe('EmptyState', () => {
6
+ it('renders children', () => {
7
+ render(
8
+ <EmptyState>
9
+ <EmptyState.Title>No results</EmptyState.Title>
10
+ </EmptyState>
11
+ );
12
+ expect(screen.getByText('No results')).toBeInTheDocument();
13
+ });
14
+
15
+ it('renders all compound sub-components', () => {
16
+ render(
17
+ <EmptyState>
18
+ <EmptyState.Icon>ICON</EmptyState.Icon>
19
+ <EmptyState.Title>Empty</EmptyState.Title>
20
+ <EmptyState.Description>Nothing to show</EmptyState.Description>
21
+ <EmptyState.Actions><button>Add item</button></EmptyState.Actions>
22
+ </EmptyState>
23
+ );
24
+ expect(screen.getByText('ICON')).toHaveClass('icon');
25
+ expect(screen.getByText('Empty').tagName).toBe('H3');
26
+ expect(screen.getByText('Nothing to show').tagName).toBe('P');
27
+ expect(screen.getByRole('button', { name: 'Add item' })).toBeInTheDocument();
28
+ });
29
+
30
+ it('applies size class', () => {
31
+ const { container } = render(
32
+ <EmptyState size="lg">
33
+ <EmptyState.Title>Large</EmptyState.Title>
34
+ </EmptyState>
35
+ );
36
+ expect(container.firstElementChild).toHaveClass('lg');
37
+ });
38
+
39
+ it('applies custom className', () => {
40
+ const { container } = render(
41
+ <EmptyState className="custom">
42
+ <EmptyState.Title>Custom</EmptyState.Title>
43
+ </EmptyState>
44
+ );
45
+ expect(container.firstElementChild).toHaveClass('custom');
46
+ });
47
+
48
+ it('defaults to md size', () => {
49
+ const { container } = render(
50
+ <EmptyState>
51
+ <EmptyState.Title>Default</EmptyState.Title>
52
+ </EmptyState>
53
+ );
54
+ expect(container.firstElementChild).toHaveClass('md');
55
+ });
56
+
57
+ it('has no accessibility violations', async () => {
58
+ const { container } = render(
59
+ <EmptyState>
60
+ <EmptyState.Icon>ICON</EmptyState.Icon>
61
+ <EmptyState.Title>No items found</EmptyState.Title>
62
+ <EmptyState.Description>Try adjusting your search</EmptyState.Description>
63
+ </EmptyState>
64
+ );
65
+ await expectNoA11yViolations(container);
66
+ });
67
+ });
@@ -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
+ });
@@ -82,9 +82,9 @@
82
82
  // ============================================
83
83
 
84
84
  .paddingNone { padding: 0; }
85
- .paddingSm { padding: var(--fui-space-3, $fui-space-3); }
86
- .paddingMd { padding: var(--fui-space-4, $fui-space-4); }
87
- .paddingLg { padding: var(--fui-space-6, $fui-space-6); }
85
+ .paddingSm { padding: var(--fui-padding-container-sm); }
86
+ .paddingMd { padding: var(--fui-padding-container-md); }
87
+ .paddingLg { padding: var(--fui-padding-container-lg); }
88
88
 
89
89
  // ============================================
90
90
  // Align items (cross-axis within cells)
@@ -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
+ });
@@ -11,7 +11,7 @@
11
11
  height: var(--header-height, var(--fui-appshell-header-height, $fui-appshell-header-height));
12
12
  min-height: var(--header-height, var(--fui-appshell-header-height, $fui-appshell-header-height));
13
13
  background-color: var(--fui-bg-primary, $fui-bg-primary);
14
- padding: 0 var(--fui-space-4, $fui-space-4);
14
+ padding: 0 var(--fui-padding-container-md, $fui-padding-container-md);
15
15
  z-index: var(--fui-header-z-index, $fui-header-z-index);
16
16
  }
17
17
 
@@ -88,7 +88,7 @@
88
88
 
89
89
  display: flex;
90
90
  align-items: center;
91
- padding: var(--fui-space-1, $fui-space-1) var(--fui-space-3, $fui-space-3);
91
+ padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-md, $fui-padding-item-md);
92
92
  border-radius: var(--fui-radius-md, $fui-radius-md);
93
93
  color: var(--fui-text-secondary, $fui-text-secondary);
94
94
  text-decoration: none;
@@ -185,7 +185,7 @@
185
185
  top: var(--fui-space-2, $fui-space-2);
186
186
  left: var(--fui-space-2, $fui-space-2);
187
187
  z-index: 100;
188
- padding: var(--fui-space-2, $fui-space-2) var(--fui-space-4, $fui-space-4);
188
+ padding: var(--fui-padding-item-sm, $fui-padding-item-sm) var(--fui-padding-item-lg, $fui-padding-item-lg);
189
189
  background-color: var(--fui-bg-primary, $fui-bg-primary);
190
190
  color: var(--fui-text-primary, $fui-text-primary);
191
191
  border: 2px solid var(--fui-color-accent, $fui-color-accent);
@@ -200,7 +200,7 @@
200
200
  clip: auto;
201
201
  white-space: normal;
202
202
  margin: 0;
203
- padding: var(--fui-space-2, $fui-space-2) var(--fui-space-4, $fui-space-4);
203
+ padding: var(--fui-padding-item-sm, $fui-padding-item-sm) var(--fui-padding-item-lg, $fui-padding-item-lg);
204
204
  overflow: visible;
205
205
 
206
206
  @include focus-ring;
@@ -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
+ });
@@ -28,6 +28,11 @@ export interface InputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, '
28
28
  name?: string;
29
29
  }
30
30
 
31
+ function mergeAriaIds(...ids: Array<string | undefined>): string | undefined {
32
+ const merged = ids.filter(Boolean).join(' ').trim();
33
+ return merged.length > 0 ? merged : undefined;
34
+ }
35
+
31
36
  export const Input = React.forwardRef<HTMLInputElement, InputProps>(
32
37
  function Input(
33
38
  {
@@ -51,10 +56,16 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
51
56
  inputClassName,
52
57
  name,
53
58
  id,
59
+ 'aria-label': ariaLabel,
60
+ 'aria-labelledby': ariaLabelledBy,
61
+ 'aria-describedby': ariaDescribedBy,
54
62
  ...htmlProps
55
63
  },
56
64
  ref
57
65
  ) {
66
+ const generatedId = React.useId();
67
+ const helperId = helperText ? `input-helper-${generatedId}` : undefined;
68
+
58
69
  const inputClasses = [
59
70
  styles.input,
60
71
  styles[size],
@@ -87,6 +98,9 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
87
98
  onBlur={onBlur}
88
99
  onFocus={onFocus}
89
100
  onKeyDown={onKeyDown}
101
+ aria-label={ariaLabel}
102
+ aria-labelledby={ariaLabelledBy}
103
+ aria-describedby={mergeAriaIds(ariaDescribedBy, helperId)}
90
104
  className={inputClasses}
91
105
  style={inputStyle}
92
106
  render={<input />}
@@ -105,7 +119,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
105
119
  inputElement
106
120
  )}
107
121
  {helperText && (
108
- <Field.Description className={helperClasses}>
122
+ <Field.Description id={helperId} className={helperClasses}>
109
123
  {helperText}
110
124
  </Field.Description>
111
125
  )}
@@ -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
+ });