Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
353 changes: 105 additions & 248 deletions ROADMAP.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions apps/registry/app/[locale]/components/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { oembedUrl, withRef } from "@/lib/share";
import {
getCategoryForComponent,
getSidebarSections,
groupedComponents,
} from "@/lib/sidebar-sections";
import type { RegistryComponent } from "@/types/registry";

Expand Down Expand Up @@ -188,6 +189,11 @@ export default async function ComponentPage(props: Props) {

const installCommand = `pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/${component.name}.json`;

const componentCategory = getCategoryForComponent(slug);
const familyGroup = componentCategory
? groupedComponents.find((group) => group.category === componentCategory)
: undefined;

const sections = [
{ id: "installation", title: "Installation" },
...(meta?.defaultStoryId ? [{ id: "preview", title: "Preview" }] : []),
Expand Down Expand Up @@ -236,6 +242,7 @@ export default async function ComponentPage(props: Props) {
href: localizePathname("/components", locale),
label: "Components",
},
...(familyGroup ? [{ label: familyGroup.label }] : []),
{ label: displayTitle },
]}
/>
Expand Down
63 changes: 63 additions & 0 deletions apps/registry/e2e/sidebar-drilldown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { expect, test } from "@playwright/test";

/**
* Sidebar drill-down (component-sidebar phase).
*
* The component sidebar groups 309 components by family and shows ONE level at
* a time: the family list (ROOT) or a single family's components (FAMILY). On a
* component page it auto-drills into that component's family, and the breadcrumb
* gains a family crumb. These tests drive the real registry like a user.
*
* Button lives in the "core" family (alongside Input / Label / Textarea); the
* "form" component lives in the "form" family — used as the drill target.
*/

const SIDEBAR = "aside";

test.describe("component sidebar drill-down", () => {
test("auto-drills into the active component's family", async ({ page }) => {
await page.goto("/components/button");
const sidebar = page.locator(SIDEBAR).first();

// Drilled in: a "back" control is present and the active item is marked.
await expect(
sidebar.getByRole("button", { name: "All families" }),
).toBeVisible();
await expect(
sidebar.getByRole("link", { exact: true, name: "Button" }),
).toHaveAttribute("aria-current", "page");

// Family listing only: a sibling core component is shown.
await expect(
sidebar.getByRole("link", { exact: true, name: "Input" }),
).toBeVisible();

// Breadcrumb gains the family crumb: Components / Core / Button.
const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" });
await expect(breadcrumb.getByText("Core", { exact: true })).toBeVisible();
});

test("back returns to the family list; selecting a family drills in", async ({
page,
}) => {
await page.goto("/components/button");
const sidebar = page.locator(SIDEBAR).first();

await sidebar.getByRole("button", { name: "All families" }).click();

// ROOT: families are listed; the previous family's items are gone.
await expect(sidebar.getByRole("button", { name: /^Form/ })).toBeVisible();
await expect(
sidebar.getByRole("link", { exact: true, name: "Button" }),
).toHaveCount(0);

// Drill into Form: the back control returns and the family's items render.
await sidebar.getByRole("button", { name: /^Form/ }).click();
await expect(
sidebar.getByRole("button", { name: "All families" }),
).toBeVisible();
await expect(
sidebar.locator('a[href$="/components/form"]'),
).toBeVisible();
});
});
3 changes: 1 addition & 2 deletions apps/registry/lib/sidebar-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ export function getSidebarSections(
title: "Documentation",
},
...groupedComponents.map((group) => ({
collapsible: true,
defaultOpen: true,
family: true,
items: group.items.map((item) => ({
href: localizePathname(`/components/${item.name}`, locale),
title: item.title,
Expand Down
2 changes: 1 addition & 1 deletion apps/registry/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -6108,5 +6108,5 @@
}
],
"version": "0.3.0",
"generatedAt": "2026-06-27T14:54:05.915Z"
"generatedAt": "2026-06-27T19:33:13.346Z"
}
171 changes: 169 additions & 2 deletions apps/registry/registry/default/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useRef, useState, useSyncExternalStore } from "react";

import { ChevronDown } from "lucide-react";
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";

Expand All @@ -18,6 +18,7 @@ export type SidebarItem = {
export type SidebarSection = {
collapsible?: boolean;
defaultOpen?: boolean;
family?: boolean;
items: SidebarItem[];
title?: string;
};
Expand Down Expand Up @@ -135,6 +136,159 @@ function CollapsibleSection({
);
}

type FamilyNavProps = {
isMobile: boolean;
onNavigate: () => void;
pathname: string;
sections: SidebarSection[];
};

function FamilyList({
onOpen,
sections,
}: {
onOpen: (title: string) => void;
sections: SidebarSection[];
}) {
return (
<div className="space-y-1">
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Components
</div>
<div className="space-y-0.5">
{sections.map((section) => (
<button
className="flex w-full items-center justify-between px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
key={section.title}
onClick={() => {
onOpen(section.title ?? "");
}}
type="button"
>
<span>{section.title}</span>
<span className="flex items-center gap-1 text-xs">
{section.items.length}
<ChevronRight className="size-3" />
</span>
</button>
))}
</div>
</div>
);
}

function FamilyItems({
isMobile,
onBack,
onNavigate,
pathname,
section,
}: {
isMobile: boolean;
onBack: () => void;
onNavigate: () => void;
pathname: string;
section: SidebarSection;
}) {
return (
<div className="space-y-1">
<button
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
onClick={onBack}
type="button"
>
<ChevronLeft className="size-3" />
<span>All families</span>
</button>
<div className="flex items-center justify-between px-3 pb-1">
<span className="text-sm font-semibold">{section.title}</span>
<span className="text-xs text-muted-foreground">
{section.items.length}
</span>
</div>
<div className="space-y-0.5">
{section.items.map((item) => (
<Link
aria-current={pathname === item.href ? "page" : undefined}
className={cn(
"block px-3 py-1.5 rounded-md text-sm transition-colors",
pathname === item.href
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
href={item.href}
key={item.href}
onClick={() => {
if (isMobile) {
onNavigate();
}
}}
>
{item.title}
</Link>
))}
</div>
</div>
);
}

/**
* Single-pane drill-down for grouped component families: the family list (ROOT)
* or one family's items (FAMILY), so a long flat list never renders at once.
* Auto-drills into the family that contains the active route, re-syncing on
* navigation while still allowing manual drill / back between routes.
*
* @param sections - family-tagged sidebar sections (each `title` is a family)
* @param pathname - current route, used to detect + auto-open the active family
*/
function FamilyNav({
isMobile,
onNavigate,
pathname,
sections,
}: FamilyNavProps) {
const activeTitle =
sections.find((section) =>
section.items.some((item) => item.href === pathname),
)?.title ?? null;

const [routeKey, setRouteKey] = useState(pathname);
const [openTitle, setOpenTitle] = useState<null | string>(activeTitle);

if (routeKey !== pathname) {
setRouteKey(pathname);
setOpenTitle(activeTitle);
}

const openSection =
openTitle === null
? null
: (sections.find((section) => section.title === openTitle) ?? null);

if (openSection) {
return (
<FamilyItems
isMobile={isMobile}
onBack={() => {
setOpenTitle(null);
}}
onNavigate={onNavigate}
pathname={pathname}
section={openSection}
/>
);
}

return (
<FamilyList
onOpen={(title) => {
setOpenTitle(title);
}}
sections={sections}
/>
);
}

// eslint-disable-next-line max-lines-per-function
export function Sidebar({ sections }: SidebarProps) {
const pathname = usePathname();
Expand All @@ -148,6 +302,9 @@ export function Sidebar({ sections }: SidebarProps) {

const collapsed = mounted && !isMobile && !open;

const familySections = sections.filter((section) => section.family);
const otherSections = sections.filter((section) => !section.family);

return (
<>
{/* Mobile overlay */}
Expand Down Expand Up @@ -199,7 +356,7 @@ export function Sidebar({ sections }: SidebarProps) {
ref={scrollContainerReference}
>
<div className="space-y-4">
{sections.map((section, sectionIndex) => {
{otherSections.map((section, sectionIndex) => {
const sectionItems = (
<div className={section.title ? "space-y-0.5" : "space-y-1"}>
{section.items.map((item) => (
Expand Down Expand Up @@ -251,6 +408,16 @@ export function Sidebar({ sections }: SidebarProps) {
</div>
);
})}
{familySections.length > 0 ? (
<FamilyNav
isMobile={isMobile}
onNavigate={() => {
setOpen(false);
}}
pathname={pathname}
sections={familySections}
/>
) : null}
</div>
</nav>
</div>
Expand Down
Loading
Loading