mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:51:13 -05:00
feat: Implement multi-select combobox filters
This commit is contained in:
@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
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 { RideCreditFilters as FilterTypes } from '@/types/ride-credits';
|
||||||
import { UserRideCredit } from '@/types/database';
|
import { UserRideCredit } from '@/types/database';
|
||||||
import { Search, X, ChevronDown, MapPin, Building2, Factory, Calendar, Star, MessageSquare, Image } from 'lucide-react';
|
import { Search, X, ChevronDown, MapPin, Building2, Factory, Calendar, Star, MessageSquare, Image } from 'lucide-react';
|
||||||
@@ -159,38 +160,36 @@ export function RideCreditFilters({
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
|
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
|
||||||
{filterOptions.countries.length > 0 && (
|
{filterOptions.countries.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className={sectionSpacing}>
|
||||||
<Label className="text-sm font-medium">Countries</Label>
|
<Label className="text-sm font-medium">Countries</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<MultiSelectCombobox
|
||||||
{filterOptions.countries.map(country => (
|
options={filterOptions.countries.map(c => ({ label: c, value: c }))}
|
||||||
<Badge
|
value={filters.countries || []}
|
||||||
key={country}
|
onValueChange={(values) =>
|
||||||
variant={(filters.countries || []).includes(country) ? 'default' : 'outline'}
|
onFilterChange('countries', values.length > 0 ? values : undefined)
|
||||||
className="cursor-pointer"
|
}
|
||||||
onClick={() => toggleArrayFilter('countries', country)}
|
placeholder="Select countries..."
|
||||||
>
|
searchPlaceholder="Search countries..."
|
||||||
{country}
|
emptyText="No countries found"
|
||||||
</Badge>
|
className={compact ? "h-9 text-sm" : ""}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filterOptions.states.length > 0 && (
|
{filterOptions.states.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className={sectionSpacing}>
|
||||||
<Label className="text-sm font-medium">States/Provinces</Label>
|
<Label className="text-sm font-medium">States/Provinces</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<MultiSelectCombobox
|
||||||
{filterOptions.states.map(state => (
|
options={filterOptions.states.map(s => ({ label: s, value: s }))}
|
||||||
<Badge
|
value={filters.statesProvinces || []}
|
||||||
key={state}
|
onValueChange={(values) =>
|
||||||
variant={(filters.statesProvinces || []).includes(state) ? 'default' : 'outline'}
|
onFilterChange('statesProvinces', values.length > 0 ? values : undefined)
|
||||||
className="cursor-pointer"
|
}
|
||||||
onClick={() => toggleArrayFilter('statesProvinces', state)}
|
placeholder="Select states/provinces..."
|
||||||
>
|
searchPlaceholder="Search states/provinces..."
|
||||||
{state}
|
emptyText="No states/provinces found"
|
||||||
</Badge>
|
className={compact ? "h-9 text-sm" : ""}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -210,20 +209,19 @@ export function RideCreditFilters({
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
|
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
|
||||||
{filterOptions.parks.length > 0 && (
|
{filterOptions.parks.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className={sectionSpacing}>
|
||||||
<Label className="text-sm font-medium">Specific Parks</Label>
|
<Label className="text-sm font-medium">Specific Parks</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<MultiSelectCombobox
|
||||||
{filterOptions.parks.map(park => (
|
options={filterOptions.parks.map(p => ({ label: p.name, value: p.id }))}
|
||||||
<Badge
|
value={filters.parks || []}
|
||||||
key={park.id}
|
onValueChange={(values) =>
|
||||||
variant={(filters.parks || []).includes(park.id) ? 'default' : 'outline'}
|
onFilterChange('parks', values.length > 0 ? values : undefined)
|
||||||
className="cursor-pointer"
|
}
|
||||||
onClick={() => toggleArrayFilter('parks', park.id)}
|
placeholder="Select parks..."
|
||||||
>
|
searchPlaceholder="Search parks..."
|
||||||
{park.name}
|
emptyText="No parks found"
|
||||||
</Badge>
|
className={compact ? "h-9 text-sm" : ""}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
@@ -243,20 +241,19 @@ export function RideCreditFilters({
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
|
<CollapsibleContent className="p-4 space-y-3 bg-card border rounded-lg mt-1">
|
||||||
{filterOptions.manufacturers.length > 0 && (
|
{filterOptions.manufacturers.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className={sectionSpacing}>
|
||||||
<Label className="text-sm font-medium">Manufacturers</Label>
|
<Label className="text-sm font-medium">Manufacturers</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<MultiSelectCombobox
|
||||||
{filterOptions.manufacturers.map(mfr => (
|
options={filterOptions.manufacturers.map(m => ({ label: m.name, value: m.id }))}
|
||||||
<Badge
|
value={filters.manufacturers || []}
|
||||||
key={mfr.id}
|
onValueChange={(values) =>
|
||||||
variant={(filters.manufacturers || []).includes(mfr.id) ? 'default' : 'outline'}
|
onFilterChange('manufacturers', values.length > 0 ? values : undefined)
|
||||||
className="cursor-pointer"
|
}
|
||||||
onClick={() => toggleArrayFilter('manufacturers', mfr.id)}
|
placeholder="Select manufacturers..."
|
||||||
>
|
searchPlaceholder="Search manufacturers..."
|
||||||
{mfr.name}
|
emptyText="No manufacturers found"
|
||||||
</Badge>
|
className={compact ? "h-9 text-sm" : ""}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
143
src/components/ui/multi-select-combobox.tsx
Normal file
143
src/components/ui/multi-select-combobox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user