action-searchbar
FC-0049search-actionblockv1.0.0Zoekveld 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.
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); }