@fragments-sdk/ui 0.7.5 → 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 (100) hide show
  1. package/README.md +58 -25
  2. package/fragments.json +1 -1
  3. package/package.json +15 -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/Alert/Alert.module.scss +4 -4
  9. package/src/components/AppShell/AppShell.fragment.tsx +1 -1
  10. package/src/components/AppShell/index.tsx +2 -0
  11. package/src/components/Avatar/Avatar.fragment.tsx +5 -1
  12. package/src/components/Avatar/Avatar.module.scss +1 -1
  13. package/src/components/Avatar/index.tsx +37 -1
  14. package/src/components/Badge/Badge.fragment.tsx +3 -3
  15. package/src/components/Badge/Badge.module.scss +4 -4
  16. package/src/components/Badge/index.tsx +5 -1
  17. package/src/components/Box/index.tsx +5 -1
  18. package/src/components/Button/Button.fragment.tsx +17 -16
  19. package/src/components/Button/index.tsx +5 -1
  20. package/src/components/ButtonGroup/index.tsx +5 -1
  21. package/src/components/Card/Card.fragment.tsx +5 -5
  22. package/src/components/Chart/Chart.fragment.tsx +9 -1
  23. package/src/components/Chart/index.tsx +22 -4
  24. package/src/components/Checkbox/index.tsx +5 -1
  25. package/src/components/Chip/Chip.fragment.tsx +0 -5
  26. package/src/components/Chip/Chip.module.scss +2 -2
  27. package/src/components/CodeBlock/CodeBlock.fragment.tsx +9 -3
  28. package/src/components/CodeBlock/CodeBlock.module.scss +1 -1
  29. package/src/components/ColorPicker/index.tsx +5 -1
  30. package/src/components/Combobox/Combobox.fragment.tsx +15 -7
  31. package/src/components/ConversationList/ConversationList.fragment.tsx +3 -3
  32. package/src/components/ConversationList/ConversationList.module.scss +1 -1
  33. package/src/components/DatePicker/DatePicker.fragment.tsx +245 -0
  34. package/src/components/DatePicker/DatePicker.module.scss +394 -0
  35. package/src/components/DatePicker/DatePicker.test.tsx +264 -0
  36. package/src/components/DatePicker/index.tsx +535 -0
  37. package/src/components/Field/Field.fragment.tsx +5 -4
  38. package/src/components/Fieldset/Fieldset.fragment.tsx +5 -4
  39. package/src/components/Form/Form.fragment.tsx +9 -3
  40. package/src/components/Form/index.tsx +5 -1
  41. package/src/components/Grid/Grid.fragment.tsx +4 -0
  42. package/src/components/Header/Header.fragment.tsx +36 -13
  43. package/src/components/Header/Header.module.scss +114 -1
  44. package/src/components/Header/Header.test.tsx +106 -1
  45. package/src/components/Header/index.tsx +100 -31
  46. package/src/components/Icon/Icon.fragment.tsx +6 -1
  47. package/src/components/Icon/index.tsx +5 -1
  48. package/src/components/Image/Image.fragment.tsx +2 -2
  49. package/src/components/Image/index.tsx +5 -1
  50. package/src/components/Input/Input.fragment.tsx +21 -3
  51. package/src/components/Input/Input.module.scss +1 -1
  52. package/src/components/Input/index.tsx +5 -1
  53. package/src/components/Link/Link.fragment.tsx +0 -4
  54. package/src/components/Link/index.tsx +5 -1
  55. package/src/components/Listbox/Listbox.fragment.tsx +0 -12
  56. package/src/components/Markdown/Markdown.module.scss +6 -3
  57. package/src/components/Markdown/index.tsx +5 -1
  58. package/src/components/Message/Message.fragment.tsx +8 -6
  59. package/src/components/Message/Message.module.scss +1 -1
  60. package/src/components/Progress/Progress.fragment.tsx +14 -0
  61. package/src/components/Progress/index.tsx +9 -2
  62. package/src/components/Prompt/Prompt.fragment.tsx +11 -0
  63. package/src/components/RadioGroup/RadioGroup.fragment.tsx +5 -0
  64. package/src/components/ScrollArea/ScrollArea.fragment.tsx +185 -0
  65. package/src/components/ScrollArea/ScrollArea.module.scss +136 -0
  66. package/src/components/ScrollArea/ScrollArea.test.tsx +38 -0
  67. package/src/components/ScrollArea/index.tsx +121 -0
  68. package/src/components/Select/Select.fragment.tsx +13 -5
  69. package/src/components/Separator/index.tsx +5 -1
  70. package/src/components/Sidebar/Sidebar.fragment.tsx +64 -11
  71. package/src/components/Sidebar/Sidebar.module.scss +68 -16
  72. package/src/components/Sidebar/Sidebar.test.tsx +31 -2
  73. package/src/components/Sidebar/index.tsx +69 -45
  74. package/src/components/Skeleton/Skeleton.fragment.tsx +5 -0
  75. package/src/components/Slider/index.tsx +5 -1
  76. package/src/components/Stack/Stack.fragment.tsx +2 -2
  77. package/src/components/Stack/index.tsx +5 -1
  78. package/src/components/Table/Table.fragment.tsx +29 -0
  79. package/src/components/Table/index.tsx +6 -1
  80. package/src/components/TableOfContents/TableOfContents.fragment.tsx +149 -0
  81. package/src/components/TableOfContents/TableOfContents.module.scss +71 -0
  82. package/src/components/TableOfContents/TableOfContents.test.tsx +126 -0
  83. package/src/components/TableOfContents/index.tsx +105 -0
  84. package/src/components/Text/index.tsx +5 -1
  85. package/src/components/Textarea/Textarea.fragment.tsx +8 -0
  86. package/src/components/Textarea/index.tsx +5 -1
  87. package/src/components/Theme/index.tsx +7 -0
  88. package/src/components/ThinkingIndicator/ThinkingIndicator.fragment.tsx +3 -2
  89. package/src/components/Toast/Toast.fragment.tsx +12 -0
  90. package/src/components/Toggle/index.tsx +5 -1
  91. package/src/components/Tooltip/Tooltip.fragment.tsx +18 -0
  92. package/src/components/Tooltip/index.tsx +6 -1
  93. package/src/components/VisuallyHidden/index.tsx +5 -1
  94. package/src/components/compound-pattern.test.ts +40 -0
  95. package/src/index.ts +29 -0
  96. package/src/recipes/AppShell.recipe.ts +2 -2
  97. package/src/recipes/LoginForm.recipe.ts +14 -7
  98. package/src/tokens/_computed.scss +12 -0
  99. package/src/tokens/_derive.scss +71 -0
  100. package/src/tokens/_variables.scss +22 -0
@@ -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: [
@@ -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
  },
@@ -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',
@@ -51,7 +51,7 @@ export interface TableProps<T> extends Omit<React.HTMLAttributes<HTMLTableElemen
51
51
  bordered?: boolean;
52
52
  }
53
53
 
54
- export function Table<T>({
54
+ function TableRoot<T>({
55
55
  columns,
56
56
  data,
57
57
  getRowId,
@@ -330,3 +330,8 @@ export function createColumns<T>(
330
330
 
331
331
  // Re-export useful types
332
332
  export type { ColumnDef, SortingState, RowSelectionState };
333
+
334
+ export const Table = Object.assign(TableRoot, {
335
+ Root: TableRoot,
336
+ Columns: createColumns,
337
+ });
@@ -0,0 +1,149 @@
1
+ import React from 'react';
2
+ import { defineSegment } from '@fragments/core';
3
+ import { TableOfContents } from '.';
4
+
5
+ export default defineSegment({
6
+ component: TableOfContents,
7
+
8
+ meta: {
9
+ name: 'TableOfContents',
10
+ description: 'Sticky sidebar navigation for long-form content. Renders heading links with active state highlighting for scroll spy integration.',
11
+ category: 'navigation',
12
+ status: 'stable',
13
+ tags: ['toc', 'table-of-contents', 'navigation', 'sidebar', 'scroll-spy', 'headings'],
14
+ since: '0.9.0',
15
+ },
16
+
17
+ usage: {
18
+ when: [
19
+ 'Long-form content pages (docs, blog posts, articles)',
20
+ 'Component documentation with multiple sections',
21
+ 'Any page with 3+ headings that benefits from quick navigation',
22
+ ],
23
+ whenNot: [
24
+ 'Short pages with only 1-2 sections',
25
+ 'Primary site navigation (use Sidebar or Header)',
26
+ 'Step-by-step flows (use Stepper)',
27
+ ],
28
+ guidelines: [
29
+ 'Pair with a scroll spy hook (e.g., IntersectionObserver) to track active heading',
30
+ 'Use indent on sub-headings (h3) to show hierarchy',
31
+ 'Place in a sticky aside for best UX',
32
+ 'Heading IDs must match between TOC items and the actual DOM headings',
33
+ ],
34
+ accessibility: [
35
+ 'Uses <nav aria-label="Table of contents"> for landmark navigation',
36
+ 'Active item is marked with aria-current="true"',
37
+ 'All items are links with smooth scroll behavior',
38
+ 'Focus-visible ring on keyboard navigation',
39
+ ],
40
+ },
41
+
42
+ props: {
43
+ children: {
44
+ type: 'node',
45
+ description: 'TableOfContents.Item elements',
46
+ required: true,
47
+ },
48
+ label: {
49
+ type: 'string',
50
+ description: 'Accessible label for the nav landmark',
51
+ default: '"Table of contents"',
52
+ },
53
+ title: {
54
+ type: 'string',
55
+ description: 'Visible title above the list',
56
+ default: '"On This Page"',
57
+ },
58
+ hideTitle: {
59
+ type: 'boolean',
60
+ description: 'Hide the visible title',
61
+ default: 'false',
62
+ },
63
+ },
64
+
65
+ relations: [
66
+ { component: 'Breadcrumbs', relationship: 'complementary', note: 'Breadcrumbs show hierarchy, TOC shows page sections' },
67
+ { component: 'Sidebar', relationship: 'complementary', note: 'Sidebar for site nav, TOC for in-page nav' },
68
+ { component: 'Tabs', relationship: 'alternative', note: 'Tabs for switching views, TOC for scrolling to sections' },
69
+ ],
70
+
71
+ contract: {
72
+ propsSummary: [
73
+ 'title: string - visible heading (default "On This Page")',
74
+ 'hideTitle: boolean - hide the title',
75
+ 'label: string - aria-label for nav landmark',
76
+ 'TableOfContents.Item id: string - heading ID to link to',
77
+ 'TableOfContents.Item active: boolean - highlight as current',
78
+ 'TableOfContents.Item indent: boolean - indent for sub-headings',
79
+ ],
80
+ scenarioTags: [
81
+ 'navigation.toc',
82
+ 'navigation.scroll-spy',
83
+ 'layout.sidebar',
84
+ ],
85
+ a11yRules: ['A11Y_NAV_LANDMARK', 'A11Y_ARIA_CURRENT'],
86
+ },
87
+
88
+ ai: {
89
+ compositionPattern: 'compound',
90
+ subComponents: ['Item'],
91
+ requiredChildren: ['Item'],
92
+ commonPatterns: [
93
+ '<TableOfContents>{headings.map(h => <TableOfContents.Item key={h.id} id={h.id} active={activeId === h.id} indent={h.level === 3}>{h.text}</TableOfContents.Item>)}</TableOfContents>',
94
+ ],
95
+ },
96
+
97
+ variants: [
98
+ {
99
+ name: 'Default',
100
+ description: 'Basic table of contents with section links',
101
+ render: () => (
102
+ <TableOfContents>
103
+ <TableOfContents.Item id="introduction">Introduction</TableOfContents.Item>
104
+ <TableOfContents.Item id="getting-started">Getting Started</TableOfContents.Item>
105
+ <TableOfContents.Item id="installation" indent>Installation</TableOfContents.Item>
106
+ <TableOfContents.Item id="configuration" indent>Configuration</TableOfContents.Item>
107
+ <TableOfContents.Item id="api-reference">API Reference</TableOfContents.Item>
108
+ <TableOfContents.Item id="examples">Examples</TableOfContents.Item>
109
+ </TableOfContents>
110
+ ),
111
+ },
112
+ {
113
+ name: 'With Active Item',
114
+ description: 'Active state highlighting the current section',
115
+ render: () => (
116
+ <TableOfContents>
117
+ <TableOfContents.Item id="overview">Overview</TableOfContents.Item>
118
+ <TableOfContents.Item id="setup" active>Setup</TableOfContents.Item>
119
+ <TableOfContents.Item id="usage" indent>Basic Usage</TableOfContents.Item>
120
+ <TableOfContents.Item id="advanced" indent>Advanced</TableOfContents.Item>
121
+ <TableOfContents.Item id="props">Props</TableOfContents.Item>
122
+ <TableOfContents.Item id="accessibility">Accessibility</TableOfContents.Item>
123
+ </TableOfContents>
124
+ ),
125
+ },
126
+ {
127
+ name: 'Custom Title',
128
+ description: 'Table of contents with a custom title',
129
+ render: () => (
130
+ <TableOfContents title="Contents">
131
+ <TableOfContents.Item id="chapter-1">Chapter 1: The Beginning</TableOfContents.Item>
132
+ <TableOfContents.Item id="chapter-2">Chapter 2: The Middle</TableOfContents.Item>
133
+ <TableOfContents.Item id="chapter-3">Chapter 3: The End</TableOfContents.Item>
134
+ </TableOfContents>
135
+ ),
136
+ },
137
+ {
138
+ name: 'No Title',
139
+ description: 'Table of contents without a visible title',
140
+ render: () => (
141
+ <TableOfContents hideTitle>
142
+ <TableOfContents.Item id="section-a">Section A</TableOfContents.Item>
143
+ <TableOfContents.Item id="section-b" active>Section B</TableOfContents.Item>
144
+ <TableOfContents.Item id="section-c">Section C</TableOfContents.Item>
145
+ </TableOfContents>
146
+ ),
147
+ },
148
+ ],
149
+ });
@@ -0,0 +1,71 @@
1
+ @use '../../tokens/variables' as *;
2
+
3
+ // Root nav wrapper
4
+ .root {
5
+ font-family: var(--fui-font-sans, $fui-font-sans);
6
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
7
+ line-height: var(--fui-line-height-normal, $fui-line-height-normal);
8
+ }
9
+
10
+ // Title text ("On This Page")
11
+ .title {
12
+ font-size: var(--fui-font-size-xs, $fui-font-size-xs);
13
+ font-weight: var(--fui-font-weight-semibold, $fui-font-weight-semibold);
14
+ color: var(--fui-text-secondary, $fui-text-secondary);
15
+ text-transform: uppercase;
16
+ letter-spacing: 0.05em;
17
+ margin: 0;
18
+ padding: 0 0 var(--fui-space-2, $fui-space-2) 0;
19
+ }
20
+
21
+ // List container
22
+ .list {
23
+ display: flex;
24
+ flex-direction: column;
25
+ list-style: none;
26
+ margin: 0;
27
+ padding: 0;
28
+ }
29
+
30
+ // Individual item
31
+ .item {
32
+ display: block;
33
+ }
34
+
35
+ // Link styling shared by all items
36
+ .link {
37
+ display: block;
38
+ padding: var(--fui-space-1, $fui-space-1) 0 var(--fui-space-1, $fui-space-1) var(--fui-space-3, $fui-space-3);
39
+ border-left: 2px solid transparent;
40
+ color: var(--fui-text-secondary, $fui-text-secondary);
41
+ font-size: var(--fui-font-size-xs, $fui-font-size-xs);
42
+ line-height: var(--fui-line-height-normal, $fui-line-height-normal);
43
+ text-decoration: none;
44
+ transition: color var(--fui-transition-fast, $fui-transition-fast),
45
+ border-color var(--fui-transition-fast, $fui-transition-fast);
46
+
47
+ &:hover {
48
+ color: var(--fui-text-primary, $fui-text-primary);
49
+ }
50
+
51
+ &:focus-visible {
52
+ outline: var(--fui-focus-ring-width, $fui-focus-ring-width) solid var(--fui-focus-ring-color, $fui-focus-ring-color);
53
+ outline-offset: var(--fui-focus-ring-offset, $fui-focus-ring-offset);
54
+ border-radius: var(--fui-radius-sm, $fui-radius-sm);
55
+ }
56
+ }
57
+
58
+ // Indent for depth > 2 (h3s)
59
+ .indent {
60
+ padding-left: var(--fui-space-6, $fui-space-6);
61
+ }
62
+
63
+ // Active state
64
+ .active {
65
+ border-left-color: var(--fui-color-accent, $fui-color-accent);
66
+ color: var(--fui-color-accent, $fui-color-accent);
67
+
68
+ &:hover {
69
+ color: var(--fui-color-accent, $fui-color-accent);
70
+ }
71
+ }