@eventcatalog/core 4.8.4 → 4.10.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 (72) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-EGWBSVND.js → chunk-5XURY2JV.js} +1 -1
  6. package/dist/{chunk-FQEK3FUI.js → chunk-BDFTJVGN.js} +1 -1
  7. package/dist/{chunk-QB4W7ZUL.js → chunk-BQXEWQ5J.js} +1 -1
  8. package/dist/{chunk-Y57SL3H3.js → chunk-HFAQXAG3.js} +1 -1
  9. package/dist/{chunk-AATZMWL3.js → chunk-IC6XSXJO.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/Badge.astro +3 -1
  19. package/eventcatalog/src/components/FieldsExplorer/FieldNodeGraph.tsx +0 -5
  20. package/eventcatalog/src/components/MDX/Design/Design.astro +2 -0
  21. package/eventcatalog/src/components/MDX/EntityMap/EntityMap.astro +2 -0
  22. package/eventcatalog/src/components/MDX/Flow/Flow.astro +2 -0
  23. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +5 -7
  24. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -0
  25. package/eventcatalog/src/components/MDX/SchemaViewer/schema-viewer-utils.spec.ts +3 -3
  26. package/eventcatalog/src/components/MDX/SchemaViewer/schema-viewer-utils.ts +2 -2
  27. package/eventcatalog/src/components/SchemaExplorer/useDarkMode.ts +13 -4
  28. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +121 -88
  29. package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +55 -7
  30. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.spec.ts +59 -15
  31. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +53 -7
  32. package/eventcatalog/src/components/Tables/columns/TeamsTableColumns.tsx +1 -1
  33. package/eventcatalog/src/components/Tables/columns/UserTableColumns.tsx +2 -2
  34. package/eventcatalog/src/content.config.ts +71 -1
  35. package/eventcatalog/src/enterprise/api/schemas/[collection]/[id]/[version]/index.ts +3 -3
  36. package/eventcatalog/src/enterprise/collections/resource-docs-utils.ts +19 -0
  37. package/eventcatalog/src/enterprise/fields/pages/fields.astro +2 -0
  38. package/eventcatalog/src/enterprise/tools/catalog-tools.ts +1 -1
  39. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +1 -1
  40. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +16 -15
  41. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +21 -17
  42. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +1 -1
  43. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +4 -4
  44. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +16 -8
  45. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +48 -65
  46. package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +45 -18
  47. package/eventcatalog/src/pages/docs/llm/schemas.txt.ts +3 -3
  48. package/eventcatalog/src/pages/docs/teams/[id]/index.astro +1 -1
  49. package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -1
  50. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts +6 -4
  51. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +6 -2
  52. package/eventcatalog/src/pages/schemas/explorer/_index.data.ts +2 -2
  53. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +2 -0
  54. package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +2 -0
  55. package/eventcatalog/src/stores/sidebar-store/builders/adr.ts +96 -34
  56. package/eventcatalog/src/stores/sidebar-store/builders/agent.ts +167 -75
  57. package/eventcatalog/src/stores/sidebar-store/builders/container.ts +135 -59
  58. package/eventcatalog/src/stores/sidebar-store/builders/data-product.ts +144 -58
  59. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +306 -188
  60. package/eventcatalog/src/stores/sidebar-store/builders/entity.ts +108 -39
  61. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +147 -63
  62. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +213 -122
  63. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +223 -127
  64. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +3 -23
  65. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +187 -97
  66. package/eventcatalog/src/stores/sidebar-store/custom-sidebar.ts +678 -0
  67. package/eventcatalog/src/stores/sidebar-store/state.ts +66 -36
  68. package/eventcatalog/src/stores/theme-store.ts +6 -3
  69. package/eventcatalog/src/styles/tailwind.css +82 -0
  70. package/eventcatalog/src/utils/collections/resource-docs.ts +1 -0
  71. package/eventcatalog/src/utils/collections/schema-loader.ts +10 -8
  72. package/package.json +3 -2
@@ -1,10 +1,17 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback, useMemo } from 'react';
3
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
4
4
  import * as LucideIcons from 'lucide-react';
5
5
  import { ChevronRight, ChevronLeft, ChevronDown, Home, Star } from 'lucide-react';
6
6
  import type { NavNode, ChildRef } from '@stores/sidebar-store/state';
7
- import { saveState, loadState, saveCollapsedSections, loadCollapsedSections } from './storage';
7
+ import {
8
+ saveState,
9
+ loadState,
10
+ saveCollapsedSections,
11
+ loadCollapsedSections,
12
+ saveScrollPosition,
13
+ loadScrollPosition,
14
+ } from './storage';
8
15
  import { useStore } from '@nanostores/react';
9
16
  import { sidebarStore } from '@stores/sidebar-store';
10
17
  import {
@@ -13,7 +20,14 @@ import {
13
20
  removeFavorite as removeFavoriteAction,
14
21
  type FavoriteItem,
15
22
  } from '@stores/favorites-store';
16
- import { canCollapseGroup, findNodeKeyByUrl, getBadgeClasses, getGroupLabel, isGroupCollapsed } from './utils';
23
+ import {
24
+ canCollapseGroup,
25
+ findNodeKeyByUrl,
26
+ getBadgeClasses,
27
+ getDefaultCollapsedState,
28
+ isGroupCollapsed,
29
+ toggleGroupCollapsed,
30
+ } from './utils';
17
31
  import { resolveIconUrl } from '@utils/icon';
18
32
 
19
33
  const cn = (...classes: (string | false | undefined)[]) => classes.filter(Boolean).join(' ');
@@ -60,6 +74,7 @@ export default function NestedSideBar() {
60
74
  const [sectionCollapsePreferences, setSectionCollapsePreferences] = useState(loadCollapsedSections);
61
75
  const [showPathPreview, setShowPathPreview] = useState(false);
62
76
  const [showFullPath, setShowFullPath] = useState(false);
77
+ const navRef = useRef<HTMLElement | null>(null);
63
78
 
64
79
  // Build a lookup map for faster URL navigation
65
80
  // Map format: "type:id" -> "nodeKey"
@@ -94,19 +109,9 @@ export default function NestedSideBar() {
94
109
  /**
95
110
  * Toggle section collapse state
96
111
  */
97
- const toggleSectionCollapse = (sectionId: string) => {
112
+ const toggleSectionCollapse = (sectionId: string, defaultCollapsed = true) => {
98
113
  setSectionCollapsePreferences((previousPreferences) => {
99
- const nextPreferences = {
100
- expanded: new Set(previousPreferences.expanded),
101
- };
102
- const isCurrentlyCollapsed = isGroupCollapsed(true, sectionId, previousPreferences);
103
-
104
- if (isCurrentlyCollapsed) {
105
- nextPreferences.expanded.add(sectionId);
106
- } else {
107
- nextPreferences.expanded.delete(sectionId);
108
- }
109
-
114
+ const nextPreferences = toggleGroupCollapsed(sectionId, previousPreferences, defaultCollapsed);
110
115
  saveCollapsedSections(nextPreferences);
111
116
  return nextPreferences;
112
117
  });
@@ -416,6 +421,50 @@ export default function NestedSideBar() {
416
421
  };
417
422
  }, [isInitialized, findAndNavigateToUrl, currentPath]);
418
423
 
424
+ /**
425
+ * Keep the selected item visible. On a full page load the nav remounts scrolled to the
426
+ * top, so first restore the offset saved for this drill-down path, then nudge the
427
+ * active item into view if it still isn't. `block: 'nearest'` never scrolls when the
428
+ * item is already visible, so clicking around near the top doesn't jump.
429
+ */
430
+ useEffect(() => {
431
+ if (!isInitialized) return;
432
+ const nav = navRef.current;
433
+ if (!nav) return;
434
+
435
+ const pathKey = getCurrentPath().join('/') || 'root';
436
+ const savedScrollTop = loadScrollPosition(pathKey);
437
+ if (savedScrollTop !== null) nav.scrollTop = savedScrollTop;
438
+
439
+ const active = nav.querySelector<HTMLElement>('[data-active="true"]');
440
+ if (!active) return;
441
+
442
+ const navRect = nav.getBoundingClientRect();
443
+ const activeRect = active.getBoundingClientRect();
444
+ const isVisible = activeRect.top >= navRect.top && activeRect.bottom <= navRect.bottom;
445
+ if (!isVisible) active.scrollIntoView({ block: 'nearest' });
446
+ }, [isInitialized, currentPath, navigationStack, getCurrentPath]);
447
+
448
+ /**
449
+ * Persist the nav scroll offset as it changes.
450
+ */
451
+ useEffect(() => {
452
+ if (!isInitialized) return;
453
+ const nav = navRef.current;
454
+ if (!nav) return;
455
+
456
+ let timer: ReturnType<typeof setTimeout> | undefined;
457
+ const onScroll = () => {
458
+ clearTimeout(timer);
459
+ timer = setTimeout(() => saveScrollPosition(getCurrentPath().join('/') || 'root', nav.scrollTop), 150);
460
+ };
461
+ nav.addEventListener('scroll', onScroll, { passive: true });
462
+ return () => {
463
+ clearTimeout(timer);
464
+ nav.removeEventListener('scroll', onScroll);
465
+ };
466
+ }, [isInitialized, navigationStack, getCurrentPath]);
467
+
419
468
  /**
420
469
  * Check if a node is favorited
421
470
  * Note: This hook must be defined before any early returns to comply with Rules of Hooks
@@ -658,14 +707,15 @@ export default function NestedSideBar() {
658
707
  };
659
708
 
660
709
  /**
661
- * Render a group with its children
710
+ * Render a group with its children.
711
+ *
712
+ * Groups render as a docs-style tree: every group has its caret on the right; top-level
713
+ * sections also carry an icon tile. Each level's children hang off a vertical guide.
662
714
  */
663
- const renderGroup = (group: NavNode, groupKey: string | null, index: number) => {
664
- // Get optional icon for group
715
+ const renderGroup = (group: NavNode, groupKey: string | null, index: number, depth = 0) => {
665
716
  const GroupIcon = group.icon ? (LucideIcons as unknown as Record<string, LucideIcons.LucideIcon>)[group.icon] : null;
666
- const isSubtleGroup = group.subtle === true;
717
+ const isNested = depth > 0;
667
718
 
668
- // Get visible children
669
719
  const visibleChildren =
670
720
  group.pages?.filter((childRef) => {
671
721
  const child = resolveRef(childRef);
@@ -673,46 +723,36 @@ export default function NestedSideBar() {
673
723
  }) ?? [];
674
724
 
675
725
  const groupId = groupKey || group.collapseKey || `${currentLevel.key ?? 'root'}:group:${group.title}`;
676
- const canCollapse = canCollapseGroup(visibleChildren.length, isTopLevel, group.collapsible);
677
- const isCollapsed = isGroupCollapsed(canCollapse, groupId, sectionCollapsePreferences);
678
-
679
- // When a group's children are subtle subgroups (e.g. Resources > Services/Flows/Data Stores),
680
- // they render flush under the parent icon instead of inside the indented border guide.
681
- const hasSubtleChildren = visibleChildren.some((childRef) => {
682
- const child = resolveRef(childRef);
683
- return child && isGroup(child) && child.subtle === true;
684
- });
685
-
686
- const headerContent = (
726
+ const canCollapse = canCollapseGroup(isTopLevel, group.collapsible, group.collapsed);
727
+ const defaultCollapsed = getDefaultCollapsedState(visibleChildren.length, group.collapsed);
728
+ const isCollapsed = isGroupCollapsed(canCollapse, groupId, sectionCollapsePreferences, defaultCollapsed);
729
+ const header = isNested ? (
687
730
  <>
688
- <div className="flex items-center gap-2">
689
- {GroupIcon && (
690
- <span
691
- className={cn(
692
- 'flex items-center justify-center w-5 h-5 rounded',
693
- isSubtleGroup
694
- ? 'bg-[rgb(var(--ec-content-hover))] text-[rgb(var(--ec-content-text-muted))]'
695
- : 'bg-[rgb(var(--ec-group-icon-bg))] text-[rgb(var(--ec-group-icon-text))]'
696
- )}
697
- >
698
- <GroupIcon className="w-3 h-3" />
699
- </span>
700
- )}
701
- <span
731
+ {GroupIcon && <GroupIcon className="w-3.5 h-3.5 flex-shrink-0 text-[rgb(var(--ec-content-text-muted))]" />}
732
+ <span className="text-[12px] font-medium text-[rgb(var(--ec-content-text))] truncate">{group.title}</span>
733
+ {canCollapse && (
734
+ <ChevronDown
702
735
  className={cn(
703
- isSubtleGroup
704
- ? 'text-[9px] font-semibold uppercase tracking-[0.1em] text-[rgb(var(--ec-content-text-muted))]'
705
- : 'text-[12px] font-semibold tracking-tight text-[rgb(var(--ec-content-text))]'
736
+ 'ml-auto w-3.5 h-3.5 flex-shrink-0 text-[rgb(var(--ec-icon-color))] transition-transform',
737
+ isCollapsed && '-rotate-90'
706
738
  )}
707
- >
708
- {getGroupLabel(group.title, visibleChildren.length)}
739
+ />
740
+ )}
741
+ </>
742
+ ) : (
743
+ <>
744
+ {GroupIcon && (
745
+ <span className="flex items-center justify-center w-5 h-5 flex-shrink-0 rounded bg-[rgb(var(--ec-group-icon-bg))] text-[rgb(var(--ec-group-icon-text))]">
746
+ <GroupIcon className="w-3 h-3" />
709
747
  </span>
710
- </div>
748
+ )}
749
+ <span className="text-[12px] font-semibold tracking-tight text-[rgb(var(--ec-content-text))] truncate">
750
+ {group.title}
751
+ </span>
711
752
  {canCollapse && (
712
753
  <ChevronDown
713
754
  className={cn(
714
- isSubtleGroup ? 'w-3.5 h-3.5' : 'w-4 h-4',
715
- 'text-[rgb(var(--ec-icon-color))] transition-transform',
755
+ 'ml-auto w-4 h-4 flex-shrink-0 text-[rgb(var(--ec-icon-color))] transition-transform',
716
756
  isCollapsed && '-rotate-90'
717
757
  )}
718
758
  />
@@ -720,32 +760,30 @@ export default function NestedSideBar() {
720
760
  </>
721
761
  );
722
762
 
763
+ const headerClasses = cn(
764
+ 'group flex items-center w-full rounded-md text-left transition-colors',
765
+ isNested ? 'gap-1.5 px-2 py-1.5' : 'gap-2 px-2 py-1.5',
766
+ canCollapse && 'cursor-pointer hover:bg-[rgb(var(--ec-content-hover))]'
767
+ );
768
+
769
+ // Top level: the guide line sits under the centre of the icon tile (8px padding + half of
770
+ // 20px). Nested: carets sit on the right, so the guide is simply inset from the label.
771
+ const childrenClasses = cn(
772
+ 'flex flex-col gap-px mt-0.5 border-l border-[rgb(var(--ec-content-border))]',
773
+ isNested ? 'ml-3 pl-1.5' : 'ml-[18px] pl-2'
774
+ );
775
+
723
776
  return (
724
- <div key={`group-${groupKey || index}`} className={cn(isSubtleGroup ? 'mb-2 last:mb-1' : 'mb-5 last:mb-2')}>
777
+ <div key={`group-${groupKey || index}`} className={cn(isNested ? 'mt-0.5' : 'mb-4 last:mb-1')}>
725
778
  {canCollapse ? (
726
- <button
727
- onClick={() => toggleSectionCollapse(groupId)}
728
- className={cn(
729
- 'flex items-center justify-between w-full rounded-md transition-colors cursor-pointer',
730
- isSubtleGroup
731
- ? 'px-2 py-1 hover:bg-[rgb(var(--ec-content-hover))]/60'
732
- : 'px-2 py-1.5 hover:bg-[rgb(var(--ec-content-hover))]'
733
- )}
734
- >
735
- {headerContent}
779
+ <button type="button" onClick={() => toggleSectionCollapse(groupId, defaultCollapsed)} className={headerClasses}>
780
+ {header}
736
781
  </button>
737
782
  ) : (
738
- <div className={cn('flex items-center justify-between', isSubtleGroup ? 'px-2 py-1' : 'px-2 py-1.5')}>
739
- {headerContent}
740
- </div>
783
+ <div className={headerClasses}>{header}</div>
741
784
  )}
742
785
  {!isCollapsed && (
743
- <div
744
- className={cn(
745
- 'flex flex-col gap-0.5 border-[rgb(var(--ec-content-border))]',
746
- isSubtleGroup ? 'border-l ml-4 mt-1' : hasSubtleChildren ? 'mt-1' : 'border-l ml-4 mt-1'
747
- )}
748
- >
786
+ <div className={childrenClasses}>
749
787
  {visibleChildren.map((childRef, childIndex) => {
750
788
  const child = resolveRef(childRef);
751
789
  if (!child) return null;
@@ -753,22 +791,13 @@ export default function NestedSideBar() {
753
791
  const childKey = typeof childRef === 'string' ? childRef : null;
754
792
 
755
793
  if (isGroup(child)) {
756
- // Skip nested groups with no visible children
757
794
  if (!hasVisibleChildren(child)) return null;
758
-
759
- return (
760
- <div
761
- key={`nested-group-${childKey || childIndex}`}
762
- className={cn(child.subtle ? 'mt-1' : 'ml-3 mt-1.5 pl-3 border-l border-[rgb(var(--ec-content-border))]')}
763
- >
764
- {renderGroup(child, childKey, childIndex)}
765
- </div>
766
- );
795
+ return renderGroup(child, childKey, childIndex, depth + 1);
767
796
  }
768
- // Inside a subtle subgroup (e.g. a domain's Resources > Services list) the
769
- // section header already conveys the resource type, so suppress the default
770
- // per-item icon and only keep a custom icon if one is defined.
771
- return renderItem(child, childKey, childIndex, isSubtleGroup);
797
+
798
+ // Under a nested group the header already conveys the resource type, so the
799
+ // default per-collection glyph is dropped; custom icons still show.
800
+ return renderItem(child, childKey, childIndex, isNested);
772
801
  })}
773
802
  </div>
774
803
  )}
@@ -845,7 +874,7 @@ export default function NestedSideBar() {
845
874
  );
846
875
 
847
876
  const baseClasses =
848
- 'group flex items-center justify-between w-full px-3 py-1.5 border border-transparent cursor-pointer text-left transition-colors hover:bg-[rgb(var(--ec-content-hover))] active:bg-[rgb(var(--ec-content-hover))]';
877
+ 'group flex items-center justify-between w-full px-2 py-1.5 rounded-md cursor-pointer text-left transition-colors hover:bg-[rgb(var(--ec-content-hover))] active:bg-[rgb(var(--ec-content-hover))]';
849
878
  const parentClasses = itemHasChildren ? 'font-medium' : '';
850
879
  const activeClasses = isActive ? 'bg-[rgb(var(--ec-rail-active-bg))] hover:bg-[rgb(var(--ec-rail-active-bg))]' : '';
851
880
 
@@ -857,6 +886,8 @@ export default function NestedSideBar() {
857
886
  href={item.href}
858
887
  title={item.title}
859
888
  target={item.external ? '_blank' : undefined}
889
+ aria-current={isActive ? 'page' : undefined}
890
+ data-active={isActive ? 'true' : undefined}
860
891
  className={cn(baseClasses, parentClasses, activeClasses)}
861
892
  >
862
893
  {content}
@@ -870,6 +901,7 @@ export default function NestedSideBar() {
870
901
  key={`item-${itemKey || index}`}
871
902
  title={item.title}
872
903
  onClick={() => handleDrillDown(item, itemKey)}
904
+ data-active={isActive ? 'true' : undefined}
873
905
  className={cn(baseClasses, parentClasses, activeClasses)}
874
906
  >
875
907
  {content}
@@ -1040,6 +1072,7 @@ export default function NestedSideBar() {
1040
1072
  {/* Navigation Content */}
1041
1073
  <nav
1042
1074
  key={animationKey}
1075
+ ref={navRef}
1043
1076
  className={cn('min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-4 px-2', getAnimationClass())}
1044
1077
  style={{
1045
1078
  scrollbarWidth: 'thin',
@@ -1,4 +1,4 @@
1
- import type { SectionCollapsePreferences } from './utils';
1
+ import { createSectionCollapsePreferences, type SectionCollapsePreferences } from './utils';
2
2
 
3
3
  // ============================================
4
4
  // Local Storage Persistence
@@ -7,6 +7,7 @@ import type { SectionCollapsePreferences } from './utils';
7
7
  const STORAGE_KEY = 'eventcatalog-sidebar-nav';
8
8
  const SECTION_PREFERENCES_KEY = 'eventcatalog-sidebar-sections:v2';
9
9
  const FAVORITES_KEY = 'eventcatalog-sidebar-favorites';
10
+ const SCROLL_KEY = 'eventcatalog-sidebar-scroll';
10
11
 
11
12
  // ============================================
12
13
  // Types
@@ -47,13 +48,59 @@ export const loadState = (): PersistedState | null => {
47
48
  }
48
49
  };
49
50
 
51
+ // ============================================
52
+ // Scroll position
53
+ // ============================================
54
+
55
+ /**
56
+ * The sidebar remounts on every full page load, which resets its scroll offset to the
57
+ * top. Persist offsets (per tab) so the list looks stationary when you follow a link near
58
+ * the bottom. A capped map keyed by drill-down path, so scrolling one level doesn't lose
59
+ * another level's offset (root -> drill in -> scroll -> back keeps the root position).
60
+ */
61
+ const MAX_SCROLL_ENTRIES = 30;
62
+
63
+ export const saveScrollPosition = (pathKey: string, scrollTop: number): void => {
64
+ try {
65
+ const stored = sessionStorage.getItem(SCROLL_KEY);
66
+ const parsed = stored ? JSON.parse(stored) : {};
67
+ const offsets: Record<string, number> = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
68
+ // Re-insert so the map stays ordered by recency, then drop the oldest beyond the cap.
69
+ delete offsets[pathKey];
70
+ offsets[pathKey] = scrollTop;
71
+ const keys = Object.keys(offsets);
72
+ for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_SCROLL_ENTRIES))) {
73
+ delete offsets[stale];
74
+ }
75
+ sessionStorage.setItem(SCROLL_KEY, JSON.stringify(offsets));
76
+ } catch (e) {
77
+ console.warn('Failed to save sidebar scroll position:', e);
78
+ }
79
+ };
80
+
81
+ export const loadScrollPosition = (pathKey: string): number | null => {
82
+ try {
83
+ const stored = sessionStorage.getItem(SCROLL_KEY);
84
+ if (!stored) return null;
85
+ const offsets = JSON.parse(stored);
86
+ const scrollTop = offsets && typeof offsets === 'object' ? offsets[pathKey] : undefined;
87
+ return typeof scrollTop === 'number' ? scrollTop : null;
88
+ } catch (e) {
89
+ console.warn('Failed to load sidebar scroll position:', e);
90
+ return null;
91
+ }
92
+ };
93
+
50
94
  // ============================================
51
95
  // Collapsed Sections
52
96
  // ============================================
53
97
 
54
98
  export const saveCollapsedSections = (preferences: SectionCollapsePreferences): void => {
55
99
  try {
56
- localStorage.setItem(SECTION_PREFERENCES_KEY, JSON.stringify({ expanded: [...preferences.expanded] }));
100
+ localStorage.setItem(
101
+ SECTION_PREFERENCES_KEY,
102
+ JSON.stringify({ expanded: [...preferences.expanded], collapsed: [...preferences.collapsed] })
103
+ );
57
104
  } catch (e) {
58
105
  console.warn('Failed to save collapsed sections:', e);
59
106
  }
@@ -64,15 +111,16 @@ export const loadCollapsedSections = (): SectionCollapsePreferences => {
64
111
  const stored = localStorage.getItem(SECTION_PREFERENCES_KEY);
65
112
  if (stored) {
66
113
  const preferences = JSON.parse(stored);
67
- return {
68
- expanded: new Set(Array.isArray(preferences.expanded) ? preferences.expanded : []),
69
- };
114
+ return createSectionCollapsePreferences(
115
+ Array.isArray(preferences.expanded) ? preferences.expanded : [],
116
+ Array.isArray(preferences.collapsed) ? preferences.collapsed : []
117
+ );
70
118
  }
71
119
 
72
- return { expanded: new Set() };
120
+ return createSectionCollapsePreferences();
73
121
  } catch (e) {
74
122
  console.warn('Failed to load collapsed sections:', e);
75
- return { expanded: new Set() };
123
+ return createSectionCollapsePreferences();
76
124
  }
77
125
  };
78
126
 
@@ -1,30 +1,40 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { canCollapseGroup, findNodeKeyByUrl, getGroupLabel, isGroupCollapsed } from './utils';
2
+ import {
3
+ canCollapseGroup,
4
+ createSectionCollapsePreferences,
5
+ findNodeKeyByUrl,
6
+ getDefaultCollapsedState,
7
+ isGroupCollapsed,
8
+ toggleGroupCollapsed,
9
+ } from './utils';
3
10
 
4
- const preferences = (expanded: string[] = []) => ({
5
- expanded: new Set(expanded),
6
- });
11
+ const preferences = (expanded: string[] = [], collapsed: string[] = []) => createSectionCollapsePreferences(expanded, collapsed);
7
12
 
8
13
  describe('sidebar group presentation', () => {
9
- it('includes the visible child count in the group label', () => {
10
- expect(getGroupLabel('Outbound Messages', 10)).toBe('Outbound Messages (10)');
14
+ it('keeps root-level groups expanded', () => {
15
+ expect(canCollapseGroup(true)).toBe(false);
11
16
  });
12
17
 
13
- it.each(['Quick Reference', 'Architecture', 'Resources'])('does not include the child count for %s', (title) => {
14
- expect(getGroupLabel(title, 10)).toBe(title);
18
+ it('lets every group inside a resource sidebar collapse, whatever its size', () => {
19
+ expect(canCollapseGroup(false)).toBe(true);
20
+ expect(canCollapseGroup(false)).toBe(true);
15
21
  });
16
22
 
17
- it('keeps top-level groups expanded', () => {
18
- expect(canCollapseGroup(6, true)).toBe(false);
23
+ it('keeps groups marked as non-collapsible expanded regardless of their size', () => {
24
+ expect(canCollapseGroup(false, false)).toBe(false);
19
25
  });
20
26
 
21
- it('only allows nested groups with more than five children to collapse', () => {
22
- expect(canCollapseGroup(5, false)).toBe(false);
23
- expect(canCollapseGroup(6, false)).toBe(true);
27
+ it('starts long lists collapsed and short ones open unless told otherwise', () => {
28
+ expect(getDefaultCollapsedState(5)).toBe(false);
29
+ expect(getDefaultCollapsedState(6)).toBe(true);
30
+ expect(getDefaultCollapsedState(6, false)).toBe(false);
31
+ expect(getDefaultCollapsedState(2, true)).toBe(true);
24
32
  });
25
33
 
26
- it('keeps groups marked as non-collapsible expanded regardless of their size', () => {
27
- expect(canCollapseGroup(100, false, false)).toBe(false);
34
+ it('always allows collapsing when an explicit collapsed state is set, overriding every other rule', () => {
35
+ expect(canCollapseGroup(false, true, true)).toBe(true);
36
+ expect(canCollapseGroup(false, true, false)).toBe(true);
37
+ expect(canCollapseGroup(true, false, true)).toBe(true);
28
38
  });
29
39
  });
30
40
 
@@ -40,6 +50,40 @@ describe('isGroupCollapsed', () => {
40
50
  it('uses an explicit expanded preference', () => {
41
51
  expect(isGroupCollapsed(true, 'outbound-messages', preferences(['outbound-messages']))).toBe(false);
42
52
  });
53
+
54
+ it('starts expanded when the group defaults to expanded, until the user collapses it', () => {
55
+ expect(isGroupCollapsed(true, 'owners', preferences(), false)).toBe(false);
56
+ expect(isGroupCollapsed(true, 'owners', preferences([], ['owners']), false)).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe('toggleGroupCollapsed', () => {
61
+ it('records the user overriding a collapsed-by-default group, then clears it on the way back', () => {
62
+ const opened = toggleGroupCollapsed('g', preferences());
63
+ expect([...opened.expanded]).toEqual(['g']);
64
+ expect(opened.collapsed.size).toBe(0);
65
+
66
+ const closedAgain = toggleGroupCollapsed('g', opened);
67
+ expect(closedAgain.expanded.size).toBe(0);
68
+ expect([...closedAgain.collapsed]).toEqual(['g']);
69
+ });
70
+
71
+ it('records the user overriding an expanded-by-default group', () => {
72
+ const closed = toggleGroupCollapsed('g', preferences(), false);
73
+ expect([...closed.collapsed]).toEqual(['g']);
74
+ expect(isGroupCollapsed(true, 'g', closed, false)).toBe(true);
75
+
76
+ const reopened = toggleGroupCollapsed('g', closed, false);
77
+ expect(reopened.collapsed.size).toBe(0);
78
+ expect([...reopened.expanded]).toEqual(['g']);
79
+ });
80
+
81
+ it('does not mutate the previous preferences', () => {
82
+ const before = preferences();
83
+ toggleGroupCollapsed('g', before);
84
+ expect(before.expanded.size).toBe(0);
85
+ expect(before.collapsed.size).toBe(0);
86
+ });
43
87
  });
44
88
 
45
89
  describe('findNodeKeyByUrl', () => {
@@ -1,20 +1,66 @@
1
1
  // Shared utilities for NestedSideBar components
2
2
 
3
3
  export const SIDEBAR_GROUP_COLLAPSE_THRESHOLD = 5;
4
- const GROUP_TITLES_WITHOUT_COUNT = new Set(['Quick Reference', 'Architecture', 'Resources']);
5
4
 
5
+ /**
6
+ * The user's overrides of each group's default collapse state, keyed by group id.
7
+ * A group is in at most one set; absence means "use the group's default".
8
+ */
6
9
  export type SectionCollapsePreferences = {
7
10
  expanded: Set<string>;
11
+ collapsed: Set<string>;
12
+ };
13
+
14
+ export const createSectionCollapsePreferences = (
15
+ expanded: Iterable<string> = [],
16
+ collapsed: Iterable<string> = []
17
+ ): SectionCollapsePreferences => ({
18
+ expanded: new Set(expanded),
19
+ collapsed: new Set(collapsed),
20
+ });
21
+
22
+ /**
23
+ * Whether a group shows a caret and can be toggled. Every group inside a resource's sidebar
24
+ * can collapse (docs-sidebar behaviour); the root catalog level stays open. An explicit
25
+ * `collapsed` (set via sidebar.json) overrides every other rule, including `collapsible: false`.
26
+ */
27
+ export const canCollapseGroup = (isTopLevel: boolean, collapsible = true, collapsed?: boolean): boolean => {
28
+ if (collapsed !== undefined) return true;
29
+ return collapsible && !isTopLevel;
8
30
  };
9
31
 
10
- export const canCollapseGroup = (childCount: number, isTopLevel: boolean, collapsible = true): boolean =>
11
- collapsible && !isTopLevel && childCount > SIDEBAR_GROUP_COLLAPSE_THRESHOLD;
32
+ /**
33
+ * The state a group starts in before the user touches it: an explicit `collapsed` wins,
34
+ * otherwise long lists start collapsed and short ones start open.
35
+ */
36
+ export const getDefaultCollapsedState = (childCount: number, collapsed?: boolean): boolean =>
37
+ collapsed ?? childCount > SIDEBAR_GROUP_COLLAPSE_THRESHOLD;
12
38
 
13
- export const getGroupLabel = (title: string, childCount: number): string =>
14
- GROUP_TITLES_WITHOUT_COUNT.has(title) ? title : `${title} (${childCount})`;
39
+ export const isGroupCollapsed = (
40
+ canCollapse: boolean,
41
+ groupId: string,
42
+ preferences: SectionCollapsePreferences,
43
+ defaultCollapsed = true
44
+ ): boolean => {
45
+ if (!canCollapse) return false;
46
+ if (preferences.expanded.has(groupId)) return false;
47
+ if (preferences.collapsed.has(groupId)) return true;
48
+ return defaultCollapsed;
49
+ };
15
50
 
16
- export const isGroupCollapsed = (canCollapse: boolean, groupId: string, preferences: SectionCollapsePreferences): boolean => {
17
- return canCollapse && !preferences.expanded.has(groupId);
51
+ /** Flip a group's state, recording it as an override of its default. */
52
+ export const toggleGroupCollapsed = (
53
+ groupId: string,
54
+ preferences: SectionCollapsePreferences,
55
+ defaultCollapsed = true
56
+ ): SectionCollapsePreferences => {
57
+ const next = createSectionCollapsePreferences(preferences.expanded, preferences.collapsed);
58
+ const isCurrentlyCollapsed = isGroupCollapsed(true, groupId, preferences, defaultCollapsed);
59
+ next.expanded.delete(groupId);
60
+ next.collapsed.delete(groupId);
61
+ if (isCurrentlyCollapsed) next.expanded.add(groupId);
62
+ else next.collapsed.add(groupId);
63
+ return next;
18
64
  };
19
65
 
20
66
  /**
@@ -82,7 +82,7 @@ export const columns = (tableConfiguration: TableConfiguration) => [
82
82
  filterFn: filterByName,
83
83
  }),
84
84
 
85
- columnHelper.accessor('data.source', {
85
+ columnHelper.accessor((row) => row.data.source, {
86
86
  id: 'source',
87
87
  header: () => <span>{tableConfiguration.columns?.source?.label || 'Source'}</span>,
88
88
  cell: (info) => <DirectorySourceCell source={info.getValue() as { provider: string; url?: string } | undefined} />,
@@ -85,7 +85,7 @@ export const columns = (tableConfiguration: TableConfiguration) => [
85
85
  filterFn: filterByName,
86
86
  }),
87
87
 
88
- columnHelper.accessor('data.source', {
88
+ columnHelper.accessor((row) => row.data.source, {
89
89
  id: 'source',
90
90
  header: () => <span>{tableConfiguration.columns?.source?.label || 'Source'}</span>,
91
91
  cell: (info) => <DirectorySourceCell source={info.getValue() as { provider: string; url?: string } | undefined} />,
@@ -95,7 +95,7 @@ export const columns = (tableConfiguration: TableConfiguration) => [
95
95
  },
96
96
  }),
97
97
 
98
- columnHelper.accessor('data.role', {
98
+ columnHelper.accessor((row) => row.data.role, {
99
99
  id: 'role',
100
100
  header: () => <span>{tableConfiguration.columns?.role?.label || 'Role'}</span>,
101
101
  cell: (info) => {