xpeditis2.0/apps/frontend/src/components/shell/command-menu.tsx
2026-08-13 17:43:11 +02:00

200 lines
6.7 KiB
TypeScript

'use client';
import * as React from 'react';
import { Search } from 'lucide-react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { useRouter } from '@/i18n/navigation';
import { cn } from '@/lib/utils';
import { isItemActive, type NavGroup, type ShellVariant } from './nav-config';
/**
* Palette de commandes (⌘K / Ctrl+K).
*
* Implementee sur Radix Dialog pour heriter du focus trap et de la fermeture
* par Escape, avec une navigation clavier explicite (fleches, Entree).
*/
interface CommandMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
groups: NavGroup[];
pathname: string;
variant: ShellVariant;
labels: {
title: string;
placeholder: string;
noResults: string;
};
}
/** Normalise pour une recherche insensible a la casse et aux accents. */
function normalize(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
export function CommandMenu({
open,
onOpenChange,
groups,
pathname,
variant,
labels,
}: CommandMenuProps) {
const router = useRouter();
const [query, setQuery] = React.useState('');
const [highlight, setHighlight] = React.useState(0);
const listRef = React.useRef<HTMLDivElement>(null);
const entries = React.useMemo(
() =>
groups.flatMap(group =>
group.items.map(item => ({ ...item, groupLabel: group.label }))
),
[groups]
);
const results = React.useMemo(() => {
const q = normalize(query.trim());
if (!q) return entries;
return entries.filter(
e => normalize(e.label).includes(q) || normalize(e.groupLabel).includes(q)
);
}, [entries, query]);
// Reinitialise a chaque ouverture, et borne l'index quand la liste retrecit.
React.useEffect(() => {
if (open) {
setQuery('');
setHighlight(0);
}
}, [open]);
React.useEffect(() => {
setHighlight(h => Math.min(h, Math.max(0, results.length - 1)));
}, [results.length]);
const select = React.useCallback(
(href: string) => {
onOpenChange(false);
router.push(href);
},
[onOpenChange, router]
);
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlight(h => (results.length ? (h + 1) % results.length : 0));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlight(h => (results.length ? (h - 1 + results.length) % results.length : 0));
} else if (event.key === 'Enter') {
event.preventDefault();
const target = results[highlight];
if (target) select(target.href);
}
};
// Maintient l'element surligne visible lors de la navigation au clavier.
React.useEffect(() => {
listRef.current
?.querySelector<HTMLElement>(`[data-index="${highlight}"]`)
?.scrollIntoView({ block: 'nearest' });
}, [highlight]);
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-brand-navy/40 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0" />
<DialogPrimitive.Content
onKeyDown={onKeyDown}
className={cn(
'fixed left-1/2 top-24 z-50 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2',
'overflow-hidden rounded-lg border border-border bg-white shadow-lg',
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0'
)}
>
<DialogPrimitive.Title className="sr-only">{labels.title}</DialogPrimitive.Title>
<div className="flex items-center gap-2 border-b border-border px-3">
<Search className="size-4 shrink-0 text-neutral-400" aria-hidden="true" />
<input
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={labels.placeholder}
aria-label={labels.title}
aria-controls="command-menu-list"
aria-activedescendant={
results[highlight] ? `command-item-${highlight}` : undefined
}
className="h-11 w-full bg-transparent font-body text-body-sm text-brand-navy outline-none placeholder:text-neutral-400"
/>
</div>
<div
id="command-menu-list"
ref={listRef}
role="listbox"
aria-label={labels.title}
className="max-h-72 overflow-y-auto p-1"
>
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-body-sm text-neutral-500">
{labels.noResults}
</p>
) : (
results.map((item, index) => {
const Icon = item.icon;
const active = isItemActive(item.href, pathname, variant);
return (
<button
key={item.key}
id={`command-item-${index}`}
data-index={index}
role="option"
aria-selected={index === highlight}
type="button"
onMouseMove={() => setHighlight(index)}
onClick={() => select(item.href)}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-body-sm transition-colors',
index === highlight ? 'bg-neutral-100 text-brand-navy' : 'text-neutral-700'
)}
>
<Icon className="size-4 shrink-0 text-neutral-400" aria-hidden="true" />
<span className="flex-1 truncate">{item.label}</span>
<span className="shrink-0 text-body-xs text-neutral-400">
{item.groupLabel}
</span>
{active && (
<span className="size-1.5 shrink-0 rounded-full bg-brand-blue" />
)}
</button>
);
})
)}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
/** Ouvre la palette sur ⌘K / Ctrl+K. */
export function useCommandMenuShortcut(onOpen: () => void) {
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
onOpen();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onOpen]);
}