Skip to content

Multiple Select Tree

Enhanced <MultiSelectTree /> component for shadcn-ui

Warning: Works for only one level of children. Does not work for multiple levels of children.


Warning: The checkbox may not work due to Astro.

Requirements

Terminal window
npx shadcn-ui@latest add button
npx shadcn-ui@latest add checkbox
npx shadcn-ui@latest add popover
npx shadcn-ui@latest add accordion
npx shadcn-ui@latest add scroll-area
MultiSelectTree.jsx
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { ChevronDown, X } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { TreeItem } from "./TreeItem"
export default function MultiSelectTree({ options, selectedList, onChangeSelectedList }) {
const [open, setOpen] = useState(false)
return (
<div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" onClick={() => setOpen(true)} className="font-normal flex justify-between items-center min-w-[20rem]">
<span>
{
selectedList?.length === 0 ? "Select items" :
`${selectedList?.length} items selected`
}
</span>
<div className="flex gap-2 items-center">
{
selectedList?.length > 0 && (
<X
className="h-4 w-4 opacity-50 cursor-pointer"
onClick={(e) => {
e.stopPropagation()
onChangeSelectedList([])
}}
/>
)
}
<span>
<ChevronDown className="h-4 w-4 opacity-50" />
</span>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-2 w-[20rem]">
<ScrollArea className="min-h-[10rem]">
{options.map((option, index) => (
<TreeItem
key={index}
label={option.label}
value={option.value}
child={option.children}
selectedList={selectedList}
onChecked={onChangeSelectedList}
/>
))}
</ScrollArea>
</PopoverContent>
</Popover>
</div>
)
}
TreeItem.jsx
import { useEffect } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Item } from "./Item";
import { TreeParentItem } from "./TreeParentItem";
export const TreeItem = ({ label, value, child, selectedList, onChecked }) => {
useEffect(() => {
let itemChild = child.map((child) => child.value);
// If the parent item is selected and none of the child items are selected
if (
selectedList.includes(value) &&
selectedList.every((item) => !itemChild.includes(item))
) {
onChecked(selectedList.filter((item) => item !== value));
}
// If the parent item is not selected and all child items are selected
else if (
!selectedList.includes(value) &&
itemChild.every((item) => selectedList.includes(item))
) {
onChecked([...selectedList, value]);
}
}, [selectedList, value, child, onChecked]);
const handleSelectAllItems = (value) => {
let itemChild = child.map((child) => child.value)
// If the parent item is selected, unselect all its children and the parent item
if (selectedList.includes(value)) {
let copy = [...selectedList]
copy = copy.filter((item) => !itemChild.includes(item))
copy = copy.filter((item) => item !== value)
onChecked(copy)
}
// If the parent item is not selected, select all its children and the parent item
else {
onChecked([...selectedList, ...itemChild, value])
}
};
const handleCheckChildItems = (value) => {
let copy = [...selectedList]
// If the child item is selected, unselect it
if (selectedList.includes(value)) {
copy = copy.filter((item) => item !== value)
}
// If the child item is not selected, select it
else {
copy.push(value)
}
onChecked(copy)
}
return (
<Accordion type="single" collapsible>
<AccordionItem value={value}>
<AccordionTrigger>
<TreeParentItem
label={label}
checked={selectedList.includes(value)}
onSelectAllItems={() => handleSelectAllItems(value)}
/>
</AccordionTrigger>
<AccordionContent>
<div className="pl-4">
{child && child.map((child, index) => (
<Item
key={index}
label={child.label}
value={child.value}
checked={selectedList.includes(child.value)}
onChecked={handleCheckChildItems}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)
}
Item.jsx
import { Checkbox } from "@/components/ui/checkbox"
export const Item = ({ label, value, checked, onChecked }) => {
return (
<div onClick={() => onChecked(value)} className="relative flex gap-2 w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-2 text-sm outline-none hover:bg-slate-100 focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50">
<Checkbox checked={checked} />
<span>{label}</span>
</div>
)
}
TreeParentItem.jsx
import { Check } from "lucide-react"
export const TreeParentItem = ({
checked,
label,
onSelectAllItems,
}) => {
return (
<div className='flex gap-2 items-center' onClick={onSelectAllItems}>
<div
className="peer flex justify-center items-center h-4 w-4 shrink-0 rounded-sm border border-slate-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-slate-900 data-[state=checked]:text-slate-50 dark:border-slate-800 dark:border-slate-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300 dark:data-[state=checked]:bg-slate-50 dark:data-[state=checked]:text-slate-900"
onClick={onSelectAllItems}
data-state={checked ? 'checked' : 'unchecked'}
>
<input
type="checkbox"
className='appearance-none'
/>
{checked && <Check className="h-4 w-4" />}
</div>
<span className="text-sm">{label}</span>
</div>
)
}

Usage

import MultiSelectTree from "@/components/extra-ui/multi-select-tree/MultiSelectTree";
import { useState } from "react";
export default function Page() {
const [selectedList, setSelectedList] = useState([]);
const items = [
{
label: "Option 1",
value: "option1",
children: [
{
label: "Option 1.1",
value: "option1.1"
},
{
label: "Option 1.2",
value: "option1.2"
}
]
},
{
label: "Option 2",
value: "option2",
children: [
{
label: "Option 2.1",
value: "option2.1"
},
{
label: "Option 2.2",
value: "option2.2"
},
{
label: "Option 2.3",
value: "option2.3"
}
]
},
{
label: "Option 3",
value: "option3",
children: [
{
label: "Option 3.1",
value: "option3.1"
},
{
label: "Option 3.2",
value: "option3.2"
},
{
label: "Option 3.3",
value: "option3.3"
},
{
label: "Option 3.4",
value: "option3.4"
}
]
}
]
return (
<div>
<MultiSelectTree
options={items}
selectedList={selectedList}
onChangeSelectedList={setSelectedList}
/>
</div>
);
}