feat(housekeeping): add global command deck search
This commit is contained in:
1 parent
2e95a106e0
commit
bcb0ca977f
18 files changed
+1353
-23
No files matched your search
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { executeHousekeepingSearch } from "@/actions/housekeeping-search";
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
surface,
|
||||
}: {
|
||||
readonly labels: CommandDeckLabels;
|
||||
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(() => {
|
||||
executeHousekeepingSearch(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, 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 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)]"
|
||||
>
|
||||
<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-4 pt-[10vh]"
|
||||
>
|
||||
<div className="w-full max-w-2xl 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)]"
|
||||
>
|
||||
{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}>
|
||||
{loading ? <p role="status">{labels.loading}</p> : null}
|
||||
{response.errors.length > 0 ? (
|
||||
<p role="status" 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user