dodil/erp-ui
public/r/app-shell.json
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "app-shell",
"title": "AppShell",
"description": "The frame: modules' navigation by section, account, light/dark, a skip link; sidebar at a desk, menu on a phone.",
"dependencies": [
"@radix-ui/themes"
],
"files": [
{
"path": "src/components/app-shell/AppShell.tsx",
"content": "/**\n * AppShell — the frame every screen sits in: the product's name, the navigation the modules contribute\n * (grouped by section, already filtered to what the caller may see), who is signed in, light/dark, and the\n * page. Router-agnostic: links are plain `<a href>` (so open-in-new-tab works) and `onNavigate` takes the\n * ordinary clicks.\n *\n * At a desk the navigation is a sidebar; on a phone it is a menu behind a button. A skip link goes straight\n * to the page for keyboard users.\n */\nimport { Box, Button, Dialog, DropdownMenu, Flex, IconButton, Separator, Text, VisuallyHidden } from \"@radix-ui/themes\";\nimport { useState, type MouseEvent, type ReactNode } from \"react\";\n\nimport \"./app-shell.css\";\n\nexport interface NavItem { href: string; label: string; active?: boolean }\nexport interface NavSection { section: string; items: NavItem[] }\nexport type Appearance = \"light\" | \"dark\" | \"system\";\n\nexport interface AppShellProps {\n product: string;\n nav: NavSection[];\n user?: { name: string; detail?: string };\n /** Items in the account menu, e.g. a sign-out link. */\n accountMenu?: ReactNode;\n appearance?: Appearance;\n onAppearanceChange?: (a: Appearance) => void;\n onNavigate?: (href: string) => void;\n children: ReactNode;\n}\n\nfunction Nav({ nav, onNavigate, onPicked }: { nav: NavSection[]; onNavigate?: (href: string) => void; onPicked?: () => void }) {\n const click = (href: string) => (e: MouseEvent) => {\n if (!onNavigate || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n e.preventDefault();\n onNavigate(href);\n onPicked?.();\n };\n return (\n <nav aria-label=\"Main\">\n <Flex direction=\"column\" gap=\"4\">\n {nav.map((s) => (\n <Box key={s.section}>\n <Text as=\"div\" size=\"1\" weight=\"bold\" color=\"gray\" mb=\"1\" className=\"erp-shell-section\">{s.section}</Text>\n <ul className=\"erp-shell-list\">\n {s.items.map((i) => (\n <li key={i.href}>\n <a href={i.href} onClick={click(i.href)} aria-current={i.active ? \"page\" : undefined} className=\"erp-shell-link\">\n {i.label}\n </a>\n </li>\n ))}\n </ul>\n </Box>\n ))}\n </Flex>\n </nav>\n );\n}\n\nconst NEXT: Record<Appearance, Appearance> = { system: \"light\", light: \"dark\", dark: \"system\" };\nconst LABEL: Record<Appearance, string> = { system: \"Theme: system\", light: \"Theme: light\", dark: \"Theme: dark\" };\n\nexport function AppShell({ product, nav, user, accountMenu, appearance, onAppearanceChange, onNavigate, children }: AppShellProps) {\n const [menu, setMenu] = useState(false);\n return (\n <Box className=\"erp-shell\">\n <a href=\"#main\" className=\"erp-shell-skip\">Skip to content</a>\n <header className=\"erp-shell-header\">\n <Flex align=\"center\" gap=\"3\" px=\"4\" py=\"2\" justify=\"between\">\n <Flex align=\"center\" gap=\"2\">\n <Dialog.Root open={menu} onOpenChange={setMenu}>\n <Dialog.Trigger>\n <IconButton variant=\"ghost\" color=\"gray\" aria-label=\"Open navigation\" className=\"erp-shell-burger\">\n <span aria-hidden=\"true\">☰</span>\n </IconButton>\n </Dialog.Trigger>\n <Dialog.Content className=\"erp-shell-drawer\" aria-describedby={undefined}>\n <VisuallyHidden><Dialog.Title>Navigation</Dialog.Title></VisuallyHidden>\n <Nav nav={nav} onNavigate={onNavigate} onPicked={() => setMenu(false)} />\n </Dialog.Content>\n </Dialog.Root>\n <Text size=\"4\" weight=\"bold\">{product}</Text>\n </Flex>\n <Flex align=\"center\" gap=\"2\">\n {onAppearanceChange && appearance && (\n <Button variant=\"ghost\" color=\"gray\" size=\"1\" onClick={() => onAppearanceChange(NEXT[appearance])}\n aria-label={`${LABEL[appearance]} — change`}>\n <span aria-hidden=\"true\">{appearance === \"dark\" ? \"☾\" : appearance === \"light\" ? \"☀\" : \"◐\"}</span>\n <span className=\"erp-shell-hide-sm\">{appearance}</span>\n </Button>\n )}\n {user && (\n <DropdownMenu.Root>\n <DropdownMenu.Trigger>\n <Button variant=\"soft\" color=\"gray\" size=\"1\" aria-label={`Account: ${user.name}`}>\n <span className=\"erp-shell-user\">{user.name}</span>\n <DropdownMenu.TriggerIcon />\n </Button>\n </DropdownMenu.Trigger>\n <DropdownMenu.Content align=\"end\">\n <DropdownMenu.Label>{user.name}</DropdownMenu.Label>\n {user.detail && <DropdownMenu.Label>{user.detail}</DropdownMenu.Label>}\n {accountMenu && <DropdownMenu.Separator />}\n {accountMenu}\n </DropdownMenu.Content>\n </DropdownMenu.Root>\n )}\n </Flex>\n </Flex>\n <Separator size=\"4\" />\n </header>\n <Flex className=\"erp-shell-body\">\n <aside className=\"erp-shell-aside\"><Nav nav={nav} onNavigate={onNavigate} /></aside>\n <main id=\"main\" tabIndex={-1} className=\"erp-shell-main\">{children}</main>\n </Flex>\n </Box>\n );\n}\n",
"type": "registry:component",
"target": "erp-ui/components/app-shell/AppShell.tsx"
},
{
"path": "src/components/app-shell/app-shell.css",
"content": "/* The shell's layout. Colours, spacing and radius come from Radix tokens only. */\n.erp-shell { min-height: 100vh; background: var(--color-background); }\n.erp-shell-header { position: sticky; top: 0; z-index: 10; background: var(--color-panel-solid); }\n.erp-shell-body { align-items: stretch; }\n.erp-shell-aside {\n width: 220px; flex: 0 0 220px; padding: var(--space-4) var(--space-3);\n border-right: 1px solid var(--gray-a5); min-height: calc(100vh - 49px);\n}\n.erp-shell-main { flex: 1; min-width: 0; padding: var(--space-5); outline: none; }\n.erp-shell-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }\n.erp-shell-section { text-transform: uppercase; letter-spacing: 0.04em; padding: 0 var(--space-2); }\n.erp-shell-link {\n display: block; padding: var(--space-1) var(--space-2); border-radius: var(--radius-2);\n color: var(--gray-12); text-decoration: none; font-size: var(--font-size-2);\n}\n.erp-shell-link:hover { background: var(--gray-a3); }\n.erp-shell-link[aria-current=\"page\"] { background: var(--accent-a4); color: var(--accent-12); font-weight: 500; }\n.erp-shell-link:focus-visible, .erp-shell-skip:focus-visible { outline: 2px solid var(--focus-8); outline-offset: 2px; }\n.erp-shell-skip { position: absolute; left: -9999px; top: var(--space-2); z-index: 20; background: var(--color-panel-solid);\n padding: var(--space-2) var(--space-3); border-radius: var(--radius-2); color: var(--accent-11); }\n.erp-shell-skip:focus { left: var(--space-2); }\n.erp-shell-burger { display: none; }\n.erp-shell-user { max-width: 16ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.erp-shell-drawer.rt-DialogContent { position: fixed; left: 0; top: 0; bottom: 0; max-height: 100vh; height: 100vh;\n width: min(300px, 85vw); border-radius: 0; margin: 0; }\n@media (max-width: 767px) {\n .erp-shell-aside { display: none; }\n .erp-shell-burger { display: inline-flex; }\n .erp-shell-main { padding: var(--space-4) var(--space-4); }\n .erp-shell-hide-sm { display: none; }\n}\n",
"type": "registry:file",
"target": "erp-ui/components/app-shell/app-shell.css"
}
],
"type": "registry:component"
}