marmar-context / web / context / src / clinician / shell / CommandMenu.tsx
CommandMenu.tsx
Raw
import { Dialog } from "radix-ui";
import type { KeyboardEvent } from "react";
import { useEffect, useMemo, useState } from "react";
import { clinicianApi } from "../../api/client";
import type { PatientSummary } from "../../api/types";
import { searchPatients, sortPatients } from "../../logic/patients";
import { personLine } from "../../logic/today";
import { cn, Icon, Kbd, type IconName } from "../kit";
import { PatientAvatar } from "../patients/bits";
import { navigate } from "../router";
import { navItems } from "./nav";

type Result = { id: string; label: string; line: string; to: string; group: "Patients" | "Pages"; icon?: IconName; patient?: PatientSummary; keys?: string[] };
const MAX_PATIENTS = 8;

const pageResults = (query: string): Result[] =>
  searchPatients(
    navItems.map((n) => ({ name: n.label, n })),
    query,
  ).map(({ n }) => ({ id: `cmd-page-${n.id}`, label: n.label, line: "Page", to: n.to, group: "Pages", icon: n.icon, keys: ["G", n.key] }));

/** ⌘K: jump to a patient by name, or to a page. Arrow keys move, Enter opens, Esc closes. */
export function CommandMenu({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const [patients, setPatients] = useState<PatientSummary[] | null>(null);
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    if (!open) return;
    setQuery("");
    setActive(0);
    let alive = true;
    clinicianApi.patients().then(
      (list) => {
        if (!alive) return;
        setPatients(list.patients);
        setFailed(false);
      },
      () => alive && setFailed(true),
    );
    return () => {
      alive = false;
    };
  }, [open]);

  const results = useMemo(() => {
    const people: Result[] = searchPatients(sortPatients(patients ?? []), query)
      .slice(0, query.trim() ? MAX_PATIENTS : 5)
      .map((p) => ({ id: `cmd-patient-${p.id}`, label: p.name, line: personLine(p) || "Patient", to: `/app/patients/${p.id}`, group: "Patients", patient: p }));
    const pages = pageResults(query);
    return query.trim() ? [...people, ...pages] : [...pages, ...people];
  }, [patients, query]);
  const current = results[Math.min(active, results.length - 1)];

  useEffect(() => {
    if (current) document.getElementById(current.id)?.scrollIntoView({ block: "nearest" });
  }, [current]);

  const go = (r: Result | undefined) => {
    if (!r) return;
    onOpenChange(false);
    navigate(r.to);
  };
  const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
    if (!results.length) return;
    const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
    if (step) {
      e.preventDefault();
      setActive((i) => (Math.min(i, results.length - 1) + step + results.length) % results.length);
    } else if (e.key === "Home" || e.key === "End") {
      e.preventDefault();
      setActive(e.key === "Home" ? 0 : results.length - 1);
    } else if (e.key === "Enter") {
      e.preventDefault();
      go(current);
    }
  };

  const grouped = (["Patients", "Pages"] as const)
    .map((g) => ({ g, items: results.filter((r) => r.group === g) }))
    .filter((x) => x.items.length)
    .sort((a, b) => results.indexOf(a.items[0]) - results.indexOf(b.items[0]));

  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 z-40 bg-[rgba(24,24,27,0.24)] data-[state=open]:animate-[context-tip_120ms_var(--ease-out-expo)]" />
        <Dialog.Content
          aria-describedby={undefined}
          className="fixed top-[12vh] left-1/2 z-50 flex max-h-[min(520px,76vh)] w-[min(560px,calc(100vw-32px))] -translate-x-1/2 flex-col overflow-hidden rounded-2xl bg-page font-sans text-ink shadow-pop outline-none data-[state=open]:animate-[context-pop_160ms_var(--ease-out-expo)]"
        >
          <Dialog.Title className="sr-only">Search Context</Dialog.Title>
          <div className="flex h-[52px] shrink-0 items-center gap-2.5 px-4 shadow-[inset_0_-1px_0_var(--color-hairline)]">
            <Icon name="search-line" size={18} className="text-subtle" />
            <input
              role="combobox"
              aria-expanded="true"
              aria-controls="cmd-results"
              aria-autocomplete="list"
              aria-activedescendant={current?.id}
              aria-label="Search patients and pages"
              placeholder="Search patients and pages"
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setActive(0);
              }}
              onKeyDown={onKey}
              className="min-h-0 w-full min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 font-sans text-[15px] text-ink shadow-none outline-none placeholder:text-subtle hover:border-0 focus-visible:shadow-none"
            />
            <Kbd>esc</Kbd>
          </div>
          <div id="cmd-results" role="listbox" aria-label="Results" className="min-h-0 flex-1 overflow-y-auto p-1.5">
            {grouped.map(({ g, items }) => (
              <div key={g} role="group" aria-label={g} className="pb-1">
                <div aria-hidden="true" className="px-2.5 pt-2 pb-1 text-[11.5px] font-medium text-subtle">
                  {g}
                </div>
                {items.map((r) => (
                  <div
                    key={r.id}
                    id={r.id}
                    role="option"
                    aria-selected={r === current}
                    onMouseMove={() => setActive(results.indexOf(r))}
                    onClick={() => go(r)}
                    className={cn("flex h-11 cursor-pointer items-center gap-3 rounded-lg px-2.5", r === current && "bg-quiet")}
                  >
                    {r.patient ? (
                      <PatientAvatar name={r.patient.name} size={28} />
                    ) : (
                      <span className="flex size-7 items-center justify-center rounded-full bg-quiet text-grey-icon">
                        <Icon name={r.icon ?? "arrow-right-line"} size={16} />
                      </span>
                    )}
                    <span className="flex min-w-0 flex-1 flex-col leading-tight">
                      <span className="truncate text-[14px] font-medium text-ink">{r.label}</span>
                      <span className="truncate text-[12px] text-muted">{r.line}</span>
                    </span>
                    {r.keys && (
                      <span aria-hidden="true" className="flex gap-1">
                        {r.keys.map((k) => (
                          <Kbd key={k}>{k}</Kbd>
                        ))}
                      </span>
                    )}
                  </div>
                ))}
              </div>
            ))}
            {!results.length && <p className="m-0 px-3 py-6 text-center text-[13.5px] text-muted">No patient or page matches "{query.trim()}".</p>}
            {failed && <p className="m-0 px-3 py-2 text-[12.5px] text-amber-text">Patients could not load. Pages still work.</p>}
            {!patients && !failed && <p className="m-0 px-3 py-2 text-[12.5px] text-muted" role="status">Loading patients</p>}
          </div>
          <div aria-hidden="true" className="flex shrink-0 items-center gap-4 px-4 py-2.5 text-[12px] text-muted shadow-[inset_0_1px_0_var(--color-hairline)]">
            <span className="flex items-center gap-1.5">
              <Kbd>↑</Kbd>
              <Kbd>↓</Kbd> to move
            </span>
            <span className="flex items-center gap-1.5">
              <Kbd>↵</Kbd> to open
            </span>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}