104 lines
4.0 KiB
TypeScript
104 lines
4.0 KiB
TypeScript
import { For, createSignal, Show, JSXElement } from "solid-js";
|
|
|
|
const NAV_ROOT = "bg-white rounded-default shadow-sm border border-neutral-200 py-2";
|
|
const NAV_LIST = "list-none m-0 p-0";
|
|
const NAV_BTN = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-neutral-600 border-0 hover:text-neutral-900 hover:bg-neutral-50";
|
|
const NAV_SUBBTN = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-neutral-500 border-0 hover:text-neutral-900 hover:bg-neutral-50";
|
|
const NAV_ICON = "mr-2";
|
|
|
|
const LAYOUT_ROOT = "grid grid-cols-1 gap-8 min-h-screen items-start lg:grid-cols-12";
|
|
const LAYOUT_SIDEBAR = "hidden lg:block lg:col-span-2 lg:self-start lg:h-full";
|
|
const LAYOUT_STICKY = "sticky top-20 max-h-[calc(100vh_-_7rem)] overflow-y-auto";
|
|
const LAYOUT_STICKY_FULL = "sticky top-0 h-screen overflow-y-auto";
|
|
// lg:pr-8 mirrors the grid's gap-8 (the content's left spacing from the sidebar)
|
|
// so the content has matching breathing room on the right instead of hugging the
|
|
// viewport edge. Scoped to lg, where the sidebar/gap exists.
|
|
const LAYOUT_MAIN = "col-span-1 min-w-0 lg:col-span-10 lg:pr-8";
|
|
|
|
interface SidebarNavItem {
|
|
id: string;
|
|
label: string;
|
|
icon?: JSXElement;
|
|
// Optional second-level items that jump to sub-sections within this item.
|
|
children?: SidebarNavItem[];
|
|
}
|
|
|
|
interface SidebarNavProps {
|
|
items: SidebarNavItem[];
|
|
onItemClick?: (id: string) => void;
|
|
class?: string;
|
|
}
|
|
|
|
export function SidebarNav(props: SidebarNavProps) {
|
|
const handleClick = (id: string) => {
|
|
if (props.onItemClick) {
|
|
props.onItemClick(id);
|
|
}
|
|
const element = document.getElementById(id);
|
|
if (element) {
|
|
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<nav class={NAV_ROOT + (props.class ? " " + props.class : "")}>
|
|
<ul class={NAV_LIST}>
|
|
<For each={props.items}>{(item: SidebarNavItem) => (
|
|
<li>
|
|
<button onclick={() => handleClick(item.id)} class={NAV_BTN}>
|
|
{item.icon ? <span class={NAV_ICON}>{item.icon}</span> : null}
|
|
{item.label}
|
|
</button>
|
|
<Show when={item.children && item.children.length}>
|
|
<ul class={NAV_LIST}>
|
|
<For each={item.children}>{(sub: SidebarNavItem) => (
|
|
<li>
|
|
<button onclick={() => handleClick(sub.id)} class={NAV_SUBBTN}>
|
|
{sub.icon ? <span class={NAV_ICON}>{sub.icon}</span> : null}
|
|
{sub.label}
|
|
</button>
|
|
</li>
|
|
)}</For>
|
|
</ul>
|
|
</Show>
|
|
</li>
|
|
)}</For>
|
|
</ul>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
interface SidebarLayoutProps {
|
|
sidebar: JSXElement;
|
|
class?: string;
|
|
children?: JSXElement;
|
|
fullHeight?: boolean;
|
|
collapsible?: boolean;
|
|
}
|
|
|
|
export function SidebarLayout(props: SidebarLayoutProps) {
|
|
const [collapsed, setCollapsed] = createSignal(false);
|
|
|
|
const toggleCollapse = () => {
|
|
if (props.collapsible) {
|
|
setCollapsed(!collapsed());
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div class={LAYOUT_ROOT + (props.class ? " " + props.class : "")}>
|
|
<aside class={LAYOUT_SIDEBAR}>
|
|
<div class={props.fullHeight ? LAYOUT_STICKY_FULL : LAYOUT_STICKY}>
|
|
{props.sidebar}
|
|
</div>
|
|
<Show when={props.collapsible}>
|
|
<button type="button" onclick={toggleCollapse} aria-label="Toggle sidebar"></button>
|
|
</Show>
|
|
</aside>
|
|
<main class={LAYOUT_MAIN}>
|
|
{props.children}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|