command-palette
FC-0053command-paletteblockv1.0.0Het ⌘K-venster: dialoog met zoekregel en lijst opdrachten (icoon, titel, toelichting, categorie), live gefilterd, ↑ ↓ Enter Esc, lege staat met wisknop. Sneltoets werkt paginabreed.
Klik de knop of druk ⌘K / Ctrl K. Laatst gekozen: —
blocks/command-palette/command-palette.tsx
/**
* CommandPalette — het ⌘K-venster: een dialoog midden in beeld met een
* zoekregel en een lijst opdrachten (icoon, titel, toelichting, categorie),
* gefilterd terwijl je typt; ↑ ↓ Enter Esc; lege staat met een knop om de
* zoektekst te wissen. ⌘K / Ctrl K opent en sluit hem overal op de pagina.
*
* De bron stapelde radix-dialog + vaul-drawer + cmdk + lucide + cva en 30
* ingebakken opdrachten. Hier: één dialoog (aria-modal, scrim op --overlay,
* focus in en terug), zonder library; opdrachten en iconen komen van het
* project. Componeert button (FC-0003) voor 'wissen'. Op smalle schermen
* vult de dialoog de breedte; een aparte drawer is er niet.
*/
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { Button } from "../../primitives/ui/button";
import { usePress } from "../../primitives/ui/press";
export interface PaletteCommand {
id: string;
title: string;
description?: string;
category?: string;
icon?: React.ReactNode;
shortcut?: string;
keywords?: readonly string[];
}
export interface CommandPaletteProps {
commands: readonly PaletteCommand[];
onSelect: (command: PaletteCommand) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Sneltoetsletter met ⌘/Ctrl (default k); leeg = geen sneltoets. */
shortcut?: string;
labels?: { placeholder?: string; empty?: string; clear?: string; title?: string };
/** De knop die hem opent; krijgt onPress. Zonder: alleen via sneltoets of open-prop. */
trigger?: (props: { onPress: () => void; open: boolean }) => React.ReactNode;
}
function Rij({ c, id, actief, onKies }: { c: PaletteCommand; id: string; actief: boolean; onKies: () => void }) {
const { pressProps } = usePress({ onPress: onKies });
return (
<li {...pressProps} id={id} role="option" aria-selected={actief} data-actief={actief ? "" : undefined} className="fp-cmdk__rij">
{c.icon && <span className="fp-cmdk__icoon">{c.icon}</span>}
<span className="fp-cmdk__tekst">
<span className="fp-cmdk__titel">{c.title}</span>
{c.description && <span className="fp-cmdk__toelichting">{c.description}</span>}
</span>
{c.shortcut && <span className="fp-cmdk__sneltoets">{c.shortcut}</span>}
{c.category && <span className="fp-cmdk__categorie">{c.category}</span>}
</li>
);
}
export function CommandPalette({ commands, onSelect, open: openProp, onOpenChange, shortcut = "k", labels, trigger }: CommandPaletteProps) {
const t = { placeholder: "Zoeken…", empty: "Geen opdracht gevonden voor", clear: "Zoektekst wissen", title: "Zoeken", ...labels };
const id = useId().replace(/:/g, "");
const reduced = useReducedMotion() ?? false;
const pop = useSpringTransition("pop", { mass: "system" });
const [eigenOpen, setEigenOpen] = useState(false);
const open = openProp ?? eigenOpen;
const [query, setQuery] = useState("");
const [actief, setActief] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const lijstRef = useRef<HTMLUListElement>(null);
const vorigeFocus = useRef<HTMLElement | null>(null);
const zet = useCallback((o: boolean) => {
if (openProp === undefined) setEigenOpen(o);
onOpenChange?.(o);
}, [openProp, onOpenChange]);
useEffect(() => {
if (!shortcut) return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcut.toLowerCase()) { e.preventDefault(); zet(!open); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [shortcut, open, zet]);
useEffect(() => {
if (open) {
vorigeFocus.current = document.activeElement as HTMLElement | null;
setQuery(""); setActief(0);
window.setTimeout(() => inputRef.current?.focus(), 0);
} else {
vorigeFocus.current?.focus?.();
}
}, [open]);
const q = query.trim().toLowerCase();
const zichtbaar = useMemo(
() => (q ? commands.filter((c) => [c.title, c.description ?? "", c.category ?? "", ...(c.keywords ?? [])].some((s) => s.toLowerCase().includes(q))) : commands),
[commands, q],
);
useEffect(() => { lijstRef.current?.querySelector<HTMLElement>("[data-actief]")?.scrollIntoView({ block: "nearest" }); }, [actief, zichtbaar]);
const kies = (c: PaletteCommand) => { onSelect(c); zet(false); };
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActief((a) => Math.min(zichtbaar.length - 1, a + 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActief((a) => Math.max(0, a - 1)); }
else if (e.key === "Enter") { e.preventDefault(); const c = zichtbaar[actief]; if (c) kies(c); }
else if (e.key === "Escape") { zet(false); }
};
return (
<>
{trigger?.({ onPress: () => zet(true), open })}
<AnimatePresence>
{open && (
<motion.div
className="fp-cmdk__scrim"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={pop}
onPointerDown={(e) => { if (e.target === e.currentTarget) zet(false); }}
>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-titel`}
className="fp-cmdk"
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
transition={pop}
>
<h2 id={`${id}-titel`} className="fp-sr-only">{t.title}</h2>
<div className="fp-cmdk__zoekrij">
<svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></svg>
<input
ref={inputRef}
className="fp-cmdk__zoek"
type="text"
role="combobox"
aria-expanded="true"
aria-controls={`${id}-lijst`}
aria-activedescendant={zichtbaar[actief] ? `${id}-o-${actief}` : undefined}
placeholder={t.placeholder}
value={query}
onChange={(e) => { setQuery(e.target.value); setActief(0); }}
onKeyDown={onKeyDown}
/>
</div>
<ul ref={lijstRef} id={`${id}-lijst`} role="listbox" className="fp-cmdk__lijst">
{zichtbaar.length === 0 && (
<li className="fp-cmdk__leeg">
<svg aria-hidden="true" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round"><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></svg>
<span>{t.empty} “{query}”</span>
<Button variant="ghost" onPress={() => { setQuery(""); inputRef.current?.focus(); }}>{t.clear}</Button>
</li>
)}
{zichtbaar.map((c, i) => (
<Rij key={c.id} c={c} id={`${id}-o-${i}`} actief={i === actief} onKies={() => kies(c)} />
))}
</ul>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
blocks/command-palette/command-palette.css
/** CommandPalette — importeren in de app-root (registry-site: app/globals.css). */
.fp-cmdk__scrim {
position: fixed; inset: 0; z-index: 60;
display: flex; align-items: flex-start; justify-content: center;
padding: 10vh 1rem 1rem;
background: var(--overlay);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
}
.fp-cmdk {
width: 100%; max-width: 32rem;
display: flex; flex-direction: column; overflow: hidden;
background: var(--card); color: var(--card-foreground, var(--foreground));
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-overlay);
transform-origin: top center;
}
.fp-cmdk__zoekrij { display: flex; align-items: center; gap: 0.625rem; padding: 0 1rem; border-bottom: 1px solid var(--border); color: var(--muted-foreground); }
.fp-cmdk__zoek {
flex: 1; min-width: 0; height: 3rem;
appearance: none; border: 0; background: transparent; outline: none;
font: inherit; font-size: 0.9375rem; color: var(--foreground);
}
.fp-cmdk__zoek::placeholder { color: var(--muted-foreground); }
.fp-cmdk__lijst { list-style: none; margin: 0; padding: 0.375rem; min-height: 16rem; max-height: 24rem; overflow-y: auto; }
.fp-cmdk__rij {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.5rem 0.625rem; cursor: pointer;
border-radius: calc(var(--radius) / 2);
transition: background-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-cmdk__rij:hover, .fp-cmdk__rij[data-actief] { background: var(--muted); }
.fp-cmdk__icoon { display: flex; width: 1.25rem; height: 1.25rem; flex-shrink: 0; color: var(--muted-foreground); }
.fp-cmdk__tekst { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.fp-cmdk__titel { font-size: 0.875rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-cmdk__toelichting { font-size: 0.75rem; color: var(--muted-foreground); }
.fp-cmdk__sneltoets { font-size: 0.75rem; letter-spacing: 0.1em; color: var(--muted-foreground); }
.fp-cmdk__categorie { font-size: 0.75rem; color: var(--muted-foreground); white-space: nowrap; }
.fp-cmdk__leeg {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.5rem;
min-height: 15rem; padding: 1rem; text-align: center;
font-size: 0.75rem; color: var(--muted-foreground);
}