sidebar11
A Sidebar block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call sidebar11() with no arguments for a working demo, or edit the code below live.
Implementation notes
Fully recursive folder/file tree component (arbitrary depth via a discriminated-union node type), ancestor folders of the active file pre-expanded on first render, active file highlighted, and a header breadcrumb rebuilt reactively (keyed by cumulative path, not index) from the active file's path segments on every selection. Minor, intentional parity note: like the sibling sidebar05-08 accordions, a folder's expand/collapse state is computed once at construction and does not auto re-expand ancestors on a later click-driven selection change — matches the established convention in this package rather than a divergent gap.
Status: ported · Reference: shadcn/ui original
// shadcn/ui "sidebar-11" — clean-room reimplementation from the public
// behavior description only (no upstream source viewed). A code-editor-style
// sidebar rendering a recursive, collapsible folder/file tree with
// active-file highlighting synced to a breadcrumb in the main content
// header. See ./sidebar09-12-shared.ts and ./sidebar05-08-shared.ts.
import type { DomphyElement, Listener, State } from "@domphy/core";
import { toState } from "@domphy/core";
import { breadcrumb, buttonGhost, icon, link, strong } from "@domphy/ui";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
import {
ICON_CHEVRON_RIGHT,
ICON_FILE,
ICON_FOLDER,
ICON_PANEL_TOGGLE,
renderUserFooter,
sidebarIcon,
sidebarMainContent,
verticalDivider,
type SidebarUser,
} from "./sidebar09-12-shared.js";
/** A recursive tree node: either a folder with children, or a leaf file. */
type Sidebar11TreeNode =
| { type: "folder"; name: string; children: Sidebar11TreeNode[]; icon?: string }
| { type: "file"; name: string; icon?: string };
type Sidebar11Props = {
tree?: Sidebar11TreeNode[];
activeFilePath?: string;
user?: SidebarUser;
onFolderToggle?: (path: string, open: boolean) => void;
onFileSelect?: (path: string) => void;
children?: DomphyElement | DomphyElement[];
};
const DEFAULT_TREE: Sidebar11TreeNode[] = [
{
type: "folder",
name: "app",
children: [
{ type: "file", name: "layout.tsx" },
{ type: "file", name: "page.tsx" },
],
},
{
type: "folder",
name: "components",
children: [
{
type: "folder",
name: "ui",
children: [
{ type: "file", name: "button.tsx" },
{ type: "file", name: "card.tsx" },
{ type: "file", name: "dialog.tsx" },
],
},
{ type: "file", name: "app-sidebar.tsx" },
],
},
{
type: "folder",
name: "lib",
children: [{ type: "file", name: "utils.ts" }],
},
{
type: "folder",
name: "public",
children: [{ type: "file", name: "favicon.ico" }],
},
];
const DEFAULT_ACTIVE_PATH = "components/ui/button.tsx";
const DEFAULT_USER: SidebarUser = { name: "Shad Cn", email: "shadcn@example.com" };
function joinPath(parent: string, name: string): string {
return parent ? `${parent}/${name}` : name;
}
/** True if `path` is the active file, or an ancestor folder of the active file. */
function isAncestorOrSelf(path: string, activePath: string): boolean {
return activePath === path || activePath.startsWith(`${path}/`);
}
function fileRow(path: string, node: Extract<Sidebar11TreeNode, { type: "file" }>, activeFilePath: State<string>, onSelect: (path: string) => void): DomphyElement<"li"> {
return {
li: [
{
button: [
sidebarIcon(node.icon ?? ICON_FILE),
{ span: node.name, style: { flex: "1", textAlign: "left" } } as unknown as DomphyElement,
],
type: "button",
ariaCurrent: (l: Listener) => (activeFilePath.get(l) === path ? "true" : undefined),
onClick: () => onSelect(path),
style: {
display: "flex",
alignItems: "center",
width: "100%",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1.5),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
border: "none",
background: "none",
cursor: "pointer",
textAlign: "left",
overflow: "hidden",
whiteSpace: "nowrap",
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
"&:hover": { backgroundColor: (l: Listener) => themeColor(l, "shift-2", "neutral") },
"&[aria-current=true]": {
backgroundColor: (l: Listener) => themeColor(l, "shift-3", "primary"),
color: (l: Listener) => themeColor(l, "shift-12", "primary"),
},
},
} as unknown as DomphyElement,
],
_key: node.name,
} as DomphyElement<"li">;
}
function folderRow(
path: string,
node: Extract<Sidebar11TreeNode, { type: "folder" }>,
activeFilePath: State<string>,
initialActivePath: string,
onFolderToggle: ((path: string, open: boolean) => void) | undefined,
onSelect: (path: string) => void,
): DomphyElement<"li"> {
return {
li: [
{
details: [
{
summary: [
{
span: ICON_CHEVRON_RIGHT,
dataSlot: "chevron",
style: { transition: "transform 150ms ease" },
$: [icon({ color: "neutral" })],
} as unknown as DomphyElement,
sidebarIcon(node.icon ?? ICON_FOLDER),
{ span: node.name, style: { flex: "1", textAlign: "left" } } as unknown as DomphyElement,
],
style: {
listStyle: "none",
cursor: "pointer",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
width: "100%",
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1.5),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
"&::-webkit-details-marker": { display: "none" },
"&::marker": { content: `""` },
"&:hover": { backgroundColor: (l: Listener) => themeColor(l, "shift-2", "neutral") },
},
} as unknown as DomphyElement,
{
ul: buildTreeList(path, node.children, activeFilePath, initialActivePath, onFolderToggle, onSelect),
style: {
listStyle: "none",
margin: "0",
display: "flex",
flexDirection: "column",
gap: themeSpacing(0.5),
marginInlineStart: themeSpacing(4),
paddingInlineStart: themeSpacing(2),
paddingBlock: "0",
paddingInlineEnd: "0",
borderInlineStart: (l: Listener) => `1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement,
],
open: isAncestorOrSelf(path, initialActivePath) || undefined,
onToggle: (event: Event) => onFolderToggle?.(path, (event.target as HTMLDetailsElement).open),
style: {
"&[open] summary [data-slot=chevron]": { transform: "rotate(90deg)" },
},
} as unknown as DomphyElement,
],
_key: node.name,
} as DomphyElement<"li">;
}
function buildTreeList(
parentPath: string,
nodes: Sidebar11TreeNode[],
activeFilePath: State<string>,
initialActivePath: string,
onFolderToggle: ((path: string, open: boolean) => void) | undefined,
onSelect: (path: string) => void,
): DomphyElement[] {
return nodes.map((node) => {
const path = joinPath(parentPath, node.name);
return node.type === "folder"
? folderRow(path, node, activeFilePath, initialActivePath, onFolderToggle, onSelect)
: fileRow(path, node, activeFilePath, onSelect);
});
}
/** Breadcrumb trail rebuilt reactively from the active file's path segments. */
function fileBreadcrumb(activeFilePath: State<string>): DomphyElement<"nav"> {
return {
nav: (listener: Listener) => {
const segments = activeFilePath.get(listener).split("/").filter(Boolean);
return segments.map((segment, index) => {
const isLast = index === segments.length - 1;
// Key by the cumulative path up to this segment, not its index — the
// path is the segment's actual stable identity (two breadcrumbs at the
// same position can represent different folders once the active file
// changes depth).
const cumulativePath = segments.slice(0, index + 1).join("/");
return isLast
? ({
strong: segment,
_key: cumulativePath,
ariaCurrent: "page",
$: [strong({ color: "neutral" })],
} as unknown as DomphyElement)
: ({
a: segment,
_key: cumulativePath,
href: "#",
$: [link({ color: "neutral", accentColor: "neutral" })],
} as unknown as DomphyElement);
});
},
$: [breadcrumb({ color: "neutral" })],
} as unknown as DomphyElement<"nav">;
}
/**
* IDE-style recursive folder/file tree sidebar with active-file highlighting
* synced to the main header's breadcrumb. Call with no arguments for a fully
* working demo.
*/
function sidebar11(props: Sidebar11Props = {}): DomphyElement<"div"> {
const {
tree = DEFAULT_TREE,
user = DEFAULT_USER,
onFolderToggle,
onFileSelect,
children,
} = props;
const initialActivePath = props.activeFilePath ?? DEFAULT_ACTIVE_PATH;
const activeFilePath = toState(initialActivePath);
const collapsed = toState(false);
const selectFile = (path: string) => {
activeFilePath.set(path);
onFileSelect?.(path);
};
const asideElement: DomphyElement<"aside"> = {
aside: [
{
nav: [
{
ul: buildTreeList("", tree, activeFilePath, initialActivePath, onFolderToggle, selectFile),
style: { listStyle: "none", margin: "0", padding: "0", display: "flex", flexDirection: "column", gap: themeSpacing(0.5) },
} as unknown as DomphyElement,
],
style: {
flex: "1",
minHeight: "0",
overflowY: "auto",
padding: (l: Listener) => themeSpacing(themeDensity(l) * 3),
},
} as unknown as DomphyElement,
renderUserFooter(user),
],
style: {
display: "flex",
flexDirection: "column",
flexShrink: "0",
width: (l: Listener) => (collapsed.get(l) ? "0px" : themeSpacing(64)),
overflow: "hidden",
transition: "width 180ms ease-out",
borderInlineEnd: (l: Listener) => `1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
},
} as unknown as DomphyElement<"aside">;
const headerElement: DomphyElement<"header"> = {
header: [
{
button: [sidebarIcon(ICON_PANEL_TOGGLE)],
type: "button",
ariaLabel: "Toggle sidebar",
onClick: () => collapsed.set(!collapsed.get()),
$: [buttonGhost({ color: "neutral" })],
} as unknown as DomphyElement,
verticalDivider(),
fileBreadcrumb(activeFilePath),
],
style: {
position: "sticky",
top: "0",
zIndex: "10",
display: "flex",
alignItems: "center",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 3),
height: themeSpacing(14),
flexShrink: "0",
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
borderBottom: (l: Listener) => `1px solid ${themeColor(l, "shift-3", "neutral")}`,
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement<"header">;
const mainElement: DomphyElement<"main"> = {
main: [headerElement, sidebarMainContent(children)],
style: {
display: "flex",
flexDirection: "column",
flex: "1",
minWidth: "0",
minHeight: "0",
overflow: "hidden",
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
},
} as unknown as DomphyElement<"main">;
return {
div: [asideElement, mainElement],
dataTone: "shift-0",
style: {
display: "flex",
height: "100dvh",
overflow: "hidden",
position: "relative",
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement<"div">;
}
export { sidebar11 };
export type { Sidebar11Props, Sidebar11TreeNode };