dodil/erp-ui
src/components/app-shell/AppShell.tsx
/**
* AppShell — the frame every screen sits in: the product's name, the navigation the modules contribute
* (grouped by section, already filtered to what the caller may see), who is signed in, light/dark, and the
* page. Router-agnostic: links are plain `<a href>` (so open-in-new-tab works) and `onNavigate` takes the
* ordinary clicks.
*
* At a desk the navigation is a sidebar; on a phone it is a menu behind a button. A skip link goes straight
* to the page for keyboard users.
*/
import { Box, Button, Dialog, DropdownMenu, Flex, IconButton, Separator, Text, VisuallyHidden } from "@radix-ui/themes";
import { useState, type MouseEvent, type ReactNode } from "react";
import "./app-shell.css";
export interface NavItem { href: string; label: string; active?: boolean }
export interface NavSection { section: string; items: NavItem[] }
export type Appearance = "light" | "dark" | "system";
export interface AppShellProps {
product: string;
nav: NavSection[];
user?: { name: string; detail?: string };
/** Items in the account menu, e.g. a sign-out link. */
accountMenu?: ReactNode;
appearance?: Appearance;
onAppearanceChange?: (a: Appearance) => void;
onNavigate?: (href: string) => void;
children: ReactNode;
}
function Nav({ nav, onNavigate, onPicked }: { nav: NavSection[]; onNavigate?: (href: string) => void; onPicked?: () => void }) {
const click = (href: string) => (e: MouseEvent) => {
if (!onNavigate || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onNavigate(href);
onPicked?.();
};
return (
<nav aria-label="Main">
<Flex direction="column" gap="4">
{nav.map((s) => (
<Box key={s.section}>
<Text as="div" size="1" weight="bold" color="gray" mb="1" className="erp-shell-section">{s.section}</Text>
<ul className="erp-shell-list">
{s.items.map((i) => (
<li key={i.href}>
<a href={i.href} onClick={click(i.href)} aria-current={i.active ? "page" : undefined} className="erp-shell-link">
{i.label}
</a>
</li>
))}
</ul>
</Box>
))}
</Flex>
</nav>
);
}
const NEXT: Record<Appearance, Appearance> = { system: "light", light: "dark", dark: "system" };
const LABEL: Record<Appearance, string> = { system: "Theme: system", light: "Theme: light", dark: "Theme: dark" };
export function AppShell({ product, nav, user, accountMenu, appearance, onAppearanceChange, onNavigate, children }: AppShellProps) {
const [menu, setMenu] = useState(false);
return (
<Box className="erp-shell">
<a href="#main" className="erp-shell-skip">Skip to content</a>
<header className="erp-shell-header">
<Flex align="center" gap="3" px="4" py="2" justify="between">
<Flex align="center" gap="2">
<Dialog.Root open={menu} onOpenChange={setMenu}>
<Dialog.Trigger>
<IconButton variant="ghost" color="gray" aria-label="Open navigation" className="erp-shell-burger">
<span aria-hidden="true">☰</span>
</IconButton>
</Dialog.Trigger>
<Dialog.Content className="erp-shell-drawer" aria-describedby={undefined}>
<VisuallyHidden><Dialog.Title>Navigation</Dialog.Title></VisuallyHidden>
<Nav nav={nav} onNavigate={onNavigate} onPicked={() => setMenu(false)} />
</Dialog.Content>
</Dialog.Root>
<Text size="4" weight="bold">{product}</Text>
</Flex>
<Flex align="center" gap="2">
{onAppearanceChange && appearance && (
<Button variant="ghost" color="gray" size="1" onClick={() => onAppearanceChange(NEXT[appearance])}
aria-label={`${LABEL[appearance]} — change`}>
<span aria-hidden="true">{appearance === "dark" ? "☾" : appearance === "light" ? "☀" : "◐"}</span>
<span className="erp-shell-hide-sm">{appearance}</span>
</Button>
)}
{user && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft" color="gray" size="1" aria-label={`Account: ${user.name}`}>
<span className="erp-shell-user">{user.name}</span>
<DropdownMenu.TriggerIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>{user.name}</DropdownMenu.Label>
{user.detail && <DropdownMenu.Label>{user.detail}</DropdownMenu.Label>}
{accountMenu && <DropdownMenu.Separator />}
{accountMenu}
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
</Flex>
</Flex>
<Separator size="4" />
</header>
<Flex className="erp-shell-body">
<aside className="erp-shell-aside"><Nav nav={nav} onNavigate={onNavigate} /></aside>
<main id="main" tabIndex={-1} className="erp-shell-main">{children}</main>
</Flex>
</Box>
);
}