// 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 && (
)}
)}
);
}