Files
lms-sb/src/components/admin/admin-command-menu.tsx
T
adminsandClaude Opus 5.5 ba6da38b9c Keep Tab inside the admin command menu
The search input is the only tabbable element in the menu, and Base UI's
focus guards let Tab leak out of a single-element trap: focus walked to
the sidebar while the menu stayed open. Arrow keys drive the selection
in a command menu, so Tab now stays on the input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:11:19 +05:00

278 lines
11 KiB
TypeScript

"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import { Command } from "cmdk";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { Search } from "lucide-react";
import { adminLinks } from "@/components/admin/admin-nav";
interface SearchResults {
users: { id: string; name: string; email: string; role: string }[];
courses: { id: string; title: string; slug: string; published: boolean }[];
lessons: {
id: string;
title: string;
published: boolean;
moduleId: string;
courseId: string;
courseTitle: string;
}[];
}
const EMPTY: SearchResults = { users: [], courses: [], lessons: [] };
const MIN_QUERY = 2;
const DEBOUNCE_MS = 200;
const ROLE_LABEL: Record<string, string> = {
student: "студент",
curator: "куратор",
admin: "админ",
};
const noopSubscribe = () => () => {};
function useShortcutLabel() {
return useSyncExternalStore(
noopSubscribe,
() => (/Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl K"),
() => "Ctrl K",
);
}
// DS-2: hard-edged group headings, accent highlight for the active row.
const groupClass =
"mb-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-group-heading]]:pb-1 " +
"[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-bold [&_[cmdk-group-heading]]:uppercase " +
"[&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group-heading]]:text-[var(--muted-foreground)]";
const itemClass =
"flex min-h-[44px] cursor-pointer items-center gap-3 border-2 border-transparent px-2 py-1.5 text-sm " +
"data-[selected=true]:border-[var(--foreground)] data-[selected=true]:bg-[var(--accent)]";
export function AdminCommandMenu({ onOpen }: { onOpen?: () => void }) {
const router = useRouter();
const shortcut = useShortcutLabel();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults>(EMPTY);
const [loading, setLoading] = useState(false);
// Global hotkey: Ctrl+K / ⌘K toggles the menu from anywhere in the admin.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Debounced server lookup; stale responses are aborted.
const trimmed = query.trim();
useEffect(() => {
if (trimmed.length < MIN_QUERY) return;
const controller = new AbortController();
const timer = setTimeout(() => {
setLoading(true);
fetch(`/api/admin/search?q=${encodeURIComponent(trimmed)}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
.then((data: SearchResults) => setResults(data))
.catch((e: unknown) => {
if ((e as Error).name !== "AbortError") {
console.error("admin search failed", e);
setResults(EMPTY);
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [trimmed]);
const active = trimmed.length >= MIN_QUERY;
const shown = active ? results : EMPTY;
const showLoading = active && loading;
const needle = trimmed.toLowerCase();
const sections = needle
? adminLinks.filter((l) => l.label.toLowerCase().includes(needle))
: adminLinks;
function go(href: string) {
setOpen(false);
setQuery("");
setResults(EMPTY);
router.push(href);
}
return (
<>
<button
type="button"
onClick={() => {
onOpen?.();
setOpen(true);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors hover:bg-[var(--sidebar-surface)] hover:text-[#F5F5F0]"
style={{ border: "2px solid var(--sidebar-border)", color: "var(--sidebar-text)" }}
aria-label="Поиск по админке"
>
<Search size={14} aria-hidden />
<span className="flex-1 text-left">Поиск</span>
<kbd className="text-[0.65rem] tracking-widest" style={{ fontFamily: "inherit" }}>
{shortcut}
</kbd>
</button>
<DialogPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery("");
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop
className="fixed inset-0 z-50"
style={{ background: "rgba(50,50,50,0.45)" }}
/>
<DialogPrimitive.Popup
className="fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 outline-none"
style={{
background: "var(--background)",
color: "var(--foreground)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
<DialogPrimitive.Title className="sr-only">Поиск по админке</DialogPrimitive.Title>
<Command
shouldFilter={false}
label="Поиск по админке"
loop
// The input is the only tabbable element here, and Base UI's focus
// guards let Tab leak out of a single-element trap (seen live:
// focus walked to the sidebar while the menu stayed open). In a
// command menu arrows move the selection, so Tab simply stays put.
onKeyDown={(e) => {
if (e.key === "Tab") e.preventDefault();
}}
>
<div
className="flex items-center gap-2 px-3"
style={{ borderBottom: "2px solid var(--border)" }}
>
<Search size={16} aria-hidden style={{ color: "var(--muted-foreground)" }} />
<Command.Input
value={query}
onValueChange={setQuery}
placeholder="Студент, курс, урок или раздел…"
className="h-12 flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--muted-foreground)]"
/>
{showLoading && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
ищем…
</span>
)}
</div>
<Command.List className="max-h-[60vh] overflow-y-auto p-2">
<Command.Empty className="px-2 py-6 text-center text-sm" style={{ color: "var(--muted-foreground)" }}>
{!active ? "Введите хотя бы два символа" : showLoading ? "Ищем…" : "Ничего не нашлось"}
</Command.Empty>
{shown.users.length > 0 && (
<Command.Group heading="Студенты и сотрудники" className={groupClass}>
{shown.users.map((u) => (
<Command.Item
key={u.id}
value={`user:${u.id}`}
onSelect={() => go(`/admin/users/${u.id}`)}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{u.name}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{u.email}
</span>
</span>
{u.role !== "student" && (
<span className="tag-aubade">{ROLE_LABEL[u.role] ?? u.role}</span>
)}
</Command.Item>
))}
</Command.Group>
)}
{shown.courses.length > 0 && (
<Command.Group heading="Курсы" className={groupClass}>
{shown.courses.map((c) => (
<Command.Item
key={c.id}
value={`course:${c.id}`}
onSelect={() => go(`/admin/courses/${c.id}`)}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{c.title}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{c.slug}
</span>
</span>
{!c.published && <span className="tag-aubade">черновик</span>}
</Command.Item>
))}
</Command.Group>
)}
{shown.lessons.length > 0 && (
<Command.Group heading="Уроки" className={groupClass}>
{shown.lessons.map((l) => (
<Command.Item
key={l.id}
value={`lesson:${l.id}`}
onSelect={() =>
go(`/admin/courses/${l.courseId}/modules/${l.moduleId}/lessons/${l.id}`)
}
className={itemClass}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{l.title}</span>
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
{l.courseTitle}
</span>
</span>
{!l.published && <span className="tag-aubade">черновик</span>}
</Command.Item>
))}
</Command.Group>
)}
{sections.length > 0 && (
<Command.Group heading="Разделы" className={groupClass}>
{sections.map((l) => (
<Command.Item
key={l.href}
value={`section:${l.href}`}
onSelect={() => go(l.href)}
className={itemClass}
>
{l.label}
</Command.Item>
))}
</Command.Group>
)}
</Command.List>
</Command>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</>
);
}