Files
HealthLog/src/components/command-palette/command-palette.tsx
T
Marc-André Bombeck 0a71e8ffa5 fix(ui): keep the last four small fields at 16 px on a phone
The Coach memory editor, the managed profile's handover link, the cycle
length fields and the command palette's search set a font size under
16 px on touch screens, so iOS zoomed the page when one took focus. They
now read 16 px on a touch screen and keep their smaller size under a
fine pointer, like the field primitives. With no field left over, the
guard's list of known offenders goes.
2026-11-12 13:08:45 +01:00

603 lines
20 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import { openDay } from "@/components/day/day-layer-controller";
import { useTodayKey } from "@/components/day/use-today-key";
import {
openCapturePicker,
openShortcutsHelp,
} from "@/components/layout/global-shortcuts";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import { useAuth } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useModuleToggle } from "@/hooks/use-module-toggle";
import {
EMPTY_STATE_ACTIONS,
type PaletteEntry,
type PaletteGroup,
} from "@/components/command-palette/build-index";
import { rankEntries } from "@/lib/command-palette/rank";
import { pushRecent, readRecent } from "@/lib/command-palette/recent";
import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useTranslations } from "@/lib/i18n/context";
import type { SearchHit } from "@/lib/search/contract";
import { cn } from "@/lib/utils";
import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store";
import { RecordHitContent } from "./record-hit";
import { usePaletteIndex } from "./use-palette-index";
import { useRecordSearch } from "./use-record-search";
/**
* The command palette: one field, and below it the places, settings, module
* switches and actions that match, grouped. Arrow keys move, Enter opens,
* Escape closes. A module's entry is a switch: Enter flips it and the palette
* stays open.
*
* After the local results comes "In your records": the server's search over
* the record (`useRecordSearch`, `POST /api/search`), once the typing pauses.
* Its hits are options like any other, after the local ones, so the arrows
* and Enter work across both; each opens the page that shows the record.
*
* Accessibility follows the combobox pattern: the field is the
* `role="combobox"` that keeps focus, the results are a `listbox` of grouped
* `option`s, and `aria-activedescendant` names the highlighted one.
*
* A centred dialog from 768 px; on a phone a full-height sheet with the field
* at the top. That one is a raw `Sheet` rather than `ResponsiveSheet` on
* purpose: the responsive primitive's phone branch is a 90 % bottom sheet
* that keeps the keyboard down on open, and a search wants the opposite, the
* whole height and the keyboard up at once.
*
* Loaded on first use (`command-palette.lazy.tsx`), so no page pays for it.
*/
/** At most this many hits per group, so one group cannot bury the others. */
const PER_GROUP = 7;
interface Section {
group: PaletteGroup;
entries: PaletteEntry[];
}
function hashOf(href: string): { pathname: string; hash: string } {
const at = href.indexOf("#");
return at === -1
? { pathname: href, hash: "" }
: { pathname: href.slice(0, at), hash: href.slice(at + 1) };
}
export function arrangeResults(
query: string,
index: ReadonlyArray<PaletteEntry>,
recentIds: ReadonlyArray<string>,
): Section[] {
if (query.trim() === "") {
const byId = new Map(index.map((entry) => [entry.id, entry]));
const recent = recentIds
.map((id) => byId.get(id))
.filter((entry): entry is PaletteEntry => entry !== undefined);
const actions = EMPTY_STATE_ACTIONS.map((id) => byId.get(id)).filter(
(entry): entry is PaletteEntry =>
entry !== undefined && !recentIds.includes(entry.id),
);
return [
{ group: "recent" as const, entries: recent },
{ group: "actions" as const, entries: actions },
].filter((section) => section.entries.length > 0);
}
const ranked = rankEntries(query, index);
// Groups appear in the order of their best hit, so the best match is
// always the first option.
const order: PaletteGroup[] = [];
const grouped = new Map<PaletteGroup, PaletteEntry[]>();
for (const entry of ranked) {
const list = grouped.get(entry.group);
if (!list) {
order.push(entry.group);
grouped.set(entry.group, [entry]);
} else if (list.length < PER_GROUP) {
list.push(entry);
}
}
return order.map((group) => ({ group, entries: grouped.get(group) ?? [] }));
}
export default function CommandPalette() {
const open = useCommandPaletteOpen();
const isMobile = useIsMobile();
const { t } = useTranslations();
const title = t("palette.title");
const description = t("palette.description");
const body = open ? (
<PaletteBody isMobile={isMobile} key="palette-body" />
) : null;
if (isMobile) {
return (
<Sheet open={open} onOpenChange={setCommandPaletteOpen}>
<SheetContent
side="bottom"
// The palette fills the window: no backdrop to pull it towards,
// and its own Cancel and Escape close it.
draggable={false}
showCloseButton={false}
data-slot="command-palette"
data-variant="sheet"
className="h-dvh max-h-dvh gap-0 rounded-none p-0 pt-[env(safe-area-inset-top,0px)]"
>
<SheetTitle className="sr-only">{title}</SheetTitle>
<SheetDescription className="sr-only">{description}</SheetDescription>
{body}
</SheetContent>
</Sheet>
);
}
return (
<Dialog open={open} onOpenChange={setCommandPaletteOpen}>
<DialogContent
showCloseButton={false}
data-slot="command-palette"
data-variant="dialog"
className="top-[12vh] flex translate-y-0 flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<DialogDescription className="sr-only">{description}</DialogDescription>
{body}
</DialogContent>
</Dialog>
);
}
export function PaletteBody({ isMobile }: { isMobile: boolean }) {
const { t } = useTranslations();
const router = useRouter();
const { user } = useAuth();
const today = useTodayKey();
const coachLaunch = useCoachLaunch();
const index = usePaletteIndex();
const toggle = useModuleToggle();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const [recentIds] = useState(() => readRecent(user?.id));
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const sections = useMemo(
() => arrangeResults(query, index, recentIds),
[query, index, recentIds],
);
const flat = useMemo(
() => sections.flatMap((section) => section.entries),
[sections],
);
const records = useRecordSearch(query);
const recordHits = useMemo(
() => records.data?.groups.flatMap((group) => group.hits) ?? [],
[records.data],
);
const showRecords = records.query !== null;
const shownHits = showRecords ? recordHits : [];
const total = flat.length + shownHits.length;
const activeIndex = total === 0 ? -1 : Math.min(active, total - 1);
const optionId = (i: number) => `${listId}-option-${i}`;
// Keep the highlighted option in view as the arrows move through a list
// taller than the panel.
useEffect(() => {
if (activeIndex < 0) return;
document
.getElementById(optionId(activeIndex))
?.scrollIntoView({ block: "nearest" });
// `optionId` is derived from `listId`, which is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex]);
function run(entry: PaletteEntry) {
const target = entry.run;
if (target.kind === "module") {
// A switch: flip it where it is, and stay.
toggle.mutate({ key: target.module, enabled: !target.enabled });
return;
}
setCommandPaletteOpen(false);
if (target.kind === "href") {
pushRecent(user?.id, entry.id);
router.push(target.href);
const { pathname, hash } = hashOf(target.href);
if (hash) scrollToAnchorWhenReady(hash, { pathname });
return;
}
switch (target.action) {
case "capture":
openCapturePicker();
return;
case "coach":
if (coachLaunch) coachLaunch.askCoach();
else router.push("/coach");
return;
case "backup": {
router.push("/settings/export#full-backup");
scrollToAnchorWhenReady("full-backup", {
pathname: "/settings/export",
});
return;
}
case "today":
openDay(today);
return;
case "shortcuts":
openShortcutsHelp();
return;
}
}
function openRecord(path: string) {
setCommandPaletteOpen(false);
router.push(path);
}
function runAt(index: number) {
if (index < 0) return;
if (index < flat.length) {
run(flat[index]);
return;
}
const hit = shownHits[index - flat.length];
if (hit) openRecord(hit.path);
}
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.nativeEvent.isComposing) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (total === 0) return;
const step = event.key === "ArrowDown" ? 1 : -1;
setActive((activeIndex + step + total) % total);
return;
}
if (event.key === "Enter") {
event.preventDefault();
runAt(activeIndex);
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setCommandPaletteOpen(false);
}
}
// Each section's first position in the flat option list.
const starts = sections.reduce<number[]>(
(acc, section, i) => [
...acc,
i === 0 ? 0 : acc[i - 1] + sections[i - 1].entries.length,
],
[],
);
return (
<div
className={cn(
"flex min-h-0 flex-col",
isMobile ? "h-full" : "max-h-[min(70dvh,34rem)]",
)}
>
<div className="border-border flex shrink-0 items-center gap-2 border-b px-4">
<Search
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<input
ref={inputRef}
autoFocus
type="text"
role="combobox"
aria-expanded={total > 0}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
activeIndex >= 0 ? optionId(activeIndex) : undefined
}
aria-label={t("palette.title")}
data-slot="command-palette-input"
placeholder={t("palette.placeholder")}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={onKeyDown}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
enterKeyHint="go"
className="placeholder:text-muted-foreground h-14 min-w-0 flex-1 bg-transparent text-base outline-none md:pointer-fine:text-sm"
/>
{isMobile ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 shrink-0"
onClick={() => setCommandPaletteOpen(false)}
>
{t("common.cancel")}
</Button>
) : null}
</div>
<div
id={listId}
role="listbox"
aria-label={t("palette.results")}
data-slot="command-palette-results"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2"
>
{sections.length === 0 &&
(!showRecords ||
(!records.loading && !records.error && shownHits.length === 0)) ? (
<p
data-slot="command-palette-empty"
className="text-muted-foreground px-3 py-8 text-center text-sm"
>
{t("palette.noResults", { query: query.trim() })}
</p>
) : (
sections.map((section, sectionIndex) => {
const headingId = `${listId}-group-${section.group}`;
return (
<div
key={section.group}
role="group"
aria-labelledby={headingId}
data-group={section.group}
className="pb-1"
>
<div
id={headingId}
className="text-muted-foreground px-3 pt-3 pb-1 text-xs font-medium tracking-wide uppercase"
>
{t(`palette.groups.${section.group}`)}
</div>
{section.entries.map((entry, entryIndex) => {
const i = starts[sectionIndex] + entryIndex;
const selected = i === activeIndex;
const Icon = entry.icon;
const isModule = entry.run.kind === "module";
const on = entry.run.kind === "module" && entry.run.enabled;
return (
<div
key={entry.id}
id={optionId(i)}
role="option"
aria-selected={selected}
aria-checked={isModule ? on : undefined}
data-entry={entry.id}
onMouseMove={() => {
if (!selected) setActive(i);
}}
onMouseDown={(event) => {
// Keep focus in the field; the click runs the entry.
event.preventDefault();
}}
onClick={() => run(entry)}
className={cn(
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm",
selected && "bg-accent text-accent-foreground",
)}
>
<Icon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate">
{entry.title}
</span>
{entry.hint ? (
<span className="text-muted-foreground max-w-[40%] shrink-0 truncate text-xs">
{entry.hint}
</span>
) : null}
{isModule ? (
<span className="flex shrink-0 items-center gap-2">
<span className="text-muted-foreground text-xs">
{on
? t("palette.moduleOn")
: t("palette.moduleOff")}
</span>
<span
aria-hidden="true"
data-state={on ? "checked" : "unchecked"}
className="data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-off inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full p-px"
>
{/* The Switch primitive's thumb, drawn: an
option holds no control of its own. */}
<span
className={cn(
"bg-background block size-4 rounded-full transition-transform motion-reduce:transition-none",
on
? "dark:bg-primary-foreground translate-x-[calc(100%-2px)]"
: "dark:bg-foreground",
)}
/>
</span>
</span>
) : null}
</div>
);
})}
</div>
);
})
)}
{showRecords &&
!(
sections.length === 0 &&
!records.loading &&
!records.error &&
shownHits.length === 0
) ? (
<RecordSection
headingId={`${listId}-group-records`}
hits={shownHits}
firstIndex={flat.length}
activeIndex={activeIndex}
optionId={optionId}
loading={records.loading}
error={records.error}
truncated={records.data?.truncated === true}
query={records.query ?? ""}
onHover={setActive}
onOpen={openRecord}
/>
) : null}
</div>
{isMobile ? null : (
<div
aria-hidden="true"
className="border-border text-muted-foreground flex shrink-0 items-center gap-4 border-t px-4 py-2 text-xs"
>
<span className="flex items-center gap-1.5">
<kbd className="border-border bg-muted text-foreground rounded border px-1.5 font-mono">
↑↓
</kbd>
{t("palette.hintMove")}
</span>
<span className="flex items-center gap-1.5">
<kbd className="border-border bg-muted text-foreground rounded border px-1.5 font-mono">
↵
</kbd>
{t("palette.hintOpen")}
</span>
<span className="flex items-center gap-1.5">
<kbd className="border-border bg-muted text-foreground rounded border px-1.5 font-mono">
Esc
</kbd>
{t("palette.hintClose")}
</span>
</div>
)}
</div>
);
}
/**
* "In your records": the server's hits after the local results. A status line
* (searching, nothing found, failed, older entries not read) is plain text,
* not an option, so the arrows skip it.
*/
function RecordSection({
headingId,
hits,
firstIndex,
activeIndex,
optionId,
loading,
error,
truncated,
query,
onHover,
onOpen,
}: {
headingId: string;
hits: ReadonlyArray<SearchHit>;
firstIndex: number;
activeIndex: number;
optionId: (i: number) => string;
loading: boolean;
error: boolean;
truncated: boolean;
query: string;
onHover: (i: number) => void;
onOpen: (path: string) => void;
}) {
const { t } = useTranslations();
const status = error
? t("palette.records.error")
: hits.length === 0
? loading
? t("palette.records.loading")
: t("palette.records.empty", { query })
: null;
return (
<div
role="group"
aria-labelledby={headingId}
aria-busy={loading}
data-group="records"
className="pb-1"
>
<div
id={headingId}
className="text-muted-foreground px-3 pt-3 pb-1 text-xs font-medium tracking-wide uppercase"
>
{t("palette.groups.records")}
</div>
{hits.map((hit, n) => {
const i = firstIndex + n;
const selected = i === activeIndex;
return (
<div
key={hit.id}
id={optionId(i)}
role="option"
aria-selected={selected}
data-entry={`record:${hit.id}`}
data-kind={hit.kind}
onMouseMove={() => {
if (!selected) onHover(i);
}}
onMouseDown={(event) => {
// Keep focus in the field; the click opens the record.
event.preventDefault();
}}
onClick={() => onOpen(hit.path)}
className={cn(
"group flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm",
selected && "bg-accent text-accent-foreground",
)}
>
<RecordHitContent hit={hit} />
</div>
);
})}
{status ? (
<p
data-slot="command-palette-records-status"
aria-live="polite"
className="text-muted-foreground px-3 py-2 text-xs"
>
{status}
</p>
) : null}
{truncated && !error ? (
<p
data-slot="command-palette-records-truncated"
className="text-muted-foreground px-3 pt-1 pb-2 text-xs"
>
{t("palette.records.truncated")}
</p>
) : null}
</div>
);
}