Last active
March 19, 2025 16:35
-
-
Save angelsantosa/1ae03cc7396e2bfce2fed84379409524 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useState } from "react" | |
import { MultiSelect, type Option } from "@/components/multi-select-dropdown" | |
export default function Home() { | |
// Sample hierarchical data structure | |
const options: Option[] = [ | |
{ | |
value: "fruits", | |
label: "Fruits", | |
children: [ | |
{ value: "apple", label: "Apple" }, | |
{ value: "banana", label: "Banana" }, | |
{ value: "orange", label: "Orange" }, | |
{ value: "strawberry", label: "Strawberry" }, | |
{ value: "kiwi", label: "Kiwi" }, | |
], | |
}, | |
{ | |
value: "vegetables", | |
label: "Vegetables", | |
children: [ | |
{ value: "potato", label: "Potato" }, | |
{ value: "carrot", label: "Carrot" }, | |
{ value: "broccoli", label: "Broccoli" }, | |
{ value: "spinach", label: "Spinach", disabled: true }, | |
{ value: "tomato", label: "Tomato" }, | |
], | |
}, | |
{ | |
value: "dairy", | |
label: "Dairy", | |
children: [ | |
{ value: "milk", label: "Milk" }, | |
{ value: "cheese", label: "Cheese" }, | |
{ value: "yogurt", label: "Yogurt" }, | |
], | |
}, | |
{ value: "water", label: "Water" }, | |
{ value: "soda", label: "Soda" }, | |
] | |
const [selected, setSelected] = useState<string[]>([]) | |
return ( | |
<main className="flex min-h-screen flex-col items-center justify-center p-24"> | |
<div className="w-full max-w-md space-y-8"> | |
<h1 className="text-2xl font-bold text-center">Multi-Select Dropdown</h1> | |
<div className="space-y-2"> | |
<label className="text-sm font-medium">Select Items</label> | |
<MultiSelect | |
options={options} | |
selected={selected} | |
onChange={setSelected} | |
placeholder="Select items..." | |
allowCustom={true} | |
/> | |
</div> | |
<div className="mt-8 p-4 border rounded-md"> | |
<h2 className="font-medium mb-2">Selected Values:</h2> | |
{selected.length > 0 ? ( | |
<ul className="list-disc pl-5"> | |
{selected.map((value) => ( | |
<li key={value}>{value}</li> | |
))} | |
</ul> | |
) : ( | |
<p className="text-muted-foreground">No items selected</p> | |
)} | |
</div> | |
</div> | |
</main> | |
) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react" | |
import { ChevronsUpDown, Search, X } from "lucide-react" | |
import { cn } from "@/lib/utils" | |
import { Button } from "@/components/ui/button" | |
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" | |
import { Checkbox } from "@/components/ui/checkbox" | |
import { Badge } from "@/components/ui/badge" | |
import { ScrollArea } from "@/components/ui/scroll-area" | |
import { Input } from "@/components/ui/input" | |
// Define the option type with support for nested groups | |
export type Option = { | |
value: string | |
label: string | |
group?: string | |
disabled?: boolean | |
children?: Option[] | |
} | |
type MultiSelectProps = { | |
options: Option[] | |
selected: string[] | |
onChange: (values: string[]) => void | |
placeholder?: string | |
className?: string | |
allowCustom?: boolean | |
} | |
export function MultiSelect({ | |
options, | |
selected, | |
onChange, | |
placeholder = "Select options", | |
className, | |
allowCustom = false, | |
}: MultiSelectProps) { | |
const [open, setOpen] = React.useState(false) | |
const [searchQuery, setSearchQuery] = React.useState("") | |
const [customValue, setCustomValue] = React.useState("") | |
// Flatten options for search and selection | |
const getAllOptions = React.useCallback((opts: Option[]): Option[] => { | |
return opts.reduce((acc: Option[], curr) => { | |
if (curr.children && curr.children.length > 0) { | |
return [...acc, curr, ...getAllOptions(curr.children)] | |
} | |
return [...acc, curr] | |
}, []) | |
}, []) | |
const flatOptions = React.useMemo(() => getAllOptions(options), [options, getAllOptions]) | |
// Filter options based on search query | |
const filteredOptions = React.useMemo(() => { | |
if (!searchQuery) return options | |
// Create a map to track which options should be shown | |
const showOption = new Map<string, boolean>() | |
// Helper function to check if an option or any of its children match the search | |
const checkOptionAndChildren = (option: Option): boolean => { | |
// Check if the current option matches | |
const currentMatches = option.label.toLowerCase().includes(searchQuery.toLowerCase()) | |
// If it's a leaf node or it matches, return the result | |
if (!option.children || option.children.length === 0) { | |
return currentMatches | |
} | |
// Check if any children match | |
const anyChildMatches = option.children.some((child) => checkOptionAndChildren(child)) | |
// If any child matches, mark all children as visible for proper hierarchy | |
if (anyChildMatches) { | |
option.children.forEach((child) => { | |
showOption.set(child.value, true) | |
}) | |
} | |
// Return true if either the current option or any child matches | |
return currentMatches || anyChildMatches | |
} | |
// First pass: determine which options match the search | |
options.forEach((option) => { | |
if (checkOptionAndChildren(option)) { | |
showOption.set(option.value, true) | |
} | |
}) | |
// Second pass: filter the options based on the map | |
const filterWithHierarchy = (opts: Option[]): Option[] => { | |
return opts | |
.filter((opt) => showOption.has(opt.value)) | |
.map((opt) => { | |
if (opt.children && opt.children.length > 0) { | |
// Keep the children that should be shown | |
const filteredChildren = opt.children.filter((child) => showOption.has(child.value)) | |
// Only include children array if there are children to show | |
return filteredChildren.length > 0 ? { ...opt, children: filteredChildren } : opt | |
} | |
return opt | |
}) | |
} | |
return filterWithHierarchy(options) | |
}, [options, searchQuery]) | |
// Check if all children of a group are selected | |
const areAllChildrenSelected = React.useCallback( | |
(children: Option[]) => { | |
if (!children || children.length === 0) return false | |
const selectableChildren = children.filter((child) => !child.disabled) | |
return selectableChildren.length > 0 && selectableChildren.every((child) => selected.includes(child.value)) | |
}, | |
[selected], | |
) | |
// Check if some children of a group are selected | |
const areSomeChildrenSelected = React.useCallback( | |
(children: Option[]) => { | |
if (!children || children.length === 0) return false | |
return children.some((child) => selected.includes(child.value)) | |
}, | |
[selected], | |
) | |
// Toggle selection for a group | |
const toggleGroup = React.useCallback( | |
(groupChildren: Option[]) => { | |
if (!groupChildren || groupChildren.length === 0) return | |
const selectableChildren = groupChildren.filter((child) => !child.disabled) | |
const childValues = selectableChildren.map((child) => child.value) | |
if (areAllChildrenSelected(groupChildren)) { | |
// Deselect all children | |
onChange(selected.filter((value) => !childValues.includes(value))) | |
} else { | |
// Select all children | |
const newSelected = [...selected] | |
childValues.forEach((value) => { | |
if (!newSelected.includes(value)) { | |
newSelected.push(value) | |
} | |
}) | |
onChange(newSelected) | |
} | |
}, | |
[selected, onChange, areAllChildrenSelected], | |
) | |
// Toggle selection for an individual option | |
const toggleOption = React.useCallback( | |
(value: string) => { | |
if (selected.includes(value)) { | |
onChange(selected.filter((v) => v !== value)) | |
} else { | |
onChange([...selected, value]) | |
} | |
}, | |
[selected, onChange], | |
) | |
// Handle custom value addition | |
const handleAddCustomValue = () => { | |
if (customValue && !selected.includes(customValue)) { | |
onChange([...selected, customValue]) | |
setCustomValue("") | |
} | |
} | |
// Render option or group recursively - using div instead of CommandItem | |
const renderOptions = (options: Option[], level = 0) => { | |
if (!options || options.length === 0) { | |
return null | |
} | |
return options.map((option) => { | |
if (option.children && option.children.length > 0) { | |
const isGroupSelected = areAllChildrenSelected(option.children) | |
const isPartiallySelected = !isGroupSelected && areSomeChildrenSelected(option.children) | |
return ( | |
<div key={option.value}> | |
<div | |
className={cn( | |
"flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-accent", | |
level > 0 && "pl-[28px]", | |
)} | |
onClick={() => toggleGroup(option.children!)} | |
> | |
<Checkbox | |
checked={isGroupSelected} | |
ref={(checkbox) => { | |
if (checkbox) { | |
checkbox.indeterminate = isPartiallySelected | |
} | |
}} | |
onCheckedChange={() => toggleGroup(option.children!)} | |
/> | |
<span className="font-medium">{option.label}</span> | |
{isPartiallySelected && <span className="h-2 w-2 rounded-full bg-primary ml-auto" />} | |
</div> | |
{option.children && option.children.length > 0 && ( | |
<div className="pl-4">{renderOptions(option.children, level + 1)}</div> | |
)} | |
</div> | |
) | |
} | |
return ( | |
<div | |
key={option.value} | |
className={cn( | |
"flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-accent", | |
level > 0 && "pl-[28px]", | |
option.disabled && "opacity-50 pointer-events-none", | |
)} | |
onClick={() => !option.disabled && toggleOption(option.value)} | |
> | |
<Checkbox | |
checked={selected.includes(option.value)} | |
onCheckedChange={() => toggleOption(option.value)} | |
disabled={option.disabled} | |
/> | |
<span>{option.label}</span> | |
</div> | |
) | |
}) | |
} | |
// Get display labels for selected values | |
const getSelectedLabels = () => { | |
return selected.map((value) => { | |
const option = flatOptions.find((o) => o.value === value) | |
return option ? option.label : value | |
}) | |
} | |
return ( | |
<Popover open={open} onOpenChange={setOpen}> | |
<PopoverTrigger asChild> | |
<Button | |
variant="outline" | |
role="combobox" | |
aria-expanded={open} | |
className={cn("w-full justify-between", className)} | |
> | |
<div className="flex flex-wrap gap-1 items-center"> | |
{selected.length === 0 ? ( | |
<span className="text-muted-foreground">{placeholder}</span> | |
) : selected.length <= 5 ? ( | |
getSelectedLabels().map((label, i) => ( | |
<Badge key={i} variant="secondary" className="mr-1"> | |
{label} | |
<button | |
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" | |
onMouseDown={(e) => { | |
e.preventDefault() | |
e.stopPropagation() | |
toggleOption(selected[i]) | |
}} | |
> | |
<X className="h-3 w-3" /> | |
</button> | |
</Badge> | |
)) | |
) : ( | |
<span>{selected.length} selected</span> | |
)} | |
</div> | |
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> | |
</Button> | |
</PopoverTrigger> | |
<PopoverContent className="w-[300px] p-0"> | |
<div className="flex items-center border-b px-3"> | |
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> | |
<Input | |
placeholder="Search options..." | |
value={searchQuery} | |
onChange={(e) => setSearchQuery(e.target.value)} | |
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 border-0" | |
/> | |
</div> | |
<ScrollArea className="h-[300px] overflow-y-auto"> | |
<div className="p-1"> | |
{filteredOptions.length > 0 ? ( | |
renderOptions(filteredOptions) | |
) : ( | |
<div className="p-4 text-center text-sm text-muted-foreground"> | |
{allowCustom ? ( | |
<div className="flex flex-col gap-2"> | |
<p>No options found.</p> | |
<div className="flex items-center gap-2"> | |
<Input | |
type="text" | |
value={customValue} | |
onChange={(e) => setCustomValue(e.target.value)} | |
placeholder="Add custom value" | |
/> | |
<Button size="sm" onClick={handleAddCustomValue}> | |
Add | |
</Button> | |
</div> | |
</div> | |
) : ( | |
"No options found." | |
)} | |
</div> | |
)} | |
</div> | |
</ScrollArea> | |
</PopoverContent> | |
</Popover> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment