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>
  );
}