Files
smart-org/apps/web/src/components/CommandPalette.tsx
T
mjalmoudy94 df2c8546db افزودن سیستم Motion به تمام بخش‌های رابط کاربری
واژگان مشترک حرکت در globals.css: شش @keyframes و شش کلاس .anim-*
به‌همراه .anim-stagger برای ورود آبشاری. همهٔ مدت‌زمان‌ها پشت custom
property هستند تا یک override کل اپ را تنظیم کند.

- packages/ui: Button، Card، Stat، Loading/Empty/ErrorState، UsageBar،
  Timeline، Modal، Drawer، Table، ToastStack و ArtifactViewer
- ریشهٔ هر صفحهٔ (app) با .anim-stagger وارد می‌شود
- AppShell: محتوا fade، منوی کنار stagger، منوهای کشویی slide-up
- prefers-reduced-motion مدت‌زمان‌ها را به ۰.۰۱ms می‌رساند تا
  وضعیت پایانی حفظ شود
- قرارداد motion در src/test/motion.test.tsx تست می‌شود تا
  بازنویسی بعدی آن را بی‌صدا حذف نکند
- تست‌های فرانت‌اند: ۳۵ ← ۴۵
2026-10-13 02:48:59 +03:30

207 lines
7.0 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import * as React from "react";
import { EmptyState, Modal, cx } from "@agy/ui";
import { ADMIN_ITEMS, NAV_ITEMS, type NavItem } from "@/lib/nav";
/**
* Command palette over the app's destinations.
*
* PLAN §12.3 asks for a command palette so power users can move between
* sections without hunting through the sidebar. It is a client-side filter
* over a fixed route list — the app has no dynamic search surface yet, so a
* round trip would buy nothing and would break while offline.
*
* The platform-owner group is passed in rather than read from the session so
* this component stays free of provider setup and is renderable in isolation.
*/
/** ``⌘K`` on macOS, ``Ctrl+K`` everywhere else. */
function isPaletteShortcut(event: KeyboardEvent): boolean {
return (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "k"
);
}
interface PalletGroup {
title: string;
items: NavItem[];
}
function buildGroups(isPlatformOwner: boolean): PalletGroup[] {
const groups: PalletGroup[] = [{ title: "بخش‌ها", items: NAV_ITEMS }];
if (isPlatformOwner) {
groups.push({ title: "کنسول مالک سامانه", items: ADMIN_ITEMS });
}
return groups;
}
function matches(item: NavItem, needle: string): boolean {
return (
item.label.toLowerCase().includes(needle) ||
item.hint.toLowerCase().includes(needle) ||
item.href.includes(needle)
);
}
export function CommandPalette({ isPlatformOwner = false }: { isPlatformOwner?: boolean }) {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const groups = React.useMemo(() => buildGroups(isPlatformOwner), [isPlatformOwner]);
const filteredGroups = React.useMemo(() => {
const needle = query.trim().toLowerCase();
const keep = (item: NavItem) => !needle || matches(item, needle);
return groups
.map((group) => ({ title: group.title, items: group.items.filter(keep) }))
.filter((group) => group.items.length > 0);
}, [groups, query]);
/**
* Keyboard navigation walks the flattened, visible item list. `indexOf` is
* safe here because both lists hold the same module-level object references.
*/
const visible = React.useMemo(
() => filteredGroups.flatMap((group) => group.items),
[filteredGroups],
);
// Toggle the palette from anywhere on the page without stealing focus.
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!isPaletteShortcut(event)) return;
event.preventDefault();
setQuery("");
setActive(0);
setOpen((currentlyOpen) => !currentlyOpen);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Focus the input once the dialog is mounted, so typing starts immediately.
React.useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// A narrowed list must not leave the highlight off the end.
React.useEffect(() => {
setActive(0);
}, [query]);
const close = React.useCallback(() => {
setOpen(false);
setQuery("");
}, []);
const go = React.useCallback(
(href: string) => {
close();
router.push(href);
},
[close, router],
);
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (visible.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActive((index) => (index + 1) % visible.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive((index) => (index - 1 + visible.length) % visible.length);
} else if (event.key === "Enter") {
const target = visible[active];
if (target) {
event.preventDefault();
go(target.href);
}
}
};
return (
<>
<button
type="button"
onClick={() => {
setQuery("");
setActive(0);
setOpen(true);
}}
aria-label="جست‌وجو و رفتن سریع"
className="hidden items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-2.5 py-1.5 text-xs text-slate-400 transition-colors hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 md:flex"
>
<span aria-hidden>⌕</span>
<span>جست‌وجو…</span>
<kbd className="rounded border border-surface-border px-1 text-[10px] text-slate-500">
⌘K
</kbd>
</button>
<Modal
open={open}
title="جست‌وجو و رفتن سریع"
description="با کلیدهای بالا و پایین جابه‌جایی کنید و Enter برای رفتن."
onClose={close}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onInputKeyDown}
placeholder="نام بخش…"
aria-label="جست‌وجوی بخش‌ها"
className="h-10 w-full rounded-lg border border-surface-border bg-surface-overlay px-3 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
<div className="mt-3 flex max-h-80 flex-col gap-3 overflow-y-auto">
{filteredGroups.length === 0 ? (
<EmptyState title="نتیجه‌ای پیدا نشد" description="عبارت دیگری را امتحان کنید." />
) : (
filteredGroups.map((group) => (
<div key={group.title} className="anim-stagger flex flex-col gap-1">
<p className="px-1 text-[11px] font-medium text-slate-500">{group.title}</p>
{group.items.map((item) => {
const index = visible.indexOf(item);
const isActive = index === active;
return (
<button
key={item.href}
type="button"
onMouseEnter={() => setActive(index)}
onClick={() => go(item.href)}
className={cx(
"flex items-center justify-between gap-2 rounded-md px-2 py-2 text-start",
isActive
? "bg-accent-soft text-slate-100"
: "text-slate-300 hover:bg-surface-raised",
)}
>
<span className="text-sm">{item.label}</span>
<span className="text-xs text-slate-500">{item.hint}</span>
</button>
);
})}
</div>
))
)}
</div>
</Modal>
</>
);
}