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"
}