// ComboBoxCommand.jsx // ───────────────────────────────────────────────────────────────────────────── // Generic multi-select combobox with server-search, select-all, embossed // checkboxes, and +N overflow badge popover. // // Props: // value {any[]} — array of selected IDs (controlled) // onChange {fn} — called with new array of IDs // items {object[]} — flat list of option objects // loading {boolean} — shows spinner while fetching // onSearch {fn} — called with search term when Search is clicked // // // Field mapping — tell the component which keys to read from each item: // fieldId {string} — unique identifier key default: "id" // fieldLabel {string} — primary display label key default: "name" // fieldMeta {string} — secondary mono badge key default: null (hidden) // // // Copy overrides (all optional): // placeholder {string} — trigger placeholder default: "Select items…" // searchPlaceholder {string} default: "Search…" // selectAllLabel {string} default: "Select all" // deselectAllLabel {string} default: "Deselect all" // emptyLabel {string} default: "No items available." // unit {string} — singular noun for counts default: "item" // // maxVisible {number} — badges before +N collapse default: 3 // // ── Usage examples ──────────────────────────────────────────────────────────── // // // Groups (your existing use-case) // // // // Users // // // // Tags (no meta badge) // // ───────────────────────────────────────────────────────────────────────────── import { useState, useRef } from "react"; import { Check, ChevronsUpDown, X, Search, Loader2 } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList, CommandSeparator, } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; // ─── Overflow popover ───────────────────────────────────────────────────────── function OverflowPopover({ overflow, onRemove, fieldId, fieldLabel, fieldMeta }) { const [open, setOpen] = useState(false); return (

{overflow.length} more selected

{overflow.map((item) => (
{item[fieldLabel]} {fieldMeta && item[fieldMeta] && ( {item[fieldMeta]} )}
))}
); } // ─── Main component ─────────────────────────────────────────────────────────── export function ComboBoxCommand({ // Data value = [], onChange, items = [], loading = false, onSearch, // Field mapping fieldId = "id", fieldLabel = "name", fieldMeta = null, // set to a key string to show the mono badge // Copy placeholder = "Select items…", searchPlaceholder = "Search…", selectAllLabel = "Select all", deselectAllLabel = "Deselect all", emptyLabel = "No items available.", unit = "item", // Layout maxVisible = 3, }) { const [open, setOpen] = useState(false); const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); const safeItems = Array.isArray(items) ? items : []; // ── Client-side filter ───────────────────────────────────────────────────── const filtered = safeItems.filter((item) => { if (!inputValue.trim()) return true; const q = inputValue.toLowerCase(); const matchLabel = String(item[fieldLabel] ?? "").toLowerCase().includes(q); const matchMeta = fieldMeta ? String(item[fieldMeta] ?? "").toLowerCase().includes(q) : false; return matchLabel || matchMeta; }); // ── Selection state ──────────────────────────────────────────────────────── const selected = safeItems.filter((item) => value.includes(item[fieldId])); const allSelected = filtered.length > 0 && filtered.every((item) => value.includes(item[fieldId])); const someSelected = !allSelected && filtered.some((item) => value.includes(item[fieldId])); const visibleBadges = selected.slice(0, maxVisible); const overflowBadges = selected.slice(maxVisible); // ── Handlers ─────────────────────────────────────────────────────────────── const toggle = (id) => { const key = String(id); onChange( value.map(String).includes(key) ? value.filter((v) => String(v) !== key) : [...value, key] ); }; const toggleAll = () => { if (allSelected) { const filteredIds = new Set(filtered.map((item) => item[fieldId])); onChange(value.filter((id) => !filteredIds.has(id))); } else { const merged = Array.from(new Set([...value, ...filtered.map((item) => item[fieldId])])); onChange(merged); } }; const handleSearch = () => { if (onSearch && inputValue.trim()) onSearch(inputValue.trim()); }; const handleKeyDown = (e) => { if (e.key === "Enter" && inputValue.trim()) { e.preventDefault(); handleSearch(); } }; // ── Pluralise helper ─────────────────────────────────────────────────────── const plural = (n) => `${n} ${unit}${n !== 1 ? "s" : ""}`; return (
{/* ── Trigger ── */} {/* ── Dropdown ── */} {/* Search bar */}
setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={searchPlaceholder} className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1" />
{/* Loading */} {loading && (
Loading…
)} {/* Empty */} {!loading && filtered.length === 0 && ( {inputValue ? `No results for "${inputValue}".` : emptyLabel} )} {!loading && filtered.length > 0 && ( <> {/* Select All row */}
{/* Embossed checkbox */} {allSelected && } {someSelected && } {allSelected ? deselectAllLabel : selectAllLabel} {plural(filtered.length)}
{/* Items */} {filtered.map((item) => { const id = item[fieldId]; const label = item[fieldLabel]; const meta = fieldMeta ? item[fieldMeta] : null; const isSelected = value.includes(id); return ( toggle(id)} className={cn( "flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors", "aria-selected:bg-transparent data-selected:bg-transparent", isSelected ? "bg-primary/[0.06] hover:bg-primary/[0.10]" : "hover:bg-muted/50" )} > {/* Embossed checkbox */} {isSelected && ( )} {/* Label */} {label} {/* Meta pill */} {meta && ( {meta} )} ); })} )}
{/* Footer */} {!loading && safeItems.length > 0 && ( <>
{value.length > 0 ? `${plural(value.length)} selected` : "None selected"} {filtered.length} / {safeItems.length} shown
)}
{/* ── Selected badges with +N overflow ── */} {selected.length > 0 && (
{/* First maxVisible badges */} {visibleBadges.map((item) => ( {item[fieldLabel]} {fieldMeta && item[fieldMeta] && ( {item[fieldMeta]} )} ))} {/* +N overflow → popover */} {overflowBadges.length > 0 && ( toggle(id)} fieldId={fieldId} fieldLabel={fieldLabel} fieldMeta={fieldMeta} /> )} {/* Clear all */} {selected.length > 1 && ( )}
)}
); }