v1.0

CascadeSelect

Preview

Basic

Loading…

Preview

Code

ts
import CascadeSelect from '@/components/form/CascadeSelect';

src/components/form/CascadeSelect.tsx

AI prompt

text
Build a cascade select component (single select over a tree, shown as flyout submenus) in React + TypeScript + Tailwind CSS.

## Look
- Trigger: a full-width text-input-styled button, `flex items-center gap-2`: the selected leaf's icon (14px, slate-500), the text — the leaf label, or the whole path "Canada / Ontario / Toronto" with `showPath` — or the placeholder in slate-400, and a 14px `ChevronDown` that rotates 180° when open. Disabled: 60% opacity, not-allowed cursor.
- Menu column: opaque floating surface, `absolute z-50 min-w-[11rem] w-max p-1 text-xs`. The first column drops below the trigger (`left-0 top-full mt-1`, 0.2s fade-in); each submenu sits beside its parent row (`left-full top-0 -mt-1 ml-1.5`), rendered INSIDE the parent row's `relative` wrapper. Columns have no max-height/overflow — a scrolling column would clip its own flyouts — they grow instead.
- Row: `flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 leading-none`: optional 14px icon, truncating label, then a 14px `ChevronRight` for a group (slate-400) or a `Check` for a leaf (visible only on the selected leaf).
- Row states: focused row `bg-indigo-600 text-white` (chevron white); a row on the open trail but not focused `bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300`; a row on the path to the current selection `text-indigo-600 dark:text-indigo-400`; otherwise slate-700 / dark slate-200.

## Behaviour
- Only leaves can be picked; a group (non-empty `children`) is a route. Branches can differ in depth (a top-level leaf is fine).
- State is one array, the trail: the highlighted index in each open column; its last entry is where focus is. Plus one flag: hovering a group shows its submenu while focus stays on the group row, so the pointer can travel into the submenu.
- Hover MOVES REAL FOCUS (`focus({ preventScroll: true })` + `scrollIntoView({ block: 'nearest' })`), so pointer and keyboard never disagree about what Enter picks.
- Opening (click, or ArrowDown/ArrowUp on the trigger) lands on the current selection with its whole path unfolded.
- Keys in the menu: ↑/↓ move within the column (wrapping), Home/End; → / Enter / Space on a group opens it and focuses its first child; Enter / Space on a leaf picks it; ← goes back a column; Escape goes back a column and only closes (returning focus to the trigger) from the first; Tab closes and lets focus move on. Enter must not submit a surrounding form.
- Clicking a group opens it and moves into it (for touch); clicking a leaf picks it. Picking calls `onChange(value, ['Canada', 'Ontario', 'Toronto'])` and closes, focusing the trigger. Outside click closes without refocus.

## API
- `type CascadeOption = { value: string; label: string; icon?: ReactNode; children?: CascadeOption[] }` — values unique across the tree.
- Props: `options`, `value: string | null` (the leaf), `onChange(value, pathLabels)`, `placeholder = 'Select…'`, `showPath = false`, `disabled`, `id` (on the trigger), `className`.

## Accessibility
- Trigger `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`. Each column `role="menu"`; group rows `role="menuitem"` with `aria-haspopup="menu"` and `aria-expanded`; leaves `role="menuitemradio"` with `aria-checked`. Rows are `tabIndex={-1}` (focus is managed).

## Demo
Places: Canada → Ontario (Toronto, Ottawa), Quebec (Montreal, Quebec City); Australia → New South Wales (Sydney, Newcastle), Victoria (Melbourne); and a top-level leaf "Remote". Globe / MapPin / Building2 icons by level. One 224px select with placeholder "Select a city", and one 256px with `showPath` preselected to Ottawa.

## 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';

import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';

export type CascadeOption = {
  /** Unique across the whole tree — it is what `onChange` reports for a leaf. */
  value: string;
  label: string;
  icon?: React.ReactNode;
  /** Present and non-empty makes this a group: it opens a submenu and cannot itself be selected. */
  children?: CascadeOption[];
};

const hasKids = (o: CascadeOption | undefined): o is CascadeOption & { children: CascadeOption[] } =>
  !!o?.children && o.children.length > 0;

/** Index path from the roots to the leaf with `value`, or null. */
function findPath(options: CascadeOption[], value: string | null): number[] | null {
  if (value == null) return null;
  for (let i = 0; i < options.length; i++) {
    const o = options[i];
    if (!hasKids(o)) {
      if (o.value === value) return [i];
      continue;
    }
    const rest = findPath(o.children, value);
    if (rest) return [i, ...rest];
  }
  return null;
}

/** The option at each step of an index path. */
function walk(options: CascadeOption[], path: number[]): CascadeOption[] {
  const out: CascadeOption[] = [];
  let level: CascadeOption[] | undefined = options;
  for (const i of path) {
    const o: CascadeOption | undefined = level?.[i];
    if (!o) break;
    out.push(o);
    level = o.children;
  }
  return out;
}

export interface CascadeSelectProps {
  options: CascadeOption[];
  /** The selected LEAF's value. */
  value: string | null;
  /** Called with the leaf's value and the labels from root to leaf, e.g. `['Canada', 'Ontario', 'Toronto']`. */
  onChange: (value: string, pathLabels: string[]) => void;
  placeholder?: string;
  /** Show the whole path (`Canada / Ontario / Toronto`) on the trigger instead of just the leaf. */
  showPath?: boolean;
  disabled?: boolean;
  /** Lands on the trigger, so `<Field>`'s `<label for>` reaches it. */
  id?: string;
  className?: string;
}

/**
 * Single select over a tree, as flyout menus: the first level drops down, and
 * each group opens its children in a submenu beside it. Only leaves are
 * selectable — a group is a route, not an answer.
 *
 * State is one array, `trail`: the highlighted index in each open column. The
 * last entry is where focus is; every column after the first exists because the
 * entry before it is a group. `expandLast` covers the one case the trail cannot
 * — hovering a group shows its submenu while focus stays on the group row, so
 * the pointer can travel into the submenu without the keyboard jumping there.
 *
 * Hover moves real focus, so the mouse and the arrow keys can never disagree
 * about which row Enter would pick.
 */
export default function CascadeSelect({
  options,
  value,
  onChange,
  placeholder = 'Select…',
  showPath = false,
  disabled = false,
  id,
  className,
}: CascadeSelectProps) {
  const [open, setOpen] = useState(false);
  const [trail, setTrail] = useState<number[]>([0]);
  const [expandLast, setExpandLast] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const items = useRef(new Map<string, HTMLButtonElement>());
  const baseId = useId();

  const selectedPath = useMemo(() => findPath(options, value), [options, value]);
  const selected = useMemo(() => (selectedPath ? walk(options, selectedPath) : []), [options, selectedPath]);
  const leaf = selected[selected.length - 1];

  const close = useCallback((refocus: boolean) => {
    setOpen(false);
    if (refocus) triggerRef.current?.focus();
  }, []);

  // Escape steps back one column before it closes anything — the same thing
  // ArrowLeft does, so a person three levels deep is not thrown out entirely.
  const onEscape = useCallback(() => {
    if (trail.length > 1) {
      setTrail((t) => t.slice(0, -1));
      setExpandLast(false);
    } else close(true);
  }, [trail.length, close]);
  useDismiss(ref, open, () => close(false), onEscape);

  // Reopening lands on the current selection with its whole path unfolded, so
  // changing Toronto to Ottawa is one step, not a re-navigation from the top.
  const openMenu = () => {
    setTrail(selectedPath ?? [0]);
    setExpandLast(false);
    setOpen(true);
  };

  // Focus follows the trail. preventScroll + scrollIntoView('nearest') so
  // moving focus into a flyout never scrolls the whole page to it.
  useEffect(() => {
    if (!open) return;
    const k = trail.length - 1;
    const el = items.current.get(`${k}:${trail[k]}`);
    el?.focus({ preventScroll: true });
    el?.scrollIntoView({ block: 'nearest' });
  }, [open, trail]);

  const pick = (path: number[]) => {
    const chain = walk(options, path);
    const target = chain[chain.length - 1];
    if (!target || hasKids(target)) return;
    onChange(target.value, chain.map((o) => o.label));
    close(true);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    // The trigger handles its own keys; this delegate is for the menu rows.
    if (e.target === triggerRef.current) return;
    const k = trail.length - 1;
    const column = k === 0 ? options : walk(options, trail.slice(0, k))[k - 1]?.children ?? [];
    const i = trail[k];
    const current = column[i];
    if (column.length === 0) return;
    const move = (to: number) => {
      e.preventDefault();
      setTrail([...trail.slice(0, k), (to + column.length) % column.length]);
      setExpandLast(false);
    };
    switch (e.key) {
      case 'ArrowDown': return move(i + 1);
      case 'ArrowUp': return move(i - 1);
      case 'Home': return move(0);
      case 'End': return move(column.length - 1);
      case 'ArrowRight':
      case 'Enter':
      case ' ':
        // preventDefault on Enter also stops a surrounding <form> submitting.
        e.preventDefault();
        if (hasKids(current)) {
          setTrail([...trail, 0]);
          setExpandLast(false);
        } else if (e.key !== 'ArrowRight') {
          pick(trail);
        }
        return;
      case 'ArrowLeft':
        e.preventDefault();
        if (trail.length > 1) {
          setTrail(trail.slice(0, -1));
          setExpandLast(false);
        }
        return;
      case 'Tab':
        // Let focus leave, but not with a menu left hanging over the next field.
        setOpen(false);
        return;
    }
  };

  const renderColumn = (column: CascadeOption[], depth: number): React.ReactNode => (
    <div
      role="menu"
      id={depth === 0 ? `${baseId}-menu` : undefined}
      aria-label={depth === 0 ? 'Options' : undefined}
      // No max-height / overflow here, deliberately: each submenu is nested
      // INSIDE its parent row, so a scrolling column would clip its own flyout
      // (the clipping trap in ui-conventions). Columns grow instead.
      className={cn(
        'absolute z-50 panel panel-solid min-w-[11rem] w-max p-1 text-xs',
        depth === 0 ? 'left-0 top-full mt-1 animate-fade-in' : 'left-full top-0 -mt-1 ml-1.5',
      )}
    >
      {column.map((o, i) => {
        const inTrail = trail[depth] === i;
        const isFocus = inTrail && depth === trail.length - 1;
        const group = hasKids(o);
        const childOpen = group && inTrail && (depth < trail.length - 1 || expandLast);
        const onSelectedPath = selectedPath?.[depth] === i && selectedPath.slice(0, depth).every((n, d) => n === trail[d]);
        const isSelectedLeaf = !group && onSelectedPath && depth === selectedPath!.length - 1;
        return (
          <div key={o.value} className="relative">
            <button
              ref={(el) => {
                const key = `${depth}:${i}`;
                if (el) items.current.set(key, el);
                else items.current.delete(key);
              }}
              type="button"
              tabIndex={-1}
              role={group ? 'menuitem' : 'menuitemradio'}
              aria-haspopup={group ? 'menu' : undefined}
              aria-expanded={group ? childOpen : undefined}
              aria-checked={group ? undefined : isSelectedLeaf}
              onMouseEnter={() => {
                setTrail([...trail.slice(0, depth), i]);
                setExpandLast(group);
              }}
              onClick={() => {
                if (group) {
                  // A click on a group opens it (for touch, where there is no
                  // hover) and moves focus into it, like ArrowRight.
                  setTrail([...trail.slice(0, depth), i, 0]);
                  setExpandLast(false);
                } else pick([...trail.slice(0, depth), i]);
              }}
              className={cn(
                'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left leading-none outline-none transition-colors',
                isFocus
                  ? 'bg-indigo-600 text-white'
                  : inTrail
                    ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
                    : onSelectedPath
                      ? 'text-indigo-600 dark:text-indigo-400'
                      : 'text-slate-700 dark:text-slate-200',
              )}
            >
              {o.icon && <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center [&>svg]:h-3.5 [&>svg]:w-3.5" aria-hidden>{o.icon}</span>}
              <span className="flex-1 truncate py-0.5">{o.label}</span>
              {group ? (
                <ChevronRight aria-hidden className={cn('h-3.5 w-3.5 shrink-0', isFocus ? 'text-white' : 'text-slate-400')} />
              ) : (
                <Check aria-hidden className={cn('h-3.5 w-3.5 shrink-0', isSelectedLeaf ? 'opacity-100' : 'opacity-0')} />
              )}
            </button>
            {childOpen && renderColumn(o.children!, depth + 1)}
          </div>
        );
      })}
    </div>
  );

  const shown = showPath ? selected.map((o) => o.label).join(' / ') : leaf?.label;

  return (
    <div ref={ref} className={cn('relative', className)} onKeyDown={open ? onKeyDown : undefined}>
      <button
        ref={triggerRef}
        id={id}
        type="button"
        disabled={disabled}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? `${baseId}-menu` : undefined}
        onClick={() => (open ? close(false) : openMenu())}
        onKeyDown={(e) => {
          if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
            e.preventDefault();
            openMenu();
          }
        }}
        className="field-input flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-60"
      >
        {leaf?.icon && <span className="flex shrink-0 items-center text-slate-500 dark:text-slate-400 [&>svg]:h-3.5 [&>svg]:w-3.5" aria-hidden>{leaf.icon}</span>}
        <span className={cn('flex-1 truncate', !leaf && 'text-slate-400 dark:text-slate-500')}>{shown ?? placeholder}</span>
        <ChevronDown aria-hidden className={cn('h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform', open && 'rotate-180')} />
      </button>
      {open && options.length > 0 && renderColumn(options, 0)}
    </div>
  );
}

Props

PropTypeDefaultDescription
options*CascadeOption[]—
value*string | null—The selected LEAF's value.
onChange*(value: string, pathLabels: string[]) => void—Called with the leaf's value and the labels from root to leaf, e.g. `['Canada', 'Ontario', 'Toronto']`.
placeholderstring'Select…'
showPathbooleanfalseShow the whole path (`Canada / Ontario / Toronto`) on the trigger instead of just the leaf.
disabledbooleanfalse
idstring—Lands on the trigger, so `<Field>`'s `<label for>` reaches it.
classNamestring—