MultilevelMenu
Preview
Basic
Loading…
Preview
Code
ts
import MultilevelMenu from '@/components/overlay/MultilevelMenu';src/components/overlay/MultilevelMenu.tsx
AI prompt
text
Build a cascading multilevel filter menu component in React + TypeScript + Tailwind CSS: the "Add filter" menu of a reporting page. Each level opens beside the last, level with the row that opened it, and ends in searchable lists of values to tick.
## Look
- Trigger: `inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium`. It holds a 14px ListFilter icon, the label ("Add filter"), and a total-picked pill when anything is picked (`rounded-full bg-indigo-600 px-1.5 text-[10px] font-semibold leading-4 text-white dark:bg-indigo-400 dark:text-slate-900`).
- Idle: `border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700`.
- Open, or with picks: `border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/50 dark:bg-indigo-500/15 dark:text-indigo-200`.
- Each level: portalled, `fixed z-[200] flex flex-col py-1 max-h-[min(28rem,calc(100vh-1rem))]` on the opaque floating surface, with a scale-in (from `opacity 0, scale(0.96) translateY(6px)`, 220ms `cubic-bezier(0.34, 1.56, 0.64, 1)`). Width 232px by default, `max(width, 300)` for option lists, or the node's own `width`.
- Level header: `flex items-center justify-between gap-2 px-3 pb-2 pt-1.5`.
- The heading: the `title` prop on the first level, the node label below it; text-sm font-semibold slate-800 / dark slate-100.
- On the right, "Select all | Clear" for a multi-select list, "Clear" alone elsewhere. The links are `text-[11px] font-medium text-slate-500 hover:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-300 disabled:opacity-40`, and the "|" is slate-300 / dark slate-600.
- A `border-t` rule follows.
- Search row, when searchable: `flex items-center gap-2 border-b px-3 py-1.5`, a 14px Search icon and a borderless, transparent `type="search"` input with placeholder "Search {heading in lowercase}".
- Hint row, on option lists: `flex items-center gap-1.5 border-b px-3 py-2 text-[11px] text-slate-500 dark:text-slate-400`, a 14px Info icon, and the node's `hint` (default for a multi-select list: "You can select multiple items").
- Rows sit in `min-h-0 flex-1 overflow-y-auto px-1 pt-1` and share `flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-slate-700 hover:bg-slate-100 focus-visible:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/60 dark:focus-visible:bg-slate-700/60 disabled:opacity-40`.
- Branch rows: an optional 14px icon (slate-400), the label (truncating), a count pill of the picks beneath it (`rounded-full bg-indigo-50 px-1.5 text-[10px] font-semibold leading-4 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300`), and a 14px ChevronRight. The row whose level is open keeps `bg-slate-100 dark:bg-slate-700/60`.
- Option rows: a 14px box, `rounded` for multi-select and `rounded-full` for single, `border-slate-300 dark:border-slate-600`. Picked, it is filled `border-indigo-600 bg-indigo-600` with a white 10px Check (strokeWidth 3); in dark mode the fill is indigo-400 and the check slate-900. A picked label is `font-medium text-indigo-700 dark:text-indigo-300`.
- No results: "No matches", 11px, centred, slate-400.
## Data
- A node is `{ key, label, icon?, children?, options?: { value, label, disabled? }[], multiple? (default true), hint?, searchable?, width?, disabled?, onSelect? }`. With `children` it opens another level; with `options` it opens a list to pick from; with neither it is an action, which runs `onSelect` and closes the menu.
- `value: Record<listKey, string[]>` is controlled through `onChange`; a list emptied of picks is deleted from the object.
- Single-select: picking replaces the value, and clicking the picked option unpicks it.
- "Select all" adds every enabled option the search currently shows. "Clear" empties every list at or under that level's node (on the first level: everything).
- A level is searchable when its node says so, or by default when it has more than `searchFrom` (6) rows. Each newly opened level starts with an empty query.
- Export `selectedLists(nodes, value)`, which returns `[{ node, path: string[], options }]` in menu order, the data for a chip row.
## Placement
- First level: under the trigger, left-aligned, with a 6px gap. It flips above when it doesn't fit below and does fit above.
- Each later level: at `parent.left + parent.width + 6`, or at `parent.left − width − 6` when that would pass the right edge. Its top is the opening row's top − 10, so the level is level with its row.
- Clamp every level to an 8px margin, which slides it up at the bottom of the screen.
- Measure in a layout effect after every render and store only when a position changed, so it settles in one pass. A level is `visibility: hidden` on its first frame. Follow window scroll (capture phase) and resize.
## Behaviour
- It is a cascade, not a drill-down: parents stay visible, and hovering another row swaps the level beside it.
- Hover opens a row's level 120ms after the pointer SETTLES:
- Use `pointermove` (mouse only), restarting the timer on each move. Don't use `pointerenter`: rows re-rendering under a resting pointer (a search narrowing, an Escape) fire enter and would reopen a level just closed.
- `pointerleave` cancels the timer, and entering a level cancels a hover still pending in the level before it.
- Hovering an action row closes the deeper levels.
- A click opens a level immediately.
- Keyboard:
- Opening focuses the first level's search, or its first row.
- Up / Down move within a level and wrap. Up from the first row goes to the search box; Down from the search box goes to the first row. Home / End jump to the ends.
- Right or Enter opens the row's level and focuses its search or first row.
- Left, or Escape, closes the deepest level and refocuses its row. Escape on the first level closes the menu and refocuses the trigger.
- Inside the search box, Left / Right / Home / End stay with the text.
- An outside click (outside both the trigger root and the portalled layer) closes everything.
- The portalled layer carries `data-overlay="menu"`, so other anchored panels avoid it.
## API
- `nodes`, `value`, `onChange`
- `title?` = "Filters", `label?` = "Add filter", `icon?`, `searchFrom?` = 6, `width?` = 232, `className?`
## Accessibility
- Trigger: `aria-haspopup="menu" aria-expanded`. Each level: `role="menu" aria-label={heading}`. Branch rows: `role="menuitem"` with `aria-haspopup` and `aria-expanded`. Options: `role="menuitemcheckbox"` or `"menuitemradio"` with `aria-checked`. Search: `aria-label="Search {heading}"`.
## Demo
A "Reporting filters" menu with two branches.
Filters:
- Offer: six options such as "Spring sale landing page (ID: 15)" and "Annual plan upsell (ID: 17)"
- Smart Link
- Channel: Search, Social, Email, Display, Affiliate, Direct
- Region
- City: ten cities, so it gets a search box
- Country, Country Code
- Adv1–Adv5
Metric Filters:
- Clicks: single-select, hint "Pick one threshold", options "More than 0", "More than 100", "More than 1,000"
- Conversions
- Duplicate clicks
Start from `{ offer: ['1', '3'], channel: ['2'] }`. Beside the trigger, render one chip per picked list ("Offer: A, B", or "3 selected" above two picks) with an × to remove it.
## 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 {
useEffect,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { Check, ChevronRight, Info, ListFilter, Search } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
/** One value a leaf list offers. */
export type MultilevelMenuOption = { value: string; label: string; disabled?: boolean };
/**
* A row in the menu. With `children` it opens another level; with `options`
* it opens a list of values to pick from; with neither it is an action and
* runs `onSelect`.
*/
export type MultilevelMenuNode = {
key: string;
label: string;
icon?: ReactNode;
children?: MultilevelMenuNode[];
options?: MultilevelMenuOption[];
/** For an option list: pick several (the default) or exactly one. */
multiple?: boolean;
/** The note above an option list. Defaults to "You can select multiple items" for a multiple list. */
hint?: ReactNode;
/** Show a search box on this node's level. Default: when it has more than `searchFrom` rows. */
searchable?: boolean;
/** This node's level's width in px. */
width?: number;
disabled?: boolean;
onSelect?: () => void;
};
/** What is picked: option values per option-list key. */
export type MultilevelMenuValue = Record<string, string[]>;
/** Every option list with something picked, in menu order — the data for a row of filter chips. */
export function selectedLists(nodes: MultilevelMenuNode[], value: MultilevelMenuValue) {
const out: Array<{ node: MultilevelMenuNode; path: string[]; options: MultilevelMenuOption[] }> = [];
const walk = (list: MultilevelMenuNode[], path: string[]) => {
for (const n of list) {
if (n.options && value[n.key]?.length) {
const picked = new Set(value[n.key]);
out.push({ node: n, path: [...path, n.label], options: n.options.filter((o) => picked.has(o.value)) });
}
if (n.children) walk(n.children, [...path, n.label]);
}
};
walk(nodes, []);
return out;
}
/** Option-list keys at or under a node, for its Clear and its count. */
function listKeys(n: MultilevelMenuNode): string[] {
return [...(n.options ? [n.key] : []), ...(n.children ?? []).flatMap(listKeys)];
}
const countUnder = (n: MultilevelMenuNode, value: MultilevelMenuValue) =>
listKeys(n).reduce((s, k) => s + (value[k]?.length ?? 0), 0);
const ROW =
'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs text-slate-700 outline-none transition-colors hover:bg-slate-100 focus-visible:bg-slate-100 disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-200 dark:hover:bg-slate-700/60 dark:focus-visible:bg-slate-700/60';
const LINK =
'text-[11px] font-medium text-slate-500 hover:text-indigo-600 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-400 dark:hover:text-indigo-300';
const GAP = 6;
const MARGIN = 8;
const HOVER_DELAY = 120;
/**
* A dropdown of dropdowns — the "Add filter" menu of a reporting page. Each
* level opens BESIDE the one before, level with the row that opened it, so the
* whole route (Filters › Offer › these offers) stays on screen at once and a
* person can back up one level by moving the pointer left instead of hunting
* a back button. Levels end in lists of values to tick.
*
* ── Why a cascade and not a drill-down ──────────────────────────────────────
*
* A drill-down (one panel that replaces its content) hides where you came
* from, and hopping from "Offer" to "Channel" costs back-then-forward. Here
* the parent stays visible and hovering its next row swaps the level beside
* it. Hover opens after a short pause, so a pointer crossing rows on its way
* diagonally into the open level does not flip it to every row it grazes.
*
* ── Keyboard ────────────────────────────────────────────────────────────────
*
* Arrows move within a level; Right or Enter opens a row's level and moves
* into it; Left or Escape closes the deepest level and returns to its row;
* Escape on the first level closes the menu. A search box, where there is
* one, takes focus when its level opens and Down moves from it into the list.
*
* Levels are portalled and placed in viewport coordinates, flipping to the
* left of their parent at the right edge of the screen and sliding up at the
* bottom, so a menu opened inside a clipped card or a table toolbar is never
* cut off.
*/
export default function MultilevelMenu({
nodes,
value,
onChange,
title = 'Filters',
label = 'Add filter',
icon,
searchFrom = 6,
width = 232,
className,
}: {
nodes: MultilevelMenuNode[];
value: MultilevelMenuValue;
onChange: (value: MultilevelMenuValue) => void;
/** Heading of the first level. */
title?: string;
/** The trigger button's text. */
label?: string;
icon?: ReactNode;
/** Levels with more rows than this get a search box. */
searchFrom?: number;
/** Default level width in px; a node's own `width` overrides it for its level. */
width?: number;
className?: string;
}) {
const [open, setOpen] = useState(false);
// The open row at each level: path[0] is the row open in the first level.
const [path, setPath] = useState<string[]>([]);
const [queries, setQueries] = useState<string[]>([]);
const [pos, setPos] = useState<Array<{ left: number; top: number }>>([]);
const rootRef = useRef<HTMLDivElement>(null);
const layerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panels = useRef<Array<HTMLDivElement | null>>([]);
const hoverTimer = useRef<number | undefined>(undefined);
const focusNext = useRef<{ level: number; key?: string } | null>(null);
const [, bump] = useState(0);
// The chain of open levels: the root, then each open row's node.
const levels: Array<{ node: MultilevelMenuNode | null; rows: MultilevelMenuNode[] }> = [{ node: null, rows: nodes }];
for (const key of path) {
const node = levels[levels.length - 1].rows.find((n) => n.key === key);
if (!node || (!node.children && !node.options)) break;
levels.push({ node, rows: node.children ?? [] });
}
const close = () => {
window.clearTimeout(hoverTimer.current);
setOpen(false);
setPath([]);
setQueries([]);
setPos([]);
};
const back = () => {
if (path.length === 0) {
close();
triggerRef.current?.focus();
return;
}
focusNext.current = { level: path.length - 1, key: path[path.length - 1] };
setPath(path.slice(0, -1));
};
useDismiss([rootRef, layerRef], open, close, back);
const openAt = (level: number, key: string | null, focus = false) => {
window.clearTimeout(hoverTimer.current);
const next = key === null ? path.slice(0, level) : [...path.slice(0, level), key];
if (next.join('\u0000') !== path.join('\u0000')) {
setPath(next);
// A new level starts unfiltered.
setQueries((q) => q.slice(0, level + 1));
}
if (focus && key !== null) focusNext.current = { level: level + 1 };
};
// Place every level: the first under the trigger, each next one beside its
// parent and level with the row that opened it. Measured after every
// render, stored only when it moved, so it settles in a pass.
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const trig = triggerRef.current.getBoundingClientRect();
const out: Array<{ left: number; top: number }> = [];
for (let i = 0; i < levels.length; i++) {
const el = panels.current[i];
if (!el) break;
const w = el.offsetWidth;
const h = el.offsetHeight;
let left: number;
let top: number;
if (i === 0) {
left = trig.left;
top = trig.bottom + GAP;
if (top + h > vh - MARGIN && trig.top - GAP - h > MARGIN) top = trig.top - GAP - h;
} else {
const parent = panels.current[i - 1]!;
const p = out[i - 1];
const pr = parent.getBoundingClientRect();
const row = parent.querySelector<HTMLElement>(`[data-key="${CSS.escape(path[i - 1])}"]`)?.getBoundingClientRect();
// The row's place in its panel, moved to where the panel is going.
const rowTop = row ? row.top - pr.top + p.top : p.top;
left = p.left + parent.offsetWidth + GAP;
if (left + w > vw - MARGIN) left = p.left - w - GAP;
top = rowTop - 10;
}
out.push({
left: Math.min(Math.max(MARGIN, left), Math.max(MARGIN, vw - MARGIN - w)),
top: Math.min(Math.max(MARGIN, top), Math.max(MARGIN, vh - MARGIN - h)),
});
}
const same = out.length === pos.length && out.every((o, i) => o.left === pos[i].left && o.top === pos[i].top);
if (!same) setPos(out);
});
// Scrolling the page or resizing the window moves the trigger; follow it.
useEffect(() => {
if (!open) return;
const re = () => bump((n) => n + 1);
window.addEventListener('resize', re);
window.addEventListener('scroll', re, true);
return () => {
window.removeEventListener('resize', re);
window.removeEventListener('scroll', re, true);
};
}, [open]);
// Move focus where the last keyboard action asked, once that level exists.
useEffect(() => {
const want = focusNext.current;
if (!want) return;
const el = panels.current[want.level];
// Not until the level is placed: it is `visibility: hidden` for its first
// frame, and focusing a hidden element silently does nothing.
if (!el || !pos[want.level]) return;
focusNext.current = null;
const target = want.key
? el.querySelector<HTMLElement>(`[data-key="${CSS.escape(want.key)}"]`)
: el.querySelector<HTMLElement>('input[type="search"]') ?? el.querySelector<HTMLElement>('[data-row]:not(:disabled)');
target?.focus({ preventScroll: true });
});
useEffect(() => () => window.clearTimeout(hoverTimer.current), []);
const total = nodes.reduce((s, n) => s + countUnder(n, value), 0);
const clearKeys = (keys: string[]) => {
const next = { ...value };
for (const k of keys) delete next[k];
onChange(next);
};
const onListKey = (level: number) => (e: KeyboardEvent<HTMLDivElement>) => {
const panel = panels.current[level];
if (!panel) return;
const rows = [...panel.querySelectorAll<HTMLElement>('[data-row]:not(:disabled)')];
const at = rows.indexOf(document.activeElement as HTMLElement);
const inSearch = (document.activeElement as HTMLElement | null)?.matches('input[type="search"]');
const move = (i: number) => rows[(i + rows.length) % rows.length]?.focus();
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
move(inSearch ? 0 : at + 1);
break;
case 'ArrowUp':
e.preventDefault();
if (at <= 0 && panel.querySelector('input[type="search"]')) panel.querySelector<HTMLElement>('input[type="search"]')!.focus();
else move(at - 1);
break;
case 'Home':
case 'End':
if (inSearch) return;
e.preventDefault();
move(e.key === 'Home' ? 0 : rows.length - 1);
break;
case 'ArrowRight': {
if (inSearch) return;
const key = (document.activeElement as HTMLElement | null)?.dataset.key;
const node = levels[level].rows.find((n) => n.key === key);
if (node && (node.children || node.options)) {
e.preventDefault();
openAt(level, node.key, true);
}
break;
}
case 'ArrowLeft':
if (inSearch || level === 0) return;
e.preventDefault();
focusNext.current = { level: level - 1, key: path[level - 1] };
setPath(path.slice(0, level - 1));
break;
}
};
const layer = open && typeof document !== 'undefined'
? createPortal(
<div ref={layerRef} data-overlay="menu">
{levels.map((lvl, i) => {
const node = lvl.node;
const isList = !!node?.options;
const query = (queries[i] ?? '').trim().toLowerCase();
const count = isList ? node!.options!.length : lvl.rows.length;
const searchable = node?.searchable ?? count > searchFrom;
const setQuery = (q: string) => setQueries((qs) => Object.assign([...qs], { [i]: q }));
const heading = node ? node.label : title;
const clearable = node ? listKeys(node) : nodes.flatMap(listKeys);
const picked = isList ? new Set(value[node!.key] ?? []) : null;
const multiple = isList && node!.multiple !== false;
const shownOptions = isList ? node!.options!.filter((o) => !query || o.label.toLowerCase().includes(query)) : [];
const shownRows = isList ? [] : lvl.rows.filter((r) => !query || r.label.toLowerCase().includes(query));
const levelWidth = node?.width ?? (isList ? Math.max(width, 300) : width);
const hasAny = clearable.some((k) => value[k]?.length);
const pick = (o: MultilevelMenuOption) => {
const cur = value[node!.key] ?? [];
const next = multiple
? cur.includes(o.value)
? cur.filter((v) => v !== o.value)
: [...cur, o.value]
: cur[0] === o.value
? []
: [o.value];
const out = { ...value, [node!.key]: next };
if (next.length === 0) delete out[node!.key];
onChange(out);
};
return (
<div
key={node?.key ?? '__root'}
ref={(el) => {
panels.current[i] = el;
}}
role="menu"
aria-label={heading}
onKeyDown={onListKey(i)}
// Reaching a level cancels a hover still pending in the level before it.
onPointerEnter={() => window.clearTimeout(hoverTimer.current)}
style={{
width: levelWidth,
left: pos[i]?.left ?? 0,
top: pos[i]?.top ?? 0,
// Unplaced for one frame, while it is measured.
visibility: pos[i] ? undefined : 'hidden',
}}
className="panel panel-solid fixed z-[200] flex max-h-[min(28rem,calc(100vh-1rem))] flex-col py-1 animate-scale-in"
>
<div className="flex shrink-0 items-center justify-between gap-2 px-3 pb-2 pt-1.5">
<h3 className="truncate text-sm font-semibold text-slate-800 dark:text-slate-100">{heading}</h3>
<div className="flex shrink-0 items-center gap-1.5">
{multiple && (
<>
<button
type="button"
className={LINK}
disabled={shownOptions.every((o) => o.disabled || picked!.has(o.value))}
onClick={() => {
// Select all that the search shows, the ones in view.
const add = shownOptions.filter((o) => !o.disabled).map((o) => o.value);
onChange({ ...value, [node!.key]: [...new Set([...(value[node!.key] ?? []), ...add])] });
}}
>
Select all
</button>
<span aria-hidden className="text-slate-300 dark:text-slate-600">|</span>
</>
)}
<button type="button" className={LINK} disabled={!hasAny} onClick={() => clearKeys(clearable)}>
Clear
</button>
</div>
</div>
<div className="border-t border-slate-200 dark:border-slate-700" />
{searchable && (
<label className="flex shrink-0 items-center gap-2 border-b border-slate-200 px-3 py-1.5 dark:border-slate-700">
<Search aria-hidden className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<input
type="search"
value={queries[i] ?? ''}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${heading.toLowerCase()}`}
aria-label={`Search ${heading}`}
className="min-w-0 flex-1 bg-transparent py-1 text-xs text-slate-700 outline-none placeholder:text-slate-400 dark:text-slate-200"
/>
</label>
)}
{isList && (node!.hint !== undefined || multiple) && (
<p className="flex shrink-0 items-center gap-1.5 border-b border-slate-200 px-3 py-2 text-[11px] text-slate-500 dark:border-slate-700 dark:text-slate-400">
<Info aria-hidden className="h-3.5 w-3.5 shrink-0" />
{node!.hint ?? 'You can select multiple items'}
</p>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-1 pt-1">
{isList
? shownOptions.map((o) => {
const on = picked!.has(o.value);
return (
<button
key={o.value}
type="button"
data-row
role={multiple ? 'menuitemcheckbox' : 'menuitemradio'}
aria-checked={on}
disabled={o.disabled}
onClick={() => pick(o)}
className={cn(ROW, on && 'font-medium text-indigo-700 dark:text-indigo-300')}
>
<span
aria-hidden
className={cn(
'flex h-3.5 w-3.5 shrink-0 items-center justify-center border',
multiple ? 'rounded' : 'rounded-full',
on
? 'border-indigo-600 bg-indigo-600 text-white dark:border-indigo-400 dark:bg-indigo-400 dark:text-slate-900'
: 'border-slate-300 dark:border-slate-600',
)}
>
{on && <Check className="h-2.5 w-2.5" strokeWidth={3} />}
</span>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
</button>
);
})
: shownRows.map((r) => {
const opens = !!(r.children || r.options);
const active = path[i] === r.key;
const n = countUnder(r, value);
return (
<button
key={r.key}
type="button"
data-row
data-key={r.key}
role="menuitem"
aria-haspopup={opens ? 'menu' : undefined}
aria-expanded={opens ? active : undefined}
disabled={r.disabled}
// On MOVE, not enter: rows that re-render under a resting pointer
// (a search narrowing, an Escape) fire enter without anyone
// pointing, and would reopen the level just closed. Restarting
// the timer on each move opens a row once the pointer settles.
onPointerMove={(e) => {
if (e.pointerType !== 'mouse') return;
// Already where this row leads: its level open, or (an action row) nothing open beside it.
if (opens ? path[i] === r.key : path.length <= i) return;
window.clearTimeout(hoverTimer.current);
hoverTimer.current = window.setTimeout(() => openAt(i, opens ? r.key : null), HOVER_DELAY);
}}
onPointerLeave={() => window.clearTimeout(hoverTimer.current)}
onClick={() => {
if (opens) openAt(i, r.key);
else {
r.onSelect?.();
close();
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && opens) {
e.preventDefault();
openAt(i, r.key, true);
}
}}
className={cn(ROW, active && 'bg-slate-100 dark:bg-slate-700/60')}
>
{r.icon && <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-slate-400 [&>svg]:h-3.5 [&>svg]:w-3.5">{r.icon}</span>}
<span className="min-w-0 flex-1 truncate">{r.label}</span>
{n > 0 && (
<span className="rounded-full bg-indigo-50 px-1.5 text-[10px] font-semibold leading-4 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
{n}
</span>
)}
{opens && <ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-slate-400" />}
</button>
);
})}
{(isList ? shownOptions : shownRows).length === 0 && (
<p className="px-2.5 py-3 text-center text-[11px] text-slate-400">No matches</p>
)}
<div className="h-1" />
</div>
</div>
);
})}
</div>,
document.body,
)
: null;
return (
<div ref={rootRef} className={cn('inline-flex', className)}>
<button
ref={triggerRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => (open ? close() : (setOpen(true), (focusNext.current = { level: 0 })))}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400',
open || total > 0
? 'border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-500/50 dark:bg-indigo-500/15 dark:text-indigo-200'
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700',
)}
>
{icon ?? <ListFilter aria-hidden className="h-3.5 w-3.5" />}
{label}
{total > 0 && (
<span className="rounded-full bg-indigo-600 px-1.5 text-[10px] font-semibold leading-4 text-white dark:bg-indigo-400 dark:text-slate-900">
{total}
</span>
)}
</button>
{layer}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
nodes* | MultilevelMenuNode[] | — | |
value* | MultilevelMenuValue | — | |
onChange* | (value: MultilevelMenuValue) => void | — | |
title | string | 'Filters' | Heading of the first level. |
label | string | 'Add filter' | The trigger button's text. |
icon | ReactNode | — | |
searchFrom | number | 6 | Levels with more rows than this get a search box. |
width | number | 232 | Default level width in px; a node's own `width` overrides it for its level. |
className | string | — |