mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
463 lines
18 KiB
React
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>
|
|
);
|
|
} |