v1.0

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

PropTypeDefaultDescription
options*TreeOption[]—
value*string[]—Selected LEAF ids only.
onChange*(value: string[]) => void—
placeholderstring'All'
searchPlaceholderstring'Search...'
emptyTextstring'No options found.'
classNamestring''
disabledbooleanfalse
countLabelsCountLabels—