TreeMultiSelectDropdown
Preview
Basic
Loading…
Preview
Code
ts
import TreeMultiSelectDropdown from '@/components/form/TreeMultiSelectDropdown';src/components/form/TreeMultiSelectDropdown.tsx
AI prompt
text
Build a tree multi-select dropdown (nested options, add-from-tree / remove-from-Selected) component in React + TypeScript + Tailwind CSS.
## Look
- Trigger: a div with the house input recipe, `flex items-center gap-2 cursor-text`, containing a borderless transparent text input (`flex-1 min-w-0 text-xs`), a clear-all X (14px slate-400, only with a selection and not disabled) and a 14px ChevronsUpDown. Clicking anywhere on it focuses the input. Closed, the input shows a summary ("12 members selected", "3 teams, 12 members selected") or the placeholder "All"; open, it is the search box ("Search..."). Disabled: `opacity-60 cursor-not-allowed`.
- Panel: absolute `z-50 mt-1 w-full min-w-[440px]`, opaque floating surface with `p-0 overflow-hidden`, fade-in, split into two equal columns `grid grid-cols-2 divide-x divide-slate-200 dark:divide-slate-700`, each `p-2 min-w-0` with a `max-h-80` scrolling list.
- LEFT, the tree of what is still available: rows `flex items-center gap-1.5 py-1.5 pr-2 rounded-lg text-xs text-slate-700 dark:text-slate-200 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 cursor-pointer`, indented `depth * 18 + 4`px. A branch starts with a chevron toggle (ChevronRight / ChevronDown 14px, slate-400 → hover slate-600); a leaf gets an 18px spacer instead. Label truncates; a 14px Plus in slate-400 at the end. Empty: "No options found." (11px slate-400).
- RIGHT, "Selected": a header with the section-title text "Selected (N)" and, when non-empty, a "Clear all" link (11px medium indigo-600 → hover indigo-800; dark indigo-400 → indigo-300). Selected leaves are grouped under their IMMEDIATE parent's label, groups sorted alphabetically, leaves in selection order; top-level leaves get no heading.
- Group heading: 10px semibold uppercase wide-tracking slate-400 (dark slate-500) with a 13px X that fades in on hover (turns rose-500); clicking the heading removes the whole group.
- Leaf chip (indented `pl-2`, `space-y-0.5`): `flex justify-between px-2 py-1.5 text-xs rounded-lg bg-indigo-50 text-indigo-700 hover:bg-indigo-100`, dark `bg-indigo-500/10 text-indigo-300 hover:bg-indigo-500/20`, with a 13px X; clicking removes it. Empty: "None selected".
## Behaviour
- The value is a flat array of LEAF ids only; branches are sugar. Clicking a leaf row adds it; clicking a branch row adds every leaf still beneath it. The chevron only expands/collapses (stop propagation).
- The tree is add-only and re-derived from `value` every render: selected leaves are pruned out, and a branch disappears once all its leaves are picked. No checkboxes, no indeterminate state. Removing in the Selected column makes items reappear in the tree.
- Branches start collapsed and toggle independently. Search (trimmed, case-insensitive) keeps a node if its label matches or any descendant's does; a branch matching ITSELF keeps its whole subtree. While searching every branch is expanded. Search never resurfaces a selected leaf, and the Selected column ignores it.
- Summary: `countLabels = { leaf: 'member', mid?: 'team' }` → "N members selected"; with `mid`, prefix the number of second-level branches holding at least one selected leaf. Pluralise by adding "s" when the count isn't 1; default leaf word "item".
- Focus opens. Escape in the input closes, clears the search and blurs; outside click closes and clears the search. Becoming disabled closes it.
## API
`options: TreeOption[]` with `TreeOption = { id: string; label: string; children?: TreeOption[] }` — ids unique across the WHOLE tree, so prefix branch ids (`r-1`, `t-1`); `value: string[]`, `onChange(value)`, `placeholder = 'All'`, `searchPlaceholder = 'Search...'`, `emptyText = 'No options found.'`, `className`, `disabled = false`, `countLabels?`. Named export.
## Accessibility
Chevrons are buttons labelled "Expand <label>" / "Collapse <label>"; the trigger's X is "Clear all selected".
## Demo
Region → Team → Person, placeholder "Region / Team / Person": Europe (Engineering: Ada Lovelace, Grace Hopper, Barbara Liskov; Research: Alan Turing, Katherine J., Edsger D.), Americas (Design: Alan Kay, Margaret H.), Asia-Pacific (Operations: no people, so a leaf). Echo the selected ids below.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: marketing-stats (96S1), verbatim. */
/**
* TreeMultiSelectDropdown
* ------------------------------------------------------------------
* A single combined multi-select over a nested tree (e.g. Platform -> Brand
* -> Agent), replacing what would otherwise be one flat `MultiSelectDropdown`
* per level. Selection is always tracked as a flat array of LEAF ids only
* (`value`/`onChange`, same shape `MultiSelectDropdown` uses) — checking a
* non-leaf node (a "branch", e.g. a Platform or Brand) is just sugar for
* selecting/deselecting every leaf underneath it, so callers that only care
* about leaf ids (e.g. an `agentIds` filter) need no extra flattening logic
* beyond passing `value` straight through.
*
* The left tree is an "available" list, mirroring `MultiSelectDropdown`'s own
* Available/Selected split rather than a checkbox tree: a leaf already in
* `value` is pruned out of it entirely, and a branch node is pruned once
* every leaf underneath it has been selected (a branch with some-but-not-all
* leaves selected just shows fewer children, no indeterminate state to
* track). Clicking a row (or its trailing `+`) adds it — a branch adds every
* remaining leaf beneath it — and clicking a row in the Selected panel on
* the right removes it, which makes it reappear here since the tree is
* re-derived from `value` on every render. Each branch is independently
* collapsible; the trigger's own text box doubles as a type-to-filter search
* box once focused (same convention as `MultiSelectDropdown`'s
* `searchPlaceholder`) — a node matches if its own label matches or any
* descendant's does, and a self-matching branch keeps its whole subtree
* rather than pruning to just the matching descendants (mirrors the
* brand/agent text-search behavior this component replaces on the Agent
* Score report). Matching branches auto-expand while a search is active.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, ChevronsUpDown, Plus, X } from 'lucide-react';
import { useDismiss } from '@/lib/use-dismiss';
export interface TreeOption {
/** Must be unique across the ENTIRE tree, not just among siblings — a
* Platform, Brand and Agent all share one id namespace here, so callers
* should prefix non-leaf ids (e.g. `platform:1`, `brand:1`) to avoid
* colliding with leaf ids (typically raw numeric-string entity ids). */
id: string;
label: string;
/** Omit or leave empty for a leaf node. */
children?: TreeOption[];
}
/** Singular labels used to build the trigger's selection summary, e.g.
* `{ leaf: 'agent', mid: 'brand' }` -> "3 brands, 12 agents selected".
* `mid` is optional — omit it to only ever show the leaf count. */
interface CountLabels {
leaf: string;
mid?: string;
}
interface TreeMultiSelectDropdownProps {
options: TreeOption[];
/** Selected LEAF ids only. */
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
className?: string;
disabled?: boolean;
countLabels?: CountLabels;
}
function collectLeafIds(node: TreeOption): string[] {
if (!node.children || node.children.length === 0) return [node.id];
return node.children.flatMap(collectLeafIds);
}
// Every leaf's label plus its immediate parent's label (undefined for a
// top-level leaf) — powers the Selected panel's disambiguation below, same
// "name repeats across parents" concern the tree already accounts for via
// `filterTree`'s whole-subtree matching.
interface LeafInfo {
id: string;
label: string;
parentLabel?: string;
}
function collectLeavesWithParent(nodes: TreeOption[], parentLabel?: string): LeafInfo[] {
const out: LeafInfo[] = [];
for (const node of nodes) {
if (!node.children || node.children.length === 0) {
out.push({ id: node.id, label: node.label, parentLabel });
} else {
out.push(...collectLeavesWithParent(node.children, node.label));
}
}
return out;
}
// Drops every already-selected leaf from the tree, and any branch left with
// no children as a result — the tree only ever shows what's still pickable.
function pruneSelected(nodes: TreeOption[], valueSet: Set<string>): TreeOption[] {
const out: TreeOption[] = [];
for (const node of nodes) {
if (!node.children || node.children.length === 0) {
if (!valueSet.has(node.id)) out.push(node);
continue;
}
const prunedChildren = pruneSelected(node.children, valueSet);
if (prunedChildren.length > 0) out.push({ ...node, children: prunedChildren });
}
return out;
}
// A branch keeps its whole subtree once its own label matches, rather than
// pruning down to only the descendants that individually match — same
// "brand matches -> show every agent underneath" convention as the search
// this component replaces.
function filterTree(nodes: TreeOption[], query: string): TreeOption[] {
if (!query) return nodes;
const q = query.toLowerCase();
const result: TreeOption[] = [];
for (const node of nodes) {
if (node.label.toLowerCase().includes(q)) {
result.push(node);
continue;
}
if (node.children && node.children.length > 0) {
const filteredChildren = filterTree(node.children, query);
if (filteredChildren.length > 0) {
result.push({ ...node, children: filteredChildren });
}
}
}
return result;
}
function pluralize(count: number, word: string): string {
return count === 1 ? word : `${word}s`;
}
interface TreeRowProps {
node: TreeOption;
depth: number;
isExpanded: (id: string) => boolean;
onToggleExpand: (id: string) => void;
onAddNode: (node: TreeOption) => void;
}
function TreeRow({ node, depth, isExpanded, onToggleExpand, onAddNode }: TreeRowProps) {
const hasChildren = !!node.children && node.children.length > 0;
const expanded = hasChildren && isExpanded(node.id);
return (
<li>
<div
className="flex items-center gap-1.5 py-1.5 pr-2 rounded-lg text-xs text-slate-700 dark:text-slate-200 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 cursor-pointer transition-colors"
style={{ paddingLeft: depth * 18 + 4 }}
onClick={() => onAddNode(node)}
>
{hasChildren ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleExpand(node.id); }}
className="p-0.5 flex-shrink-0 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
aria-label={expanded ? `Collapse ${node.label}` : `Expand ${node.label}`}
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="w-[18px] flex-shrink-0" />
)}
<span className="truncate flex-1 min-w-0">{node.label}</span>
<Plus size={14} className="flex-shrink-0 text-slate-400" />
</div>
{hasChildren && expanded && (
<ul>
{node.children!.map(child => (
<TreeRow
key={child.id}
node={child}
depth={depth + 1}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
onAddNode={onAddNode}
/>
))}
</ul>
)}
</li>
);
}
export function TreeMultiSelectDropdown({
options,
value,
onChange,
placeholder = 'All',
searchPlaceholder = 'Search...',
emptyText = 'No options found.',
className = '',
disabled = false,
countLabels,
}: TreeMultiSelectDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useDismiss(containerRef, isOpen, () => {
setIsOpen(false);
setSearchQuery('');
});
useEffect(() => {
if (disabled) {
setIsOpen(false);
setSearchQuery('');
}
}, [disabled]);
const valueSet = useMemo(() => new Set(value), [value]);
const trimmedQuery = searchQuery.trim();
const isSearching = trimmedQuery.length > 0;
// Prune already-selected leaves out first, then apply the search filter on
// top of what's left — so a fully-selected branch (and its leaves)
// disappears from the tree, and search never re-surfaces a selected leaf.
const availableTree = useMemo(() => pruneSelected(options, valueSet), [options, valueSet]);
const visibleTree = useMemo(() => filterTree(availableTree, trimmedQuery), [availableTree, trimmedQuery]);
// Flat leaf lookup off the FULL tree (not `visibleTree`) — the Selected
// panel lists every currently selected leaf regardless of whatever search
// filter is active on the tree side.
const leafInfoById = useMemo(() => {
const map = new Map<string, LeafInfo>();
for (const leaf of collectLeavesWithParent(options)) map.set(leaf.id, leaf);
return map;
}, [options]);
const selectedLeaves = useMemo(
() => value.map(id => leafInfoById.get(id) ?? { id, label: id }),
[value, leafInfoById]
);
// Grouped by immediate parent (e.g. Platform) — same grouped-list
// convention AutocompleteDropdown uses for its agent-by-brand groups, and
// it makes the parent qualifier from the flat version redundant (the group
// heading already says which platform a repeated brand name belongs to).
// A top-level leaf (no parent) falls into the '' bucket, rendered with no
// heading. Groups sort alphabetically; leaves keep selection order within
// their group.
const groupedSelectedLeaves = useMemo(() => {
const groups = new Map<string, LeafInfo[]>();
for (const leaf of selectedLeaves) {
const key = leaf.parentLabel ?? '';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(leaf);
}
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [selectedLeaves]);
const handleDeselectLeaf = (id: string) => onChange(value.filter(v => v !== id));
// Removes every leaf in one Selected-panel group at once — the symmetric
// counterpart to a tree branch's "add all remaining leaves" click.
const handleDeselectGroup = (leaves: LeafInfo[]) => {
const removeIds = new Set(leaves.map(l => l.id));
onChange(value.filter(id => !removeIds.has(id)));
};
const isExpanded = (id: string) => (isSearching ? true : !!expandedIds[id]);
const toggleExpand = (id: string) => setExpandedIds(prev => ({ ...prev, [id]: !prev[id] }));
// The tree is add-only (removal happens via the Selected panel) — a leaf
// adds itself, a branch adds every leaf still beneath it (already-selected
// ones were pruned out of `node`, so this can't re-add anything).
const handleAddNode = (node: TreeOption) => {
const leaves = collectLeafIds(node);
const merged = new Set(value);
leaves.forEach(id => merged.add(id));
onChange([...merged]);
};
const handleClearAll = () => onChange([]);
// Selection summary shown on the trigger while closed — "All" when
// nothing's selected, otherwise a leaf count (and, if `countLabels.mid` is
// supplied, how many second-level branches — e.g. brands — have at least
// one selected leaf underneath them).
const summary = useMemo(() => {
if (value.length === 0) return '';
let midCount = 0;
if (countLabels?.mid) {
for (const top of options) {
for (const mid of top.children ?? []) {
const leaves = collectLeafIds(mid);
if (leaves.some(id => valueSet.has(id))) midCount++;
}
}
}
const leafPart = `${value.length} ${pluralize(value.length, countLabels?.leaf ?? 'item')}`;
if (countLabels?.mid && midCount > 0) {
return `${midCount} ${pluralize(midCount, countLabels.mid)}, ${leafPart} selected`;
}
return `${leafPart} selected`;
}, [value, options, valueSet, countLabels]);
return (
<div className={`relative ${className}`} ref={containerRef}>
<div
className={`field-input flex items-center gap-2 ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-text'}`}
onClick={() => !disabled && inputRef.current?.focus()}
>
<input
ref={inputRef}
type="text"
value={isOpen ? searchQuery : (value.length > 0 ? summary : '')}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => !disabled && setIsOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setIsOpen(false);
setSearchQuery('');
e.currentTarget.blur();
}
}}
placeholder={isOpen ? searchPlaceholder : placeholder}
autoComplete="off"
disabled={disabled}
className="flex-1 min-w-0 bg-transparent outline-none border-none text-xs text-slate-800 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 disabled:cursor-not-allowed"
/>
{!disabled && value.length > 0 && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleClearAll(); }}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 flex-shrink-0"
aria-label="Clear all selected"
>
<X className="h-3.5 w-3.5" />
</button>
)}
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden />
</div>
{isOpen && (
// z-50: the one band every in-flow popover in this library uses. See the
// Z-INDEX SCALE note in globals.css — table chrome tops out at 20, so a
// menu opened over a table clears its sticky header and frozen column.
// Two columns (tree left, Selected right) — same split MultiSelectDropdown
// uses for its flat Available/Selected panes, so a selection stays visible
// and individually removable without hunting through the tree for its checkbox.
<div data-overlay="picker" className="absolute z-50 mt-1 w-full min-w-[440px] panel panel-solid p-0 overflow-hidden animate-fade-in grid grid-cols-2 divide-x divide-slate-200 dark:divide-slate-700">
<div className="p-2 min-w-0">
<ul className="max-h-80 overflow-y-auto space-y-0.5 scrollbar-thin">
{visibleTree.length > 0 ? (
visibleTree.map(node => (
<TreeRow
key={node.id}
node={node}
depth={0}
isExpanded={isExpanded}
onToggleExpand={toggleExpand}
onAddNode={handleAddNode}
/>
))
) : (
<li className="px-2 py-1.5 text-[11px] text-slate-400">{emptyText}</li>
)}
</ul>
</div>
<div className="p-2 min-w-0">
<div className="flex items-center justify-between gap-2 px-1 pb-1">
<span className="panel-title truncate">Selected ({selectedLeaves.length})</span>
{selectedLeaves.length > 0 && (
<button
type="button"
onClick={handleClearAll}
className="pr-3 text-[11px] font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 flex-shrink-0"
>
Clear all
</button>
)}
</div>
<ul className="max-h-80 overflow-y-auto space-y-2 scrollbar-thin">
{groupedSelectedLeaves.length > 0 ? (
groupedSelectedLeaves.map(([group, leaves]) => (
<li key={group || '__ungrouped'}>
{group && (
<div
onClick={() => handleDeselectGroup(leaves)}
title={`Remove all in ${group}`}
className="group/header flex items-center justify-between gap-1 px-2 pb-1 cursor-pointer"
>
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 truncate">{group}</span>
<X size={13} className="flex-shrink-0 text-slate-400 opacity-0 group-hover/header:opacity-100 hover:text-rose-500 transition-opacity" />
</div>
)}
<ul className="space-y-0.5 pl-2">
{leaves.map(leaf => (
<li
key={leaf.id}
onClick={() => handleDeselectLeaf(leaf.id)}
className="flex items-center justify-between gap-1 px-2 py-1.5 text-xs rounded-lg text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-500/10 hover:bg-indigo-100 dark:hover:bg-indigo-500/20 cursor-pointer transition-colors"
>
<span className="truncate">{leaf.label}</span>
<X size={13} className="flex-shrink-0" />
</li>
))}
</ul>
</li>
))
) : (
<li className="px-2 py-1.5 text-[11px] text-slate-400">None selected</li>
)}
</ul>
</div>
</div>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
options* | TreeOption[] | — | |
value* | string[] | — | Selected LEAF ids only. |
onChange* | (value: string[]) => void | — | |
placeholder | string | 'All' | |
searchPlaceholder | string | 'Search...' | |
emptyText | string | 'No options found.' | |
className | string | '' | |
disabled | boolean | false | |
countLabels | CountLabels | — |