← alle entries

action-searchbar

FC-0049search-actionblockv1.0.0

Zoekveld dat bij focus een lijst acties toont en filtert terwijl je typt; loep wisselt naar verzendpijl zodra er tekst staat. Klik, pijltjes en Enter kiezen. Componeert field.

zelfde component — andere tokens

Klik in het veld: de acties verschijnen; typ om te filteren. Laatst gekozen:

blocks/action-searchbar/action-searchbar.tsx
/**
 * ActionSearchbar — een zoekveld dat bij focus een lijst acties toont
 * (commando's, snelkoppelingen) en die filtert terwijl je typt; het icoon
 * rechts wisselt van loep naar verzendpijl zodra er tekst staat. Kies met
 * klik, pijltjes en Enter.
 *
 * Componeert field (FC-0005, trailing-slot). De bron animeerde de hoogte van het
 * paneel (height: 0 → auto — layout per frame); hier komt het paneel met
 * opacity + y en verschuiven de rijen via de layout-animatie van Framer.
 * De vijf ingebakken acties met gekleurde lucide-iconen (blue-500 …) zijn
 * een contract: het project levert acties, iconen en kleuren.
 */

"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import type * as React from "react";
import { AnimatePresence, motion, useReducedMotion, type Transition } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { usePress } from "../../primitives/ui/press";
import { Field } from "../../primitives/ui/input";

export interface SearchAction {
  id: string;
  label: string;
  icon?: React.ReactNode;
  /** Kleine toelichting achter het label. */
  description?: string;
  /** Sneltoets-tekst rechts ("⌘K"). */
  shortcut?: string;
  /** Soort, helemaal rechts ("Opdracht", "App"). */
  kind?: string;
}

export interface ActionSearchbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect" | "onSubmit"> {
  actions: readonly SearchAction[];
  onSelect: (action: SearchAction) => void;
  /** Vrije zoekopdracht (Enter zonder gekozen actie). */
  onSubmit?: (query: string) => void;
  label?: string;
  placeholder?: string;
  /** Eigen filter; default: label bevat de tekst. */
  filter?: (action: SearchAction, query: string) => boolean;
  debounceMs?: number;
  footer?: React.ReactNode;
}

function useDebounce<T>(value: T, ms: number): T {
  const [v, setV] = useState(value);
  useEffect(() => {
    const t = window.setTimeout(() => setV(value), ms);
    return () => window.clearTimeout(t);
  }, [value, ms]);
  return v;
}

function Rij({ a, actief, onKies, reduced, veer }: { a: SearchAction; actief: boolean; onKies: () => void; reduced: boolean; veer: Transition }) {
  const { pressProps } = usePress({ onPress: onKies });
  return (
    <motion.li
      {...pressProps}
      layout={!reduced}
      transition={veer}
      initial={reduced ? false : { opacity: 0, y: 8 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0 }}
      role="option"
      aria-selected={actief}
      data-actief={actief ? "" : undefined}
      className="fp-asb__rij"
    >
      <span className="fp-asb__links">
        {a.icon && <span className="fp-asb__icoon">{a.icon}</span>}
        <span className="fp-asb__label">{a.label}</span>
        {a.description && <span className="fp-asb__toelichting">{a.description}</span>}
      </span>
      <span className="fp-asb__rechts">
        {a.shortcut && <span className="fp-asb__sneltoets">{a.shortcut}</span>}
        {a.kind && <span className="fp-asb__soort">{a.kind}</span>}
      </span>
    </motion.li>
  );
}

export function ActionSearchbar({ actions, onSelect, onSubmit, label = "Zoek een opdracht", placeholder = "Wat wil je doen?", filter, debounceMs = 200, footer, className, ...rest }: ActionSearchbarProps) {
  const reduced = useReducedMotion() ?? false;
  const veer = useSpringTransition("default", { mass: "content" });
  const pop = useSpringTransition("pop", { mass: "content" });
  const [query, setQuery] = useState("");
  const [focus, setFocus] = useState(false);
  const [actief, setActief] = useState(0);
  const wortel = useRef<HTMLDivElement>(null);
  const q = useDebounce(query, debounceMs).trim().toLowerCase();

  const zichtbaar = useMemo(
    () => (q ? actions.filter((a) => (filter ? filter(a, q) : a.label.toLowerCase().includes(q))) : actions),
    [actions, q, filter],
  );
  useEffect(() => setActief(0), [zichtbaar]);

  /* Sluiten bij klik buiten — geen blur-timeout zoals in de bron. */
  useEffect(() => {
    if (!focus) return;
    const buiten = (e: PointerEvent) => { if (!wortel.current?.contains(e.target as Node)) setFocus(false); };
    document.addEventListener("pointerdown", buiten);
    return () => document.removeEventListener("pointerdown", buiten);
  }, [focus]);

  const kies = (a: SearchAction) => { onSelect(a); setFocus(false); setQuery(""); };
  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "ArrowDown") { e.preventDefault(); setActief((i) => Math.min(zichtbaar.length - 1, i + 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActief((i) => Math.max(0, i - 1)); }
    else if (e.key === "Enter") { e.preventDefault(); const a = zichtbaar[actief]; if (a) kies(a); else if (query.trim()) onSubmit?.(query.trim()); }
    else if (e.key === "Escape") { setFocus(false); (e.target as HTMLInputElement).blur(); }
  };

  return (
    <div {...rest} ref={wortel} className={["fp-asb", className].filter(Boolean).join(" ")}>
      <div className="fp-asb__veld">
        <Field
          type="search"
          label={label}
          placeholder={placeholder}
          value={query}
          autoComplete="off"
          onChange={(e) => setQuery(e.target.value)}
          onFocus={() => setFocus(true)}
          onKeyDown={onKeyDown}
          role="combobox"
          aria-expanded={focus}
          aria-autocomplete="list"
          trailing={
        /* Loep → verzendpijl zodra er tekst staat; in één vak, geen sprong. */
        <span className="fp-asb__teken" aria-hidden="true">
          <AnimatePresence mode="popLayout" initial={false}>
            {query.length > 0 ? (
              <motion.span key="stuur" initial={reduced ? { opacity: 0 } : { y: -16, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={reduced ? { opacity: 0 } : { y: 16, opacity: 0 }} transition={pop}>
                <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></svg>
              </motion.span>
            ) : (
              <motion.span key="loep" initial={reduced ? { opacity: 0 } : { y: -16, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={reduced ? { opacity: 0 } : { y: 16, opacity: 0 }} transition={pop}>
                <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></svg>
              </motion.span>
            )}
          </AnimatePresence>
        </span>
          }
        />
      </div>
      <AnimatePresence>
        {focus && (
          <motion.div
            className="fp-asb__paneel"
            initial={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
            transition={veer}
          >
            <ul role="listbox" className="fp-asb__lijst">
              {zichtbaar.length === 0 && <li className="fp-asb__leeg">Niets gevonden.</li>}
              {zichtbaar.map((a, i) => (
                <Rij key={a.id} a={a} actief={i === actief} onKies={() => kies(a)} reduced={reduced} veer={veer} />
              ))}
            </ul>
            {footer && <div className="fp-asb__voet">{footer}</div>}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
blocks/action-searchbar/action-searchbar.css
/**
 * ActionSearchbar — zoekveld met actielijst. Importeren in de app-root (de
 * registry-site doet dat in app/globals.css). Het veld komt uit primitives.css.
 */

.fp-asb { position: relative; width: 100%; max-width: 24rem; color: var(--foreground); }
.fp-asb__veld { position: relative; }
.fp-asb__teken { display: grid; width: 1rem; height: 1rem; color: var(--muted-foreground); }
.fp-asb__teken > * { grid-area: 1 / 1; display: flex; }

.fp-asb__paneel {
  position: absolute; left: 0; right: 0; top: calc(100% + 0.25rem); z-index: 30;
  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);
}
.fp-asb__lijst { list-style: none; margin: 0; padding: 0.25rem; max-height: 18rem; overflow-y: auto; }
.fp-asb__rij {
  display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
  padding: 0.5rem 0.75rem; cursor: pointer;
  border-radius: calc(var(--radius) / 2);
  transition: background-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-asb__rij[data-actief], .fp-asb__rij:hover { background: var(--muted); }
.fp-asb__links { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.fp-asb__icoon { display: flex; flex-shrink: 0; color: var(--muted-foreground); }
.fp-asb__label { font-size: 0.875rem; font-weight: 500; white-space: nowrap; }
.fp-asb__toelichting { font-size: 0.75rem; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fp-asb__rechts { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-asb__leeg { padding: 1.25rem 0; text-align: center; font-size: 0.875rem; color: var(--muted-foreground); }
.fp-asb__voet { display: flex; justify-content: space-between; gap: 1rem; padding: 0.5rem 0.75rem; border-top: 1px solid var(--border); font-size: 0.75rem; color: var(--muted-foreground); }