200 lines
6.7 KiB
TypeScript
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]);
|
|
}
|