Skip to content

Instantly share code, notes, and snippets.

@angelsantosa
Last active March 19, 2025 16:35
Show Gist options
  • Save angelsantosa/1ae03cc7396e2bfce2fed84379409524 to your computer and use it in GitHub Desktop.
Save angelsantosa/1ae03cc7396e2bfce2fed84379409524 to your computer and use it in GitHub Desktop.
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>
)
}
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