Skip to content

Instantly share code, notes, and snippets.

@nixjs
Created March 22, 2025 04:40
Show Gist options
  • Save nixjs/3af5a91d8d79c6032f1367e9ca32c19e to your computer and use it in GitHub Desktop.
Save nixjs/3af5a91d8d79c6032f1367e9ca32c19e to your computer and use it in GitHub Desktop.
Multi level dropdown
"use client"
import { useState } from "react"
import { MultiLevelDropdown, type MenuItem } from "./multi-level-dropdown-controlled"
// Sample data structure
const locationData: MenuItem = {
id: "root",
label: "Location",
children: [
{
id: "hcm",
label: "Ho Chi Minh",
value: "hcm",
children: [
{
id: "district1",
label: "District 1",
value: "hcm-district1",
children: [
{ id: "ward1", label: "Ward 1", value: "hcm-district1-ward1" },
{ id: "ward2", label: "Ward 2", value: "hcm-district1-ward2" },
{ id: "ward3", label: "Ward 3", value: "hcm-district1-ward3" },
{ id: "ward4", label: "Ward 4", value: "hcm-district1-ward4" },
{ id: "ward5", label: "Ward 5", value: "hcm-district1-ward5" },
],
},
{ id: "district2", label: "District 2", value: "hcm-district2" },
{ id: "district3", label: "District 3", value: "hcm-district3" },
{ id: "district4", label: "District 4", value: "hcm-district4" },
{ id: "district5", label: "District 5", value: "hcm-district5" },
],
},
{ id: "hanoi", label: "Ha Noi", value: "hanoi" },
{ id: "vungtau", label: "Vung Tau", value: "vungtau" },
{ id: "dalat", label: "Da Lat", value: "dalat" },
{ id: "nhatrang", label: "Nha Trang", value: "nhatrang" },
],
}
export default function DemoControlled() {
// For controlled component
const [controlledValue, setControlledValue] = useState<string | null>("hcm-district1-ward3")
// For tracking selection path
const [selectedPath, setSelectedPath] = useState<MenuItem[]>([])
const handleLocationChange = (value: string | null, path: MenuItem[]) => {
setControlledValue(value)
setSelectedPath(path)
}
// Buttons to demonstrate controlling the dropdown from parent
const selectPresetLocation = (newValue: string | null) => {
setControlledValue(newValue)
}
return (
<div className="p-6 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-6">Multi-level Dropdown Demo</h1>
<div className="mb-8">
<h2 className="text-lg font-medium mb-2">Controlled Dropdown</h2>
<MultiLevelDropdown
data={locationData}
value={controlledValue}
onChange={handleLocationChange}
placeholder="Select location"
/>
</div>
<div className="mb-8">
<h2 className="text-lg font-medium mb-2">Default Value Dropdown</h2>
<MultiLevelDropdown
data={locationData}
defaultValue="hanoi"
onChange={(value, path) => console.log("Default value dropdown changed:", value)}
placeholder="Select location"
/>
</div>
<div className="mb-8">
<h2 className="text-lg font-medium mb-2">Empty Default Value</h2>
<MultiLevelDropdown
data={locationData}
defaultValue={null}
onChange={(value, path) => console.log("Empty default dropdown changed:", value)}
placeholder="No initial selection"
/>
</div>
<div className="mb-8">
<h2 className="text-lg font-medium mb-2">Control from Parent</h2>
<div className="flex flex-wrap gap-2 mb-4">
<button
onClick={() => selectPresetLocation("hcm")}
className="px-3 py-1 bg-blue-100 rounded hover:bg-blue-200"
>
Set to Ho Chi Minh
</button>
<button
onClick={() => selectPresetLocation("hanoi")}
className="px-3 py-1 bg-blue-100 rounded hover:bg-blue-200"
>
Set to Ha Noi
</button>
<button
onClick={() => selectPresetLocation("hcm-district1-ward2")}
className="px-3 py-1 bg-blue-100 rounded hover:bg-blue-200"
>
Set to District 1, Ward 2
</button>
<button onClick={() => selectPresetLocation(null)} className="px-3 py-1 bg-red-100 rounded hover:bg-red-200">
Clear selection
</button>
</div>
</div>
<div className="p-4 bg-gray-50 rounded-md">
<h3 className="font-medium mb-2">Selected Value:</h3>
<p className="text-blue-600">{controlledValue || "(empty)"}</p>
<h3 className="font-medium mt-4 mb-2">Selection Path:</h3>
{selectedPath.length > 0 ? (
<ul className="list-disc pl-5 space-y-1">
{selectedPath.map((item, index) => (
<li key={index} className={index === selectedPath.length - 1 ? "font-medium" : ""}>
{item.label}
</li>
))}
</ul>
) : (
<p className="text-gray-500">No selection</p>
)}
</div>
</div>
)
}
"use client"
import { useState, useRef, useEffect } from "react"
import { ChevronLeft, ChevronRight, Menu } from "lucide-react"
// Define the data structure for menu items
export type MenuItem = {
id: string
label: string
children?: MenuItem[]
value?: string
}
interface MultiLevelDropdownProps {
data: MenuItem
value?: string | null
defaultValue?: string | null
onChange?: (value: string | null, path: MenuItem[]) => void
placeholder?: string
className?: string
}
export function MultiLevelDropdown({
data,
value,
defaultValue,
onChange,
placeholder = "Select an option",
className = "",
}: MultiLevelDropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [navigationPath, setNavigationPath] = useState<MenuItem[]>([data])
const [internalValue, setInternalValue] = useState<string | null>(defaultValue ?? null)
const [selectedLabel, setSelectedLabel] = useState<string>("")
const [selectedPath, setSelectedPath] = useState<MenuItem[]>([])
const dropdownRef = useRef<HTMLDivElement>(null)
const isControlled = value !== undefined
// Current level is the last item in the navigation path
const currentLevel = navigationPath[navigationPath.length - 1]
// Find an item and its path by value
const findItemByValue = (
item: MenuItem,
searchValue: string,
currentPath: MenuItem[] = [],
): { item: MenuItem; path: MenuItem[] } | null => {
const newPath = [...currentPath, item]
// Check if current item matches
if (item.value === searchValue || item.id === searchValue) {
return { item, path: newPath }
}
// Check children if they exist
if (item.children) {
for (const child of item.children) {
const result = findItemByValue(child, searchValue, newPath)
if (result) return result
}
}
return null
}
// Initialize with default value or controlled value
useEffect(() => {
const valueToUse = isControlled ? value : defaultValue
if (valueToUse) {
const result = findItemByValue(data, valueToUse)
if (result) {
setSelectedLabel(result.item.label)
setSelectedPath(result.path)
if (!isControlled) {
setInternalValue(valueToUse)
}
}
} else if (valueToUse === null || valueToUse === "") {
// Handle explicit empty values
setSelectedLabel("")
setSelectedPath([])
if (!isControlled) {
setInternalValue(null)
}
}
}, [data, defaultValue, isControlled, value])
// Update when controlled value changes
useEffect(() => {
if (isControlled) {
if (value) {
const result = findItemByValue(data, value)
if (result) {
setSelectedLabel(result.item.label)
setSelectedPath(result.path)
}
} else {
// Handle null, empty string, or undefined
setSelectedLabel("")
setSelectedPath([])
}
}
}, [data, isControlled, value])
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
// Navigate to a child item
const navigateToChild = (item: MenuItem) => {
if (item.children && item.children.length > 0) {
setNavigationPath([...navigationPath, item])
} else {
// If it's a leaf node, select it and close the dropdown
const newPath = [...navigationPath, item]
const newValue = item.value || item.id
setSelectedLabel(item.label)
setSelectedPath(newPath)
if (!isControlled) {
setInternalValue(newValue)
}
onChange?.(newValue, newPath)
setIsOpen(false)
// Reset navigation path for next open
setNavigationPath([data])
}
}
// Navigate back to parent
const navigateBack = () => {
if (navigationPath.length > 1) {
setNavigationPath(navigationPath.slice(0, -1))
}
}
// Handle done button click
const handleDone = () => {
const newPath = [...navigationPath]
const newValue = currentLevel.value || currentLevel.id
setSelectedLabel(currentLevel.label)
setSelectedPath(newPath)
if (!isControlled) {
setInternalValue(newValue)
}
onChange?.(newValue, newPath)
setIsOpen(false)
setNavigationPath([data])
}
// Clear selection
const clearSelection = () => {
setSelectedLabel("")
setSelectedPath([])
if (!isControlled) {
setInternalValue(null)
}
onChange?.(null, [])
setIsOpen(false)
setNavigationPath([data])
}
// Animation classes for smooth transitions
const dropdownClasses = `absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg border overflow-hidden
transition-all duration-200 ${isOpen ? "opacity-100 scale-100" : "opacity-0 scale-95 pointer-events-none"}`
// When dropdown opens, navigate to the appropriate level if a value is selected
const handleOpenDropdown = () => {
if (!isOpen && selectedPath.length > 0) {
// If we have a selected path, open the dropdown at the parent of the selected item
const parentPath = selectedPath.length > 1 ? selectedPath.slice(0, -1) : [data]
setNavigationPath(parentPath)
}
setIsOpen(!isOpen)
}
return (
<div className={`relative w-full ${className}`} ref={dropdownRef}>
{/* Dropdown trigger button */}
<button
className="flex items-center justify-between w-full p-3 border rounded-md bg-white hover:border-gray-300 transition-colors"
onClick={handleOpenDropdown}
aria-expanded={isOpen}
aria-haspopup="true"
>
<span className={`${!selectedLabel ? "text-gray-500" : ""}`}>{selectedLabel || placeholder}</span>
<Menu className="h-5 w-5 text-gray-500" />
</button>
{/* Dropdown menu */}
<div className={dropdownClasses}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
{navigationPath.length > 1 ? (
<button
onClick={navigateBack}
className="p-1 hover:bg-gray-100 rounded-full transition-colors"
aria-label="Go back"
>
<ChevronLeft className="h-5 w-5" />
</button>
) : (
<div className="w-5"></div> // Empty space for alignment
)}
<span className="font-medium">{currentLevel.label}</span>
<div className="flex items-center gap-2">
{selectedLabel && (
<button
onClick={clearSelection}
className="text-red-500 text-sm font-medium hover:text-red-600 transition-colors"
>
Clear
</button>
)}
<button onClick={handleDone} className="text-blue-500 font-medium hover:text-blue-600 transition-colors">
Done
</button>
</div>
</div>
{/* Menu items */}
<div className="max-h-60 overflow-y-auto">
{currentLevel.children?.map((item) => {
const isSelected = selectedPath.some((p) => p.id === item.id)
return (
<div
key={item.id}
className={`flex items-center justify-between p-4 hover:bg-gray-50 cursor-pointer border-b border-gray-100 transition-colors
${isSelected ? "bg-blue-50" : ""}`}
onClick={() => navigateToChild(item)}
role="option"
aria-selected={isSelected}
>
<span>{item.label}</span>
{item.children && item.children.length > 0 && <ChevronRight className="h-4 w-4 text-gray-400" />}
</div>
)
})}
</div>
</div>
</div>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment