Hierarchical Selection
Learn how to build hierarchical checkbox selection patterns using foundation components and reusable utilities.
Overview
Hierarchical selection allows users to select items in a parent-child relationship structure, where selecting a parent automatically selects all its children, and selecting all children automatically selects the parent. This pattern is commonly used for category selection, permissions management, location pickers, and filtering interfaces.
Why a Guide?
Rather than providing a rigid component with numerous props, this guide teaches you how to build hierarchical selection patterns using our existing foundation components. This approach:
- Teaches the pattern instead of hiding complexity
- Provides flexibility to adapt to your specific needs
- Uses foundation components like
Disclosure,Checkbox, andInput - Offers reusable utilities that can be copied to your project
- Follows composition principles established in the foundation system
Core Concepts
Data Structure
Hierarchical data follows a simple recursive structure:
interface HierarchicalItem {
id: string;
label: string;
children?: HierarchicalItem[];
disabled?: boolean;
}
Selection Logic
The key behaviors in hierarchical selection are:
- Parent Selection: Selecting a parent selects all enabled children
- Child Selection: When all children are selected, the parent becomes selected
- Indeterminate State: When some (but not all) children are selected, the parent shows indeterminate
- Disabled Items: Disabled items are excluded from parent-child selection logic
Source Code
Data Utilities
These utilities handle the hierarchical data structure and selection logic:
export interface HierarchicalItem {
id: string;
label: string;
children?: HierarchicalItem[];
disabled?: boolean;
}
export interface HierarchicalSelectionState {
selectedIds: Set<string>;
openParentIds: Set<string>;
}
export interface SelectionStatus {
checked: boolean;
indeterminate: boolean;
}
/**
* Flattens a hierarchical data structure to get only leaf items (items without children)
*/
export function flattenHierarchicalData(
items: HierarchicalItem[]
): HierarchicalItem[] {
return items.reduce<HierarchicalItem[]>((acc, item) => {
if (item.children) {
acc.push(...flattenHierarchicalData(item.children));
} else {
acc.push(item);
}
return acc;
}, []);
}
/**
* Gets all child IDs for a given parent item
*/
export function getChildIds(item: HierarchicalItem): string[] {
if (!item.children) return [];
return flattenHierarchicalData(item.children).map((child) => child.id);
}
/**
* Gets all enabled child IDs for a given parent item
*/
export function getEnabledChildIds(item: HierarchicalItem): string[] {
if (!item.children) return [];
return flattenHierarchicalData(item.children)
.filter((child) => !child.disabled)
.map((child) => child.id);
}
/**
* Determines the selection status of a parent item based on its children
*/
export function getParentSelectionStatus(
parentItem: HierarchicalItem,
selectedIds: Set<string>
): SelectionStatus {
if (parentItem.disabled) {
return { checked: false, indeterminate: false };
}
const enabledChildIds = getEnabledChildIds(parentItem);
if (enabledChildIds.length === 0) {
return { checked: false, indeterminate: false };
}
const selectedChildCount = enabledChildIds.filter((id) =>
selectedIds.has(id)
).length;
if (selectedChildCount === 0) {
return { checked: false, indeterminate: false };
} else if (selectedChildCount === enabledChildIds.length) {
return { checked: true, indeterminate: false };
} else {
return { checked: false, indeterminate: true };
}
}
/**
* Gets the selection status for "select all" functionality
*/
export function getSelectAllStatus(
items: HierarchicalItem[],
selectedIds: Set<string>
): SelectionStatus {
const allEnabledIds = flattenHierarchicalData(items)
.filter((item) => !item.disabled)
.map((item) => item.id);
if (allEnabledIds.length === 0) {
return { checked: false, indeterminate: false };
}
const selectedEnabledCount = allEnabledIds.filter((id) =>
selectedIds.has(id)
).length;
if (selectedEnabledCount === 0) {
return { checked: false, indeterminate: false };
} else if (selectedEnabledCount === allEnabledIds.length) {
return { checked: true, indeterminate: false };
} else {
return { checked: false, indeterminate: true };
}
}
/**
* Filters hierarchical data based on a search query
*/
export function filterHierarchicalData(
items: HierarchicalItem[],
searchQuery: string
): HierarchicalItem[] {
if (!searchQuery.trim()) return items;
const query = searchQuery.toLowerCase();
return items
.map((item) => {
const matchingChildren = item.children?.filter((child) =>
child.label.toLowerCase().includes(query)
);
if (item.label.toLowerCase().includes(query)) {
return item;
} else if (matchingChildren?.length) {
return { ...item, children: matchingChildren };
}
return null;
})
.filter(Boolean) as HierarchicalItem[];
} Selection Hook
The useHierarchicalSelection hook provides state management and selection methods:
'use client';
import { useCallback, useMemo, useState } from 'react';
import {
flattenHierarchicalData,
getEnabledChildIds,
getParentSelectionStatus,
getSelectAllStatus,
type HierarchicalItem,
type HierarchicalSelectionState,
type SelectionStatus,
} from './hierarchical-data';
export interface UseHierarchicalSelectionOptions {
/** Initial selected item IDs */
defaultSelected?: string[];
/** Initial opened parent IDs */
defaultOpened?: string[];
/** Callback when selection changes */
onSelectionChange?: (selectedIds: string[]) => void;
/** Callback when opened parents change */
onOpenChange?: (openedIds: string[]) => void;
}
export interface UseHierarchicalSelectionResult {
/** Current selected item IDs as a Set for efficient lookup */
selectedIds: Set<string>;
/** Current opened parent IDs as a Set for efficient lookup */
openParentIds: Set<string>;
/** Current selected item IDs as an array */
selectedArray: string[];
/** Current opened parent IDs as an array */
openedArray: string[];
/** Toggle selection of a single item */
toggleItem: (itemId: string) => void;
/** Select/deselect a parent and all its enabled children */
toggleParent: (parentItem: HierarchicalItem, checked: boolean) => void;
/** Select/deselect all enabled items */
toggleAll: (items: HierarchicalItem[], checked: boolean) => void;
/** Toggle the opened state of a parent */
toggleParentOpen: (parentId: string) => void;
/** Clear all selections */
clearSelection: () => void;
/** Select specific items */
selectItems: (itemIds: string[]) => void;
/** Get selection status for a parent item */
getParentStatus: (parentItem: HierarchicalItem) => SelectionStatus;
/** Get selection status for "select all" */
getSelectAllStatus: (items: HierarchicalItem[]) => SelectionStatus;
/** Check if an item is selected */
isSelected: (itemId: string) => boolean;
/** Check if a parent is opened */
isParentOpen: (parentId: string) => boolean;
}
export function useHierarchicalSelection(
options: UseHierarchicalSelectionOptions = {}
): UseHierarchicalSelectionResult {
const {
defaultSelected = [],
defaultOpened = [],
onSelectionChange,
onOpenChange,
} = options;
// Internal state
const [state, setState] = useState<HierarchicalSelectionState>({
selectedIds: new Set(defaultSelected),
openParentIds: new Set(defaultOpened),
});
// Derived values
const selectedArray = useMemo(
() => Array.from(state.selectedIds),
[state.selectedIds]
);
const openedArray = useMemo(
() => Array.from(state.openParentIds),
[state.openParentIds]
);
// Update callbacks when arrays change
const notifySelectionChange = useCallback(
(newSelectedIds: Set<string>) => {
const newArray = Array.from(newSelectedIds);
onSelectionChange?.(newArray);
},
[onSelectionChange]
);
const notifyOpenChange = useCallback(
(newOpenIds: Set<string>) => {
const newArray = Array.from(newOpenIds);
onOpenChange?.(newArray);
},
[onOpenChange]
);
// Selection methods
const toggleItem = useCallback(
(itemId: string) => {
setState((prev) => {
const newSelectedIds = new Set(prev.selectedIds);
if (newSelectedIds.has(itemId)) {
newSelectedIds.delete(itemId);
} else {
newSelectedIds.add(itemId);
}
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
},
[notifySelectionChange]
);
const toggleParent = useCallback(
(parentItem: HierarchicalItem, checked: boolean) => {
if (parentItem.disabled) return;
setState((prev) => {
const newSelectedIds = new Set(prev.selectedIds);
const enabledChildIds = getEnabledChildIds(parentItem);
if (checked) {
enabledChildIds.forEach((id) => {
newSelectedIds.add(id);
});
} else {
enabledChildIds.forEach((id) => {
newSelectedIds.delete(id);
});
}
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
},
[notifySelectionChange]
);
const toggleAll = useCallback(
(items: HierarchicalItem[], checked: boolean) => {
setState((prev) => {
const allEnabledIds = flattenHierarchicalData(items)
.filter((item) => !item.disabled)
.map((item) => item.id);
let newSelectedIds: Set<string>;
if (checked) {
newSelectedIds = new Set([...prev.selectedIds, ...allEnabledIds]);
} else {
newSelectedIds = new Set(
Array.from(prev.selectedIds).filter(
(id) => !allEnabledIds.includes(id)
)
);
}
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
},
[notifySelectionChange]
);
const toggleParentOpen = useCallback(
(parentId: string) => {
setState((prev) => {
const newOpenIds = new Set(prev.openParentIds);
if (newOpenIds.has(parentId)) {
newOpenIds.delete(parentId);
} else {
newOpenIds.add(parentId);
}
notifyOpenChange(newOpenIds);
return {
...prev,
openParentIds: newOpenIds,
};
});
},
[notifyOpenChange]
);
const clearSelection = useCallback(() => {
setState((prev) => {
const newSelectedIds = new Set<string>();
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
}, [notifySelectionChange]);
const selectItems = useCallback(
(itemIds: string[]) => {
setState((prev) => {
const newSelectedIds = new Set(itemIds);
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
},
[notifySelectionChange]
);
// Status methods
const getParentStatus = useCallback(
(parentItem: HierarchicalItem): SelectionStatus => {
return getParentSelectionStatus(parentItem, state.selectedIds);
},
[state.selectedIds]
);
const getSelectAllStatusResult = useCallback(
(items: HierarchicalItem[]): SelectionStatus => {
return getSelectAllStatus(items, state.selectedIds);
},
[state.selectedIds]
);
const isSelected = useCallback(
(itemId: string): boolean => {
return state.selectedIds.has(itemId);
},
[state.selectedIds]
);
const isParentOpen = useCallback(
(parentId: string): boolean => {
return state.openParentIds.has(parentId);
},
[state.openParentIds]
);
return {
selectedIds: state.selectedIds,
openParentIds: state.openParentIds,
selectedArray,
openedArray,
toggleItem,
toggleParent,
toggleAll,
toggleParentOpen,
clearSelection,
selectItems,
getParentStatus,
getSelectAllStatus: getSelectAllStatusResult,
isSelected,
isParentOpen,
};
} Examples
Basic Implementation
A simple parent-child checkbox structure with collapsible sections using the Disclosure component:
Selected: spain
'use client';
import { Checkbox } from '@/components/checkbox';
import {
Disclosure,
DisclosureChevron,
DisclosureContent,
DisclosureTrigger,
} from '@/components/disclosure';
import type { HierarchicalItem } from '../hierarchical-data';
import { useHierarchicalSelection } from '../use-hierarchical-selection';
const sampleData: HierarchicalItem[] = [
{
id: 'europe',
label: 'Europe',
children: [
{ id: 'portugal', label: 'Portugal' },
{ id: 'spain', label: 'Spain' },
{ id: 'france', label: 'France' },
],
},
{
id: 'asia',
label: 'Asia',
children: [
{ id: 'south-korea', label: 'South Korea' },
{ id: 'japan', label: 'Japan' },
],
},
];
export default function BasicHierarchicalSelection() {
const {
selectedArray,
toggleItem,
toggleParent,
toggleParentOpen,
getParentStatus,
isSelected,
isParentOpen,
} = useHierarchicalSelection({
defaultSelected: ['spain'],
defaultOpened: ['europe'],
onSelectionChange: (selected) => {
console.log('Selected items:', selected);
},
});
return (
<div className="space-y-2">
<div className="mb-4">
<p className="text-foreground-secondary text-sm">
Selected: {selectedArray.join(', ') || 'None'}
</p>
</div>
<div className="space-y-1">
{sampleData.map((parent) => {
const parentStatus = getParentStatus(parent);
const isOpen = isParentOpen(parent.id);
return (
<Disclosure key={parent.id} open={isOpen}>
<div className="flex items-center justify-between gap-2">
<label className="flex flex-1 cursor-pointer items-center gap-2">
<Checkbox
checked={parentStatus.checked}
indeterminate={parentStatus.indeterminate}
onChange={(e) => toggleParent(parent, e.target.checked)}
/>
<span className="cursor-pointer font-medium text-base">
{parent.label}
</span>
</label>
<DisclosureTrigger
onClick={() => toggleParentOpen(parent.id)}
className="w-auto cursor-pointer rounded p-1"
>
<DisclosureChevron />
</DisclosureTrigger>
</div>
<DisclosureContent>
<div className="mt-2 ml-6 space-y-2">
{parent.children?.map((child) => (
<label
key={child.id}
className="flex cursor-pointer items-center gap-2"
>
<Checkbox
checked={isSelected(child.id)}
onChange={() => toggleItem(child.id)}
/>
<span className="cursor-pointer font-medium text-base">
{child.label}
</span>
</label>
))}
</div>
</DisclosureContent>
</Disclosure>
);
})}
</div>
</div>
);
} With Search Functionality
Adding search capabilities to filter the hierarchy while preserving the selection state:
Selected countries:
None
'use client';
import { useState } from 'react';
import { Checkbox } from '@/components/checkbox';
import {
Disclosure,
DisclosureChevron,
DisclosureContent,
DisclosureTrigger,
} from '@/components/disclosure';
import { Divider } from '@/components/divider';
import { Input } from '@/components/input';
import {
filterHierarchicalData,
type HierarchicalItem,
} from '../hierarchical-data';
import { useHierarchicalSelection } from '../use-hierarchical-selection';
const sampleData: HierarchicalItem[] = [
{
id: 'europe',
label: 'Europe',
children: [
{ id: 'portugal', label: 'Portugal' },
{ id: 'spain', label: 'Spain' },
{ id: 'france', label: 'France' },
{ id: 'germany', label: 'Germany' },
{ id: 'italy', label: 'Italy' },
],
},
{
id: 'asia',
label: 'Asia',
children: [
{ id: 'south-korea', label: 'South Korea' },
{ id: 'japan', label: 'Japan' },
{ id: 'china', label: 'China' },
{ id: 'thailand', label: 'Thailand' },
],
},
{
id: 'north-america',
label: 'North America',
children: [
{ id: 'united-states', label: 'United States' },
{ id: 'canada', label: 'Canada' },
{ id: 'mexico', label: 'Mexico' },
],
},
];
export default function HierarchicalSelectionWithSearch() {
const [searchQuery, setSearchQuery] = useState('');
const {
selectedArray,
toggleItem,
toggleParent,
toggleParentOpen,
toggleAll,
getParentStatus,
getSelectAllStatus,
isSelected,
isParentOpen,
} = useHierarchicalSelection({
defaultOpened: ['europe', 'asia'],
onSelectionChange: (selected) => {
console.log('Selected items:', selected);
},
});
// Filter data based on search query
const filteredData = filterHierarchicalData(sampleData, searchQuery);
const selectAllStatus = getSelectAllStatus(sampleData);
return (
<div className="w-full space-y-4">
{/* Search input */}
<Input
placeholder="Search countries..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
{/* Select all option - only show when not searching */}
{!searchQuery && (
<label className="flex cursor-pointer items-center gap-2">
<Checkbox
checked={selectAllStatus.checked}
indeterminate={selectAllStatus.indeterminate}
onChange={(e) => toggleAll(sampleData, e.target.checked)}
/>
<span className="cursor-pointer font-medium text-base">
All Countries
</span>
</label>
)}
<Divider />
{/* Results */}
{filteredData.length === 0 && searchQuery ? (
<p className="p-2 text-foreground-secondary text-sm">
No countries found
</p>
) : (
<div className="space-y-1">
{filteredData.map((parent) => {
const parentStatus = getParentStatus(parent);
const isOpen = isParentOpen(parent.id);
return (
<Disclosure key={parent.id} open={isOpen || !!searchQuery}>
<div className="flex items-center justify-between gap-2">
<label className="flex flex-1 cursor-pointer items-center gap-2">
<Checkbox
checked={parentStatus.checked}
indeterminate={parentStatus.indeterminate}
onChange={(e) => toggleParent(parent, e.target.checked)}
/>
<span className="cursor-pointer font-medium text-base">
{parent.label}
</span>
</label>
{!searchQuery && (
<DisclosureTrigger
onClick={() => toggleParentOpen(parent.id)}
className="w-auto cursor-pointer rounded p-1"
>
<DisclosureChevron />
</DisclosureTrigger>
)}
</div>
<DisclosureContent>
<div className="mt-2 ml-6 space-y-2">
{parent.children?.map((child) => (
<label
key={child.id}
className="flex cursor-pointer items-center gap-2"
>
<Checkbox
checked={isSelected(child.id)}
onChange={() => toggleItem(child.id)}
/>
<span className="cursor-pointer font-medium text-base">
{child.label}
</span>
</label>
))}
</div>
</DisclosureContent>
</Disclosure>
);
})}
</div>
)}
{/* Selected items display */}
<div className="mt-4 rounded bg-background-secondary p-3">
<p className="mb-1 font-medium text-sm">Selected countries:</p>
<p className="text-foreground-secondary text-sm">
{selectedArray.length > 0 ? selectedArray.join(', ') : 'None'}
</p>
</div>
</div>
);
} Complex Example
A full-featured implementation with disabled items, search, select-all, and action buttons:
Selected countries (2):
portugal, spain
Opened regions (1):
europe
'use client';
import { useState } from 'react';
import { Button } from '@/components/button';
import { Checkbox } from '@/components/checkbox';
import {
Disclosure,
DisclosureChevron,
DisclosureContent,
DisclosureTrigger,
} from '@/components/disclosure';
import { Divider } from '@/components/divider';
import { Input } from '@/components/input';
import { cn } from '@/lib/utils/classnames';
import {
filterHierarchicalData,
type HierarchicalItem,
} from '../hierarchical-data';
import { useHierarchicalSelection } from '../use-hierarchical-selection';
const sampleData: HierarchicalItem[] = [
{
id: 'europe',
label: 'Europe',
children: [
{ id: 'portugal', label: 'Portugal' },
{ id: 'spain', label: 'Spain' },
{ id: 'france', label: 'France' },
{ id: 'germany', label: 'Germany', disabled: true },
{ id: 'italy', label: 'Italy' },
{ id: 'netherlands', label: 'Netherlands' },
],
},
{
id: 'asia',
label: 'Asia',
disabled: true, // Entire region disabled
children: [
{ id: 'south-korea', label: 'South Korea' },
{ id: 'japan', label: 'Japan' },
{ id: 'china', label: 'China' },
{ id: 'thailand', label: 'Thailand' },
],
},
{
id: 'north-america',
label: 'North America',
children: [
{ id: 'united-states', label: 'United States' },
{ id: 'canada', label: 'Canada' },
{ id: 'mexico', label: 'Mexico', disabled: true },
],
},
{
id: 'oceania',
label: 'Oceania',
children: [
{ id: 'australia', label: 'Australia' },
{ id: 'new-zealand', label: 'New Zealand' },
{ id: 'fiji', label: 'Fiji' },
],
},
];
export default function ComplexHierarchicalSelection() {
const [searchQuery, setSearchQuery] = useState('');
const {
selectedArray,
openedArray,
toggleItem,
toggleParent,
toggleParentOpen,
toggleAll,
clearSelection,
selectItems,
getParentStatus,
getSelectAllStatus,
isSelected,
isParentOpen,
} = useHierarchicalSelection({
defaultSelected: ['portugal', 'spain'],
defaultOpened: ['europe'],
onSelectionChange: (selected) => {
console.log('Selected items:', selected);
},
});
// Filter data based on search query
const filteredData = filterHierarchicalData(sampleData, searchQuery);
const selectAllStatus = getSelectAllStatus(sampleData);
return (
<div className="w-full space-y-4">
{/* Quick actions */}
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
disabled={selectedArray.length === 0}
>
Clear All
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => selectItems(['portugal', 'spain', 'france'])}
>
Select EU Core
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => selectItems(['united-states', 'canada', 'australia'])}
>
Select English Speaking
</Button>
</div>
<Input
placeholder="Search countries..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
{/* Select all option - only show when not searching */}
{!searchQuery && (
<label className="flex cursor-pointer items-center gap-2 rounded">
<Checkbox
checked={selectAllStatus.checked}
indeterminate={selectAllStatus.indeterminate}
onChange={(e) => toggleAll(sampleData, e.target.checked)}
/>
<span className="cursor-pointer font-medium text-base">
All Countries
</span>
</label>
)}
<Divider />
{/* Results */}
{filteredData.length === 0 && searchQuery ? (
<p className="p-2 text-foreground-secondary text-sm">
No countries found
</p>
) : (
<div className="space-y-1">
{filteredData.map((parent) => {
const parentStatus = getParentStatus(parent);
const isOpen = isParentOpen(parent.id);
const isParentDisabled = parent.disabled;
return (
<Disclosure key={parent.id} open={isOpen || !!searchQuery}>
<div className="flex items-center justify-between gap-2">
<label
className={cn(
'flex flex-1 items-center gap-2',
isParentDisabled ? 'opacity-50' : 'cursor-pointer'
)}
>
<Checkbox
checked={parentStatus.checked}
indeterminate={parentStatus.indeterminate}
disabled={isParentDisabled}
onChange={(e) => toggleParent(parent, e.target.checked)}
/>
<span
className={cn(
'font-medium text-base',
isParentDisabled ? '' : 'cursor-pointer'
)}
>
{parent.label}
{isParentDisabled && (
<span className="ml-2 text-foreground-secondary text-xs">
(Disabled)
</span>
)}
</span>
</label>
{!searchQuery && (
<DisclosureTrigger
onClick={() => toggleParentOpen(parent.id)}
className="w-auto cursor-pointer rounded p-1"
>
<DisclosureChevron />
</DisclosureTrigger>
)}
</div>
<DisclosureContent>
<div className="mt-2 ml-6 space-y-2 border-border border-l-2 pl-4">
{parent.children?.map((child) => {
const isChildDisabled =
child.disabled || isParentDisabled;
return (
<label
key={child.id}
className={cn(
'flex items-center gap-2',
isChildDisabled ? 'opacity-50' : 'cursor-pointer'
)}
>
<Checkbox
checked={isSelected(child.id)}
disabled={isChildDisabled}
onChange={() => toggleItem(child.id)}
/>
<span
className={cn(
'font-medium text-base',
isChildDisabled ? '' : 'cursor-pointer'
)}
>
{child.label}
{child.disabled && (
<span className="ml-2 text-foreground-secondary text-xs">
(Disabled)
</span>
)}
</span>
</label>
);
})}
</div>
</DisclosureContent>
</Disclosure>
);
})}
</div>
)}
{/* Status display */}
<div className="mt-4 space-y-2 rounded bg-background-secondary p-3">
<div>
<p className="mb-1 font-medium text-sm">
Selected countries ({selectedArray.length}):
</p>
<p className="text-foreground-secondary text-sm">
{selectedArray.length > 0 ? selectedArray.join(', ') : 'None'}
</p>
</div>
<div>
<p className="mb-1 font-medium text-sm">
Opened regions ({openedArray.length}):
</p>
<p className="text-foreground-secondary text-sm">
{openedArray.length > 0 ? openedArray.join(', ') : 'None'}
</p>
</div>
</div>
</div>
);
} Form Integration
Integration with react-hook-form showing how to use hierarchical selection in forms:
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/button';
import { Checkbox } from '@/components/checkbox';
import {
Disclosure,
DisclosureChevron,
DisclosureContent,
DisclosureTrigger,
} from '@/components/disclosure';
import { Input } from '@/components/input';
import { Label } from '@/components/label';
import { cn } from '@/lib/utils/classnames';
import {
filterHierarchicalData,
type HierarchicalItem,
} from '../hierarchical-data';
import { useHierarchicalSelection } from '../use-hierarchical-selection';
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email').min(1, 'Email is required'),
countries: z.array(z.string()).min(1, 'Please select at least one country'),
});
type FormData = z.infer<typeof schema>;
const sampleData: HierarchicalItem[] = [
{
id: 'europe',
label: 'Europe',
children: [
{ id: 'portugal', label: 'Portugal' },
{ id: 'spain', label: 'Spain' },
{ id: 'france', label: 'France' },
{ id: 'germany', label: 'Germany' },
],
},
{
id: 'asia',
label: 'Asia',
children: [
{ id: 'south-korea', label: 'South Korea' },
{ id: 'japan', label: 'Japan' },
{ id: 'china', label: 'China' },
],
},
{
id: 'north-america',
label: 'North America',
children: [
{ id: 'united-states', label: 'United States' },
{ id: 'canada', label: 'Canada' },
{ id: 'mexico', label: 'Mexico' },
],
},
];
interface HierarchicalSelectionFieldProps {
value: string[];
onChange: (value: string[]) => void;
data: HierarchicalItem[];
error?: string;
}
function HierarchicalSelectionField({
value,
onChange,
data,
error,
}: HierarchicalSelectionFieldProps) {
const [searchQuery, setSearchQuery] = useState('');
const {
toggleItem,
toggleParent,
toggleParentOpen,
getParentStatus,
isSelected,
isParentOpen,
} = useHierarchicalSelection({
defaultSelected: value,
defaultOpened: ['europe'],
onSelectionChange: onChange,
});
const filteredData = filterHierarchicalData(data, searchQuery);
return (
<div className="space-y-3">
<Input
placeholder="Search countries..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
<div
className={cn(
'max-h-64 space-y-1 overflow-y-auto rounded-md border p-3',
error && 'border-red-500'
)}
>
{filteredData.length === 0 && searchQuery ? (
<p className="p-2 text-foreground-secondary text-sm">
No countries found
</p>
) : (
filteredData.map((parent) => {
const parentStatus = getParentStatus(parent);
const isOpen = isParentOpen(parent.id);
return (
<Disclosure key={parent.id} open={isOpen || !!searchQuery}>
<div className="flex items-center justify-between gap-2">
<label className="flex flex-1 cursor-pointer items-center gap-2">
<Checkbox
checked={parentStatus.checked}
indeterminate={parentStatus.indeterminate}
onChange={(e) => toggleParent(parent, e.target.checked)}
/>
<span className="cursor-pointer font-medium text-base">
{parent.label}
</span>
</label>
{!searchQuery && (
<DisclosureTrigger
onClick={() => toggleParentOpen(parent.id)}
className="w-auto cursor-pointer rounded p-1"
>
<DisclosureChevron />
</DisclosureTrigger>
)}
</div>
<DisclosureContent>
<div className="mt-2 ml-6 space-y-2">
{parent.children?.map((child) => (
<label
key={child.id}
className="flex cursor-pointer items-center gap-2"
>
<Checkbox
checked={isSelected(child.id)}
onChange={() => toggleItem(child.id)}
/>
<span className="cursor-pointer font-medium text-base">
{child.label}
</span>
</label>
))}
</div>
</DisclosureContent>
</Disclosure>
);
})
)}
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
</div>
);
}
export default function HierarchicalSelectionForm() {
const {
register,
handleSubmit,
control,
watch,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
email: '',
countries: ['spain'],
},
});
const watchedCountries = watch('countries');
const onSubmit = async (data: FormData) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
alert(
`Form submitted!\n\nName: ${data.name}\nEmail: ${data.email}\nCountries: ${data.countries.join(', ')}`
);
console.log('Form data:', data);
};
return (
<div className="mx-auto w-full p-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<h2 className="font-semibold text-xl">Registration Form</h2>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter your name"
/>
{errors.name && (
<p className="text-red-600 text-sm">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register('email')}
placeholder="Enter your email"
/>
{errors.email && (
<p className="text-red-600 text-sm">{errors.email.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label>Countries of Interest *</Label>
<p className="mb-3 text-foreground-secondary text-sm">
Select the countries you are interested in for our services
</p>
<Controller
name="countries"
control={control}
render={({ field }) => (
<HierarchicalSelectionField
value={field.value}
onChange={field.onChange}
data={sampleData}
error={errors.countries?.message}
/>
)}
/>
</div>
<div className="rounded bg-background-secondary p-3">
<p className="mb-1 font-medium text-sm">
Selected countries ({watchedCountries.length}):
</p>
<p className="text-foreground-secondary text-sm">
{watchedCountries.length > 0 ? watchedCountries.join(', ') : 'None'}
</p>
</div>
<div className="flex gap-3">
<Button
type="submit"
disabled={isSubmitting}
className="min-w-[120px]"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => window.location.reload()}
>
Reset Form
</Button>
</div>
</form>
</div>
);
} Building Your Own
Step 1: Set Up the Data Structure
Define your hierarchical data using the HierarchicalItem interface:
const data: HierarchicalItem[] = [
{
id: "frontend",
label: "Frontend",
children: [
{ id: "react", label: "React" },
{ id: "vue", label: "Vue" },
{ id: "angular", label: "Angular" },
],
},
{
id: "backend",
label: "Backend",
children: [
{ id: "node", label: "Node.js" },
{ id: "python", label: "Python" },
{ id: "java", label: "Java" },
],
},
];
Step 2: Initialize the Hook
Set up the selection hook with your desired options:
const { selectedArray, toggleItem, toggleParent, toggleParentOpen, getParentStatus, isSelected, isParentOpen } =
useHierarchicalSelection({
defaultSelected: ["react"],
defaultOpened: ["frontend"],
onSelectionChange: (selected) => {
console.log("Selected:", selected);
},
});
Step 3: Build the UI
Compose the UI using foundation components:
return (
<div className="space-y-2">
{data.map((parent) => {
const parentStatus = getParentStatus(parent);
const isOpen = isParentOpen(parent.id);
return (
<Disclosure key={parent.id} open={isOpen}>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2">
<Checkbox
checked={parentStatus.checked}
indeterminate={parentStatus.indeterminate}
onChange={(e) => toggleParent(parent, e.target.checked)}
/>
<span>{parent.label}</span>
</label>
<DisclosureTrigger onClick={() => toggleParentOpen(parent.id)}>
<DisclosureChevron />
</DisclosureTrigger>
</div>
<DisclosureContent>
<div className="ml-6 space-y-2">
{parent.children?.map((child) => (
<label key={child.id} className="flex items-center gap-2">
<Checkbox
checked={isSelected(child.id)}
onChange={() => toggleItem(child.id)}
/>
<span>{child.label}</span>
</label>
))}
</div>
</DisclosureContent>
</Disclosure>
);
})}
</div>
);
Advanced Features
Adding Search
Use the filterHierarchicalData utility to add search functionality:
const [searchQuery, setSearchQuery] = useState("");
const filteredData = filterHierarchicalData(data, searchQuery);
Select All Functionality
Implement select-all with the toggleAll method and getSelectAllStatus utility:
const selectAllStatus = getSelectAllStatus(data);
<Checkbox
checked={selectAllStatus.checked}
indeterminate={selectAllStatus.indeterminate}
onChange={(e) => toggleAll(data, e.target.checked)}
/>
Disabled Items
Mark items as disabled in your data structure:
const dataWithDisabled: HierarchicalItem[] = [
{
id: "features",
label: "Features",
disabled: true, // Entire section disabled
children: [
{ id: "feature-a", label: "Feature A" },
{ id: "feature-b", label: "Feature B", disabled: true }, // Individual item disabled
],
},
];
Form Integration
Create a controlled component for forms:
function HierarchicalField({ value, onChange, data, error }) {
const selection = useHierarchicalSelection({
defaultSelected: value,
onSelectionChange: onChange,
});
return (
<div>
{/* Your hierarchical selection UI */}
{error && <p className="text-red-600">{error}</p>}
</div>
);
}
// In your form
<Controller
name="selectedItems"
control={control}
render={({ field }) => (
<HierarchicalField
value={field.value}
onChange={field.onChange}
data={hierarchicalData}
error={errors.selectedItems?.message}
/>
)}
/>
Best Practices
Data Structure
- Use meaningful IDs: Make IDs descriptive for better debugging
- Keep data flat: Avoid deep nesting beyond 2-3 levels
- Consider disabled states: Plan for items that shouldn’t be selectable
- Separate display from logic: Keep business logic separate from display logic
Performance
- Memoize filtered data: Use
useMemofor filtered results in search - Debounce search: Prevent excessive filtering on every keystroke
- Virtualize large lists: Consider virtualization for hundreds of items
- Lazy load children: Load child data on demand for large datasets
Accessibility
- Use proper labels: Associate labels with checkboxes correctly
- Keyboard navigation: Ensure all interactions work with keyboard
- Screen reader support: Use
aria-expandedand proper roles - Focus management: Maintain logical focus flow
User Experience
- Preserve selection: Maintain selections when searching or filtering
- Show selection count: Display how many items are selected
- Quick actions: Provide shortcuts like “Select All” or preset selections
- Clear feedback: Show loading states and error messages clearly
Common Patterns
Location Picker
const locationData = [
{
id: "north-america",
label: "North America",
children: [
{ id: "usa", label: "United States" },
{ id: "canada", label: "Canada" },
],
},
];
Permissions Management
const permissionData = [
{
id: "user-management",
label: "User Management",
children: [
{ id: "create-users", label: "Create Users" },
{ id: "edit-users", label: "Edit Users" },
{ id: "delete-users", label: "Delete Users" },
],
},
];
Category Selection
const categoryData = [
{
id: "electronics",
label: "Electronics",
children: [
{ id: "smartphones", label: "Smartphones" },
{ id: "laptops", label: "Laptops" },
{ id: "tablets", label: "Tablets" },
],
},
];
Previous
Automated tests
Next
Performance tracking & bundle analyzer