350 lines
9.1 KiB
TypeScript
350 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
type KeyboardEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import type {
|
|
HousekeepingSearchResult,
|
|
HousekeepingSurface,
|
|
} from "../contracts";
|
|
import { toHousekeepingHref } from "../routing/href";
|
|
import type {
|
|
HousekeepingCommandHit,
|
|
HousekeepingNavigationHit,
|
|
} from "../search/navigation-search";
|
|
import type { HousekeepingSearchResponse } from "../search/search-service";
|
|
|
|
export interface CommandDeckLabels {
|
|
readonly trigger: string;
|
|
readonly placeholder: string;
|
|
readonly navigation: string;
|
|
readonly commands: string;
|
|
readonly entities: string;
|
|
readonly loading: string;
|
|
readonly empty: string;
|
|
readonly partial: string;
|
|
readonly close: string;
|
|
}
|
|
|
|
export type HousekeepingSearchExecutor = (
|
|
term: string,
|
|
) => Promise<HousekeepingSearchResponse>;
|
|
|
|
type CommandDeckHit =
|
|
| HousekeepingNavigationHit
|
|
| HousekeepingCommandHit
|
|
| HousekeepingSearchResult;
|
|
type CommandDeckGroup = "navigation" | "commands" | "entities";
|
|
export type CommandDeckKeyboardAction =
|
|
| "close"
|
|
| "next"
|
|
| "previous"
|
|
| "activate"
|
|
| null;
|
|
|
|
export interface CommandDeckEntry {
|
|
readonly group: CommandDeckGroup;
|
|
readonly value: string;
|
|
readonly hit: CommandDeckHit;
|
|
}
|
|
|
|
const EMPTY_RESPONSE: HousekeepingSearchResponse = {
|
|
navigation: [],
|
|
commands: [],
|
|
entities: [],
|
|
errors: [],
|
|
correlationId: "initial-search",
|
|
};
|
|
|
|
export function isCommandDeckShortcut(input: {
|
|
readonly key: string;
|
|
readonly ctrlKey: boolean;
|
|
readonly metaKey: boolean;
|
|
readonly altKey?: boolean;
|
|
readonly shiftKey?: boolean;
|
|
}): boolean {
|
|
return (
|
|
input.key.toLocaleLowerCase() === "k" &&
|
|
(input.ctrlKey || input.metaKey) &&
|
|
input.altKey !== true &&
|
|
input.shiftKey !== true
|
|
);
|
|
}
|
|
|
|
export function commandDeckKeyAction(
|
|
key: string,
|
|
open: boolean,
|
|
): CommandDeckKeyboardAction {
|
|
if (!open) return null;
|
|
if (key === "Escape") return "close";
|
|
if (key === "ArrowDown") return "next";
|
|
if (key === "ArrowUp") return "previous";
|
|
if (key === "Enter") return "activate";
|
|
return null;
|
|
}
|
|
|
|
export function nextCommandDeckIndex(
|
|
current: number,
|
|
count: number,
|
|
direction: "next" | "previous",
|
|
): number {
|
|
if (count <= 0) return -1;
|
|
if (direction === "next") return (current + 1 + count) % count;
|
|
return (current - 1 + count) % count;
|
|
}
|
|
|
|
export function restoreCommandDeckFocus(
|
|
target: {
|
|
focus(): void;
|
|
} | null,
|
|
): void {
|
|
target?.focus();
|
|
}
|
|
|
|
export function projectHousekeepingSearchHref(
|
|
href: HousekeepingNavigationHit["href"],
|
|
surface: HousekeepingSurface,
|
|
) {
|
|
return toHousekeepingHref(href, surface);
|
|
}
|
|
|
|
export function flattenHousekeepingSearchResults(
|
|
response: HousekeepingSearchResponse,
|
|
): readonly CommandDeckEntry[] {
|
|
const group = (
|
|
name: CommandDeckGroup,
|
|
hits: readonly CommandDeckHit[],
|
|
): readonly CommandDeckEntry[] =>
|
|
hits.map((hit) => ({
|
|
group: name,
|
|
value: `${name}:${hit.domain}:${hit.id}`,
|
|
hit,
|
|
}));
|
|
return [
|
|
...group("navigation", response.navigation),
|
|
...group("commands", response.commands),
|
|
...group("entities", response.entities),
|
|
];
|
|
}
|
|
|
|
export function CommandDeck({
|
|
labels,
|
|
search,
|
|
surface,
|
|
}: {
|
|
readonly labels: CommandDeckLabels;
|
|
readonly search: HousekeepingSearchExecutor;
|
|
readonly surface: HousekeepingSurface;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [term, setTerm] = useState("");
|
|
const [response, setResponse] =
|
|
useState<HousekeepingSearchResponse>(EMPTY_RESPONSE);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const requestSequence = useRef(0);
|
|
const wasOpen = useRef(false);
|
|
const entries = useMemo(
|
|
() => flattenHousekeepingSearchResults(response),
|
|
[response],
|
|
);
|
|
|
|
const close = useCallback(() => setOpen(false), []);
|
|
|
|
useEffect(() => {
|
|
const onShortcut = (event: globalThis.KeyboardEvent) => {
|
|
if (!isCommandDeckShortcut(event)) return;
|
|
event.preventDefault();
|
|
setOpen((current) => !current);
|
|
};
|
|
document.addEventListener("keydown", onShortcut);
|
|
return () => document.removeEventListener("keydown", onShortcut);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
wasOpen.current = true;
|
|
inputRef.current?.focus();
|
|
return;
|
|
}
|
|
if (wasOpen.current) {
|
|
wasOpen.current = false;
|
|
restoreCommandDeckFocus(triggerRef.current);
|
|
}
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const sequence = ++requestSequence.current;
|
|
let active = true;
|
|
setLoading(true);
|
|
const timer = setTimeout(() => {
|
|
search(term)
|
|
.then((nextResponse) => {
|
|
if (active && sequence === requestSequence.current) {
|
|
setResponse(nextResponse);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (active && sequence === requestSequence.current) {
|
|
setResponse({
|
|
...EMPTY_RESPONSE,
|
|
errors: [{ providerId: "search", code: "INTERNAL" }],
|
|
correlationId: "client-search",
|
|
});
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (active && sequence === requestSequence.current) setLoading(false);
|
|
});
|
|
}, 120);
|
|
return () => {
|
|
active = false;
|
|
clearTimeout(timer);
|
|
};
|
|
}, [open, search, term]);
|
|
|
|
useEffect(() => {
|
|
setSelectedIndex(entries.length === 0 ? -1 : 0);
|
|
}, [entries.length]);
|
|
|
|
const activate = useCallback(
|
|
(entry: CommandDeckEntry) => {
|
|
const href = projectHousekeepingSearchHref(entry.hit.href, surface);
|
|
close();
|
|
window.location.assign(href);
|
|
},
|
|
[close, surface],
|
|
);
|
|
|
|
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
const action = commandDeckKeyAction(event.key, open);
|
|
if (action === null) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (action === "close") {
|
|
close();
|
|
return;
|
|
}
|
|
if (action === "activate") {
|
|
const selected = entries[selectedIndex];
|
|
if (selected) activate(selected);
|
|
return;
|
|
}
|
|
setSelectedIndex((current) =>
|
|
nextCommandDeckIndex(current, entries.length, action),
|
|
);
|
|
};
|
|
|
|
const selectedValue = entries[selectedIndex]?.value ?? "";
|
|
const renderGroup = (group: CommandDeckGroup, heading: string) => {
|
|
const groupedEntries = entries.filter((entry) => entry.group === group);
|
|
if (groupedEntries.length === 0) return null;
|
|
return (
|
|
<CommandGroup heading={heading}>
|
|
{groupedEntries.map((entry) => (
|
|
<CommandItem
|
|
key={entry.value}
|
|
value={entry.value}
|
|
onSelect={() => activate(entry)}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{entry.hit.title}</span>
|
|
<span className="text-xs text-[var(--admin-text-muted)]">
|
|
{entry.hit.domain}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen(true)}
|
|
className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
|
>
|
|
<span>{labels.trigger}</span>
|
|
<kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd>
|
|
</button>
|
|
{open ? (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={labels.trigger}
|
|
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-2 sm:p-4 pt-4 sm:pt-[10vh]"
|
|
>
|
|
<div className="max-h-[calc(100dvh-2rem)] min-w-0 w-full max-w-2xl overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
|
|
<div className="flex justify-end border-b border-[var(--admin-border)] p-2">
|
|
<button
|
|
type="button"
|
|
onClick={close}
|
|
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
|
>
|
|
{labels.close}
|
|
</button>
|
|
</div>
|
|
<Command
|
|
shouldFilter={false}
|
|
value={selectedValue}
|
|
onValueChange={(value) => {
|
|
const index = entries.findIndex(
|
|
(entry) => entry.value === value,
|
|
);
|
|
if (index >= 0) setSelectedIndex(index);
|
|
}}
|
|
onKeyDownCapture={onKeyDown}
|
|
>
|
|
<CommandInput
|
|
ref={inputRef}
|
|
value={term}
|
|
onValueChange={setTerm}
|
|
placeholder={labels.placeholder}
|
|
aria-label={labels.placeholder}
|
|
/>
|
|
<CommandList
|
|
aria-busy={loading}
|
|
className="min-w-0 overflow-y-auto"
|
|
>
|
|
{loading ? <p role="status">{labels.loading}</p> : null}
|
|
{response.errors.length > 0 ? (
|
|
<p
|
|
role="alert"
|
|
aria-live="assertive"
|
|
className="px-3 py-2 text-sm"
|
|
>
|
|
{labels.partial}
|
|
</p>
|
|
) : null}
|
|
{!loading ? <CommandEmpty>{labels.empty}</CommandEmpty> : null}
|
|
{renderGroup("navigation", labels.navigation)}
|
|
{renderGroup("commands", labels.commands)}
|
|
{renderGroup("entities", labels.entities)}
|
|
</CommandList>
|
|
</Command>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|