@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.
- package/README.md +58 -25
- package/fragments.json +1 -1
- package/package.json +22 -5
- package/src/blocks/AppShell.block.ts +2 -2
- package/src/blocks/InsetDashboardLayout.block.ts +1 -1
- package/src/blocks/LoginForm.block.ts +14 -7
- package/src/components/Accordion/Accordion.fragment.tsx +8 -2
- package/src/components/Accordion/Accordion.test.tsx +171 -0
- package/src/components/Alert/Alert.module.scss +4 -4
- package/src/components/Alert/Alert.test.tsx +127 -0
- package/src/components/AppShell/AppShell.fragment.tsx +1 -1
- package/src/components/AppShell/AppShell.test.tsx +80 -0
- package/src/components/AppShell/index.tsx +2 -0
- package/src/components/Avatar/Avatar.fragment.tsx +5 -1
- package/src/components/Avatar/Avatar.module.scss +1 -1
- package/src/components/Avatar/Avatar.test.tsx +40 -0
- package/src/components/Avatar/index.tsx +37 -1
- package/src/components/Badge/Badge.fragment.tsx +3 -3
- package/src/components/Badge/Badge.module.scss +4 -4
- package/src/components/Badge/Badge.test.tsx +58 -0
- package/src/components/Badge/index.tsx +5 -1
- package/src/components/Box/Box.test.tsx +43 -0
- package/src/components/Box/index.tsx +5 -1
- package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +75 -0
- package/src/components/Button/Button.fragment.tsx +17 -16
- package/src/components/Button/Button.test.tsx +53 -0
- package/src/components/Button/index.tsx +5 -1
- package/src/components/ButtonGroup/ButtonGroup.test.tsx +44 -0
- package/src/components/ButtonGroup/index.tsx +5 -1
- package/src/components/Card/Card.fragment.tsx +5 -5
- package/src/components/Card/Card.test.tsx +71 -0
- package/src/components/Chart/Chart.fragment.tsx +9 -1
- package/src/components/Chart/Chart.test.tsx +123 -0
- package/src/components/Chart/index.tsx +22 -4
- package/src/components/Checkbox/Checkbox.test.tsx +63 -0
- package/src/components/Checkbox/index.tsx +5 -1
- package/src/components/Chip/Chip.fragment.tsx +0 -5
- package/src/components/Chip/Chip.module.scss +55 -2
- package/src/components/Chip/Chip.test.tsx +50 -0
- package/src/components/CodeBlock/CodeBlock.fragment.tsx +9 -3
- package/src/components/CodeBlock/CodeBlock.module.scss +1 -1
- 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 +9 -2
- package/src/components/Combobox/Combobox.fragment.tsx +15 -7
- package/src/components/Combobox/Combobox.test.tsx +202 -0
- package/src/components/ConversationList/ConversationList.fragment.tsx +3 -3
- package/src/components/ConversationList/ConversationList.module.scss +1 -1
- package/src/components/ConversationList/ConversationList.test.tsx +79 -0
- package/src/components/DatePicker/DatePicker.fragment.tsx +245 -0
- package/src/components/DatePicker/DatePicker.module.scss +394 -0
- package/src/components/DatePicker/DatePicker.test.tsx +264 -0
- package/src/components/DatePicker/index.tsx +535 -0
- package/src/components/Dialog/Dialog.test.tsx +277 -0
- package/src/components/EmptyState/EmptyState.test.tsx +67 -0
- package/src/components/Field/Field.fragment.tsx +5 -4
- package/src/components/Field/Field.test.tsx +65 -0
- package/src/components/Fieldset/Fieldset.fragment.tsx +5 -4
- package/src/components/Fieldset/Fieldset.test.tsx +48 -0
- package/src/components/Form/Form.fragment.tsx +9 -3
- package/src/components/Form/Form.test.tsx +41 -0
- package/src/components/Form/index.tsx +5 -1
- package/src/components/Grid/Grid.fragment.tsx +4 -0
- package/src/components/Grid/Grid.test.tsx +65 -0
- package/src/components/Header/Header.fragment.tsx +36 -13
- package/src/components/Header/Header.module.scss +114 -1
- package/src/components/Header/Header.test.tsx +188 -0
- package/src/components/Header/index.tsx +100 -31
- package/src/components/Icon/Icon.fragment.tsx +6 -1
- package/src/components/Icon/Icon.test.tsx +38 -0
- package/src/components/Icon/index.tsx +5 -1
- package/src/components/Image/Image.fragment.tsx +2 -2
- package/src/components/Image/Image.test.tsx +39 -0
- package/src/components/Image/index.tsx +5 -1
- package/src/components/Input/Input.fragment.tsx +21 -3
- package/src/components/Input/Input.module.scss +1 -1
- package/src/components/Input/Input.test.tsx +72 -0
- package/src/components/Input/index.tsx +5 -1
- package/src/components/Link/Link.fragment.tsx +0 -4
- package/src/components/Link/Link.test.tsx +37 -0
- package/src/components/Link/index.tsx +5 -1
- package/src/components/List/List.test.tsx +57 -0
- package/src/components/Listbox/Listbox.fragment.tsx +0 -12
- 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.module.scss +6 -3
- package/src/components/Markdown/Markdown.test.tsx +41 -0
- package/src/components/Markdown/index.tsx +5 -1
- package/src/components/Menu/Menu.test.tsx +336 -0
- package/src/components/Message/Message.fragment.tsx +8 -6
- package/src/components/Message/Message.module.scss +1 -1
- package/src/components/Message/Message.test.tsx +75 -0
- package/src/components/Popover/Popover.test.tsx +105 -0
- package/src/components/Progress/Progress.fragment.tsx +14 -0
- package/src/components/Progress/Progress.test.tsx +58 -0
- package/src/components/Progress/index.tsx +9 -2
- package/src/components/Prompt/Prompt.fragment.tsx +11 -0
- package/src/components/Prompt/Prompt.test.tsx +89 -0
- package/src/components/RadioGroup/RadioGroup.fragment.tsx +5 -0
- package/src/components/RadioGroup/RadioGroup.test.tsx +105 -0
- package/src/components/ScrollArea/ScrollArea.fragment.tsx +185 -0
- package/src/components/ScrollArea/ScrollArea.module.scss +136 -0
- package/src/components/ScrollArea/ScrollArea.test.tsx +38 -0
- package/src/components/ScrollArea/index.tsx +121 -0
- package/src/components/Select/Select.fragment.tsx +13 -5
- package/src/components/Select/Select.test.tsx +161 -0
- package/src/components/Separator/Separator.test.tsx +33 -0
- package/src/components/Separator/index.tsx +5 -1
- package/src/components/Sidebar/Sidebar.fragment.tsx +64 -11
- package/src/components/Sidebar/Sidebar.module.scss +68 -16
- package/src/components/Sidebar/Sidebar.test.tsx +114 -0
- package/src/components/Sidebar/index.tsx +69 -45
- package/src/components/Skeleton/Skeleton.fragment.tsx +5 -0
- package/src/components/Skeleton/Skeleton.test.tsx +56 -0
- package/src/components/Slider/Slider.test.tsx +51 -0
- package/src/components/Slider/index.tsx +5 -1
- package/src/components/Stack/Stack.fragment.tsx +2 -2
- package/src/components/Stack/Stack.test.tsx +47 -0
- package/src/components/Stack/index.tsx +5 -1
- package/src/components/Table/Table.fragment.tsx +29 -0
- package/src/components/Table/Table.test.tsx +129 -0
- package/src/components/Table/index.tsx +6 -1
- package/src/components/TableOfContents/TableOfContents.fragment.tsx +149 -0
- package/src/components/TableOfContents/TableOfContents.module.scss +71 -0
- package/src/components/TableOfContents/TableOfContents.test.tsx +126 -0
- package/src/components/TableOfContents/index.tsx +105 -0
- package/src/components/Tabs/Tabs.test.tsx +180 -0
- package/src/components/Text/Text.test.tsx +40 -0
- package/src/components/Text/index.tsx +5 -1
- package/src/components/Textarea/Textarea.fragment.tsx +8 -0
- package/src/components/Textarea/Textarea.test.tsx +57 -0
- package/src/components/Textarea/index.tsx +5 -1
- package/src/components/Theme/Theme.test.tsx +114 -0
- package/src/components/Theme/index.tsx +7 -0
- package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +3 -2
- package/src/components/ThinkingIndicator/ThinkingIndicator.test.tsx +54 -0
- package/src/components/Toast/Toast.fragment.tsx +12 -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/Toggle/index.tsx +5 -1
- 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.fragment.tsx +18 -0
- package/src/components/Tooltip/Tooltip.test.tsx +107 -0
- package/src/components/Tooltip/index.tsx +6 -1
- package/src/components/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/components/VisuallyHidden/index.tsx +5 -1
- package/src/components/compound-pattern.test.ts +40 -0
- package/src/index.ts +29 -0
- package/src/recipes/AppShell.recipe.ts +2 -2
- package/src/recipes/LoginForm.recipe.ts +14 -7
- package/src/test/setup.ts +74 -0
- package/src/test/utils.tsx +71 -0
- package/src/tokens/_computed.scss +12 -0
- package/src/tokens/_derive.scss +71 -0
- package/src/tokens/_variables.scss +22 -0
- 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},
|
|
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
|
|
28
|
-
'Navigation labels (use
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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', '
|
|
62
|
+
values: ['button', 'a'],
|
|
72
63
|
default: 'button',
|
|
73
|
-
description: '
|
|
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: '
|
|
85
|
-
relationship: '
|
|
86
|
-
note: 'Use
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
66
|
+
component: 'Grid',
|
|
67
67
|
relationship: 'parent',
|
|
68
|
-
note: 'Use
|
|
68
|
+
note: 'Use Grid + Card for responsive card layouts',
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
|
-
component: '
|
|
71
|
+
component: 'List',
|
|
72
72
|
relationship: 'alternative',
|
|
73
|
-
note: 'Use
|
|
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: '
|
|
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: [
|