diff --git a/.changeset/crisp-rabbits-wave.md b/.changeset/crisp-rabbits-wave.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/crisp-rabbits-wave.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index f8230b84c5e..f159adc029d 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -5,35 +5,41 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import type { ComponentProps } from 'react'; +import { type NavigationGroup, SidebarNavigation } from '@/components/sidebar-navigation'; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, - SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, } from '@/components/ui/sidebar'; +import { getLiveSidebarGroups } from '@/lib/live-navigation'; -const flows = [ - { title: 'Account section', href: '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/live/account-section' }, - { title: 'API keys', href: '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/live/api-keys' }, - { title: 'Password', href: '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/live/password' }, - { title: 'Connected accounts', href: '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/live/connected-accounts' }, - { title: 'Enterprise accounts', href: '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/live/enterprise-accounts' }, - { title: 'Delete account', href: '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/live/delete-account' }, - { title: 'Reverification', href: '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/live/reverification' }, -]; +const groups = getLiveSidebarGroups(); -function flowIsActive(pathname: string, href: string) { +function pageIsActive(pathname: string, href: string) { return pathname === href || pathname.startsWith(`${href}/`); } export function LiveSidebar(props: ComponentProps) { const pathname = usePathname(); + const navigationGroups: NavigationGroup[] = groups.map(({ group, categories }) => ({ + label: group, + categories: categories.map(({ category, components }) => ({ + label: category, + items: components.map(({ mod, href }) => ({ + href, + usage: `<${mod.meta.title} />`, + isActive: pageIsActive(pathname, href), + status: mod.meta.status, + substatus: mod.meta.substatus, + })), + })), + })); return ( @@ -66,26 +72,7 @@ export function LiveSidebar(props: ComponentProps) { - - - Flows - - - - {flows.map(flow => ( - - } - > - {flow.title} - - - ))} - - - + diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index bc206465ab2..a2c55d3d321 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -1,19 +1,17 @@ 'use client'; -import { ChevronRightIcon, FlaskConicalIcon } from 'lucide-react'; +import { FlaskConicalIcon } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import * as React from 'react'; -import { StatusDot } from '@/components/StatusDot'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { type NavigationGroup, SidebarNavigation } from '@/components/sidebar-navigation'; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, - SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, @@ -21,109 +19,19 @@ import { SidebarRail, SidebarSeparator, } from '@/components/ui/sidebar'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { getSidebarGroups } from '@/lib/registry'; -import type { StoryStatus, WipSubstatus } from '@/lib/types'; +import { getSidebarCategories } from '@/lib/sidebar-navigation'; const groups = getSidebarGroups(); -const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks', 'Localization']); - -type SidebarEntry = ReturnType[number]['components'][number]; - -// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and -// entry order both follow first appearance in the registry; uncategorized entries get no subheading. -function byCategory(components: SidebarEntry[]) { - const categories: { category: string; components: SidebarEntry[] }[] = []; - for (const component of components) { - const category = component.mod.meta.navigation?.category ?? ''; - const bucket = categories.find(c => c.category === category); - if (bucket) { - bucket.components.push(component); - } else { - categories.push({ category, components: [component] }); - } - } - return categories; -} - -function SidebarUsageItem({ - usage, - href, - isActive, - status, - substatus, -}: { - usage: string; - href: string; - isActive: boolean; - status?: StoryStatus; - substatus?: WipSubstatus; -}) { - const labelRef = React.useRef(null); - const [isTruncated, setIsTruncated] = React.useState(false); - - React.useEffect(() => { - const label = labelRef.current; - if (!label) { - return; - } - const check = () => setIsTruncated(label.scrollWidth > label.clientWidth); - check(); - const observer = new ResizeObserver(check); - observer.observe(label); - return () => observer.disconnect(); - }, []); - - return ( - - - } - > - {status ? ( - - ) : null} - - {usage} - - - } - /> - - {usage} - - - - ); -} - -function SidebarEntryMenu({ - components, - groupSlug, - pathname, -}: { - components: SidebarEntry[]; - groupSlug: string; - pathname: string; -}) { - return ( - - {components.map(({ mod, componentSlug }) => { +export function AppSidebar({ ...props }: React.ComponentProps) { + const pathname = usePathname(); + const navigationGroups: NavigationGroup[] = groups.map(({ group, groupSlug, components }) => ({ + label: group, + separatorBefore: group === 'Blocks', + categories: getSidebarCategories(components).map(({ category, components }) => ({ + label: category, + items: components.map(({ mod, componentSlug }) => { const href = `/${groupSlug}/${componentSlug}`; // How an entry is USED differs by layer, so the label follows the layer rather // than a guess at the title: hooks are called, atomic styles are a set of @@ -135,23 +43,16 @@ function SidebarEntryMenu({ : mod.meta.group === 'Styles' || mod.meta.group === 'Localization' ? mod.meta.title : `<${mod.meta.title} />`; - return ( - - ); - })} - - ); -} - -export function AppSidebar({ ...props }: React.ComponentProps) { - const pathname = usePathname(); + return { + href, + usage, + isActive: pathname === href, + status: mod.meta.status, + substatus: mod.meta.substatus, + }; + }), + })), + })); return ( @@ -188,71 +89,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { Mosaic - Swingset - {groups.map(({ group, groupSlug, components }) => ( - - {group === 'Blocks' && } - - - } - > - {group} - - - - - {byCategory(components).map(({ category, components }) => - category ? ( - pathname === `/${groupSlug}/${componentSlug}`, - )} - className='group/category' - > - - - {category} - - - -
- -
-
-
- ) : ( - - ), - )} -
-
-
-
-
- ))} +
diff --git a/packages/swingset/src/components/sidebar-navigation.tsx b/packages/swingset/src/components/sidebar-navigation.tsx new file mode 100644 index 00000000000..5b7f100102a --- /dev/null +++ b/packages/swingset/src/components/sidebar-navigation.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { ChevronRightIcon } from 'lucide-react'; +import Link from 'next/link'; +import * as React from 'react'; + +import { StatusDot } from '@/components/StatusDot'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, +} from '@/components/ui/sidebar'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import type { StoryStatus, WipSubstatus } from '@/lib/types'; + +type NavigationItem = { + href: string; + usage: string; + isActive: boolean; + status?: StoryStatus; + substatus?: WipSubstatus; +}; + +type NavigationCategory = { label: string; items: NavigationItem[] }; + +export type NavigationGroup = { + label: string; + separatorBefore?: boolean; + categories: NavigationCategory[]; +}; + +const COLLAPSED_BY_DEFAULT = new Set(['Blocks', 'Primitives', 'Components', 'Styles', 'Hooks', 'Localization']); + +function SidebarUsageItem({ usage, href, isActive, status, substatus }: NavigationItem) { + const labelRef = React.useRef(null); + const [isTruncated, setIsTruncated] = React.useState(false); + + React.useEffect(() => { + const label = labelRef.current; + if (!label) { + return; + } + const check = () => setIsTruncated(label.scrollWidth > label.clientWidth); + check(); + const observer = new ResizeObserver(check); + observer.observe(label); + return () => observer.disconnect(); + }, []); + + return ( + + + } + > + {status ? ( + + ) : null} + + {usage} + + + } + /> + + {usage} + + + + ); +} + +function NavigationItems({ items }: { items: NavigationItem[] }) { + return ( + + {items.map(item => ( + + ))} + + ); +} + +export function SidebarNavigation({ groups }: { groups: NavigationGroup[] }) { + return groups.map(({ label, separatorBefore, categories }) => ( + + {separatorBefore && } + + + } + > + {label} + + + + + {categories.map(({ label: category, items }) => + category ? ( + item.isActive)} + className='group/category' + > + + + {category} + + + +
+ +
+
+
+ ) : ( + + ), + )} +
+
+
+
+
+ )); +} diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts new file mode 100644 index 00000000000..da710a19657 --- /dev/null +++ b/packages/swingset/src/lib/live-navigation.ts @@ -0,0 +1,49 @@ +import { getSidebarGroups } from '@/lib/registry'; +import { getSidebarCategories, type SidebarEntry } from '@/lib/sidebar-navigation'; + +type LiveSidebarEntry = SidebarEntry & { href: string }; + +type LiveSidebarGroup = { + group: string; + groupSlug: string; + categories: Array<{ category: string; components: LiveSidebarEntry[] }>; +}; + +const liveRoutes = new Map([ + ['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/user-profile/user-profile-api-keys-panel', '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/live/api-keys'], + ['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/user-profile/user-profile-account-section', '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/live/account-section'], + ['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/user-profile/user-profile-password-section', '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/live/password'], + ['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/user-profile/user-profile-connected-accounts-section', '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/live/connected-accounts'], + ['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/user-profile/user-profile-enterprise-accounts-section', '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/live/enterprise-accounts'], + ['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/user-profile/user-profile-delete-section', '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/live/delete-account'], + ['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/reverification/reverification', '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/live/reverification'], +]); + +export function getLiveSidebarGroups(): LiveSidebarGroup[] { + const liveGroups: LiveSidebarGroup[] = []; + + for (const { group, groupSlug, components } of getSidebarGroups()) { + const categories: LiveSidebarGroup['categories'] = []; + + for (const { category, components: categoryComponents } of getSidebarCategories(components)) { + const liveComponents: LiveSidebarEntry[] = []; + + for (const component of categoryComponents) { + const href = liveRoutes.get(`/${groupSlug}/${component.componentSlug}`); + if (href) { + liveComponents.push({ ...component, href }); + } + } + + if (liveComponents.length > 0) { + categories.push({ category, components: liveComponents }); + } + } + + if (categories.length > 0) { + liveGroups.push({ group, groupSlug, categories }); + } + } + + return liveGroups; +} diff --git a/packages/swingset/src/lib/sidebar-navigation.ts b/packages/swingset/src/lib/sidebar-navigation.ts new file mode 100644 index 00000000000..499fb89c09c --- /dev/null +++ b/packages/swingset/src/lib/sidebar-navigation.ts @@ -0,0 +1,21 @@ +import type { getSidebarGroups } from '@/lib/registry'; + +export type SidebarEntry = ReturnType[number]['components'][number]; + +export type SidebarCategory = { category: string; components: SidebarEntry[] }; + +// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and +// entry order both follow first appearance in the registry; uncategorized entries get no subheading. +export function getSidebarCategories(components: SidebarEntry[]): SidebarCategory[] { + const categories: SidebarCategory[] = []; + for (const component of components) { + const category = component.mod.meta.navigation?.category ?? ''; + const bucket = categories.find(c => c.category === category); + if (bucket) { + bucket.components.push(component); + } else { + categories.push({ category, components: [component] }); + } + } + return categories; +}