Created
March 22, 2025 04:40
-
-
Save nixjs/3af5a91d8d79c6032f1367e9ca32c19e to your computer and use it in GitHub Desktop.
Multi level dropdown
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
"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> | |
) | |
} | |
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
"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