9f0bd87c40
- Projectenpagina met pipeline (standaard) en tabelweergave, filters op status, Rikx Ronde en zoeken - Projectpagina in stappen: Project plan (velden uit de Rikx-aanvraag) en Rikx calculatie achter een schuif, live berekend volgens de rekenmethode - Rikx rondes beheren (label, Rikx waarde, Rikx prijs) en koppelen aan projecten - Oranje Rikx-menubalk met menu per module, gedeeld door alle pagina's - Oude offerte-tabellen opgeruimd: leeg wordt verwijderd, met data hernoemd naar legacy_* - CLAUDE.md bijgewerkt naar de huidige stand Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
// Dropdown waarin meerdere opties aangevinkt kunnen worden. De keuzes gaan als
|
|
// checkboxes met dezelfde `name` mee in het formulier (FormData.getAll).
|
|
export function MultiSelectDropdown({
|
|
id,
|
|
name,
|
|
options,
|
|
defaultValue,
|
|
onChange,
|
|
className,
|
|
}: {
|
|
id: string;
|
|
name: string;
|
|
options: string[];
|
|
defaultValue: string[];
|
|
onChange?: (values: string[]) => void;
|
|
className: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [selected, setSelected] = useState<string[]>(defaultValue);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const close = (e: MouseEvent | KeyboardEvent) => {
|
|
if (e instanceof KeyboardEvent ? e.key === "Escape" : !rootRef.current?.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", close);
|
|
document.addEventListener("keydown", close);
|
|
return () => {
|
|
document.removeEventListener("mousedown", close);
|
|
document.removeEventListener("keydown", close);
|
|
};
|
|
}, [open]);
|
|
|
|
function toggle(option: string, checked: boolean) {
|
|
const next = checked ? options.filter((o) => o === option || selected.includes(o)) : selected.filter((o) => o !== option);
|
|
setSelected(next);
|
|
onChange?.(next);
|
|
}
|
|
|
|
const summary =
|
|
selected.length === 0 ? "Maak een keuze" : selected.length === 1 ? selected[0] : `${selected.length} geselecteerd`;
|
|
|
|
return (
|
|
<div ref={rootRef} className="relative">
|
|
<button
|
|
id={id}
|
|
type="button"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((o) => !o)}
|
|
className={`${className} flex items-center justify-between gap-2 text-left`}
|
|
>
|
|
<span className={`truncate ${selected.length ? "" : "text-zinc-400"}`}>{summary}</span>
|
|
<svg viewBox="0 0 10 6" aria-hidden className="w-2.5 h-2.5 shrink-0 fill-zinc-500">
|
|
<path d="M0 0h10L5 6z" />
|
|
</svg>
|
|
</button>
|
|
{/* Altijd gerenderd zodat de checkboxes meegaan in het formulier, ook als de lijst dicht is. */}
|
|
<div
|
|
hidden={!open}
|
|
className="absolute left-0 right-0 top-full z-30 mt-1 max-h-72 overflow-y-auto rounded-lg bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-black/10 py-1"
|
|
>
|
|
{options.map((option) => (
|
|
<label
|
|
key={option}
|
|
className="flex items-start gap-2 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200 hover:bg-orange-50 dark:hover:bg-zinc-800 cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
name={name}
|
|
value={option}
|
|
checked={selected.includes(option)}
|
|
onChange={(e) => toggle(option, e.target.checked)}
|
|
className="mt-0.5 accent-rikx-orange"
|
|
/>
|
|
{option}
|
|
</label>
|
|
))}
|
|
</div>
|
|
{selected.length > 1 && (
|
|
<p className="mt-1 text-xs text-zinc-500">{selected.join(" · ")}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|