@fragments-sdk/ui 0.7.4 → 0.8.0

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 (162) hide show
  1. package/README.md +58 -25
  2. package/fragments.json +1 -1
  3. package/package.json +22 -5
  4. package/src/blocks/AppShell.block.ts +2 -2
  5. package/src/blocks/InsetDashboardLayout.block.ts +1 -1
  6. package/src/blocks/LoginForm.block.ts +14 -7
  7. package/src/components/Accordion/Accordion.fragment.tsx +8 -2
  8. package/src/components/Accordion/Accordion.test.tsx +171 -0
  9. package/src/components/Alert/Alert.module.scss +4 -4
  10. package/src/components/Alert/Alert.test.tsx +127 -0
  11. package/src/components/AppShell/AppShell.fragment.tsx +1 -1
  12. package/src/components/AppShell/AppShell.test.tsx +80 -0
  13. package/src/components/AppShell/index.tsx +2 -0
  14. package/src/components/Avatar/Avatar.fragment.tsx +5 -1
  15. package/src/components/Avatar/Avatar.module.scss +1 -1
  16. package/src/components/Avatar/Avatar.test.tsx +40 -0
  17. package/src/components/Avatar/index.tsx +37 -1
  18. package/src/components/Badge/Badge.fragment.tsx +3 -3
  19. package/src/components/Badge/Badge.module.scss +4 -4
  20. package/src/components/Badge/Badge.test.tsx +58 -0
  21. package/src/components/Badge/index.tsx +5 -1
  22. package/src/components/Box/Box.test.tsx +43 -0
  23. package/src/components/Box/index.tsx +5 -1
  24. package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
  25. package/src/components/Button/Button.fragment.tsx +17 -16
  26. package/src/components/Button/Button.test.tsx +53 -0
  27. package/src/components/Button/index.tsx +5 -1
  28. package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
  29. package/src/components/ButtonGroup/index.tsx +5 -1
  30. package/src/components/Card/Card.fragment.tsx +5 -5
  31. package/src/components/Card/Card.test.tsx +71 -0
  32. package/src/components/Chart/Chart.fragment.tsx +9 -1
  33. package/src/components/Chart/Chart.test.tsx +123 -0
  34. package/src/components/Chart/index.tsx +22 -4
  35. package/src/components/Checkbox/Checkbox.test.tsx +63 -0
  36. package/src/components/Checkbox/index.tsx +5 -1
  37. package/src/components/Chip/Chip.fragment.tsx +0 -5
  38. package/src/components/Chip/Chip.module.scss +55 -2
  39. package/src/components/Chip/Chip.test.tsx +50 -0
  40. package/src/components/CodeBlock/CodeBlock.fragment.tsx +9 -3
  41. package/src/components/CodeBlock/CodeBlock.module.scss +1 -1
  42. package/src/components/CodeBlock/CodeBlock.test.tsx +78 -0
  43. package/src/components/Collapsible/Collapsible.test.tsx +103 -0
  44. package/src/components/ColorPicker/ColorPicker.test.tsx +55 -0
  45. package/src/components/ColorPicker/index.tsx +9 -2
  46. package/src/components/Combobox/Combobox.fragment.tsx +15 -7
  47. package/src/components/Combobox/Combobox.test.tsx +202 -0
  48. package/src/components/ConversationList/ConversationList.fragment.tsx +3 -3
  49. package/src/components/ConversationList/ConversationList.module.scss +1 -1
  50. package/src/components/ConversationList/ConversationList.test.tsx +79 -0
  51. package/src/components/DatePicker/DatePicker.fragment.tsx +245 -0
  52. package/src/components/DatePicker/DatePicker.module.scss +394 -0
  53. package/src/components/DatePicker/DatePicker.test.tsx +264 -0
  54. package/src/components/DatePicker/index.tsx +535 -0
  55. package/src/components/Dialog/Dialog.test.tsx +277 -0
  56. package/src/components/EmptyState/EmptyState.test.tsx +67 -0
  57. package/src/components/Field/Field.fragment.tsx +5 -4
  58. package/src/components/Field/Field.test.tsx +65 -0
  59. package/src/components/Fieldset/Fieldset.fragment.tsx +5 -4
  60. package/src/components/Fieldset/Fieldset.test.tsx +48 -0
  61. package/src/components/Form/Form.fragment.tsx +9 -3
  62. package/src/components/Form/Form.test.tsx +41 -0
  63. package/src/components/Form/index.tsx +5 -1
  64. package/src/components/Grid/Grid.fragment.tsx +4 -0
  65. package/src/components/Grid/Grid.test.tsx +65 -0
  66. package/src/components/Header/Header.fragment.tsx +36 -13
  67. package/src/components/Header/Header.module.scss +114 -1
  68. package/src/components/Header/Header.test.tsx +188 -0
  69. package/src/components/Header/index.tsx +100 -31
  70. package/src/components/Icon/Icon.fragment.tsx +6 -1
  71. package/src/components/Icon/Icon.test.tsx +38 -0
  72. package/src/components/Icon/index.tsx +5 -1
  73. package/src/components/Image/Image.fragment.tsx +2 -2
  74. package/src/components/Image/Image.test.tsx +39 -0
  75. package/src/components/Image/index.tsx +5 -1
  76. package/src/components/Input/Input.fragment.tsx +21 -3
  77. package/src/components/Input/Input.module.scss +1 -1
  78. package/src/components/Input/Input.test.tsx +72 -0
  79. package/src/components/Input/index.tsx +5 -1
  80. package/src/components/Link/Link.fragment.tsx +0 -4
  81. package/src/components/Link/Link.test.tsx +37 -0
  82. package/src/components/Link/index.tsx +5 -1
  83. package/src/components/List/List.test.tsx +57 -0
  84. package/src/components/Listbox/Listbox.fragment.tsx +0 -12
  85. package/src/components/Listbox/Listbox.module.scss +2 -1
  86. package/src/components/Listbox/Listbox.test.tsx +100 -0
  87. package/src/components/Listbox/index.tsx +26 -3
  88. package/src/components/Loading/Loading.test.tsx +38 -0
  89. package/src/components/Markdown/Markdown.module.scss +6 -3
  90. package/src/components/Markdown/Markdown.test.tsx +41 -0
  91. package/src/components/Markdown/index.tsx +5 -1
  92. package/src/components/Menu/Menu.test.tsx +336 -0
  93. package/src/components/Message/Message.fragment.tsx +8 -6
  94. package/src/components/Message/Message.module.scss +1 -1
  95. package/src/components/Message/Message.test.tsx +75 -0
  96. package/src/components/Popover/Popover.test.tsx +105 -0
  97. package/src/components/Progress/Progress.fragment.tsx +14 -0
  98. package/src/components/Progress/Progress.test.tsx +58 -0
  99. package/src/components/Progress/index.tsx +9 -2
  100. package/src/components/Prompt/Prompt.fragment.tsx +11 -0
  101. package/src/components/Prompt/Prompt.test.tsx +89 -0
  102. package/src/components/RadioGroup/RadioGroup.fragment.tsx +5 -0
  103. package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
  104. package/src/components/ScrollArea/ScrollArea.fragment.tsx +185 -0
  105. package/src/components/ScrollArea/ScrollArea.module.scss +136 -0
  106. package/src/components/ScrollArea/ScrollArea.test.tsx +38 -0
  107. package/src/components/ScrollArea/index.tsx +121 -0
  108. package/src/components/Select/Select.fragment.tsx +13 -5
  109. package/src/components/Select/Select.test.tsx +161 -0
  110. package/src/components/Separator/Separator.test.tsx +33 -0
  111. package/src/components/Separator/index.tsx +5 -1
  112. package/src/components/Sidebar/Sidebar.fragment.tsx +64 -11
  113. package/src/components/Sidebar/Sidebar.module.scss +68 -16
  114. package/src/components/Sidebar/Sidebar.test.tsx +114 -0
  115. package/src/components/Sidebar/index.tsx +69 -45
  116. package/src/components/Skeleton/Skeleton.fragment.tsx +5 -0
  117. package/src/components/Skeleton/Skeleton.test.tsx +56 -0
  118. package/src/components/Slider/Slider.test.tsx +51 -0
  119. package/src/components/Slider/index.tsx +5 -1
  120. package/src/components/Stack/Stack.fragment.tsx +2 -2
  121. package/src/components/Stack/Stack.test.tsx +47 -0
  122. package/src/components/Stack/index.tsx +5 -1
  123. package/src/components/Table/Table.fragment.tsx +29 -0
  124. package/src/components/Table/Table.test.tsx +129 -0
  125. package/src/components/Table/index.tsx +6 -1
  126. package/src/components/TableOfContents/TableOfContents.fragment.tsx +149 -0
  127. package/src/components/TableOfContents/TableOfContents.module.scss +71 -0
  128. package/src/components/TableOfContents/TableOfContents.test.tsx +126 -0
  129. package/src/components/TableOfContents/index.tsx +105 -0
  130. package/src/components/Tabs/Tabs.test.tsx +180 -0
  131. package/src/components/Text/Text.test.tsx +40 -0
  132. package/src/components/Text/index.tsx +5 -1
  133. package/src/components/Textarea/Textarea.fragment.tsx +8 -0
  134. package/src/components/Textarea/Textarea.test.tsx +57 -0
  135. package/src/components/Textarea/index.tsx +5 -1
  136. package/src/components/Theme/Theme.test.tsx +114 -0
  137. package/src/components/Theme/index.tsx +7 -0
  138. package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +3 -2
  139. package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
  140. package/src/components/Toast/Toast.fragment.tsx +12 -0
  141. package/src/components/Toast/Toast.test.tsx +192 -0
  142. package/src/components/Toast/index.tsx +14 -4
  143. package/src/components/Toggle/Toggle.test.tsx +49 -0
  144. package/src/components/Toggle/index.tsx +5 -1
  145. package/src/components/ToggleGroup/ToggleGroup.fragment.tsx +96 -78
  146. package/src/components/ToggleGroup/ToggleGroup.test.tsx +90 -0
  147. package/src/components/ToggleGroup/index.tsx +17 -2
  148. package/src/components/Tooltip/Tooltip.fragment.tsx +18 -0
  149. package/src/components/Tooltip/Tooltip.test.tsx +107 -0
  150. package/src/components/Tooltip/index.tsx +6 -1
  151. package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  152. package/src/components/VisuallyHidden/index.tsx +5 -1
  153. package/src/components/compound-pattern.test.ts +40 -0
  154. package/src/index.ts +29 -0
  155. package/src/recipes/AppShell.recipe.ts +2 -2
  156. package/src/recipes/LoginForm.recipe.ts +14 -7
  157. package/src/test/setup.ts +74 -0
  158. package/src/test/utils.tsx +71 -0
  159. package/src/tokens/_computed.scss +12 -0
  160. package/src/tokens/_derive.scss +71 -0
  161. package/src/tokens/_variables.scss +22 -0
  162. package/src/utils/a11y.test.tsx +79 -0
@@ -54,7 +54,39 @@ function stringToColor(str: string): string {
54
54
  hash = str.charCodeAt(i) + ((hash << 5) - hash);
55
55
  }
56
56
  const hue = Math.abs(hash % 360);
57
- return `hsl(${hue}, 65%, 50%)`;
57
+ return `hsl(${hue}, 55%, 40%)`;
58
+ }
59
+
60
+ /**
61
+ * Compute a contrast-safe text color (white or black) for a given HSL background.
62
+ * Uses WCAG relative luminance to pick whichever gives higher contrast.
63
+ */
64
+ function getContrastTextColor(bgColor: string): string | undefined {
65
+ const match = bgColor.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
66
+ if (!match) return undefined;
67
+
68
+ const h = parseInt(match[1]);
69
+ const s = parseInt(match[2]) / 100;
70
+ const l = parseInt(match[3]) / 100;
71
+
72
+ // HSL → RGB
73
+ const c = (1 - Math.abs(2 * l - 1)) * s;
74
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1));
75
+ const m = l - c / 2;
76
+ let r = 0, g = 0, b = 0;
77
+ if (h < 60) { r = c; g = x; }
78
+ else if (h < 120) { r = x; g = c; }
79
+ else if (h < 180) { g = c; b = x; }
80
+ else if (h < 240) { g = x; b = c; }
81
+ else if (h < 300) { r = x; b = c; }
82
+ else { r = c; b = x; }
83
+ r += m; g += m; b += m;
84
+
85
+ // Relative luminance (sRGB → linear)
86
+ const lin = (v: number) => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
87
+ const lum = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
88
+
89
+ return lum > 0.179 ? '#000000' : '#ffffff';
58
90
  }
59
91
 
60
92
  // ============================================
@@ -100,6 +132,10 @@ const AvatarBase = React.forwardRef<HTMLDivElement, AvatarProps>(
100
132
  const style: React.CSSProperties = { ...styleProp };
101
133
  if (showFallback && fallbackColor) {
102
134
  style.backgroundColor = fallbackColor;
135
+ const textColor = getContrastTextColor(fallbackColor);
136
+ if (textColor) {
137
+ (style as Record<string, string>)['--avatar-initials-color'] = textColor;
138
+ }
103
139
  }
104
140
 
105
141
  return (
@@ -24,8 +24,8 @@ export default defineSegment({
24
24
  whenNot: [
25
25
  'Conveying critical errors (use Alert instead)',
26
26
  'Long-form status messages (use Alert)',
27
- 'Interactive filtering (use chip/toggle group)',
28
- 'Navigation labels (use tabs or links)',
27
+ 'Interactive filtering (use Chip/ToggleGroup)',
28
+ 'Navigation labels (use Tabs or links)',
29
29
  ],
30
30
  guidelines: [
31
31
  'Keep badge text under 20 characters',
@@ -75,7 +75,7 @@ export default defineSegment({
75
75
 
76
76
  relations: [
77
77
  { component: 'Alert', relationship: 'alternative', note: 'Use Alert for prominent, longer messages with actions' },
78
- { component: 'Tag', relationship: 'sibling', note: 'Tag is interactive (clickable/filterable); Badge is display-only' },
78
+ { component: 'Chip', relationship: 'sibling', note: 'Chip is interactive (clickable/filterable); Badge is display-only' },
79
79
  ],
80
80
 
81
81
  contract: {
@@ -31,22 +31,22 @@
31
31
 
32
32
  .success {
33
33
  background-color: var(--fui-color-success-bg, $fui-color-success-bg);
34
- color: var(--fui-color-success, $fui-color-success);
34
+ color: var(--fui-color-success-text, $fui-color-success-text);
35
35
  }
36
36
 
37
37
  .warning {
38
38
  background-color: var(--fui-color-warning-bg, $fui-color-warning-bg);
39
- color: var(--fui-color-warning, $fui-color-warning);
39
+ color: var(--fui-color-warning-text, $fui-color-warning-text);
40
40
  }
41
41
 
42
42
  .error {
43
43
  background-color: var(--fui-color-danger-bg, $fui-color-danger-bg);
44
- color: var(--fui-color-danger, $fui-color-danger);
44
+ color: var(--fui-color-danger-text, $fui-color-danger-text);
45
45
  }
46
46
 
47
47
  .info {
48
48
  background-color: var(--fui-color-info-bg, $fui-color-info-bg);
49
- color: var(--fui-color-info, $fui-color-info);
49
+ color: var(--fui-color-info-text, $fui-color-info-text);
50
50
  }
51
51
 
52
52
  .outline {
@@ -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
+ });
@@ -13,7 +13,7 @@ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
13
13
  onRemove?: () => void;
14
14
  }
15
15
 
16
- export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
16
+ const BadgeRoot = React.forwardRef<HTMLSpanElement, BadgeProps>(
17
17
  function Badge(
18
18
  {
19
19
  children,
@@ -67,3 +67,7 @@ export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
67
67
  );
68
68
  }
69
69
  );
70
+
71
+ export const Badge = Object.assign(BadgeRoot, {
72
+ Root: BadgeRoot,
73
+ });
@@ -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
+ });
@@ -65,7 +65,7 @@ function toCss(value: string | number): string {
65
65
  return typeof value === 'number' ? `${value}px` : value;
66
66
  }
67
67
 
68
- export const Box = React.forwardRef<HTMLElement, BoxProps>(
68
+ const BoxRoot = React.forwardRef<HTMLElement, BoxProps>(
69
69
  function Box(
70
70
  {
71
71
  children,
@@ -143,3 +143,7 @@ export const Box = React.forwardRef<HTMLElement, BoxProps>(
143
143
  );
144
144
  }
145
145
  );
146
+
147
+ export const Box = Object.assign(BoxRoot, {
148
+ Root: BoxRoot,
149
+ });
@@ -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
+ });
@@ -22,7 +22,7 @@ export default defineSegment({
22
22
  ],
23
23
  whenNot: [
24
24
  'Simple navigation (use Link)',
25
- 'Toggling state (use Switch or Checkbox)',
25
+ 'Toggling state (use Toggle or Checkbox)',
26
26
  'Selecting from options (use Select or RadioGroup)',
27
27
  ],
28
28
  guidelines: [
@@ -57,20 +57,21 @@ export default defineSegment({
57
57
  default: 'md',
58
58
  description: 'Button size',
59
59
  },
60
- disabled: {
61
- type: 'boolean',
62
- default: false,
63
- description: 'Whether the button is disabled',
64
- },
65
- onClick: {
66
- type: 'function',
67
- description: 'Click handler',
68
- },
69
- type: {
60
+ as: {
70
61
  type: 'enum',
71
- values: ['button', 'submit', 'reset'],
62
+ values: ['button', 'a'],
72
63
  default: 'button',
73
- description: 'HTML button type attribute',
64
+ description: 'Render as a native button or anchor element',
65
+ },
66
+ icon: {
67
+ type: 'boolean',
68
+ default: 'false',
69
+ description: 'Render as icon-only button (square aspect ratio)',
70
+ },
71
+ fullWidth: {
72
+ type: 'boolean',
73
+ default: 'false',
74
+ description: 'Make button full width of container',
74
75
  },
75
76
  },
76
77
 
@@ -81,9 +82,9 @@ export default defineSegment({
81
82
  note: 'Use Link for navigation without action context',
82
83
  },
83
84
  {
84
- component: 'IconButton',
85
- relationship: 'alternative',
86
- note: 'Use IconButton for icon-only actions',
85
+ component: 'Icon',
86
+ relationship: 'complementary',
87
+ note: 'Use Icon inside Button for icon-leading/trailing or icon-only actions',
87
88
  },
88
89
  {
89
90
  component: 'ButtonGroup',
@@ -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
+ });
@@ -30,7 +30,7 @@ export interface ButtonAsAnchorProps
30
30
 
31
31
  export type ButtonProps = ButtonAsButtonProps | ButtonAsAnchorProps;
32
32
 
33
- export const Button = React.forwardRef<
33
+ const ButtonRoot = React.forwardRef<
34
34
  HTMLButtonElement | HTMLAnchorElement,
35
35
  ButtonProps
36
36
  >(function Button(props, ref) {
@@ -83,3 +83,7 @@ export const Button = React.forwardRef<
83
83
  </BaseButton>
84
84
  );
85
85
  });
86
+
87
+ export const Button = Object.assign(ButtonRoot, {
88
+ Root: ButtonRoot,
89
+ });
@@ -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
+ });
@@ -10,7 +10,7 @@ export interface ButtonGroupProps {
10
10
  className?: string;
11
11
  }
12
12
 
13
- export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
13
+ const ButtonGroupRoot = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
14
14
  function ButtonGroup(
15
15
  {
16
16
  children,
@@ -38,3 +38,7 @@ export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
38
38
  );
39
39
  }
40
40
  );
41
+
42
+ export const ButtonGroup = Object.assign(ButtonGroupRoot, {
43
+ Root: ButtonGroupRoot,
44
+ });
@@ -23,7 +23,7 @@ export default defineSegment({
23
23
  whenNot: [
24
24
  'Simple text content that does not need grouping',
25
25
  'Modal or dialog content (use Dialog component)',
26
- 'Navigation items (use NavItem or similar)',
26
+ 'Navigation items (use List or Sidebar patterns)',
27
27
  ],
28
28
  guidelines: [
29
29
  'Use consistent card variants within the same context',
@@ -63,14 +63,14 @@ export default defineSegment({
63
63
 
64
64
  relations: [
65
65
  {
66
- component: 'CardGrid',
66
+ component: 'Grid',
67
67
  relationship: 'parent',
68
- note: 'Use CardGrid for responsive card layouts',
68
+ note: 'Use Grid + Card for responsive card layouts',
69
69
  },
70
70
  {
71
- component: 'ListItem',
71
+ component: 'List',
72
72
  relationship: 'alternative',
73
- note: 'Use ListItem for linear list layouts',
73
+ note: 'Use List for linear, text-first layouts',
74
74
  },
75
75
  ],
76
76
 
@@ -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
+ });
@@ -83,9 +83,17 @@ export default defineSegment({
83
83
  description: 'ChartConfig mapping data keys to labels and colors',
84
84
  },
85
85
  children: {
86
- type: 'ReactElement',
86
+ type: 'element',
87
87
  description: 'A recharts chart component (LineChart, BarChart, etc.)',
88
88
  },
89
+ summary: {
90
+ type: 'string',
91
+ description: 'Non-visual summary announced to assistive technology users',
92
+ },
93
+ dataTable: {
94
+ type: 'node',
95
+ description: 'Optional accessible data table or textual fallback',
96
+ },
89
97
  },
90
98
 
91
99
  relations: [