Files
EpicNext-Cms/src/features/housekeeping/foundation/shell/command-deck.tsx
T
Simo 8a31556d51
CI / check (pull_request) Successful in 1m9s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
test(housekeeping): prove 137 route parity
2026-08-30 19:11:14 +02:00

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