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