← alle entries

command-palette

FC-0053command-paletteblockv1.0.0

Het ⌘K-venster: dialoog met zoekregel en lijst opdrachten (icoon, titel, toelichting, categorie), live gefilterd, ↑ ↓ Enter Esc, lege staat met wisknop. Sneltoets werkt paginabreed.

zelfde component — andere tokens

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);
}