Files
starr-philproperties/src/components/generic/ComboBoxCommand.jsx
T
2026-06-24 13:43:28 +08:00

463 lines
18 KiB
React

// 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)
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={groups}
// loading={groupsLoading}
// onSearch={onSearchGroups}
// fieldId="group_id"
// fieldLabel="name"
// fieldMeta="group_code"
// placeholder="Select groups…"
// unit="group"
// />
//
// // Users
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={users}
// fieldId="user_id"
// fieldLabel="full_name"
// fieldMeta="email"
// placeholder="Assign users…"
// unit="user"
// />
//
// // Tags (no meta badge)
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={tags}
// fieldId="tag_id"
// fieldLabel="label"
// placeholder="Select tags…"
// unit="tag"
// />
// ─────────────────────────────────────────────────────────────────────────────
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center h-6 px-2 rounded-full text-xs font-medium transition-colors",
"bg-primary/10 text-primary border border-primary/20",
"hover:bg-primary/20 hover:border-primary/40",
open && "bg-primary/20 border-primary/40"
)}
>
+{overflow.length} more
</button>
</PopoverTrigger>
<PopoverContent className="p-0 w-64" align="start" sideOffset={4}>
<div className="px-3 py-2 border-b border-border">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{overflow.length} more selected
</p>
</div>
<div className="p-2 space-y-0.5 max-h-52 overflow-y-auto">
{overflow.map((item) => (
<div
key={item[fieldId]}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-muted/50 group transition-colors"
>
<span className="flex-1 text-sm truncate">{item[fieldLabel]}</span>
{fieldMeta && item[fieldMeta] && (
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded shrink-0">
{item[fieldMeta]}
</span>
)}
<button
type="button"
onClick={() => onRemove(item[fieldId])}
className={cn(
"shrink-0 rounded-sm p-0.5 opacity-0 group-hover:opacity-60",
"hover:!opacity-100 hover:bg-destructive/20 transition-all"
)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
// ─── 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 (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
{/* ── Trigger ── */}
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal h-9"
>
<span className="truncate">
{selected.length > 0
? `${plural(selected.length)} selected`
: placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{/* ── Dropdown ── */}
<PopoverContent className="p-0 w-[480px]" align="start" sideOffset={4}>
<Command shouldFilter={false}>
{/* Search bar */}
<div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1"
/>
<button
type="button"
onClick={handleSearch}
disabled={!inputValue.trim() || loading}
title="Search on server"
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
"border border-border bg-muted hover:bg-muted/70",
"disabled:opacity-40 disabled:cursor-not-allowed",
inputValue.trim() && !loading &&
"border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
)}
>
{loading
? <Loader2 className="h-3 w-3 animate-spin" />
: <Search className="h-3 w-3" />
}
Search
</button>
</div>
<CommandList className="max-h-[300px]">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
</div>
)}
{/* Empty */}
{!loading && filtered.length === 0 && (
<CommandEmpty className="py-6 text-sm text-muted-foreground text-center">
{inputValue ? `No results for "${inputValue}".` : emptyLabel}
</CommandEmpty>
)}
{!loading && filtered.length > 0 && (
<>
{/* Select All row */}
<div
role="button"
onClick={toggleAll}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none",
"border-b border-border transition-colors hover:bg-muted/50",
(allSelected || someSelected) && "bg-primary/5"
)}
>
{/* Embossed checkbox */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.12),inset_0_1px_2px_rgba(0,0,0,0.08)]",
allSelected
? "bg-primary border-primary shadow-none"
: someSelected
? "bg-primary/15 border-primary"
: "bg-background border-border"
)}>
{allSelected && <Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />}
{someSelected && <span className="h-1.5 w-1.5 rounded-[2px] bg-primary block" />}
</span>
<span className="text-sm font-semibold text-foreground">
{allSelected ? deselectAllLabel : selectAllLabel}
</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{plural(filtered.length)}
</span>
</div>
{/* Items */}
<CommandGroup>
{filtered.map((item) => {
const id = item[fieldId];
const label = item[fieldLabel];
const meta = fieldMeta ? item[fieldMeta] : null;
const isSelected = value.includes(id);
return (
<CommandItem
key={id}
value={String(id)}
onSelect={() => 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 */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
isSelected
? "bg-primary border-primary shadow-none ring-2 ring-primary/25 ring-offset-1"
: [
"bg-background border-border",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.10),inset_0_1px_2px_rgba(0,0,0,0.06)]",
"hover:border-primary/60",
]
)}>
{isSelected && (
<Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />
)}
</span>
{/* Label */}
<span className={cn(
"flex-1 truncate text-sm",
isSelected ? "font-medium text-foreground" : "text-foreground/90"
)}>
{label}
</span>
{/* Meta pill */}
{meta && (
<span className={cn(
"font-mono text-[11px] px-1.5 py-0.5 rounded shrink-0",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
{meta}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
{/* Footer */}
{!loading && safeItems.length > 0 && (
<>
<CommandSeparator />
<div className="flex items-center justify-between px-3 py-2 text-[11px] text-muted-foreground">
<span>
{value.length > 0 ? `${plural(value.length)} selected` : "None selected"}
</span>
<span className="tabular-nums">
{filtered.length} / {safeItems.length} shown
</span>
</div>
</>
)}
</Command>
</PopoverContent>
</Popover>
{/* ── Selected badges with +N overflow ── */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center">
{/* First maxVisible badges */}
{visibleBadges.map((item) => (
<Badge
key={item[fieldId]}
variant="secondary"
className="gap-1 pr-1 text-xs h-6"
>
{item[fieldLabel]}
{fieldMeta && item[fieldMeta] && (
<span className="font-mono opacity-50 text-[10px]">{item[fieldMeta]}</span>
)}
<button
type="button"
onClick={() => toggle(item[fieldId])}
className="rounded-sm opacity-60 hover:opacity-100 hover:bg-destructive/20 p-0.5 ml-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{/* +N overflow → popover */}
{overflowBadges.length > 0 && (
<OverflowPopover
overflow={overflowBadges}
onRemove={(id) => toggle(id)}
fieldId={fieldId}
fieldLabel={fieldLabel}
fieldMeta={fieldMeta}
/>
)}
{/* Clear all */}
{selected.length > 1 && (
<button
type="button"
onClick={() => onChange([])}
className="text-[11px] text-muted-foreground hover:text-destructive transition-colors underline underline-offset-2 ml-0.5"
>
Clear all
</button>
)}
</div>
)}
</div>
);
}