marmar-context / web / context / src / clinician / shell / Sidebar.tsx
Sidebar.tsx
Raw
import { Dialog } from "radix-ui";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { Button, cn, Count, Icon, Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger, SolidBadge, Tip } from "../kit";
import { Link, navigate } from "../router";
import { displayName, useClinician } from "../session";
import { Brand } from "../ui";
import { modKey, navItems, type NavId, type NavItem } from "./nav";

type NavProps = { active: NavId; urgent: number };
const focusRing = "outline-none focus-visible:shadow-[0_0_0_2px_#fff,0_0_0_4px_var(--color-brand)]";
const groups = [
  { id: "workspace", label: "Workspace", visible: false },
  { id: "guides", label: "Guides", visible: true },
] as const;

/** The clinic, who is signed in, Settings, Lock now and Sign out. */
function ClinicMenu({ rail = false }: { rail?: boolean }) {
  const { me, clinic, lock, signOut } = useClinician();
  const initial = clinic.name.trim().charAt(0).toUpperCase() || "C";
  const trigger = rail ? (
    <button type="button" aria-label={`${clinic.name}: clinic menu`} className={cn("flex size-[30px] cursor-pointer items-center justify-center rounded-lg border-0 bg-action p-0 font-sans text-[13px] font-semibold text-white", focusRing)}>
      {initial}
    </button>
  ) : (
    <button
      type="button"
      className={cn(
        "flex h-[34px] w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent px-2 text-left font-sans transition-colors hover:bg-quiet-hover data-[state=open]:bg-line",
        focusRing,
      )}
    >
      <span aria-hidden="true" className="flex size-5 shrink-0 items-center justify-center rounded-[5px] bg-action text-[11px] font-semibold text-white">
        {initial}
      </span>
      <span className="min-w-0 flex-1 truncate text-[14px] font-medium text-ink">{clinic.name}</span>
      <Icon name="expand-up-down-line" size={16} className="text-muted" />
    </button>
  );
  return (
    <Menu>
      {rail ? (
        <Tip label={clinic.name} side="right">
          <MenuTrigger asChild>{trigger}</MenuTrigger>
        </Tip>
      ) : (
        <MenuTrigger asChild>{trigger}</MenuTrigger>
      )}
      <MenuContent align="start" side={rail ? "right" : "bottom"} className="w-[264px] p-1.5">
        <div className="flex gap-2.5 px-1.5 pt-1.5 pb-3">
          <span aria-hidden="true" className="flex size-9 shrink-0 items-center justify-center rounded-[9px] bg-action text-[16px] font-semibold text-white">
            {initial}
          </span>
          <span className="flex min-w-0 flex-col gap-0.5">
            <strong className="truncate text-[14px] leading-[18px] font-semibold text-ink">{clinic.name}</strong>
            <span className="truncate text-[12px] leading-4 text-muted">{displayName(me)}</span>
            {me.verifiedAt && (
              <span className="mt-1 self-start">
                <SolidBadge tone="green" icon="shield-check-fill">
                  Verified · {me.licensingBody}
                </SolidBadge>
              </span>
            )}
          </span>
        </div>
        <MenuSeparator />
        <MenuItem icon="settings-3-line" onSelect={() => navigate("/app/settings")}>
          Settings
        </MenuItem>
        <MenuSeparator />
        <MenuItem icon="lock-line" onSelect={lock}>
          Lock Context now
        </MenuItem>
        <MenuItem icon="logout-box-r-line" onSelect={() => void signOut()}>
          Sign out
        </MenuItem>
      </MenuContent>
    </Menu>
  );
}

function FullLink({ item, on, urgent, onNavigate }: { item: NavItem; on: boolean; urgent: number; onNavigate?: () => void }) {
  return (
    <Link
      to={item.to}
      aria-current={on ? "page" : undefined}
      onClick={onNavigate}
      className={cn(
        "flex h-9 items-center gap-2.5 rounded-lg px-2.5 text-[14px] text-[#27272a] no-underline transition-colors hover:bg-quiet-hover",
        on && "bg-line font-semibold hover:bg-line",
        focusRing,
      )}
    >
      <Icon name={on ? item.activeIcon : item.icon} size={18} className="text-grey-icon" />
      <span className="min-w-0 flex-1 truncate">{item.label}</span>
      {item.id === "patients" && urgent > 0 && (
        <>
          <Count tone="red">{urgent}</Count>
          <span className="sr-only"> urgent</span>
        </>
      )}
    </Link>
  );
}

/** The expanded sidebar; on a phone it is the sheet, with a close button for the fold one. */
export function FullNav({ active, urgent, end, onNavigate }: NavProps & { end: ReactNode; onNavigate?: () => void }) {
  return (
    <div className="flex min-h-0 w-[240px] max-w-full flex-1 flex-col gap-[18px] px-3 py-3.5">
      <div className="flex h-8 items-center justify-between gap-2 pl-2">
        <Brand to="/app/today" />
        {end}
      </div>
      <ClinicMenu />
      <nav aria-label="Main navigation" className="flex min-h-0 flex-col gap-[18px] overflow-y-auto">
        {groups.map((g) => (
          <div key={g.id} role="group" aria-label={g.label} className="flex flex-col gap-0.5">
            {g.visible && <p className="m-0 px-2.5 pb-1.5 text-[12px] leading-4 text-subtle">{g.label}</p>}
            {navItems
              .filter((n) => n.group === g.id)
              .map((n) => (
                <FullLink key={n.id} item={n} on={n.id === active} urgent={urgent} onNavigate={onNavigate} />
              ))}
          </div>
        ))}
      </nav>
      <div className="mt-auto flex items-center gap-2.5 px-2.5 py-1">
        <Icon name="shield-check-line" size={18} className="text-grey-icon" />
        <span className="flex min-w-0 flex-col leading-4">
          <strong className="text-[13px] font-medium text-body">Information tool</strong>
          <small className="text-[12px] text-muted">A clinician decides what to do</small>
        </span>
      </div>
    </div>
  );
}

function RailLink({ item, on, urgent }: { item: NavItem; on: boolean; urgent: number }) {
  const dot = item.id === "patients" && urgent > 0;
  return (
    <Tip label={item.label} keys={["G", item.key]} side="right">
      <Link
        to={item.to}
        aria-current={on ? "page" : undefined}
        aria-label={dot ? `${item.label}, ${urgent} urgent` : item.label}
        className={cn(
          "relative flex h-[38px] w-10 items-center justify-center rounded-[9px] text-grey-icon no-underline transition-colors hover:bg-quiet-hover",
          on && "bg-line hover:bg-line",
          focusRing,
        )}
      >
        <Icon name={on ? item.activeIcon : item.icon} size={19} />
        {dot && <span aria-hidden="true" className="absolute top-2 right-[9px] size-[7px] rounded-full bg-red-icon shadow-[0_0_0_2px_var(--color-frame)]" />}
      </Link>
    </Tip>
  );
}

/** The slim icon rail: each icon names itself and its keys in a tooltip. */
function Rail({ active, urgent, onToggle }: NavProps & { onToggle: () => void }) {
  return (
    <div className="flex w-[60px] flex-1 flex-col items-center gap-1 py-3.5">
      <div className="flex h-8 items-center [&_span]:hidden">
        <Brand to="/app/today" />
      </div>
      <ClinicMenu rail />
      <nav aria-label="Main navigation" className="flex flex-col items-center gap-1">
        {groups.map((g) => (
          <div key={g.id} role="group" aria-label={g.label} className="flex flex-col items-center gap-1">
            <span aria-hidden="true" className="my-2 h-px w-6 bg-line" />
            {navItems
              .filter((n) => n.group === g.id)
              .map((n) => (
                <RailLink key={n.id} item={n} on={n.id === active} urgent={urgent} />
              ))}
          </div>
        ))}
      </nav>
      <div className="mt-auto flex flex-col items-center gap-1">
        <Tip label="Information tool · A clinician decides what to do" side="right">
          <span className="flex h-[38px] w-10 items-center justify-center text-subtle">
            <Icon name="shield-check-line" size={18} />
            <span className="sr-only">Information tool. A clinician decides what to do.</span>
          </span>
        </Tip>
        <FoldButton collapsed onToggle={onToggle} />
      </div>
    </div>
  );
}

export function FoldButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
  const label = collapsed ? "Open sidebar" : "Close sidebar";
  return (
    <Tip label={label} keys={[modKey(), "B"]} side="right">
      <Button variant="ghost" size="icon-sm" aria-label={label} aria-expanded={!collapsed} onClick={onToggle} className="size-8 rounded-lg text-grey-icon hover:bg-quiet-hover">
        <Icon name={collapsed ? "sidebar-unfold-line" : "sidebar-fold-line"} size={18} />
      </Button>
    </Tip>
  );
}

/** Desktop and tablet: the rail or the full sidebar on the grey frame. Hidden on a phone. */
export function Sidebar({ collapsed, onToggle, ...nav }: NavProps & { collapsed: boolean; onToggle: () => void }) {
  return (
    <aside
      aria-label="Context navigation"
      className={cn(
        "sticky top-0 hidden h-dvh shrink-0 overflow-hidden transition-[width] duration-300 ease-out-expo md:flex motion-reduce:transition-none",
        collapsed ? "w-[60px]" : "w-[240px]",
      )}
    >
      {collapsed ? <Rail {...nav} onToggle={onToggle} /> : <FullNav {...nav} end={<FoldButton collapsed={false} onToggle={onToggle} />} />}
    </aside>
  );
}

/** Phone: the full sidebar as a sheet from the left, with focus kept inside until it closes. */
export function MobileSheet({ open, onOpenChange, ...nav }: NavProps & { open: boolean; onOpenChange: (open: boolean) => void }) {
  useEffect(() => {
    if (!open || typeof window.matchMedia !== "function") return;
    const wide = window.matchMedia("(min-width: 768px)");
    const close = () => wide.matches && onOpenChange(false);
    wide.addEventListener("change", close);
    return () => wide.removeEventListener("change", close);
  }, [open, onOpenChange]);
  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 z-40 bg-[rgba(15,23,42,0.32)] md:hidden" />
        <Dialog.Content aria-describedby={undefined} className="fixed inset-y-0 left-0 z-50 flex w-[min(300px,86vw)] flex-col bg-frame shadow-pop outline-none md:hidden data-[state=open]:animate-[slide-in_0.3s_var(--ease-out-expo)_both]">
          <Dialog.Title className="sr-only">Context navigation</Dialog.Title>
          <aside aria-label="Context navigation" className="flex min-h-0 flex-1 flex-col [&>div]:w-full">
            <FullNav
              {...nav}
              onNavigate={() => onOpenChange(false)}
              end={
                <Dialog.Close asChild>
                  <Button variant="ghost" size="icon-sm" aria-label="Close navigation" className="size-8 rounded-lg text-grey-icon hover:bg-quiet-hover">
                    <Icon name="close-line" size={18} />
                  </Button>
                </Dialog.Close>
              }
            />
          </aside>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}