sidebar05
A Sidebar block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call sidebar05() with no arguments for a working demo, or edit the code below live.
Implementation notes
Inline accordion sidebar. Plus/minus toggle glyph swapped purely via the details[open] CSS attribute selector (no JS, no rotation) — matches the spec's key visual signature. Multiple groups can be open simultaneously (native <details> behavior), one group open by default. Sub-list open/close animates via max-height/opacity transition (~180ms linear). Sidebar hide/show is a width transition + overflow on desktop; on narrow viewports (@media max-width) it becomes a position transform-slide overlay with a semi-transparent backdrop. Active sub-link gets accent-tinted background (listItemButton() patch) + bold text (nested strong() patch). Doctor self-check: 0 issues.
Status: ported · Reference: shadcn/ui original
// shadcn/ui "sidebar-05" — clean-room reimplementation from the public behavior
// description only (no upstream source viewed). Inline-accordion nav: each
// top-level group expands/collapses in place with a plus/minus toggle glyph
// (not a rotating chevron), multiple groups can stay open at once, and the
// sidebar itself slides fully off-canvas when hidden (no icon-rail collapse).
import type { DomphyElement, Listener } from "@domphy/core";
import { toState } from "@domphy/core";
import { icon, inputSearch, list, listItemButton, small, strong } from "@domphy/ui";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
import {
ICON_BAR_CHART,
ICON_FOLDER,
ICON_GRID,
ICON_INBOX,
ICON_MARK,
ICON_MINUS,
ICON_PLUS,
ICON_SEARCH,
sidebarBackdrop,
sidebarIcon,
sidebarMainContent,
sidebarStickyHeader,
type SidebarBreadcrumbItem,
} from "./sidebar05-08-shared.js";
/** One nested link under a top-level nav group. */
type Sidebar05SubItem = {
title: string;
href?: string;
active?: boolean;
};
/** One collapsible top-level nav category. */
type Sidebar05NavGroup = {
title: string;
href?: string;
icon?: string;
defaultOpen?: boolean;
items: Sidebar05SubItem[];
};
type Sidebar05Props = {
header?: { icon?: string; title?: string; subtitle?: string };
searchPlaceholder?: string;
navGroups?: Sidebar05NavGroup[];
breadcrumbItems?: SidebarBreadcrumbItem[];
children?: DomphyElement | DomphyElement[];
};
const DEFAULT_NAV_GROUPS: Sidebar05NavGroup[] = [
{
title: "Getting Started",
icon: ICON_GRID,
defaultOpen: true,
items: [
{ title: "Installation", href: "#" },
{ title: "Project Structure", href: "#", active: true },
],
},
{
title: "Building Your Application",
icon: ICON_INBOX,
items: [
{ title: "Routing", href: "#" },
{ title: "Data Fetching", href: "#" },
{ title: "Rendering", href: "#" },
{ title: "Caching", href: "#" },
],
},
{
title: "API Reference",
icon: ICON_BAR_CHART,
items: [
{ title: "Components", href: "#" },
{ title: "File Conventions", href: "#" },
],
},
{
title: "Architecture",
icon: ICON_FOLDER,
items: [
{ title: "Accessibility", href: "#" },
{ title: "Fast Refresh", href: "#" },
],
},
];
/** A single sub-link row: plain text, or bold + accent-tinted when active. */
function renderSubItem(item: Sidebar05SubItem, key: string | number): DomphyElement<"li"> {
return {
li: [
{
a: item.active
? ([{ strong: item.title, $: [strong({ color: "neutral" })] }] as unknown as DomphyElement)
: item.title,
href: item.href ?? "#",
ariaCurrent: item.active ? "page" : undefined,
$: [listItemButton({ color: "neutral", accentColor: "primary", dense: true })],
} as unknown as DomphyElement,
],
_key: key,
} as DomphyElement<"li">;
}
/** A collapsible top-level group: native <details> disclosure with a
* plus/minus toggle glyph swapped purely via the `[open]` CSS attribute
* selector (no JS, no rotating chevron). */
function renderNavGroup(group: Sidebar05NavGroup, key: string | number): DomphyElement<"li"> {
const subItems = group.items.map((item, index) => renderSubItem(item, `${group.title}-${index}`));
return {
li: [
{
details: [
{
summary: [
...(group.icon ? [sidebarIcon(group.icon)] : []),
{ span: group.title, style: { flex: "1", textAlign: "left" } } as unknown as DomphyElement,
{ span: ICON_PLUS, dataSlot: "toggle-plus", $: [icon({ color: "neutral" })] } as unknown as DomphyElement,
{ span: ICON_MINUS, dataSlot: "toggle-minus", $: [icon({ color: "neutral" })] } 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) * 2),
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: subItems,
style: {
listStyle: "none",
margin: "0",
display: "flex",
flexDirection: "column",
gap: themeSpacing(0.5),
marginInlineStart: themeSpacing(5),
paddingInlineStart: themeSpacing(3),
paddingBlock: "0",
paddingInlineEnd: "0",
borderInlineStart: (l: Listener) => `1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
maxHeight: "0px",
overflow: "hidden",
opacity: "0",
transition: "max-height 180ms linear, opacity 180ms linear",
},
} as unknown as DomphyElement,
],
open: group.defaultOpen ?? false,
style: {
"&[open] [data-slot=toggle-plus]": { display: "none" },
"&:not([open]) [data-slot=toggle-minus]": { display: "none" },
"&[open] > ul": {
maxHeight: themeSpacing(240),
opacity: "1",
paddingBlock: themeSpacing(1),
},
},
} as unknown as DomphyElement,
],
_key: key,
} as DomphyElement<"li">;
}
/**
* shadcn/ui "sidebar-05" — inline-accordion collapsible nav with a plus/minus
* toggle glyph, a search field in the header, and a sticky breadcrumb header
* for the content area. Call with no arguments for a fully working demo.
*/
function sidebar05(props: Sidebar05Props = {}): DomphyElement<"div"> {
const {
header = { icon: ICON_MARK, title: "Acme Inc", subtitle: "v1.0.0" },
searchPlaceholder = "Search the docs...",
navGroups = DEFAULT_NAV_GROUPS,
breadcrumbItems = [{ label: "Building Your Application" }, { label: "Data Fetching" }],
children,
} = props;
const sidebarOpen = toState(true);
const asideElement: DomphyElement<"aside"> = {
aside: [
{
div: [
{
div: [
{
span: header.icon ?? ICON_MARK,
dataTone: "shift-0",
style: {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: themeSpacing(8),
height: themeSpacing(8),
flexShrink: "0",
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 2),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "primary"),
color: (l: Listener) => themeColor(l, "shift-10", "primary"),
},
} as unknown as DomphyElement,
{
div: [
{ strong: header.title ?? "Acme Inc", $: [strong({ color: "neutral" })] } as unknown as DomphyElement,
{ small: header.subtitle ?? "v1.0.0", $: [small({ color: "neutral" })] } as unknown as DomphyElement,
],
style: { display: "flex", flexDirection: "column", gap: themeSpacing(0.5), minWidth: "0" },
} as unknown as DomphyElement,
],
style: { display: "flex", alignItems: "center", gap: (l: Listener) => themeSpacing(themeDensity(l) * 3) },
} as unknown as DomphyElement,
{
div: [
{
span: ICON_SEARCH,
style: {
position: "absolute",
insetInlineStart: themeSpacing(3),
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "none",
display: "inline-flex",
color: (l: Listener) => themeColor(l, "shift-6", "neutral"),
},
} as unknown as DomphyElement,
{
input: null,
type: "search",
placeholder: searchPlaceholder,
style: { width: "100%", paddingInlineStart: themeSpacing(9) },
$: [inputSearch({ color: "neutral", accentColor: "primary" })],
} as unknown as DomphyElement,
],
style: { position: "relative", display: "flex", alignItems: "center" },
} as unknown as DomphyElement,
],
style: {
display: "flex",
flexDirection: "column",
gap: themeSpacing(4),
flexShrink: "0",
padding: (l: Listener) => themeSpacing(themeDensity(l) * 4),
},
} as unknown as DomphyElement,
{
nav: [
{
ul: navGroups.map((group, index) => renderNavGroup(group, group.title ?? index)),
$: [list()],
} as unknown as DomphyElement,
],
style: {
flex: "1",
minHeight: "0",
overflowY: "auto",
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
paddingBottom: (l: Listener) => themeSpacing(themeDensity(l) * 4),
},
} as unknown as DomphyElement,
],
dataTone: "shift-2",
style: {
display: "flex",
flexDirection: "column",
flexShrink: "0",
width: (l: Listener) => (sidebarOpen.get(l) ? themeSpacing(64) : "0px"),
overflow: "hidden",
transition: "width 0.2s linear",
borderInlineEnd: (l: Listener) => `1px solid ${themeColor(l, "shift-3", "neutral")}`,
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
"@media (max-width: 768px)": {
position: "fixed",
insetBlock: "0",
insetInlineStart: "0",
zIndex: "15",
width: themeSpacing(72),
transform: (l: Listener) => (sidebarOpen.get(l) ? "translateX(0)" : "translateX(-100%)"),
transition: "transform 0.2s ease",
boxShadow: (l: Listener) => `0 0 ${themeSpacing(6)} ${themeColor(l, "shift-3", "neutral")}`,
},
},
} as unknown as DomphyElement<"aside">;
const mainElement: DomphyElement<"main"> = {
main: [
sidebarStickyHeader({
onToggle: () => sidebarOpen.set(!sidebarOpen.get()),
breadcrumbItems,
}),
sidebarMainContent(children),
],
style: {
display: "flex",
flexDirection: "column",
flex: "1",
minWidth: "0",
minHeight: "0",
overflow: "auto",
},
} as unknown as DomphyElement<"main">;
return {
div: [asideElement, mainElement, sidebarBackdrop(sidebarOpen, () => sidebarOpen.set(false))],
style: {
display: "flex",
height: "100dvh",
overflow: "hidden",
position: "relative",
},
} as unknown as DomphyElement<"div">;
}
export { sidebar05 };
export type { Sidebar05NavGroup, Sidebar05Props, Sidebar05SubItem };