@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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Sidebar } from './index';
|
|
4
|
+
|
|
5
|
+
// Mock matchMedia for jsdom
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
8
|
+
writable: true,
|
|
9
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
10
|
+
matches: false,
|
|
11
|
+
media: query,
|
|
12
|
+
onchange: null,
|
|
13
|
+
addListener: vi.fn(),
|
|
14
|
+
removeListener: vi.fn(),
|
|
15
|
+
addEventListener: vi.fn(),
|
|
16
|
+
removeEventListener: vi.fn(),
|
|
17
|
+
dispatchEvent: vi.fn(),
|
|
18
|
+
})),
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function renderSidebar(props: Partial<React.ComponentProps<typeof Sidebar>> = {}) {
|
|
23
|
+
return render(
|
|
24
|
+
<Sidebar aria-label="Test sidebar" {...props}>
|
|
25
|
+
<Sidebar.Header>Header Content</Sidebar.Header>
|
|
26
|
+
<Sidebar.Nav aria-label="Main">
|
|
27
|
+
<Sidebar.Section label="Section One">
|
|
28
|
+
<Sidebar.Item icon={<span>I</span>}>Dashboard</Sidebar.Item>
|
|
29
|
+
<Sidebar.Item icon={<span>I</span>} active>Settings</Sidebar.Item>
|
|
30
|
+
<Sidebar.Item icon={<span>I</span>} disabled>Disabled</Sidebar.Item>
|
|
31
|
+
</Sidebar.Section>
|
|
32
|
+
</Sidebar.Nav>
|
|
33
|
+
<Sidebar.Footer>Footer Content</Sidebar.Footer>
|
|
34
|
+
</Sidebar>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('Sidebar', () => {
|
|
39
|
+
it('renders as an aside element', () => {
|
|
40
|
+
renderSidebar();
|
|
41
|
+
const aside = document.querySelector('aside');
|
|
42
|
+
expect(aside).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders compound sub-components', () => {
|
|
46
|
+
renderSidebar();
|
|
47
|
+
expect(screen.getByText('Header Content')).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
49
|
+
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText('Footer Content')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders nav landmark', () => {
|
|
54
|
+
renderSidebar();
|
|
55
|
+
expect(screen.getByRole('navigation', { name: /main/i })).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders section with label', () => {
|
|
59
|
+
renderSidebar();
|
|
60
|
+
expect(screen.getByText('Section One')).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('marks active item with aria-current="page"', () => {
|
|
64
|
+
renderSidebar();
|
|
65
|
+
const activeItem = screen.getByText('Settings').closest('[aria-current]');
|
|
66
|
+
expect(activeItem).toHaveAttribute('aria-current', 'page');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('disables items with disabled prop', () => {
|
|
70
|
+
renderSidebar();
|
|
71
|
+
const disabledItem = screen.getByText('Disabled').closest('button');
|
|
72
|
+
expect(disabledItem).toHaveAttribute('tabindex', '-1');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('supports data-state attribute', () => {
|
|
76
|
+
renderSidebar();
|
|
77
|
+
const aside = document.querySelector('aside');
|
|
78
|
+
expect(aside).toHaveAttribute('data-state');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses icon collapse width when collapsed with icons', () => {
|
|
82
|
+
renderSidebar({ collapsed: true });
|
|
83
|
+
const aside = document.querySelector('aside');
|
|
84
|
+
expect(aside).toHaveStyle('--sidebar-effective-collapsed-width: 56px');
|
|
85
|
+
expect(aside).toHaveAttribute('data-icon-collapse', 'icons');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('collapses fully when collapsed with no item icons and keeps toggle visible', () => {
|
|
89
|
+
render(
|
|
90
|
+
<Sidebar collapsed aria-label="Text-only sidebar">
|
|
91
|
+
<Sidebar.Header>
|
|
92
|
+
Header Content
|
|
93
|
+
<Sidebar.CollapseToggle />
|
|
94
|
+
</Sidebar.Header>
|
|
95
|
+
<Sidebar.Nav aria-label="Main">
|
|
96
|
+
<Sidebar.Section label="Section One">
|
|
97
|
+
<Sidebar.Item>Dashboard</Sidebar.Item>
|
|
98
|
+
<Sidebar.Item active>Settings</Sidebar.Item>
|
|
99
|
+
</Sidebar.Section>
|
|
100
|
+
</Sidebar.Nav>
|
|
101
|
+
</Sidebar>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const aside = document.querySelector('aside');
|
|
105
|
+
expect(aside).toHaveStyle('--sidebar-effective-collapsed-width: 0px');
|
|
106
|
+
expect(aside).toHaveAttribute('data-icon-collapse', 'none');
|
|
107
|
+
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('has no accessibility violations', async () => {
|
|
111
|
+
const { container } = renderSidebar();
|
|
112
|
+
await expectNoA11yViolations(container);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -212,7 +212,7 @@ function CloseIcon() {
|
|
|
212
212
|
);
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
function
|
|
215
|
+
function CollapsePanelIcon() {
|
|
216
216
|
return (
|
|
217
217
|
<svg
|
|
218
218
|
xmlns="http://www.w3.org/2000/svg"
|
|
@@ -222,22 +222,7 @@ function CollapseLeftIcon() {
|
|
|
222
222
|
fill="currentColor"
|
|
223
223
|
aria-hidden="true"
|
|
224
224
|
>
|
|
225
|
-
<path d="
|
|
226
|
-
</svg>
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function CollapseRightIcon() {
|
|
231
|
-
return (
|
|
232
|
-
<svg
|
|
233
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
234
|
-
width="20"
|
|
235
|
-
height="20"
|
|
236
|
-
viewBox="0 0 256 256"
|
|
237
|
-
fill="currentColor"
|
|
238
|
-
aria-hidden="true"
|
|
239
|
-
>
|
|
240
|
-
<path d="M141.66,133.66l-48,48a8,8,0,0,1-11.32-11.32L124.69,128,82.34,85.66a8,8,0,0,1,11.32-11.32l48,48A8,8,0,0,1,141.66,133.66Zm40-11.32-48-48a8,8,0,0,0-11.32,11.32L164.69,128l-42.35,42.34a8,8,0,0,0,11.32,11.32l48-48A8,8,0,0,0,181.66,122.34Z" />
|
|
225
|
+
<path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,56H80V200H40ZM216,200H96V56H216V200Z" />
|
|
241
226
|
</svg>
|
|
242
227
|
);
|
|
243
228
|
}
|
|
@@ -271,6 +256,7 @@ interface SidebarContextValue {
|
|
|
271
256
|
width: string;
|
|
272
257
|
collapsedWidth: string;
|
|
273
258
|
collapsible: SidebarCollapsible;
|
|
259
|
+
hasIcons: boolean;
|
|
274
260
|
toggleSidebar: () => void;
|
|
275
261
|
sidebarId: string;
|
|
276
262
|
}
|
|
@@ -293,8 +279,9 @@ function useSidebar() {
|
|
|
293
279
|
isMobile: false,
|
|
294
280
|
position: 'left' as const,
|
|
295
281
|
width: '240px',
|
|
296
|
-
collapsedWidth: '
|
|
282
|
+
collapsedWidth: '56px',
|
|
297
283
|
collapsible: 'icon' as SidebarCollapsible,
|
|
284
|
+
hasIcons: true,
|
|
298
285
|
toggleSidebar: () => {},
|
|
299
286
|
sidebarId: 'sidebar',
|
|
300
287
|
state: 'expanded' as 'expanded' | 'collapsed' | 'open' | 'closed',
|
|
@@ -359,6 +346,32 @@ function useControllableState<T>(
|
|
|
359
346
|
return [value, setValue];
|
|
360
347
|
}
|
|
361
348
|
|
|
349
|
+
function hasSidebarItemIcons(children: React.ReactNode): boolean {
|
|
350
|
+
let found = false;
|
|
351
|
+
|
|
352
|
+
const visit = (nodes: React.ReactNode) => {
|
|
353
|
+
React.Children.forEach(nodes, child => {
|
|
354
|
+
if (found || !React.isValidElement(child)) return;
|
|
355
|
+
|
|
356
|
+
if (child.type === SidebarItem) {
|
|
357
|
+
const props = child.props as SidebarItemProps;
|
|
358
|
+
if (props.icon) {
|
|
359
|
+
found = true;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const childProps = child.props as { children?: React.ReactNode };
|
|
365
|
+
if (childProps?.children) {
|
|
366
|
+
visit(childProps.children);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
visit(children);
|
|
372
|
+
return found;
|
|
373
|
+
}
|
|
374
|
+
|
|
362
375
|
// ============================================
|
|
363
376
|
// Components
|
|
364
377
|
// ============================================
|
|
@@ -376,7 +389,7 @@ function SidebarProvider({
|
|
|
376
389
|
defaultOpen = false,
|
|
377
390
|
onOpenChange,
|
|
378
391
|
width = '240px',
|
|
379
|
-
collapsedWidth = '
|
|
392
|
+
collapsedWidth = '56px',
|
|
380
393
|
position = 'left',
|
|
381
394
|
collapsible = 'icon',
|
|
382
395
|
enableKeyboardShortcut = true,
|
|
@@ -459,6 +472,7 @@ function SidebarProvider({
|
|
|
459
472
|
width,
|
|
460
473
|
collapsedWidth,
|
|
461
474
|
collapsible,
|
|
475
|
+
hasIcons: true,
|
|
462
476
|
toggleSidebar,
|
|
463
477
|
sidebarId,
|
|
464
478
|
};
|
|
@@ -479,7 +493,7 @@ function SidebarRoot({
|
|
|
479
493
|
defaultOpen = false,
|
|
480
494
|
onOpenChange,
|
|
481
495
|
width = '240px',
|
|
482
|
-
collapsedWidth = '
|
|
496
|
+
collapsedWidth = '56px',
|
|
483
497
|
position = 'left',
|
|
484
498
|
collapsible = 'icon',
|
|
485
499
|
className,
|
|
@@ -512,6 +526,10 @@ function SidebarRoot({
|
|
|
512
526
|
const resolvedWidth = existingContext ? existingContext.width : width;
|
|
513
527
|
const resolvedCollapsedWidth = existingContext ? existingContext.collapsedWidth : collapsedWidth;
|
|
514
528
|
const resolvedCollapsible = existingContext ? existingContext.collapsible : collapsible;
|
|
529
|
+
const hasIcons = React.useMemo(() => hasSidebarItemIcons(children), [children]);
|
|
530
|
+
const shouldCollapseToZero = !isMobile && resolvedCollapsible === 'icon' && collapsed && !hasIcons;
|
|
531
|
+
const isOffcanvasCollapsed = !isMobile && resolvedCollapsible === 'offcanvas' && collapsed;
|
|
532
|
+
const effectiveCollapsedWidth = (shouldCollapseToZero || isOffcanvasCollapsed) ? '0px' : resolvedCollapsedWidth;
|
|
515
533
|
const sidebarId = React.useId();
|
|
516
534
|
const resolvedSidebarId = existingContext ? existingContext.sidebarId : sidebarId;
|
|
517
535
|
const sidebarRef = React.useRef<HTMLElement>(null);
|
|
@@ -558,28 +576,32 @@ function SidebarRoot({
|
|
|
558
576
|
};
|
|
559
577
|
}, [existingContext, isMobile, open]);
|
|
560
578
|
|
|
561
|
-
const contextValue: SidebarContextValue =
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
579
|
+
const contextValue: SidebarContextValue = {
|
|
580
|
+
...(existingContext || {
|
|
581
|
+
collapsed,
|
|
582
|
+
setCollapsed,
|
|
583
|
+
open,
|
|
584
|
+
setOpen,
|
|
585
|
+
isMobile,
|
|
586
|
+
position: resolvedPosition,
|
|
587
|
+
width: resolvedWidth,
|
|
588
|
+
collapsedWidth: resolvedCollapsedWidth,
|
|
589
|
+
collapsible: resolvedCollapsible,
|
|
590
|
+
hasIcons,
|
|
591
|
+
toggleSidebar,
|
|
592
|
+
sidebarId: resolvedSidebarId,
|
|
593
|
+
}),
|
|
594
|
+
hasIcons,
|
|
573
595
|
};
|
|
574
596
|
|
|
575
597
|
const isCollapsedForStyle = resolvedCollapsible === 'icon' && collapsed;
|
|
576
|
-
const isOffcanvas = resolvedCollapsible === 'offcanvas' && collapsed;
|
|
577
598
|
|
|
578
599
|
const classes = [
|
|
579
600
|
styles.root,
|
|
580
601
|
isMobile && styles.mobile,
|
|
581
602
|
!isMobile && isCollapsedForStyle && styles.collapsed,
|
|
582
|
-
!isMobile &&
|
|
603
|
+
!isMobile && isCollapsedForStyle && shouldCollapseToZero && styles.collapsedNoIcons,
|
|
604
|
+
isOffcanvasCollapsed && styles.offcanvasCollapsed,
|
|
583
605
|
resolvedPosition === 'right' && styles.positionRight,
|
|
584
606
|
className,
|
|
585
607
|
].filter(Boolean).join(' ');
|
|
@@ -587,6 +609,7 @@ function SidebarRoot({
|
|
|
587
609
|
const style: React.CSSProperties = {
|
|
588
610
|
'--sidebar-width': resolvedWidth,
|
|
589
611
|
'--sidebar-collapsed-width': resolvedCollapsedWidth,
|
|
612
|
+
'--sidebar-effective-collapsed-width': effectiveCollapsedWidth,
|
|
590
613
|
...styleProp,
|
|
591
614
|
} as React.CSSProperties;
|
|
592
615
|
|
|
@@ -604,16 +627,12 @@ function SidebarRoot({
|
|
|
604
627
|
data-state={isMobile ? (open ? 'open' : 'closed') : (collapsed ? 'collapsed' : 'expanded')}
|
|
605
628
|
data-position={resolvedPosition}
|
|
606
629
|
data-collapsible={resolvedCollapsible}
|
|
630
|
+
data-icon-collapse={resolvedCollapsible === 'icon' ? (hasIcons ? 'icons' : 'none') : undefined}
|
|
607
631
|
>
|
|
608
632
|
{children}
|
|
609
633
|
</aside>
|
|
610
634
|
);
|
|
611
635
|
|
|
612
|
-
// If already inside a provider, don't wrap with another provider
|
|
613
|
-
if (existingContext) {
|
|
614
|
-
return content;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
636
|
return (
|
|
618
637
|
<SidebarContext.Provider value={contextValue}>
|
|
619
638
|
{content}
|
|
@@ -951,19 +970,24 @@ function SidebarOverlay({ className }: SidebarOverlayProps) {
|
|
|
951
970
|
}
|
|
952
971
|
|
|
953
972
|
function SidebarCollapseToggle({ 'aria-label': ariaLabel, className }: SidebarCollapseToggleProps) {
|
|
954
|
-
const { collapsed, setCollapsed, isMobile,
|
|
973
|
+
const { collapsed, setCollapsed, isMobile, collapsible, hasIcons } = useSidebarContext();
|
|
955
974
|
|
|
956
975
|
// Don't show on mobile or when collapsing is disabled
|
|
957
976
|
if (isMobile || collapsible === 'none') {
|
|
958
977
|
return null;
|
|
959
978
|
}
|
|
960
979
|
|
|
961
|
-
const
|
|
980
|
+
const shouldFloat = collapsed && (
|
|
981
|
+
(collapsible === 'icon' && !hasIcons) ||
|
|
982
|
+
collapsible === 'offcanvas'
|
|
983
|
+
);
|
|
984
|
+
const classes = [
|
|
985
|
+
styles.collapseToggle,
|
|
986
|
+
shouldFloat && styles.collapseToggleFloating,
|
|
987
|
+
className,
|
|
988
|
+
].filter(Boolean).join(' ');
|
|
962
989
|
const label = ariaLabel || (collapsed ? 'Expand sidebar' : 'Collapse sidebar');
|
|
963
990
|
|
|
964
|
-
// Determine which icon to show based on position and state
|
|
965
|
-
const showExpandIcon = position === 'left' ? collapsed : !collapsed;
|
|
966
|
-
|
|
967
991
|
return (
|
|
968
992
|
<button
|
|
969
993
|
type="button"
|
|
@@ -971,7 +995,7 @@ function SidebarCollapseToggle({ 'aria-label': ariaLabel, className }: SidebarCo
|
|
|
971
995
|
onClick={() => setCollapsed(!collapsed)}
|
|
972
996
|
aria-label={label}
|
|
973
997
|
>
|
|
974
|
-
|
|
998
|
+
<CollapsePanelIcon />
|
|
975
999
|
</button>
|
|
976
1000
|
);
|
|
977
1001
|
}
|
|
@@ -69,6 +69,11 @@ export default defineSegment({
|
|
|
69
69
|
values: ['none', 'sm', 'md', 'lg', 'full'],
|
|
70
70
|
description: 'Border radius override',
|
|
71
71
|
},
|
|
72
|
+
static: {
|
|
73
|
+
type: 'boolean',
|
|
74
|
+
default: false,
|
|
75
|
+
description: 'Disable skeleton animation',
|
|
76
|
+
},
|
|
72
77
|
},
|
|
73
78
|
|
|
74
79
|
relations: [
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Skeleton } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Skeleton', () => {
|
|
6
|
+
it('renders with aria-hidden="true"', () => {
|
|
7
|
+
const { container } = render(<Skeleton />);
|
|
8
|
+
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('applies variant classes', () => {
|
|
12
|
+
const { container: c1 } = render(<Skeleton variant="text" />);
|
|
13
|
+
expect(c1.firstElementChild).toHaveClass('text');
|
|
14
|
+
|
|
15
|
+
const { container: c2 } = render(<Skeleton variant="avatar" />);
|
|
16
|
+
expect(c2.firstElementChild).toHaveClass('avatar');
|
|
17
|
+
|
|
18
|
+
const { container: c3 } = render(<Skeleton variant="button" />);
|
|
19
|
+
expect(c3.firstElementChild).toHaveClass('button');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('applies static class when animation is disabled', () => {
|
|
23
|
+
const { container } = render(<Skeleton static />);
|
|
24
|
+
expect(container.firstElementChild).toHaveClass('static');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('applies custom dimensions via style', () => {
|
|
28
|
+
const { container } = render(<Skeleton width={200} height={100} />);
|
|
29
|
+
const el = container.firstElementChild as HTMLElement;
|
|
30
|
+
expect(el.style.width).toBe('200px');
|
|
31
|
+
expect(el.style.height).toBe('100px');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('applies fill class', () => {
|
|
35
|
+
const { container } = render(<Skeleton fill />);
|
|
36
|
+
expect(container.firstElementChild).toHaveClass('fill');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders Skeleton.Text with multiple lines', () => {
|
|
40
|
+
const { container } = render(<Skeleton.Text lines={4} />);
|
|
41
|
+
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
|
42
|
+
const lines = container.querySelectorAll('.textLine');
|
|
43
|
+
expect(lines).toHaveLength(4);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('has no accessibility violations', async () => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<div>
|
|
49
|
+
<Skeleton variant="text" />
|
|
50
|
+
<Skeleton variant="avatar" />
|
|
51
|
+
<Skeleton.Text lines={3} />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
await expectNoA11yViolations(container);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Slider } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Slider', () => {
|
|
6
|
+
it('renders a slider role', () => {
|
|
7
|
+
render(<Slider aria-label="Volume" />);
|
|
8
|
+
expect(screen.getByRole('slider')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('sets aria-valuemin and aria-valuemax from min/max props', () => {
|
|
12
|
+
render(<Slider aria-label="Volume" min={10} max={90} defaultValue={50} />);
|
|
13
|
+
const slider = screen.getByRole('slider');
|
|
14
|
+
// Base UI Slider sets min/max on the group or thumb — check the output element
|
|
15
|
+
expect(slider).toBeInTheDocument();
|
|
16
|
+
// The slider group should be present with the right configuration
|
|
17
|
+
const output = slider.closest('[role="group"]') || slider;
|
|
18
|
+
expect(output).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('sets aria-valuenow from value prop', () => {
|
|
22
|
+
render(<Slider aria-label="Volume" value={42} onChange={() => {}} />);
|
|
23
|
+
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '42');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders a label via Field.Label', () => {
|
|
27
|
+
render(<Slider label="Volume" />);
|
|
28
|
+
expect(screen.getByText('Volume')).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('disables the slider', () => {
|
|
32
|
+
render(<Slider aria-label="Volume" disabled />);
|
|
33
|
+
expect(screen.getByRole('slider')).toBeDisabled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('respects step attribute', () => {
|
|
37
|
+
render(<Slider aria-label="Volume" step={5} defaultValue={0} />);
|
|
38
|
+
// step is part of the slider control — no direct ARIA but functional
|
|
39
|
+
expect(screen.getByRole('slider')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('displays the value when showValue is true', () => {
|
|
43
|
+
render(<Slider label="Volume" value={75} showValue onChange={() => {}} />);
|
|
44
|
+
expect(screen.getByText('75')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('has no accessibility violations', async () => {
|
|
48
|
+
const { container } = render(<Slider label="Accessible slider" defaultValue={50} />);
|
|
49
|
+
await expectNoA11yViolations(container);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -24,7 +24,7 @@ export interface SliderProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
|
|
|
24
24
|
'aria-describedby'?: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const SliderRoot = React.forwardRef<HTMLDivElement, SliderProps>(
|
|
28
28
|
function Slider(
|
|
29
29
|
{
|
|
30
30
|
label,
|
|
@@ -96,3 +96,7 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
);
|
|
99
|
+
|
|
100
|
+
export const Slider = Object.assign(SliderRoot, {
|
|
101
|
+
Root: SliderRoot,
|
|
102
|
+
});
|
|
@@ -48,12 +48,12 @@ export default defineSegment({
|
|
|
48
48
|
required: true,
|
|
49
49
|
},
|
|
50
50
|
direction: {
|
|
51
|
-
type: '
|
|
51
|
+
type: 'union',
|
|
52
52
|
description: 'Stack direction: "row", "column", or responsive object',
|
|
53
53
|
default: 'column',
|
|
54
54
|
},
|
|
55
55
|
gap: {
|
|
56
|
-
type: '
|
|
56
|
+
type: 'union',
|
|
57
57
|
description: 'Spacing between items: "none", "xs", "sm", "md", "lg", "xl", or responsive object',
|
|
58
58
|
default: 'md',
|
|
59
59
|
},
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen, expectNoA11yViolations } from '../../test/utils';
|
|
3
|
+
import { Stack } from './index';
|
|
4
|
+
|
|
5
|
+
describe('Stack', () => {
|
|
6
|
+
it('renders children in a div by default', () => {
|
|
7
|
+
render(<Stack><span>A</span><span>B</span></Stack>);
|
|
8
|
+
expect(screen.getByText('A')).toBeInTheDocument();
|
|
9
|
+
expect(screen.getByText('B')).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('applies direction class', () => {
|
|
13
|
+
const { container } = render(<Stack direction="row"><span>A</span></Stack>);
|
|
14
|
+
expect(container.firstChild).toHaveClass('row');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('applies gap class', () => {
|
|
18
|
+
const { container } = render(<Stack gap="lg"><span>A</span></Stack>);
|
|
19
|
+
expect(container.firstChild).toHaveClass('gap-lg');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('applies alignment and justify classes', () => {
|
|
23
|
+
const { container } = render(
|
|
24
|
+
<Stack align="center" justify="between"><span>A</span></Stack>
|
|
25
|
+
);
|
|
26
|
+
const el = container.firstChild as HTMLElement;
|
|
27
|
+
expect(el).toHaveClass('align-center');
|
|
28
|
+
expect(el).toHaveClass('justify-between');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders as a different element via "as" prop', () => {
|
|
32
|
+
render(<Stack as="nav"><span>Item</span></Stack>);
|
|
33
|
+
const nav = screen.getByText('Item').parentElement!;
|
|
34
|
+
expect(nav.tagName).toBe('NAV');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('forwards ref', () => {
|
|
38
|
+
const ref = vi.fn();
|
|
39
|
+
render(<Stack ref={ref}><span>A</span></Stack>);
|
|
40
|
+
expect(ref).toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('has no accessibility violations', async () => {
|
|
44
|
+
const { container } = render(<Stack><span>A</span><span>B</span></Stack>);
|
|
45
|
+
await expectNoA11yViolations(container);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -71,7 +71,7 @@ function isResponsiveGap(gap: StackProps['gap']): gap is ResponsiveGap {
|
|
|
71
71
|
return typeof gap === 'object' && gap !== null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
const StackRoot = React.forwardRef<HTMLElement, StackProps>(
|
|
75
75
|
function Stack(
|
|
76
76
|
{
|
|
77
77
|
children,
|
|
@@ -180,3 +180,7 @@ function gapToSpace(gap: Gap): string {
|
|
|
180
180
|
};
|
|
181
181
|
return map[gap];
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
export const Stack = Object.assign(StackRoot, {
|
|
185
|
+
Root: StackRoot,
|
|
186
|
+
});
|
|
@@ -86,16 +86,36 @@ export default defineSegment({
|
|
|
86
86
|
description: 'Data rows to display',
|
|
87
87
|
required: true,
|
|
88
88
|
},
|
|
89
|
+
getRowId: {
|
|
90
|
+
type: 'function',
|
|
91
|
+
description: 'Unique key extractor for each row',
|
|
92
|
+
},
|
|
89
93
|
sortable: {
|
|
90
94
|
type: 'boolean',
|
|
91
95
|
description: 'Enable column sorting',
|
|
92
96
|
default: 'false',
|
|
93
97
|
},
|
|
98
|
+
sorting: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
description: 'Controlled sorting state',
|
|
101
|
+
},
|
|
102
|
+
onSortingChange: {
|
|
103
|
+
type: 'function',
|
|
104
|
+
description: 'Sorting change handler',
|
|
105
|
+
},
|
|
94
106
|
selectable: {
|
|
95
107
|
type: 'boolean',
|
|
96
108
|
description: 'Enable row selection',
|
|
97
109
|
default: 'false',
|
|
98
110
|
},
|
|
111
|
+
rowSelection: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
description: 'Controlled row selection state',
|
|
114
|
+
},
|
|
115
|
+
onRowSelectionChange: {
|
|
116
|
+
type: 'function',
|
|
117
|
+
description: 'Row selection change handler',
|
|
118
|
+
},
|
|
99
119
|
onRowClick: {
|
|
100
120
|
type: 'function',
|
|
101
121
|
description: 'Handler for row clicks',
|
|
@@ -111,6 +131,15 @@ export default defineSegment({
|
|
|
111
131
|
values: ['sm', 'md'],
|
|
112
132
|
default: 'md',
|
|
113
133
|
},
|
|
134
|
+
caption: {
|
|
135
|
+
type: 'string',
|
|
136
|
+
description: 'Visible caption for the table',
|
|
137
|
+
},
|
|
138
|
+
captionHidden: {
|
|
139
|
+
type: 'boolean',
|
|
140
|
+
default: 'false',
|
|
141
|
+
description: 'Hide caption visually but keep it for screen readers',
|
|
142
|
+
},
|
|
114
143
|
striped: {
|
|
115
144
|
type: 'boolean',
|
|
116
145
|
description: 'Show alternating row backgrounds',
|