@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
@@ -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 CollapseLeftIcon() {
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="M141.66,181.66a8,8,0,0,1-11.32,0l-48-48a8,8,0,0,1,0-11.32l48-48a8,8,0,0,1,11.32,11.32L99.31,128l42.35,42.34A8,8,0,0,1,141.66,181.66Zm40-96L139.31,128l42.35,42.34a8,8,0,0,1-11.32,11.32l-48-48a8,8,0,0,1,0-11.32l48-48a8,8,0,0,1,11.32,11.32Z" />
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: '64px',
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 = '64px',
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 = '64px',
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 = existingContext || {
562
- collapsed,
563
- setCollapsed,
564
- open,
565
- setOpen,
566
- isMobile,
567
- position: resolvedPosition,
568
- width: resolvedWidth,
569
- collapsedWidth: resolvedCollapsedWidth,
570
- collapsible: resolvedCollapsible,
571
- toggleSidebar,
572
- sidebarId: resolvedSidebarId,
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 && isOffcanvas && styles.offcanvas,
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, position, collapsible } = useSidebarContext();
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 classes = [styles.collapseToggle, className].filter(Boolean).join(' ');
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
- {showExpandIcon ? <CollapseRightIcon /> : <CollapseLeftIcon />}
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
- export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
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: 'string | object',
51
+ type: 'union',
52
52
  description: 'Stack direction: "row", "column", or responsive object',
53
53
  default: 'column',
54
54
  },
55
55
  gap: {
56
- type: 'string | object',
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
- export const Stack = React.forwardRef<HTMLElement, StackProps>(
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',