Files
silo-server/web/src/components/AdminSectionCommandDialog.tsx
T

250 lines
8.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import { VisuallyHidden } from "radix-ui";
import { useNavigate } from "react-router";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import {
countSettingsSearchItems,
filterSettingsSearchEntries,
filterSettingsSearchGroups,
} from "@/components/settings/settingsSearch";
import { navigateToPluginRoute } from "@/lib/buildPluginHref";
import type { AdminNavGroup, AdminNavItem } from "@/lib/adminNavigation";
import { cn } from "@/lib/utils";
interface AdminSectionCommandDialogProps {
sections: readonly AdminNavGroup[];
}
export function AdminSectionCommandDialog({ sections }: AdminSectionCommandDialogProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const filteredSections = useMemo(
() => filterSettingsSearchGroups(sections, query),
[query, sections],
);
const results = useMemo(
() => filteredSections.flatMap((section) => section.items),
[filteredSections],
);
const resultIndexByHref = useMemo(
() => new Map(results.map((item, index) => [item.href, index])),
[results],
);
const totalCount = countSettingsSearchItems(sections);
const resultCount = results.length;
const selectedResult = selectedIndex >= 0 ? results[selectedIndex] : undefined;
const focusSearch = useCallback(() => {
const focus = () => {
inputRef.current?.focus();
inputRef.current?.select();
};
if (typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(focus);
return;
}
window.setTimeout(focus, 0);
}, []);
const closeDialog = useCallback(() => {
setOpen(false);
setQuery("");
setSelectedIndex(0);
}, []);
const openDialog = useCallback(() => {
setOpen(true);
focusSearch();
}, [focusSearch]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return;
if (event.key.toLowerCase() !== "k") return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
openDialog();
};
window.addEventListener("keydown", onKeyDown, { capture: true });
return () => window.removeEventListener("keydown", onKeyDown, { capture: true });
}, [openDialog]);
const pickResult = useCallback(
(item: AdminNavItem) => {
closeDialog();
if (item.external) {
void navigateToPluginRoute(item.href);
return;
}
navigate(item.href);
},
[closeDialog, navigate],
);
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((current) => (resultCount === 0 ? -1 : (current + 1) % resultCount));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((current) =>
resultCount === 0 ? -1 : current <= 0 ? resultCount - 1 : current - 1,
);
} else if (event.key === "Enter") {
if (!selectedResult) return;
event.preventDefault();
pickResult(selectedResult);
} else if (event.key === "Escape") {
event.preventDefault();
closeDialog();
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
openDialog();
return;
}
closeDialog();
}}
>
<DialogContent
className="top-[18%] max-h-[min(34rem,calc(100dvh-4rem))] translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-xl"
showCloseButton={false}
>
<VisuallyHidden.Root>
<DialogTitle>Search admin sections</DialogTitle>
<DialogDescription>Search and open admin sections.</DialogDescription>
</VisuallyHidden.Root>
<div className="border-border flex h-12 items-center border-b px-4">
<Search className="text-muted-foreground mr-3 h-4 w-4 shrink-0" aria-hidden="true" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleInputKeyDown}
placeholder="Search admin sections..."
aria-label="Search admin sections"
aria-activedescendant={selectedResult ? resultId(selectedResult.href) : undefined}
className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent text-sm outline-none"
autoComplete="off"
autoFocus
/>
<kbd className="bg-muted text-muted-foreground pointer-events-none ml-3 hidden rounded border px-1.5 py-0.5 text-[10px] font-medium select-none sm:inline-flex">
ESC
</kbd>
</div>
<div className="max-h-[min(25rem,58vh)] overflow-y-auto overscroll-contain p-2">
{filteredSections.length > 0 ? (
<div role="listbox" aria-label="Admin sections" className="space-y-3">
{filteredSections.map((section) => (
<div key={section.label}>
<div className="text-muted-foreground px-2 pb-1 text-xs font-medium">
{section.label}
</div>
<div className="space-y-1">
{section.items.map((item) => {
const index = resultIndexByHref.get(item.href) ?? -1;
return (
<AdminCommandResultRow
key={item.href}
item={item}
query={query}
selected={index === selectedIndex}
onMouseEnter={() => setSelectedIndex(index)}
onPick={() => pickResult(item)}
/>
);
})}
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground px-3 py-6 text-center text-sm">
No matching admin sections
</p>
)}
</div>
<div className="text-muted-foreground border-border border-t px-4 py-2 text-xs">
{query.trim()
? `${resultCount} ${resultCount === 1 ? "match" : "matches"}`
: `${totalCount} admin sections`}
</div>
</DialogContent>
</Dialog>
);
}
function AdminCommandResultRow({
item,
query,
selected,
onMouseEnter,
onPick,
}: {
item: AdminNavItem;
query: string;
selected: boolean;
onMouseEnter: () => void;
onPick: () => void;
}) {
const Icon = item.icon;
const matchingSettings = filterSettingsSearchEntries(item.settings, query).slice(0, 3);
return (
<button
id={resultId(item.href)}
type="button"
role="option"
aria-selected={selected}
data-selected={selected || undefined}
onMouseEnter={onMouseEnter}
onClick={onPick}
className={cn(
"focus-visible:ring-ring/50 flex w-full items-start gap-3 rounded-md px-3 py-2 text-left transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/70",
)}
>
<span className="text-muted-foreground mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="text-foreground block text-sm font-medium">{item.label}</span>
{item.description ? (
<span className="text-muted-foreground mt-0.5 block text-xs leading-relaxed">
{item.description}
</span>
) : null}
{matchingSettings.length > 0 ? (
<span className="text-muted-foreground mt-1 block text-xs">
{matchingSettings.map((setting) => setting.label).join(", ")}
</span>
) : null}
</span>
</button>
);
}
function resultId(href: string) {
return `admin-command-${href.replace(/[^a-z0-9]+/gi, "-")}`;
}