feat: Implement multi-select combobox filters

This commit is contained in:
gpt-engineer-app[bot]
2025-10-16 15:52:00 +00:00
parent e881778659
commit d9e05125fe
2 changed files with 192 additions and 52 deletions

View File

@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { RideCreditFilters as FilterTypes } from '@/types/ride-credits';
import { UserRideCredit } from '@/types/database';
import { Search, X, ChevronDown, MapPin, Building2, Factory, Calendar, Star, MessageSquare, Image } from 'lucide-react';
@@ -159,38 +160,36 @@ export function RideCreditFilters({
</CollapsibleTrigger>
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
{filterOptions.countries.length > 0 && (
<div className="space-y-2">
<div className={sectionSpacing}>
<Label className="text-sm font-medium">Countries</Label>
<div className="flex flex-wrap gap-2">
{filterOptions.countries.map(country => (
<Badge
key={country}
variant={(filters.countries || []).includes(country) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => toggleArrayFilter('countries', country)}
>
{country}
</Badge>
))}
</div>
<MultiSelectCombobox
options={filterOptions.countries.map(c => ({ label: c, value: c }))}
value={filters.countries || []}
onValueChange={(values) =>
onFilterChange('countries', values.length > 0 ? values : undefined)
}
placeholder="Select countries..."
searchPlaceholder="Search countries..."
emptyText="No countries found"
className={compact ? "h-9 text-sm" : ""}
/>
</div>
)}
{filterOptions.states.length > 0 && (
<div className="space-y-2">
<div className={sectionSpacing}>
<Label className="text-sm font-medium">States/Provinces</Label>
<div className="flex flex-wrap gap-2">
{filterOptions.states.map(state => (
<Badge
key={state}
variant={(filters.statesProvinces || []).includes(state) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => toggleArrayFilter('statesProvinces', state)}
>
{state}
</Badge>
))}
</div>
<MultiSelectCombobox
options={filterOptions.states.map(s => ({ label: s, value: s }))}
value={filters.statesProvinces || []}
onValueChange={(values) =>
onFilterChange('statesProvinces', values.length > 0 ? values : undefined)
}
placeholder="Select states/provinces..."
searchPlaceholder="Search states/provinces..."
emptyText="No states/provinces found"
className={compact ? "h-9 text-sm" : ""}
/>
</div>
)}
</CollapsibleContent>
@@ -210,20 +209,19 @@ export function RideCreditFilters({
</CollapsibleTrigger>
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
{filterOptions.parks.length > 0 && (
<div className="space-y-2">
<div className={sectionSpacing}>
<Label className="text-sm font-medium">Specific Parks</Label>
<div className="flex flex-wrap gap-2">
{filterOptions.parks.map(park => (
<Badge
key={park.id}
variant={(filters.parks || []).includes(park.id) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => toggleArrayFilter('parks', park.id)}
>
{park.name}
</Badge>
))}
</div>
<MultiSelectCombobox
options={filterOptions.parks.map(p => ({ label: p.name, value: p.id }))}
value={filters.parks || []}
onValueChange={(values) =>
onFilterChange('parks', values.length > 0 ? values : undefined)
}
placeholder="Select parks..."
searchPlaceholder="Search parks..."
emptyText="No parks found"
className={compact ? "h-9 text-sm" : ""}
/>
</div>
)}
</CollapsibleContent>
@@ -243,20 +241,19 @@ export function RideCreditFilters({
</CollapsibleTrigger>
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
{filterOptions.manufacturers.length > 0 && (
<div className="space-y-2">
<div className={sectionSpacing}>
<Label className="text-sm font-medium">Manufacturers</Label>
<div className="flex flex-wrap gap-2">
{filterOptions.manufacturers.map(mfr => (
<Badge
key={mfr.id}
variant={(filters.manufacturers || []).includes(mfr.id) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => toggleArrayFilter('manufacturers', mfr.id)}
>
{mfr.name}
</Badge>
))}
</div>
<MultiSelectCombobox
options={filterOptions.manufacturers.map(m => ({ label: m.name, value: m.id }))}
value={filters.manufacturers || []}
onValueChange={(values) =>
onFilterChange('manufacturers', values.length > 0 ? values : undefined)
}
placeholder="Select manufacturers..."
searchPlaceholder="Search manufacturers..."
emptyText="No manufacturers found"
className={compact ? "h-9 text-sm" : ""}
/>
</div>
)}

View File

@@ -0,0 +1,143 @@
import * as React from "react";
import { useState, useMemo } from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface MultiSelectOption {
label: string;
value: string;
}
interface MultiSelectComboboxProps {
options: MultiSelectOption[];
value?: string[];
onValueChange: (values: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
maxDisplay?: number;
className?: string;
}
export function MultiSelectCombobox({
options,
value = [],
onValueChange,
placeholder = "Select options...",
searchPlaceholder = "Search...",
emptyText = "No options found.",
maxDisplay = 2,
className,
}: MultiSelectComboboxProps) {
const [open, setOpen] = useState(false);
const handleSelect = (selectedValue: string) => {
const newValues = value.includes(selectedValue)
? value.filter((v) => v !== selectedValue)
: [...value, selectedValue];
onValueChange(newValues);
};
const handleRemove = (valueToRemove: string) => {
onValueChange(value.filter((v) => v !== valueToRemove));
};
const displayText = useMemo(() => {
if (value.length === 0) return placeholder;
if (value.length <= maxDisplay) {
return value
.map((v) => options.find((o) => o.value === v)?.label)
.filter(Boolean)
.join(", ");
}
return `${value.length} selected`;
}, [value, options, maxDisplay, placeholder]);
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", className)}
>
<span className="truncate">{displayText}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0 z-50" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Checkbox
checked={value.includes(option.value)}
className="mr-2"
/>
<span>{option.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Selected Items Display */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2">
{value.map((v) => {
const option = options.find((o) => o.value === v);
return option ? (
<Badge key={v} variant="secondary" className="gap-1">
{option.label}
<X
className="w-3 h-3 cursor-pointer hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleRemove(v);
}}
/>
</Badge>
) : null;
})}
{value.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => onValueChange([])}
>
Clear all
</Button>
)}
</div>
)}
</div>
);
}