Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.3K
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Agent Skills
  • llms.txt
  • RTL
  • Changelog
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • Grok
  • Conductor
  • v0
  • Lovable
  • Replit
  • Bolt
  • OpenCode
  • VS Code
  • GitHub Copilot
  • Kilo Code
  • Zed
  • Antigravity
Components
  • Alert
  • Autocomplete
  • Badge
  • CascaderNew component for nested multi-level selection
  • Data Grid
  • Date Selector
  • Event Calendar
  • File Upload
  • FiltersRebuilt around a boolean query tree, with breaking API changes and a migration prompt
  • Frame
  • Gantt
  • Icon Stack
  • Icon Tile
  • Kanban
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • Sortable
  • Stepper
  • Timeline
  • Tree

Application

  • App Shell
  • Auth
  • Card
  • Chart
  • Dashboard
  • Dialog
  • Empty State
  • Event Calendar
  • Form
  • Gantt
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Schedule
  • Settings
  • Sheet
  • Stats
  • Timeline
  • Wizard

Solutions

  • Agents
  • AI Ops
  • Analytics
  • Billing
  • Bookings
  • CRM
  • FilesDrive Explorer gained drag and drop upload with a transfer panel
  • Inventory
  • Users

Templates

  • E-commerce
  • SaaS
  • Dashboard
  • Landing
  • All templates

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist

Data Grid

  • Base
  • Columns
  • Drag & Drop
  • Editing
  • Expansion
  • Filtering
  • Grouping
  • Virtualization

Marketing

  • Blog
  • Contact
  • CTA
  • FAQ
  • HeroSixteen new marketing hero blocks added

Resources

  • Components
  • Blocks
  • Icons
  • MCP for Agents
  • Docs
  • Support
  • Pricing
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026 ReUI. All rights reserved.

3.3K

Shadcn Cascader

PreviousNext

Custom Shadcn Cascader for React and Tailwind CSS. A nested multi-level combobox with drill-down navigation, breadcrumbs, search and custom rows.

Base UIRadix UI

Cascader is a select whose options form a tree. Instead of scrolling one long list you drill from a category into its children and commit a value at whatever depth matters. It is the control for attribute pickers, category trees, org charts, region pickers, permission scopes and file paths.

What it adds over a flat combobox is level navigation: pressing a branch opens it instead of committing a selection, the popup stays open, and a breadcrumb and a back control say where you are. The trigger then shows the selection's full path rather than a bare leaf label, because in a nested picker the leaf alone is frequently ambiguous.

Installation

pnpm dlx shadcn@latest add @reui/cascader

Usage

import {















Shadcn Cascader Free Components

Browse 20 production-ready Shadcn Cascader components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.

Browse all 20 Shadcn Cascader components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.

BadgeData Grid

On This Page

InstallationUsageAnatomyWhat each part is worthPlacement rulesExamplesMulti-selectColumns modeTree modeAsync levelsFooter actionsAPI ReferenceCascaderSingle selectMulti selectPartsCascaderTriggerCascaderValueCascaderContentCascaderPanelCascaderNavCascaderInputCascaderBreadcrumbCascaderListCascaderItemsCascaderItemCascaderEmptyCascaderStatusOptional partsCascaderFooterCascaderActionCascaderSubmenuCascaderSubmenuTriggerCascaderSubmenuContentCascaderChipsCascaderGroupCascaderLabelCascaderSeparatorCascaderColumnsCascaderVirtualItemsTypesCascaderNodeCascaderItemStateCascaderChangeDetailsCascaderActionItemCascaderLoadResultCascaderLoadContextCascaderSearchContextCascaderLoadStateHooksuseCascaderSelectionuseCascaderSubmenuuseCascaderAnchorGuidesFormsRight to leftLabelsDevelopment warningsAdvancedInternal exportsKeyboardTree mode keysFooter flyoutAccessibilityStructureNames and announcementsDeliberately not done
Cascader,
CascaderContent,
CascaderEmpty,
CascaderList,
CascaderPanel,
CascaderStatus,
CascaderTrigger,
} from "@/components/reui/cascader/cascader"
import { CascaderItems } from "@/components/reui/cascader/cascader-item"
import {
CascaderBreadcrumb,
CascaderInput,
CascaderNav,
CascaderValue,
} from "@/components/reui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/reui/cascader/cascader-types"
const items: CascaderNode[] = [
  { value: "record-id", label: "Record ID" },
  {
    value: "person",
    label: "Person",
    children: [
      { value: "person.name", label: "Name" },
      { value: "person.email", label: "Email addresses" },
    ],
  },
]
 
export function AttributePicker() {
  const [value, setValue] = useState("person.name")
 
  return (
    <Cascader items={items} value={value} onValueChange={setValue}>
      <CascaderTrigger render={<Button variant="outline" />}>
        <CascaderValue placeholder="Select an attribute" />
      </CascaderTrigger>
 
      <CascaderContent className="w-80">
        <CascaderPanel>
          <CascaderNav>
            <CascaderInput />
          </CascaderNav>
          <CascaderBreadcrumb />
          <CascaderEmpty />
          <CascaderList>
            <CascaderItems />
          </CascaderList>
          <CascaderStatus />
        </CascaderPanel>
      </CascaderContent>
    </Cascader>
  )
}

That is the minimum worth shipping. Three of those parts are load-bearing beyond their own markup - CascaderInput owns the level keys, CascaderValue resolves a selection whose node is not in items, and CascaderStatus is the panel's only live region - and Anatomy below has the full shape, what each part is worth, and the placement rules that are enforced at runtime rather than by the types.

One thing no part supplies for you is a NAME for the field. The trigger's contents are the field's VALUE - they change with the selection - so exactly like a native <select>, the accessible name has to come from the author: an aria-label, an aria-labelledby, or a <label> pointed at the trigger's id. Without one a screen reader hears what is picked and never what the field is for, and the cascader warns about it once in development (trigger-unnamed), checking the DOM rather than props so a <label for> pairing counts.

Anatomy

Cascader is composed rather than configured. Nothing inside the panel is implied: every part you leave out is simply not drawn, and there is no arrangement the root will silently correct for you. This is the whole shape, with every part that carries a placement rule in it.

<Cascader items={items} value={value} onValueChange={setValue}>
  {/* TRIGGER SURFACE. Exactly one of CascaderTrigger and CascaderChips, and
      it sits OUTSIDE CascaderContent: it is the anchor the popup positions
      against, so it cannot live inside the portal it anchors. Both are
      omitted only with <Cascader inline>. */}
  <CascaderTrigger aria-label="Attribute">
    <CascaderValue placeholder="Select an attribute" />
  </CascaderTrigger>
 
  {/* PORTAL, POSITIONER AND POPUP, in one part. Dropped by `inline`. */}
  <CascaderContent className="w-80">
    {/* THE PANEL. Also owns the panel's Tab order, which is what keeps the
        footer one press from the field in every mode. */}
    <CascaderPanel>
      <CascaderNav>
        {/* Must render in here, inside the positioner. Owns the level keys,
            so a panel without one has no keyboard path into a branch. */}
        <CascaderInput />
      </CascaderNav>
 
      {/* Drill mode only. Renders null in columns and in tree. */}
      <CascaderBreadcrumb />
 
      {/* Empty, loading and error, all three out of one element. */}
      <CascaderEmpty />
 
      {/* THE ROWS. In mode="columns", CascaderColumns replaces this pair. */}
      <CascaderList>
        <CascaderItems />
      </CascaderList>
 
      {/* A SIBLING of the list, never a child of it: inside the list it
          would be a row, and would vanish the moment a query matched
          nothing - which is when a command is most useful. */}
      <CascaderFooter>
        <CascaderAction onSelect={createAttribute}>
          Create new attribute
        </CascaderAction>
      </CascaderFooter>
 
      {/* The panel's only announcement channel. */}
      <CascaderStatus />
    </CascaderPanel>
  </CascaderContent>
</Cascader>

What each part is worth

PartRequiredLeft out
CascaderAlwaysEvery part that reads state throws by name from the context hook it reads, rather than answering undefined. The five that read none - CascaderNav, CascaderSeparator, CascaderAction, CascaderGroup and a CascaderLabel outside a group - render anyway, as chrome with nothing behind it.
CascaderTrigger or CascaderChipsUnless inlineNothing opens the popup, and CascaderContent has no anchor to position against.
CascaderContentUnless inlineThe panel renders in place, unportalled and unpositioned.
CascaderPanelYesThe panel's Tab order is not installed, so the scroll area's own tab stop reappears between the search field and the footer - a variable number of presses, behind an unnamed stop that rings the whole list.
CascaderList or CascaderColumnsYesNo rows, in any mode.
CascaderItemsYes, unless you place rows yourselfThe list renders empty. Hand-composed CascaderItem runs are the supported alternative - see .
CascaderInputNo, butYou lose the level keys. →, ← and Backspace are handled on the search field, so the list still arrows up and down and nothing opens a branch or steps back from the keyboard.
CascaderStatusNo, butNothing is announced at all. It is the panel's only live region, and drill-down hides its context in a visual breadcrumb a screen reader user never sees.
CascaderValueNo, butThe trigger renders whatever you put in it. CascaderValue is also what resolves a selection whose node is not in items, so a hand-written trigger has to handle that case itself or render blank.
CascaderNavNoLayout and the header separator only. CascaderInput needs no parent of its own.
CascaderBreadcrumbNoDrill navigation says nothing about where you are.
CascaderEmptyNoAn empty, loading or failed level draws nothing at all.
CascaderFooterNoNothing, quite literally: without children and without a root actions array it renders nothing anyway.

Placement rules

These are enforced at runtime, not by the types, so a part in the wrong place still compiles. Most of them fail quietly: the part renders, and something else stops working.

  • CascaderInput goes inside CascaderContent. Base UI only skips refilling the input from the committed selection when the input lives inside the popup, and that refill fights every level swap. It is also what makes the popup a role="dialog", which is why labels.panelLabel exists.
  • CascaderTrigger and CascaderChips go outside CascaderContent, as siblings of it. They are the anchor and the focusable element outside the popup, so ref and onBlur belong there too.
  • CascaderChips REPLACES CascaderTrigger. The popup then has to be anchored by hand: useCascaderAnchor() returns the ref and CascaderContent takes it as anchor.
  • CascaderFooter is a SIBLING of CascaderList, inside CascaderPanel.
  • CascaderItem belongs inside CascaderList, or inside a CascaderColumns column. Base UI collects its options through the list's own collection, so a row rendered outside one is invisible to the arrow keys and to aria-activedescendant.
  • CascaderLabel belongs inside CascaderGroup. Outside a group it still draws, and it names nothing: a heading loose inside a listbox is dropped from the accessibility tree entirely.
  • CascaderSubmenuTrigger and CascaderSubmenuContent belong inside CascaderSubmenu, whose state hook throws by name outside one.
  • CascaderColumns replaces CascaderList and renders null outside mode="columns", warning in development when it is mounted in the wrong mode. CascaderBreadcrumb and CascaderBack render null outside mode="drill" SILENTLY, so a columns or tree panel that mounts one gets an invisible part and no hint about why.
  • Windowing is opt in at the MARKUP level as well as through virtualize. CascaderVirtualItems goes inside CascaderList; CascaderVirtualColumn goes through the CascaderColumns children slot. virtualize with neither mounted windows nothing, and says so in the console.

Examples

Multi-select

Columns mode

mode="columns" renders the whole open trail side by side, one pane per level (Miller columns), for when comparing siblings across levels matters more than screen width.

Tree mode

mode="tree" expands branches in place so several groups stay open at once, which is what a multi-select over a whole tree needs and what drill-down actively works against.

Async levels

Footer actions

The other fourteen ship exactly as these do and stay browsable on the Cascader components page: deep search with path-annotated results, custom rows with avatars, color-coded rows and color-coded categories, trigger path formatting, an embedded panel, flat adjacency data, controlled state, translated labels, descriptions and disabled options, a form field, hand-composed groups and labels, consumer-rendered chips, and a windowed level of thousands of rows.

API Reference

The surface is tiered, and every exported symbol sits in exactly one tier.

  • Public. Everything from Cascader down to the end of Guides: what you compose or call, with a full prop table each. This is what the twenty shipped examples are built out of.
  • Extension. Advanced: exported on purpose so you can go further than the parts do - the context hooks, the pure helpers, the lower-level parts a default already renders for you. Named with one line of purpose each rather than a prop table. Supported, but not the happy path.
  • Internal. Internal exports: exported only because the registry ships one file per module and TypeScript needs cross-file access. Not public API. Listed so you can recognise one when your editor offers it, and skip it.

Five parts render a plain element of their own and accept Base UI's render prop: CascaderPanel, CascaderNav, CascaderBack, CascaderBreadcrumb and CascaderValue. Each becomes the element or component you hand it while keeping its own classes, data attributes and behaviour.

<CascaderPanel render={<Frame />} />
<CascaderBack render={<Button variant="ghost" size="icon" />} />

render takes a React element or a function returning one. Props are merged rather than replaced, so the part's own onClick, aria-label and data-slot survive, and anything you pass wins over the default.

Thirteen more are Base UI parts, so they take the same render prop for the same reason: CascaderTrigger, CascaderContent, CascaderInput, CascaderList, CascaderEmpty, CascaderStatus, CascaderItem, CascaderChips, CascaderChip, CascaderGroup, CascaderLabel, CascaderSeparator and CascaderSubmenuContent. Two footnotes inside that list. CascaderLabel is a Base UI GroupLabel inside a CascaderGroup and a plain rendered element outside one, and takes render either way. And CascaderItem forwards render only on its default as="option" path: as="button" renders a plain <button> outside Base UI's listbox for the columns trail, where aria-setsize, aria-posinset and aria-level are explicitly DELETED as illegal on role="button". render is not deleted - it is simply never read on that path, so it reaches the DOM as an unrecognised attribute and nothing says so: an element lands there as render="[object Object]" in silence, and only the callback form trips React's own Invalid value for prop error.

Everything else composes through children rather than through its own element, and accepts neither prop: CascaderFooter, CascaderColumns, CascaderColumnPanel, CascaderItems, CascaderVirtualItems, CascaderVirtualColumn, CascaderAction, CascaderSubmenu and CascaderSubmenuTrigger.

Cascader

T is inferred from items. multiple is the discriminant rather than an ordinary boolean: value, defaultValue, onValueChange and max all narrow off it, so the two mode-dependent shapes are listed separately below.

Five pieces of state are independently controllable, each with an uncontrolled default* twin and a change callback: value, path, expanded, open and inputValue. Decide per prop and stay with it - switching one between controlled and uncontrolled mid-life strands half its state, because the first update after the switch reads from whichever source is no longer authoritative, and the cascader warns about it in development.

PropTypeDefaultDescription
itemsCascaderNode<T>[]-Nested tree, or a flat list when getParent is supplied. Rows may arrive in any order, an unknown parent makes a node a root rather than dropping it, and duplicate values are ignored after the first occurrence.
getParent(node) => string | null | undefined-Opt in to flat adjacency input, indexed in one linear pass with no client-side re-nesting step, which is what keeps a large normalized dataset cheap to render and cheap to update. undefined and null both mean "root", so a row typed with an optional parentId?: string satisfies it as written.
getChildren(node, ctx) => nodes | result | Promise-Fetch one level on demand; node is null for the root. Return an array, or a when the level pages. Only the levels on screen are asked for, a level items already fills is never fetched, and fetched pages are kept apart from items and merged on top of it, so a new items identity never discards what the user drilled into. Mark every branch whose children are not loaded yet hasChildren: true, or the row renders as a selectable leaf with nothing to drill into.
onSearch(query, ctx) => nodes | result | Promise-Server-side search, replacing the local index scan entirely while a query is set. Hits are resolvable by value but belong to no level, so they never appear in a level list and never come back twice from a deep search. The previous query is aborted the moment a new one is typed. Ignored in mode="tree", where the request is never fired and a development warning says so: a server hit belongs to no visible branch.
searchDebouncenumber250Milliseconds of quiet before onSearch fires.
resolveValue(value, ctx) => nodes | Promise-Ancestor chain for a selection that has not been loaded, root first and the node itself last, so display="path" renders the full trail immediately. The chain is placed WITHOUT marking those levels as loaded, so opening one still fetches it for real. Each value is resolved at most once.
loadKeyunknown-Cache key for everything fetched. Changing it drops every page, every load state and every resolved node, and aborts anything in flight. Change it when the data source itself changes - a different tenant, a different filter - and reach for invalidateLevel(value) on when only one branch went stale.
prefetchbooleanfalseFetch a branch's children while it is merely highlighted, after a 150ms pause. Off by default: it trades requests for latency, and the pause is what stops a held ↓ firing a request per row.
onLoadError(error, context) => void-Called when a load fails, alongside the panel's own error state - for logging, a toast, retry telemetry. Carries the level that failed (context.parent, null for the root) and the context.reason that asked for it: the five reasons plus "search", which a level fetch can never report because a search belongs to no level. Typed as a plain string for that reason. Never called for an aborted or superseded request: those are navigation, not failures.
mode"drill" | "columns" | "tree""drill"Panel layout, row ARIA and arrow keys; the items, the selection and the search model are the same in all three. "drill" replaces the list one level at a time, the smallest popup and the only one that stays legible at a phone width. See and .
expandTrigger"click" | "hover""click"How a branch row in columns mode's active column opens from the pointer. Hover navigates after a short rest and never commits, so a pointer crossing the panel cannot change the value. Drill and tree ignore it, because there a navigation replaces or reflows the rows under the pointer.
multiplefalse | truefalseCheckbox rows; the popup stays open while picking. A literal union, not a boolean: it is optional false on the single arm and a REQUIRED true on the multi one, so a boolean held in a variable cannot be spread in and the union will not resolve.
selectableCascaderSelectable<T>"leaf"Which nodes may be committed: "leaf", "any", or a predicate. The predicate arm is generic over the payload, so an annotated (node: CascaderNode<Member>) => boolean fits. When a branch is selectable, pressing the row commits it and the chevron becomes its own target for opening it; both the check and the chevron columns are reserved from THIS setting rather than from each row's own answer, so a predicate that accepts one branch and refuses the next does not make the counts and arrows jump down the list.
indicatorbooleantrueDraws the built-in single-select check. false also gives back the inline-end gutter every style reserves for it, so trailing content ends flush instead of stopping one gutter short. Reach for it when the picker marks selection through something it already draws - a tinted row, a filled leading tile - since data-selected stays on the row in every mode. No-op with multiple, where the checkbox is the selection CONTROL rather than a decoration, and a development warning says so. aria-selected is untouched either way.
actionsCascaderActionItem[]-Footer commands, drawn by <CascaderFooter />. See .
cascadebooleanfalsePropagate a commit over the pressed node's LOADED subtree and reconcile its ancestors. The invariant every rendered state reads off: a branch's value is in the selection exactly when every selectable child of it is, and indeterminate is derived - not selected itself, at least one descendant selected - never stored. What is STORED is the full closure, the branch and every selected node under it, which is what keeps a windowed row's checked state an O(1) set lookup; condense at the edge with or, for display only, with CascaderChips' strategy. Needs multiple and committable branches, and warns without them. Nodes the selectable predicate refuses are skipped on the way down AND ignored on the way up. max refuses an over-cap subtree outright rather than keeping an arbitrary prefix of it.
pathstring[]-Controlled navigation path, deepest last.
defaultPathstring[][]Uncontrolled initial path.
onPathChange(path: string[], details) => void-Fires on every level change. details.reason is a CascaderPathChangeReason - "drill", "back", "breadcrumb", "reveal", or "external" for a setPath from outside the primitive's own flows - so a controller can tell the user's drill from its own jump without diffing paths. The second argument is optional at the consumer: an existing (path) => void keeps compiling.
expandedstring[]-Controlled expansion. Tree mode only.
defaultExpandedstring[][]Uncontrolled initial expansion.
onExpandedChange(expanded: string[]) => void-Fires when a branch expands or collapses.
openboolean-Controlled open state.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpenChange(open: boolean, details) => void-Fires when the popup opens or closes. details.reason forwards Base UI's own reason string ("escape-key", "outside-press", ...); the close a leaf commit performs reports "item-press".
closeOnSelectbooleantrueWhether a single-select leaf commit closes the popup. false keeps it open for a compare-and-repick flow; Escape, outside presses and the trigger still close it. Ignored with multiple, which already stays open on every commit.
inputValuestring-Controlled search query.
defaultInputValuestring""Uncontrolled initial search query.
onInputValueChange(value: string) => void-Fires as the query changes.
searchScope"level" | "deep""level"The field filters and never jumps: typing narrows what the current view shows and leaves the selection and the navigation path untouched, so clearing the query puts the user back exactly where they were. "level" filters the level on screen, which is the scope a drill-down implies. "deep" matches the level you are on AND everything under it - not the whole tree from wherever you happen to be - so a query typed inside Person cannot answer with fields from Workspaces and make the breadcrumb above the results a lie; each hit renders its ancestor trail under its label, and results are capped at 200. In mode="columns" only the ACTIVE column is filtered. In mode="tree" a query already matches at any depth and auto-expands the ancestors of every hit, so "deep" is a no-op there and warns in development.
filter(node, query) => booleanlabel + keywordsCustom matcher. The default is a substring test over the label plus any keywords on the node, with both sides folded through toLocaleLowerCase so the Turkish dotted and dotless I fold the way that locale expects. A replacement is used in every mode, tree filtering included, and receives an already normalized query so the folding does not repeat per node.
revealSelectedbooleantrueOn open, navigate to the level holding the selection in the same commit that opens the popup. Turn it off to always reopen at the root - which is also the lever for a deep preselection into a very large level, where the popup's first mount renders that whole level. See .
maxHeightnumber | string-Upper CAP on each level's height, not a fixed height: the panel takes min(var(--available-height, 100vh), var(--cascader-max-height, 24rem)), and --available-height is the distance from the trigger to the edge of the viewport that the positioner publishes. So a panel opened 200px above the fold is 200px tall and a panel with the whole window under it stops at 24rem, with nothing to configure. Do not reach for it by default. Three cases genuinely want one: an inline panel, which has no positioner and therefore no --available-height; a windowed list, where the scrollport height is what the virtualizer divides into rows; and columns mode, where panes side by side have to agree on a height or the popup grows and shrinks as you move between them.
virtualizebooleanautoWindow the rendered rows. true always windows, false never does, and left unset the row count decides. Windowing is opt in at the MARKUP level as well: it happens only where a or a CascaderVirtualColumn is actually mounted, so a cascader that renders neither never runs a virtualizer whatever this is set to - and true with no windowed list mounted is reported to the console in development. The decision is latched per level, so a query that narrows 5,000 rows to 12 does not tear the virtualizer down on the next character.
virtualizeThresholdnumber100Row count at which windowing turns itself on.
estimateRowSizenumber32Row height used before a row has been measured. A starting point, not a contract: every row is measured after it mounts, since rows are two lines tall with a description, three in deep search, and each style sets its own row height.
overscannumber8Rows rendered beyond each edge of the viewport.
labelsPartial<CascaderLabels>EnglishEvery user-facing string, including announcements. Shallow-merged over the English defaults. See .
renderItem(node, state) => ReactNode-Replaces the whole row, affordances included. Reach for it when the row shares nothing with the default.
renderLabel(node, state) => ReactNode-Replaces only the label block, so avatars, badges and status dots drop in while the count, chevron and selected check keep working. Building a leading tile here rather than in the node's icon puts it in the LABEL column and leaves the icon slot empty.
disabledboolean-Disables the whole control. Unset it reaches Base UI as undefined, which reads as false.
namestring-Native form field name. See .
formstring-Id of the form the hidden input belongs to.
idstring-Id of the control, for a <label htmlFor>.
requiredboolean-Marks the field required for native validation. Omitting it is not the same as passing false: omitted, the key is not spread onto Base UI's hidden input at all, so a Field wrapper's own value survives.
readOnlyboolean-The value can be read and submitted, but not changed. Spread conditionally, on the same terms as required.
invalidboolean-Sets aria-invalid and data-invalid on the trigger, chips and search input. Coerced with !!, so unset reads as false.
inputRefRef<HTMLInputElement>-Ref to the hidden input a form library focuses on error.
inlineboolean-Render without a floating popup, for in a sidebar, a dialog body or a settings screen. Unset it reaches Base UI as undefined, which reads as false.
childrenReactNode-The trigger, content and panel parts.

Single select

PropTypeDefaultDescription
multiplefalse-Optional on this arm. Omit it, or pass a literal false.
valuestring-Controlled selection.
defaultValuestring""Uncontrolled initial selection.
onValueChange(value: string, details: CascaderChangeDetails<T>) => void-Fires when the selection changes. The value alone is a bare id, so the second argument carries the resolved node, its path and the whole selection - see . It does not fire when a change would be a no-op: re-committing the selected value, or a max that refuses a pick, stays silent.
maxnever-Not available: there is nothing to cap. Passing it is a type error.

Multi select

PropTypeDefaultDescription
multipletrue-REQUIRED on this arm, and non-optional. It is what narrows the three below.
valuestring[]-Controlled selection.
defaultValuestring[][]Uncontrolled initial selection.
onValueChange(value: string[], details: CascaderChangeDetails<T>) => void-Fires when the selection changes. See .
maxnumber-Cap on selections. A pick past the cap is refused rather than the earliest one being dropped, and the refusal is announced through labels.maxReachedAnnouncement - a press whose effect is nothing is otherwise invisible to a screen reader user.

Parts

The skeleton nearly every cascader writes, listed in composition order.

CascaderTrigger

The field itself: a real <button> carrying the trailing chevron, and the focusable element outside the popup, so ref and onBlur belong here. See forms. A clear control belongs BESIDE it rather than inside it - a button nested in a button is invalid markup, fails the nested-interactive axe rule, and reopens the popup on the way up from its own click - so position one over the trigger's inline end, turn showIcon off for as long as it stands in for the chevron, and keep the chevron's width reserved so a long summary truncates before it reaches the button.

PropTypeDefaultDescription
showIconbooleantrueRender the trailing chevron. Turn it off for a trigger that supplies its own affordance, such as an inline clear button.
refRef<HTMLButtonElement>-The trigger element. Base UI's Trigger.Props stops short of ref, so the interface adds it back for form libraries.
onBlurFocusEventHandler<HTMLButtonElement>-The "touched" signal a form library asks for.

CascaderValue

Renders the selection's path in the trigger, with the middle collapsed to an ellipsis. It feeds the same collapsing helper the in-panel breadcrumb does, so the two can never disagree about how a deep path reads. It also resolves a selection whose node is not in items - async children that have not been fetched, or an item removed after being chosen - remembering the label of anything that has been selected and falling back to the raw value, so the trigger never renders blank.

PropTypeDefaultDescription
display"path" | "leaf" | "count""path"What the trigger renders.
maxSegmentsnumber3Visible path segments before collapsing. A limit equal to the path's own depth never collapses anything, so a three-segment path needs 2 to show the difference.
collapse"middle" | "start" | "none""middle"Where the path is shortened.
separatorReactNodechevronSeparator between segments.
showIconbooleantrueRender the selected node's icon.
placeholderReactNode-Shown when nothing is selected.
children(selected, path) => ReactNode-Replaces the whole rendering, the placeholder branch included, so the empty state goes inside it. Passing placeholder alongside it leaves a placeholder that can never be reached.
renderuseRender.RenderPropspanRender a different element while keeping the display behaviour.

CascaderContent

Portal, positioner and floating panel in one. Unlike the shadcn ComboboxContent wrapper it does not clamp the popup to the anchor's width - a cascader panel is routinely wider than its trigger, and columns mode wider again - but it does floor it there, so an ordinary single-column popup still lines up under the trigger. It takes Base UI Combobox.Popup props plus the positioner surface below and the portal's container, forwarded as-is so an unusual anchor situation - a sticky toolbar, a scroll container that clips, a fixed layout - does not force a rebuild of the whole content stack. There is deliberately no trackAnchor in the list: Base UI 1.5.0's positioner has no such prop.

PropTypeDefaultDescription
sideSide"bottom"Which side of the anchor to open on.
alignAlign"start"Alignment against the anchor.
sideOffsetnumber6Distance from the anchor.
alignOffsetnumber0Offset along the alignment axis.
anchorPositioner.Props["anchor"]triggerPosition against something other than the trigger. This is how the chips flow works - see .
collisionBoundaryPositioner.Props["collisionBoundary"]clipping ancestorsWhat the popup avoids overflowing.
collisionPaddingnumber | Rect5Space kept between the popup and the collision boundary.
stickybooleanfalseKeep the popup pinned to its side while the anchor scrolls out of view.
positionMethod"absolute" | "fixed""absolute"CSS position strategy of the positioner.
containerPortal.Props["container"]the bodyWhere the portal mounts, for a shadow root or a scoped stacking context.

Give it w-auto min-w-0 in mode="columns": the min-w-(--anchor-width) floor is what stops a columns panel shrinking to the width its columns actually need, and clearing it lets the panel size to its content in both directions.

CascaderPanel

The nav, list and footer container, with no positioning of its own. Use it inside CascaderContent for the popover case, and on its own with <Cascader inline> for an embedded one. It also owns the panel's tab order - see keyboard.

An embedded panel has no positioner, so there is no --available-height and the 24rem cap alone applies. To make one fill its container instead, the container chain has to give it a height: CascaderPanel is already flex max-h-full min-h-0 w-full flex-col, but every flex ancestor between it and whatever owns the height needs min-h-0 too. A flex child without it refuses to shrink below its content, which is the single most common reason a scroll area silently never scrolls.

PropTypeDefaultDescription
renderuseRender.RenderPropdivRender a different element while keeping the panel's behaviour.

CascaderNav

Header holding the back control and the search input. The breadcrumb is not part of it: it belongs to the list below the separator, because it describes where the rows come from rather than what the field searches.

PropTypeDefaultDescription
renderuseRender.RenderPropdivRender a different element while keeping the header's behaviour.

CascaderInput

The search field, and the element that owns the level keys.

PropTypeDefaultDescription
showBackbooleantrueRender CascaderBack inline before the field.
placeholderstringfrom labelsOverrides the per-level placeholder.

CascaderBreadcrumb

The trail of the level on screen AND its ancestors, rendered between the nav separator and the list. One drill in shows Company; two show Person > Company. The last crumb is the level you are on: it is the only segment that is not a button, and it carries aria-current="page". CascaderValue renders the same full path from the trigger, using the same collapsing helper.

Renders nothing at the root, and nothing outside mode="drill" - columns draws its own per-pane headings and tree has no single current level to name. Mount it anyway if the mode is dynamic; it costs one null.

PropTypeDefaultDescription
maxSegmentsnumber3Visible segments before the middle collapses.
collapse"middle" | "start" | "none""middle"Where the trail is shortened.
interactivebooleantrueClicking a segment navigates back to that level.
renderuseRender.RenderPropnavRender a different element while keeping the trail behaviour.

CascaderList

The scroll surface for one level. It is bounded by the room the popup actually has - min(--available-height, cap) - and the rows live in a shadcn ScrollArea, so the list gets a real, styled thumb instead of a hidden native scrollbar. The scrollport carries each style's own list padding as scroll-padding, so a row arrowed into view is never left tucked under the panel edge.

PropTypeDefaultDescription
maxHeightnumber | stringroot maxHeight, then 24remUpper CAP on this level's height, not a fixed height. A short viewport still wins.
styleCSSProperties-Applied to the outer shell, which is the element that owns the height.

CascaderItems

Renders one flat run per level, so its indices line up with Base UI's listRef and with the virtualizer's.

PropTypeDefaultDescription
children(node, index) => ReactNodeCascaderItemReplaces the default row component.

CascaderItem

One row. CascaderItems renders these for you, so reach for it directly only when you are laying rows out yourself.

PropTypeDefaultDescription
nodeCascaderNode-The node this row renders.
indexnumber-Explicit render index. Required in a windowed list, and deliberately IGNORED everywhere else: forwarded outside one it would make the row self-register into listRef and take the composite list over, which leaves aria-activedescendant pointing at nothing on the first arrow key. Pass it freely - the row decides whether it is safe to use.
depthnumberfrom the indexIndentation depth. Drives the tree indent.
showPathbooleanfalseRender the ancestor chain under the label, for deep-search results.
as"option" | "button""option"button renders identical markup outside Base UI's listbox, for the columns trail.
expandedboolean-Tree mode: this row is an expanded branch.
indentnumber16Pixels of indent per depth, ADDED to the style's own row inset rather than replacing it, which is what makes one level exactly indent px wide in all eight styles. Tree mode only.
branchbooleanfrom contextWhether the node has children.
selectablebooleanfrom contextWhether the node may be committed.
selectedbooleanfrom contextWhether the node is currently selected.
indeterminatebooleanfrom contextWhether the node has some but not all of its loaded subtree selected. cascade only.
selectedCountnumberfrom contextSelected nodes below this one, at any depth. Drives the trailing count.
loadingbooleanfalsePaging row only: a request for this level is in flight.
errorbooleanfalsePaging row only: this level's last request failed.
childrenLoadingbooleanfalseBranch row only: this node's OWN children are in flight. Swaps its chevron (or tree expander) for a spinner in place. resolves it.
childrenErrorbooleanfalseBranch row only: the last attempt to fetch this node's own children failed. Turns the same box into a retry affordance. resolves it.
onClick(event: CascaderRowEvent) => void-Runs BEFORE the row decides what a press means, so it is the place to veto one.
onMouseUp(event: CascaderRowEvent) => void-The same, on the drag-select path Base UI also commits from.
childrenReactNodethe row bodyReplaces the row's whole body - icon, label, description, count, chevron and check - while the row keeps its role, its ARIA and its press behaviour. The root's renderItem still wins over it. This is the hand-composition escape hatch: reach for it on a row you are already placing yourself.

CascaderRowEvent is React.MouseEvent<HTMLElement> widened with Base UI's optional preventBaseUIHandler(), which is the only supported way to stop a press committing - calling preventDefault() does not reach Base UI's own handler. The hook is optional because an as="button" row lives outside Base UI's listbox and gets a plain React event, and the element is widened so one handler serves the option, the button row and the trailing chevron without a cast. The row already vetoes for you on a branch that cannot be committed; this is for the cases it cannot know about.

branch, selectable, selected and indeterminate are optional and fall back to the cascader's own answers, so a hand-written row is always correct. Pass them when the caller already knows: the row is memoised, and four booleans let it skip a re-render that a context read would have forced. The three questions behind them are also answerable directly with isCascaderBranch, isCascaderSelectable and getCascaderCount from helpers.

Loading a branch happens BEFORE navigation, not after it. Pressing a branch whose children are not loaded does not move you: you stay on the level you are reading, that row's trailing chevron becomes a spinner IN PLACE - same box, same width, nothing reflows - and the panel changes level only once the children have arrived. Drill holds the level swap, columns holds the new column, and tree holds the EXPANSION, so a failure is a non-event: nothing moved, so nothing has to be undone, and pressing the row again refires that level. Going back, jumping to a crumb, or closing the popup all abandon the held navigation. The spinner is aria-hidden; the load state is spoken once, by CascaderStatus.

In mode="tree" the leading expander is its own pointer target, which matters as soon as branches are selectable: with selectable="any" a row press COMMITS, so without a separate expander there would be no way to open a branch at all with a pointer - and under cascade that press would quietly select the whole subtree of a row that never opened. Row press selects, expander press expands. A leaf reserves the expander's box even though it has no expander to put in it, so one depth keeps one label column. With multiple, the tree checkbox sits at the HEAD of the row, after the expander, rather than in the trailing gutter: labels are staggered by depth and a trailing column of boxes is not, so a grandchild's box would sit directly under its parent's with nothing to say they are different levels.

CascaderEmpty

The empty, loading and error surface, and the one element all three come out of. A level that has never been fetched says it is loading, one that came back with nothing shows the empty message, and one that failed shows the error and a real <button> retry. It SWAPS ITS CHILDREN rather than unmounting, because Base UI's Combobox.Empty renders whenever the rendered list is empty - which is as true of a level still fetching as of one that came back with nothing - so anything that mounted and unmounted around it would announce "No results found." over every async level on its way to loading. There are no skeleton rows: they promised a shape the level did not necessarily have, and paid for it with a second layout when the real rows arrived.

PropTypeDefaultDescription
skeletonRowsnumber-Deprecated and ignored. The surface no longer draws skeletons; a loading branch shows a spinner in place of its own chevron instead. Kept in the type so an existing call site still compiles.
childrenReactNodelabels.emptyReplaces the empty message. Loading and error still win over it.

CascaderStatus

The visually hidden live region. Include one; it is the only announcement channel the panel has.

PropTypeDefaultDescription
childrenReactNodethe announcementReplaces the announced text.
renderStatus.Props["render"]divRender a different element while keeping the live region's behaviour. It is a Base UI part, not a useRender one.

Optional parts

Extra parts you mount only when a specific feature calls for them.

CascaderFooter

Commands pinned below the list, where they stay put while the list scrolls, filters down to nothing, or changes level - which is exactly the moment a command like "Create new attribute" is most useful and the moment a row inside the list would have disappeared. Render it as a SIBLING of CascaderList, inside CascaderPanel; inside the list it would be a row. It renders nothing at all when it has neither children nor a root actions array, rather than reserving an empty strip under every panel.

This is not a fourth navigation mode. Drill, columns and tree stay the only ways to move through the data tree, and nothing in the footer joins the selection, the filter set or the highlight: a footer row is a plain <button>, never a Combobox.Item, because an item would be arrow-reachable, would be committed by Enter, and would vanish the moment a query matched nothing. The arrows still reach it - ↓ past the last row hands focus down, and either end of the strip hands it back - and keys pressed in the footer stay in the footer, so Enter, Space, the VERTICAL arrows, Home/End and PageUp/PageDown are swallowed there. ← and → are not: the horizontal pair is what opens and closes a footer flyout from its own row. Escape and Tab are deliberately let through as well.

import {
  CascaderAction,
  CascaderFooter,
  CascaderSubmenu,
  CascaderSubmenuContent,
  CascaderSubmenuTrigger,
} from "@/components/reui/cascader/cascader-footer"
 
;<CascaderPanel>
  <CascaderNav>
    <CascaderInput />
  </CascaderNav>
  <CascaderEmpty />
  <CascaderList>
    <CascaderItems />
  </CascaderList>
 
  {/* A SIBLING of the list, never a child of it. */}
  <CascaderFooter>
    <CascaderAction icon={<PlusIcon />} onSelect={createAttribute}>
      Create new attribute
    </CascaderAction>
 
    <CascaderSubmenu>
      <CascaderSubmenuTrigger icon={<ImportIcon />}>
        Import from
      </CascaderSubmenuTrigger>
      <CascaderSubmenuContent>
        <CascaderAction onSelect={importCsv}>CSV file</CascaderAction>
        <CascaderAction onSelect={importCrm}>Salesforce</CascaderAction>
      </CascaderSubmenuContent>
    </CascaderSubmenu>
  </CascaderFooter>
</CascaderPanel>

Or, when the actions are data rather than markup, hand them to the root as actions and render <CascaderFooter /> with no children. Children win over actions when both are supplied, so the data path is a shortcut rather than a different component.

PropTypeDefaultDescription
childrenReactNoderoot actionsComposed footer rows. Wins over actions when both are supplied.

CascaderAction

One footer command. A real <button>, never a Combobox.Item. Also usable inside a CascaderSubmenuContent.

disabled publishes aria-disabled and data-disabled rather than the native attribute, and that is the whole design rather than an oversight. A natively disabled <button> is not a tab stop, and the panel's own Tab order is computed from real tab stops, so a footer whose only row was disabled had no stop after the search field and was unreachable from the keyboard. ARIA's authoring practices answer it the other way round: a disabled command stays focusable and announces itself as disabled, so a keyboard user can discover that it exists. The three things the native attribute did for free are re-implemented by hand instead - pointer activation is vetoed in the click handler, Enter and Space are prevented rather than merely ignored, and the greyed look keys off aria-disabled instead of :disabled.

PropTypeDefaultDescription
iconReactNode-Leading icon.
onSelect() => void-Fires on press, after onClick and only if that did not preventDefault.
disabledbooleanfalseDeliberately NOT the native attribute. See below.

CascaderSubmenu

A footer row plus the side-anchored flyout it opens. Controlled or uncontrolled. One level deep on purpose: a command list that nests is a menu bar in disguise.

PropTypeDefaultDescription
openboolean-Controlled open state.
defaultOpenbooleanfalseUncontrolled initial state.
onOpenChange(open: boolean) => void-Fires on every change.

CascaderSubmenuTrigger

The footer row that opens the flyout, and the element the flyout is anchored to.

PropTypeDefaultDescription
iconReactNode-Leading icon.
disabledbooleanfalseIntercepted here rather than forwarded, exactly as on CascaderAction.

disabled cannot be passed through to Base UI's popover trigger: that runs it through useButton, which writes the NATIVE attribute on a <button> and takes the row out of the panel's Tab ring - reproducing on this row the defect CascaderAction avoids. So the prop is intercepted, republished as aria-disabled plus a data-disabled hook, and the three routes that would still open the flyout are closed by hand: the opening arrow key, Enter and Space before they can synthesize a click, and the click itself through preventBaseUIHandler(), since Base UI's useClick does not consult defaultPrevented.

CascaderSubmenuContent

The flyout. Takes Base UI Popover.Popup props plus the positioning four.

A second floating layer inside a combobox popup normally fights the combobox on four fronts at once: it gets aria-hidden, the click that opens it reads as an outside press, the focus that lands in it reads as a focus-out, and the popup's overflow-hidden clips it. This one is a Base UI Popover rendered as a REACT CHILD of the combobox popup, with its own Portal and no container prop, and Base UI resolves a nested portal's container to the parent portal node - so the flyout ends up a DOM SIBLING of the combobox popup, unclipped and not aria-hidden, while staying a React DESCENDANT of it, which is what the combobox's outside-press and focus-out whitelists are computed from. modal stays false, so the combobox keeps its own dismissal behaviour, and because Combobox builds no FloatingTree the same Escape reaches both layers: the root cancels its own escape-key close while any flyout is registered as open, so the flyout closes first and the cascader second.

PropTypeDefaultDescription
sideSide"inline-end"Which side of the footer row to open on.
alignAlign"end"Alignment against the row.
sideOffsetnumber8Distance from the row.
alignOffsetnumber0Offset along the alignment axis.

CascaderChips

The multi-select trigger surface. A multi-select trigger otherwise collapses to labels.selectedCount - "3 selected" - which is right for a narrow trigger and useless the moment the user wants to drop one of the three without reopening the panel. The chips container REPLACES CascaderTrigger, so the popup has to be positioned against it: useCascaderAnchor() returns the ref for that, and CascaderContent takes it as anchor. Pressing anywhere in the container that is not a chip opens the panel.

import {
  CascaderChips,
  useCascaderAnchor,
} from "@/components/reui/cascader/cascader"
 
const anchor = useCascaderAnchor()
 
<Cascader multiple items={items} value={value} onValueChange={setValue}>
  <CascaderChips ref={anchor} placeholder="Add a column..." />
 
  <CascaderContent anchor={anchor}>
    <CascaderPanel>...</CascaderPanel>
  </CascaderContent>
</Cascader>

Three things it does that a hand-rolled chip list routinely does not: it carries an ancestor path on a chip only where its label alone would be ambiguous (per selection, so nothing is padded for consistency's sake), it names every remove button through labels.removeChip, and it names the container through labels.chipsLabel - Base UI gives it role="toolbar" WHILE the selection is non-empty, so NVDA keeps focus mode while arrowing between chips, and an unnamed toolbar is announced as just "toolbar". With nothing selected there are no chips to arrow between, so the role is dropped and the container is an aria-labelled plain <div> showing the placeholder. Removal goes through Base UI's ChipRemove, which reports the SHORTENED selection to the root, so it lands in the same setSelection every other deselection does and cascade applies to a chip press exactly as it does to a row press. Chips inherit .cn-combobox-chip, .cn-combobox-chips and .cn-combobox-chip-remove, so they are themed by each style alongside the shadcn combobox rather than styled twice.

PropTypeDefaultDescription
placeholderReactNode-Shown in place of the chips when nothing is selected.
strategy"all" | "parent" | "child""all"Condenses a cascade closure for display: "parent" collapses a fully selected branch to the branch's own chip, "child" keeps only the deepest selected frontier, "all" keeps one chip per stored value. Display only - the stored value stays the full closure - and removing a condensed chip removes its whole subtree closure and reconciles the ancestors.
childrenReactNode | (nodes) => ReactNodeone eachReplaces the chip list. Receives the resolved selection, in selection order - keep it that way, because Base UI removes a chip by its position among its siblings.
refRef<HTMLDivElement>-The popup's anchor. Comes from .

CascaderGroup

A run of related rows. role="group", named by the CascaderLabel inside it. Reach for the group rather than a label on its own: a heading sitting loose inside a listbox names nothing and is dropped from the accessibility tree entirely, so it looks right and reads as though it were not there.

There is no group field on CascaderNode, and that is a decision rather than an omission - CascaderItems renders one flat run per level so its indices line up with Base UI's listRef and with the virtualizer's, and grouping inside it would have to renumber both. Compose your own CascaderItem runs when a level needs headings, under three rules about the array you compose them from:

  • The runs are slices of renderedItems, in order, each node once. Base UI sizes its listRef from that array and maps a highlight index straight back into it, so a run built with filter, or holding a node pulled in from elsewhere in the tree, desyncs the highlight and breaks Enter. Apply recency by reordering items, not by adding rows.
  • aria-setsize and aria-posinset count across the LEVEL, not the group. Numbering each run from one has the second heading announce "1 of 7" over what is genuinely the fourth row of ten.
  • No index prop outside a windowed list. See CascaderItem.
<CascaderGroup>
  <CascaderLabel>Recent</CascaderLabel>
  <CascaderItem node={recent} />
</CascaderGroup>
 
<CascaderSeparator />
 
<CascaderGroup>
  <CascaderLabel>All properties</CascaderLabel>
  <CascaderItems />
</CascaderGroup>
PropTypeDefaultDescription
childrenReactNode-A CascaderLabel and the rows it names.
renderReactElement | (props, state) => ReactElement<div>Render as another element.

CascaderLabel

A heading. Inside a CascaderGroup it becomes that group's accessible name; outside one it is drawn as a plain element, because the footer flyout is a popover rather than a listbox and a heading there is read in document order. It carries the shared cn-combobox-label treatment - uppercase and tracked in sera, not in the other seven - and overrides only its inset, so a heading lines up with the rows under it.

PropTypeDefaultDescription
childrenReactNode-The heading text.
renderReactElement | (props, state) => ReactElement<div>Render as another element.

CascaderSeparator

A rule between two runs. Decorative: it renders role="presentation" and aria-hidden, because a listbox may not own a role="separator" and a run that needs separating for a screen reader needs a CascaderGroup, not a line. Pass role="separator" yourself to take the role back where one is legal. It takes its negative margin from --cascader-list-pad, so the rule reaches the panel edge in the list, in the footer, and in lyra, whose list has no padding at all.

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Base UI's separator orientation.
renderReactElement | (props, state) => ReactElement<div>Render as another element.

CascaderColumns

Renders the trail in mode="columns". Replaces CascaderList. Only the deepest column is the listbox, because Base UI owns one list: ↑ and ↓ move within the active column, → opens the highlighted branch into a new column, and ← steps back one. The trail behind renders as plain buttons and is pointer-first - pressing a trail row jumps straight back to that branch. Each column gets its own scroll area, which matters more here than anywhere else: three side-by-side panes with no scrollbars give no hint that any of them has more rows below the fold.

PropTypeDefaultDescription
columnWidthnumber | string220Width of each column.
maxHeightnumber | stringroot maxHeight, then 24remUpper CAP on each column's height, not a fixed height.
children(column) => ReactNodeReceives a CascaderColumn and replaces the panel for every column. This is the slot goes through.

CascaderVirtualItems

Windowed replacement for CascaderItems in drill and tree modes: under virtualizeThreshold rows it renders exactly what CascaderItems renders, and above it, it windows. Nothing else about the cascader changes - the same keyboard model, the same search, the same row component. It lives in its own file, which @reui/cascader installs along with @tanstack/react-virtual.

import { CascaderVirtualItems } from "@/components/reui/cascader/cascader-virtual"
 
;<CascaderList maxHeight={288}>
  <CascaderVirtualItems />
</CascaderList>

Two rules are not stylistic preferences: windowing a Base UI combobox only works if they are followed. Pass elements to the list, never a function child, because Combobox.List implicitly wraps a function child in a Collection and a Collection renders every filtered item. And do not hand CascaderItem an index of your own outside a windowed list - see the index prop on CascaderItem. The highlighted row stays mounted even when scrolled far out of the window, so aria-activedescendant always points at a real element, and the panel scrolls the highlight into view itself, since Base UI's own scroll-into-view cannot see a row that is not rendered.

Windowing fixes the level; it does not fix MOUNTING the popup onto one, and that second cost is the one that gets mistaken for the first. With revealSelected on, a cascader that boots with a deep value opens straight onto that level, so the popup's very first mount is a full render of it - Base UI's per-item work at mount, which windowing cannot remove - and CascaderContent unmounts on close, so every REOPEN pays it again. Size the level, not just the total.

PropTypeDefaultDescription
estimateSizenumberroot estimateRowSizeRow height used before a row has been measured.
overscannumberroot overscanRows rendered beyond each edge of the viewport.

Types

The item payload is inferred from items, so node.data arrives typed in every callback without a type argument anywhere:

interface Member {
  initials: string
  role: string
}
 
const teams: CascaderNode<Member>[] = [...]
 
<Cascader
  items={teams}
  // `node.data` is `Member | undefined`, not `any`.
  renderLabel={(node) => <Avatar>{node.data?.initials}</Avatar>}
/>

multiple discriminates the props, so the value and the callback narrow together. Single-select hands back a string, multi-select a string[], and max only exists on the multi-select side:

// Single: `next` is a string.
<Cascader items={items} onValueChange={(next) => setValue(next)} />
 
// Multiple: `next` is a string array, and `max` is available.
<Cascader multiple max={5} items={items} onValueChange={(next) => setValues(next)} />
 
// Type error: `max` caps a multi-selection, and single mode has nothing to cap.
<Cascader items={items} max={5} />

useCascaderSelection takes no arguments, so pass the payload explicitly when you need it: useCascaderSelection<Member>().

Everything else a consumer ever names is one of these:

  • Supplied by you: CascaderNode, CascaderActionItem, CascaderLabels, CascaderLoadResult.
  • Received in a callback you write: CascaderChangeDetails, CascaderItemState, CascaderLoadContext, CascaderSearchContext, CascaderLoadState, and CascaderRowEvent on a row handler - see CascaderItem.
  • Naming the root, its props or its selection: CascaderProps, CascaderBaseProps, CascaderSingleProps, CascaderMultipleProps, and CascaderSelection, which is what useCascaderSelection() returns. CascaderGetChildren, CascaderOnSearch and CascaderResolveValue type a loader declared outside JSX.
  • String unions, spelled inline wherever the prop they type is documented: CascaderMode, CascaderSearchScope, CascaderCollapse, CascaderValueDisplay, CascaderChangeReason, CascaderPathChangeReason, CascaderCheckedStrategy and CascaderLoadReason. None of them needs a section of its own. CascaderSelectable<T> is the near-exception: its predicate arm is generic over the item payload, so an annotated (node: CascaderNode<Member>) => boolean satisfies CascaderSelectable<Member> - a per-call generic would refuse it - and the unknown default keeps a bare CascaderSelectable meaning what it always meant.

Every part's *Props type is exactly as public as the part itself, and is named <PartName>Props without exception. The types published on the context hooks - CascaderIndex, CascaderFlatNode, CascaderColumn, CascaderHighlight and the three context shapes - are extension surface and live under Advanced.

They come from three modules. The data and i18n types are in cascader-types, the context ones in cascader-context (re-exported by cascader as well), and the three loader signatures in cascader-async.

CascaderNode

Items are supplied either nested, each node carrying children, or as a flat adjacency list where each row carries a parent pointer and you pass getParent. Both normalize to the same internal index.

PropTypeDefaultDescription
valuestring-Stable unique id. Also the committed selection value.
labelstring-Display text. Used for filtering and typeahead.
iconReactNode-Leading icon rendered by the default row. Best left without a size class of its own: every style sizes a bare glyph itself through a [&_svg:not([class*=size-])] rule, so a hardcoded size pins one icon while the chevron beside it keeps following the style.
descriptionstring-Secondary line under the label.
childrenCascaderNode[]-Nested children. Omit when using getParent.
hasChildrenbooleanfalseMarks a branch before its children are known. Required for async nodes.
hasMorebooleanfalseAnother page of children exists. Only read on a node nested inside a getChildren result.
countnumberchildren lengthTrailing count on a branch row. A selected branch keeps it: how many things are inside a category and whether it is picked are different facts.
disabledbooleanfalseNot selectable; stays rendered and aria-disabled rather than being dropped, so the reason an option is unavailable stays discoverable. Refused BEFORE selectable is consulted, and the refusal inherits down the subtree - a disabled branch cannot be opened, and a deep-search hit under one cannot be committed either.
keywordsstring[]-Extra terms matched by search alongside the label.
dataT-Arbitrary payload, passed through to render callbacks.

CascaderItemState

The second argument of renderItem and renderLabel: everything the default row had already worked out, so a custom row does not derive it a second time.

FieldTypeDescription
branchbooleanThe node has children, known or declared with hasChildren.
selectedbooleanThe node is currently selected.
disabledbooleanThe node's own disabled flag, not the answer to "may this be committed".
depthnumberZero-based depth of the row.
countnumberTrailing count. An explicit count wins over the number of loaded children.
pathCascaderNode<T>[]Ancestors of the node, root first and the node itself EXCLUDED. Empty unless the row is a deep-search result.

CascaderChangeDetails

The second argument of onValueChange, so the usual follow-up questions need no lookup at the call site.

<Cascader
  items={items}
  onValueChange={(value, details) => {
    setValue(value)
    // "Person / Company / Domain", with no lookup into `items`.
    setLabel(details.path.map((node) => node.label).join(" / "))
  }}
/>
FieldTypeDescription
nodeCascaderNode<T> | nullThe node that was committed or toggled. Null when the selection was cleared.
pathCascaderNode<T>[]Ancestor chain of node, root first, node last.
nodesCascaderNode<T>[]Every currently selected node, resolved.
reason"select" | "deselect" | "clear"Why the callback fired.

nodes is resolved the same way the trigger resolves its label, so a selection whose node is not in items still comes back rather than being silently dropped. reason is "clear" only when the whole selection is dropped in one go, which is what useCascaderSelection().clear() does; deselecting the last remaining node reports "deselect" with that node, so the information is never lost.

CascaderActionItem

The shape of one entry in the root's actions array.

FieldTypeDescription
labelReactNodeRow content.
valuestringStable key. Falls back to a string label, then the index.
iconReactNodeLeading icon.
disabledbooleanaria-disabled, never the native attribute. See .
onSelect() => voidFires on press. Ignored when items is present.
itemsCascaderActionItem[]Turns the row into a submenu trigger. One level deep.
groupstringHeading rendered above this entry inside a flyout. One heading per RUN of entries sharing it.

CascaderLoadResult

What getChildren and onSearch may return instead of a bare array. Return a nextCursor and the level grows a "Load more" row after its last loaded child; pressing it calls getChildren again with that cursor and appends the result. That row is a REAL option in the rendered list, not an invisible scroll sentinel, because every index Base UI hands out is an index into the same array - and being a node also makes it keyboard reachable, which an IntersectionObserver sentinel never is. It is never selectable, whatever selectable is set to. Paging is latched per level: a page that comes back with nothing new while the server still reports hasMore: true cannot be asked for again, or one click becomes an unbounded request loop.

FieldTypeDescription
itemsCascaderNode<T>[]The level's nodes. Nested children are walked too.
nextCursorstringHanded back to getChildren for the next page.
hasMorebooleanWhether a further page exists. Defaults to whether nextCursor was supplied.

CascaderLoadContext

The second argument handed to getChildren.

FieldTypeDescription
signalAbortSignalAborted when the request is superseded, the popup closes, or loadKey changes.
cursorstringThe previous page's cursor, or undefined for the first page.
reason?"level" | "more" | "prefetch" | "retry" | "resolve"Why the level is being fetched. OPTIONAL, and additive: a loader written before the field existed still type-checks and still behaves the same.

A level load is NOT aborted merely because the user navigated elsewhere: the page is still worth caching. Responses are additionally guarded by a per-level request id, so a slow response that lands after a newer one is discarded rather than overwriting it. A loader that throws SYNCHRONOUSLY lands in the same error state rather than taking the render down with it.

CascaderSearchContext

The second argument handed to onSearch. Not the same shape as CascaderLoadContext: a search belongs to no level, so it carries no cursor and no reason, and it carries the current path instead.

FieldTypeDescription
signalAbortSignalAborted when the query changes or the popup closes.
pathstring[]The path the user is searching within, deepest last. Node VALUES, not nodes.

path is what scopes a server query to the level the user is actually looking at: path[path.length - 1] is the current parent, and an empty array means the root.

CascaderLoadState

One level's async state. Read it through useCascaderLoadState() rather than reaching into loadStates by hand.

FieldTypeDescription
loadingbooleanA request for this level is in flight.
errorbooleanThe last request for this level failed. Cleared by a retry.
hasMorebooleanMore pages are available for this node.
cursorstringOpaque cursor handed back to getChildren for the next page.

There is deliberately no status field. MAP MEMBERSHIP is the discriminator between "declared a branch but never fetched" and "fetched and genuinely empty": a level with no entry has never been loaded, and one with an entry, no loading, no error and no children came back empty for real. A status field would be a second source of the same truth and the two would eventually disagree.

Hooks

The three hooks a composed surface reaches for. The context hooks the panel's own parts read - useCascaderState(), useCascaderActions() and the rest - are extension surface and live under Advanced.

useCascaderSelection

The headless trigger. CascaderValue covers the common shapes and CascaderChips covers chips; when the trigger needs to be something else entirely - a two-line label, a table cell, an avatar stack, or chips that sit beside a trigger that stays a trigger - this hook returns the resolved nodes and their ancestor chains as plain data, plus the mutators a custom surface needs.

function ResourceValue() {
  const { firstPath, isEmpty } = useCascaderSelection()
  if (isEmpty) return <span>Select a resource</span>
 
  const brand = firstPath[0]
  const leaf = firstPath[firstPath.length - 1]
 
  return (
    <span>
      {brand?.icon}
      {leaf?.label} <code>{leaf?.value.split(".").join("/")}</code>
    </span>
  )
}
FieldTypeDescription
selectedCascaderNode[]The selected nodes, resolved.
pathsCascaderNode[][]Ancestor chain per selection, root first.
firstCascaderNode | nullThe single selection, or the first one.
firstPathCascaderNode[]Ancestor chain of first.
countnumberNumber of selections.
isEmptybooleanWhether nothing is selected.
multiplebooleanWhether the cascader is in multi-select mode.
remove(value: string) => voidDeselects one node.
clear() => voidDeselects everything.

Drive a hand-rolled chip list off paths rather than selected: a path carries the ancestors and its own leaf, and selected drops values the index cannot resolve, so the two arrays are not guaranteed to line up index for index. The three things CascaderChips does for free - a disambiguating path, a named remove button, a named container - are yours to supply here, and findAmbiguousCascaderLabels in helpers is the rule behind the first of them. Note also that remove() and setSelection() replace the selection exactly as given, with NO cascade propagation: they are the deliberate escape hatch for a caller that has already decided what the selection should be.

useCascaderSubmenu

Inside a CascaderSubmenu, returns the flyout's own state. Throws outside one.

FieldTypeDescription
openbooleanWhether the flyout is open.
setOpen(open: boolean) => voidOpens or closes it.
close() => voidThe one a custom flyout entry usually wants: a command closes its own list behind it.
rowRefRefObject<HTMLButtonElement | null>The footer row the flyout is anchored to.
triggerIdstringId of that row. A menu is labelled by the control that opens it.
keyboardRefRefObject<boolean>Whether the pending open came from the KEYBOARD, which is what decides between focusing the first entry and focusing the popup. Written by the trigger, read during the focus phase.

The interface itself is not exported, so name the return with ReturnType<typeof useCascaderSubmenu> if you need to pass it around.

useCascaderAnchor

Returns the ref for the chips container, to be handed to CascaderContent's anchor. It is a useRef and nothing more - it exists so the wiring has a name rather than being an undocumented convention.

Guides

Cross-cutting concerns that touch every part: form submission, writing direction and copy.

Forms

The cascader submits through a hidden input Base UI owns, so it behaves like a native field rather than needing a controller wrapper around it.

<Cascader
  items={items}
  name="attribute"
  id="attribute-field"
  form="settings"
  required
  invalid={!!errors.attribute}
  inputRef={register("attribute").ref}
/>
PropWhat it reaches
nameThe hidden input's name. The submitted value is the selected node's value.
formThe hidden input's form, for a cascader rendered outside the <form> it submits to.
idThe control's id, so a <label htmlFor> points at something real.
requiredThe hidden input's required, for native validation.
readOnlyRefuses every commit. The value is still readable and still submits.
disabledDisables the whole control.
invalidaria-invalid and data-invalid on the trigger, the chips container and the search input.
inputRefA ref to the hidden input - the element a form library focuses when it reports an error on this field.

invalid is a boolean and carries no message: the message belongs next to the field, in whatever Field or FormMessage the form library already renders. Every style keys its error treatment off aria-invalid, so the chips container picks it up through .cn-combobox-chips:has([aria-invalid="true"]) with nothing else to wire.

onBlur and ref go on CascaderTrigger, which is the focusable element outside the popup and therefore the one a "touched" signal should come from. With react-hook-form specifically, Controller wires all of it in one place - the value through field, the error state through fieldState:

<Controller
  name="attribute"
  control={control}
  defaultValue=""
  rules={{ required: "Pick an attribute" }}
  render={({ field, fieldState }) => (
    <Cascader
      items={items}
      value={field.value}
      onValueChange={field.onChange}
      invalid={fieldState.invalid}
      inputRef={field.ref}
    >
      <CascaderTrigger onBlur={field.onBlur}>
        <CascaderValue placeholder="Select an attribute" />
      </CascaderTrigger>
      <CascaderContent>...</CascaderContent>
    </Cascader>
  )}
/>

defaultValue="" keeps value controlled from the first render instead of switching mid-life, which the cascader warns about.

Right to left

The panel mirrors under dir="rtl", including the parts that are not visual:

  • The level keys swap. "Deeper" is the direction the text runs, so in RTL ← opens a branch and → goes back. Tree mode's expand and collapse keys swap with them. The caret-edge guards do not swap: selectionStart === 0 is the logical start of the value in both directions. labels.keyboardHint receives the resolved direction alongside the mode, so the default hint names the mirrored keys rather than teaching exactly the wrong ones.
  • The check gutter and the check follow the inline direction. Both are pinned with physical properties in the shared combobox style sheets - a flat pr-8 and a right-2 that match neither each other nor the style's own start padding. The cascader restates them on its own rows as padding-inline-* and inset-inline-end against one variable, --cascader-row-inset, so there is no second rule for RTL to keep in step and a row's two insets are equal in both directions. Set that variable on a row, or on anything above it, to change both at once without !important.
  • The tree expander stops mirroring once it is expanded, because a chevron pointing down points the same way in both writing modes.

The direction is resolved from Base UI's DirectionProvider first, then from the nearest dir attribute, then from the computed style. <html dir="rtl"> is enough on its own. Mount a DirectionProvider when dir is scoped to a subtree of the page instead: the popup is portalled to the body, so a subtree attribute never reaches it - and Base UI's own RTL behaviour reads the same provider.

Labels

labels is the entire i18n story: every user-facing string the cascader can render comes from it, aria-labels and live-region announcements included, so a translated build needs no wrapper component. It is shallow-merged over the English defaults, so pass one key without restating the rest.

Callbacks take plain strings rather than nodes on purpose. A CascaderNode<T> parameter would force the labels object to carry the item generic, and a concrete CascaderNode<string> would then fail to satisfy it.

Visible copy

KeyTypeDefaultRendered
searchstring | (parentLabel?) => stringSearch {level}..., or Search...Placeholder of the search input. The parameter is optional and is absent at the root level, where the default falls back to a bare Search... with no level name in it.
backstringBackAccessible name of CascaderBack.
emptystringNo results found.A level that came back with nothing.
loadingstringLoading...A level's first page is in flight.
loadingMorestringLoading more...The next page of a level that already has rows is in flight.
loadMorestringLoad moreThe idle paging affordance.
errorstringCould not load items.A level failed to load.
retrystringRetryThe retry affordance next to error.
selectedCount(count) => string{n} selectedCascaderValue with display="count", and any multi-selection of more than one.
removeChip(label) => stringRemove {label}Accessible name of a chip's remove button.
pathSeparatorstring/Between ancestors in a deep-search row's trail, in a path-disambiguated chip, and in the collapsed-path tooltips.

Names for things the visible markup cannot say

KeyTypeDefaultNames
rootLevelstringTop levelThe root list and the root column, which have no parent node to name them.
panelLabelstringOptionsThe popup. Base UI gives it role="dialog" once the input lives inside it, and an unnamed dialog is announced as just "dialog".
columnsLabelstringLevelsThe CascaderColumns container, so its panels read as levels of one thing.
actionsLabelstringActionsThe CascaderFooter group, so its buttons do not read as more options.
submenuAffordancestringopens a menuAppended to a CascaderSubmenuTrigger, so a footer row that opens a flyout is not announced identically to one that fires.
breadcrumbLabelstringBreadcrumbThe in-panel trail.
chipsLabelstringSelected itemsThe chips container. The name is kept whether or not Base UI is giving it role="toolbar".
itemCount(count) => string{n} item / {n} itemsAppended to a branch row's name, so "Person 24" reads as "Person, 24 items". The English default already pluralises at one.
branchAffordancestringsubmenuAppended to a branch row outside tree mode, where the row opens another list.
selectedStatestringselectedAppended to a selected columns-trail row. Those are plain buttons, so they carry no aria-selected.
partiallySelectedStatestringpartially selectedThe cascade counterpart, for a trail row with some but not all of its subtree selected. Option rows say this with aria-checked="mixed" instead.
keyboardHint(mode, dir: "ltr" | "rtl") => stringper mode and directionThe visually hidden description of the arrow-key model, read out by the search input. Per mode AND per writing direction: the level keys mirror in RTL, so a hint naming the LTR keys there would teach exactly the wrong ones.

Live-region announcements

Read by CascaderStatus, which is the panel's only announcement channel.

KeyTypeDefaultAnnounced
rootAnnouncement(count) => stringTop level, {itemCount}Navigation returns to the root level.
levelAnnouncement(parentLabel, depth, count) => string{parent}, level {d}, {itemCount}A level change.
expandedAnnouncement(label, count) => string{label} expanded, {itemCount}A tree branch expands.
collapsedAnnouncement(label) => string{label} collapsedA tree branch collapses.
resultsAnnouncement(count) => string1 result / {n} resultsThe result count after filtering.
maxReachedAnnouncement(max) => stringSelection limit of {max} reachedmax refuses a further pick.
cascadeAnnouncement(label, count, selecting) => string{label} selected / {label} deselected, then , {itemCount} followedA cascade commit sweeps a subtree. selecting is what picks the arm.
searchingAnnouncementstringSearching...An onSearch request is in flight.

Four of those defaults compose an item count rather than interpolating a bare number, which is why the English copy reads "1 item" and not "1 items": rootAnnouncement, levelAnnouncement, expandedAnnouncement and cascadeAnnouncement. resultsAnnouncement pluralises the same way on its own. levelAnnouncement's depth is one-based and matches the aria-level tree mode gives the same rows, so the spoken numbering and the ARIA numbering can never disagree by one.

Overriding itemCount does NOT reach those four. labels is a shallow merge, and each of the four English defaults closes over the module-local count helper rather than reading labels.itemCount back out, so a replacement changes only what the four do not draw: the branch row's own name. rootAnnouncement has the same relationship with rootLevel. Translate the announcements themselves, not the pieces they look like they are built from - which is also cheaper, since each one is a single template string.

Casing is never applied for display: a label is already written the way its author wants it read, and toLowerCase() is locale-hostile - it maps Turkish "İ" to a two-code-point sequence. Matching is a different matter and folds BOTH the query and the label through toLocaleLowerCase(), so the two sides always fold the same way whatever the host locale is.

Development warnings

Several ways of misconfiguring a cascader are absorbed SILENTLY at runtime, because degrading is the right behaviour for a rendering primitive: a duplicate value is dropped, a getParent cycle is clamped, a prop belonging to another mode is ignored. Each of those is also an afternoon someone will not get back, so in development the cascader says so. Each one is a console.warn behind a shared once-per-key ledger, never fires in production, and never throws. Keys that name an offending value - a duplicate, a cycle - warn once per value; the rest warn once per page.

Warned aboutWhy it is silent otherwise
A duplicate node valueThe first occurrence wins and the rest never render.
A null or undefined entry in items or in a node's childrenThe entry is skipped, so a sparse array or a failed map silently loses a row.
A cycle in flat getParent inputThe depth walk is cycle-guarded, so every depth on that chain is clamped rather than derived.
value, path, expanded, open or inputValue switching between controlled and uncontrolledThe first update after the switch reads from whichever source is no longer authoritative.
multiple with a string value, or an array value without multipleA shape mismatch resolves to no selection at all.
cascade without multiple, or with selectable="leaf"Nothing can ever cascade.
max without multipleThere is only one selection to cap.
indicator={false} with multipleThe checkbox is the selection control, so it and its gutter stay either way.
expanded outside mode="tree", path inside itThe other mode navigates with the other prop.
searchScope="deep" in mode="tree"A tree query already matches at any depth.
CascaderColumns outside mode="columns"It renders nothing, so every prop on it does nothing.
CascaderChips without multipleThere is only ever one chip.
onSearch in mode="tree"A server hit belongs to no visible branch, so its results could never render. The request is not fired.
A CascaderTrigger with no accessible nameThe trigger's contents are the field's value, so what is picked is announced and what the field is for never is.
virtualize with no windowed list mountedNothing windows.

The last row is the one exception to the paragraph above: it is a raw console.error, fired from a setTimeout(..., 0) inside an effect and outside the once-per-key ledger, because the windowing renderer registers from a layout effect and a synchronous check would report every correctly wired cascader exactly once. Different channel, different ledger, same development-only gate.

Advanced

Extension surface. Everything here is exported on purpose and is supported, but reaching for one of these means you are building something the parts above do not ship: a custom trigger, a hand-laid-out row, a windowed column, a test harness. Each entry gets a line of purpose rather than a prop table - the parts above are where the prop tables live.

Lower-level parts. Each of these is rendered for you by a part above, or replaces one that is. Reach for one only when you are laying that piece out yourself.

ExportPurpose
CascaderBackThe standalone back control, popping one level. CascaderInput's showBack already renders one inline, so this is for a header you are composing yourself. Takes children (replacing the chevron) and render. Renders null at the root and outside mode="drill".
CascaderChipOne chip, for a custom CascaderChips children list. Takes node, showPath, maxSegments, showRemove, onRemove and children. onRemove replaces Base UI's positional removal, which is exactly what a strategy-condensed chip needs, since its position no longer maps onto the stored array.
CascaderColumnPanelThe default single Miller column: its heading, its rows, and its own empty, loading and error state, because each column loads on its own. Takes column, children and virtualized. CascaderColumns renders one per column unless you fill its slot.
CascaderVirtualColumnWindowed replacement for one Miller column, passed through the CascaderColumns children slot because each column scrolls independently. Takes column, estimateSize and overscan.

Context hooks. The panel's internals are published on three contexts plus one external store, because they change at very different rates. Subscribing to the narrowest one is what keeps a long level cheap to type into: a keystroke rebuilds the entire derived view, and a component that only reads configuration must not re-render for it.

useCascaderActions() and useCascaderState() THROW by name outside a Cascader, and so do the three that read them: useCascaderLoadState(), useCascaderHasActions() and useCascader(). The other two answer a harmless default instead - useCascaderRender() an empty object, useCascaderHighlight() a shared fallback store reading { index: -1, value: null } - because neither has anything to fail loudly about.

HookRepublishes whenHolds
useCascaderActions()a config prop changes, and - for the five row predicates only - items, selectable or the selectionindex, mode, multiple, cascade, branchesSelectable, indicator, expandTrigger, actions, searchScope, maxHeight, inline, invalid, baseId, labels, the windowing state (virtualized, virtualize, virtualizeThreshold, estimateRowSize, overscan, registerVirtualRenderer), and every callback: setPath, pushLevel, popLevel, goToDepth, toggleExpanded, setQuery, setSelection, commit, navigate, navigateAt, resolveNode, isBranch, isSelectable, isSelected, isIndeterminate, selectedDescendantCount, the flyout registry (setFlyoutOpen, hasOpenFlyout), the async members (hasLoader, loadMore, retryLevel, invalidateLevel), plus the getIndex / getState / getHighlighted getters
useCascaderState()every keystroke, level change and selectionindex, path, expanded, query, currentParent, levelItems, deepResults, renderedItems, columns, treeRows, selectedValues, selectedDescendants, loadStates, searchState, announcement
useCascaderRender()the renderItem / renderLabel identities changethe two render props, always the current closure
useCascaderHighlight()every arrow key and every pointer move over the list{ index, value } of the highlighted row, { index: -1, value: null } when nothing is
useCascaderLoadState()whenever useCascaderState() does, since it reads ituseCascaderLoadState(parent?: string | null) reads loadStates for you: pass a parent value, or nothing for the root. null for a level that has never been fetched, which is also what a cascader with no loader at all answers, so one null check covers both, and neither has to be told apart from a level that came back genuinely empty.
useCascaderHasActions()whenever useCascaderActions() does, since it reads itWhether the root was given any actions. CascaderFooter already renders nothing when there is nothing to draw; this is for a wrapper around it - a separator, a grid row - that has to make the same decision one level up.
useCascader()Deprecated. every keystrokeThe actions and state contexts merged into one CascaderContextValue, exactly as it always did, so an existing call site keeps compiling. It subscribes to BOTH, which is why anything calling it re-renders on every keystroke. Reach for useCascaderActions() or useCascaderState() in new code.
function LevelCount() {
  // Re-renders on every keystroke, which is exactly what this component is for.
  const { renderedItems } = useCascaderState()
  return <span>{renderedItems.length} results</span>
}
 
function ResetButton() {
  // Never re-renders while the user types: the actions context is stable.
  const { goToDepth, setQuery } = useCascaderActions()
 
  return (
    <button
      onClick={() => {
        goToDepth(0)
        setQuery("")
      }}
    >
      Back to the top
    </button>
  )
}

invalidateLevel(value) on the actions context evicts one branch's pages and load state, clears its paging latch and aborts its in-flight request, so the level reads as never loaded and refetches the next time it is on screen. null targets the root, and resolved chains and search hits are deliberately untouched, so the trigger keeps its labels while the fresh page is in flight.

The highlight is an external store read through useSyncExternalStore, not context and not state. It moves on every arrow key and on every pointer move across the list, so routing it through a re-render would undo everything above. Read it with useCascaderHighlight() in the one component that needs it, and use getHighlighted() from the actions context anywhere that only needs to ask inside an event handler. CascaderItem is wrapped in React.memo, and CascaderItems hands each row its branch, selectable and selected answers as props: that is what makes the memo hold, so typing re-filters a level without re-rendering a single row whose own state did not change.

Types published on those hooks. Each is exported so a component taking one as a prop can name it.

TypePurpose
CascaderIndexThe normalized view of the item tree, built once per items identity and published with the same identity on both contexts. Fields: byValue, childrenOf, parentOf, depthOf, roots, all. First argument of every helper below.
CascaderFlatNodeOne row of the flattened tree-mode list, published as treeRows: node, depth, branch, expanded, setSize, posInSet.
CascaderColumnOne open level in mode="columns", handed to the CascaderColumns children slot and taken as a prop by CascaderColumnPanel and CascaderVirtualColumn: parent (null at the root), items (already filtered), depth, activeValue, active. Only the active column is the listbox.
CascaderHighlightWhat useCascaderHighlight() returns: index (-1 when nothing is highlighted) and value (null then). Neither field is ever undefined and index 0 is a real row, so branch on value === null or index < 0, never on falsiness.
CascaderHighlightStoreWhat createCascaderHighlightStore() returns: subscribe, getSnapshot (the SAME object until something actually changes) and set (a no-op when nothing changed). Each Cascader creates its own.
CascaderPathSegmentWhat collapseCascaderPath returns, one entry per rendered segment: { type: "node"; node } or { type: "ellipsis"; hidden }. The ellipsis carries the nodes it stands for rather than dropping them, which is how CascaderValue and CascaderBreadcrumb surface them in a tooltip.
CascaderStateContextValue, CascaderActionsContextValue, CascaderRenderContextValueThe three context shapes, one per hook above.
CascaderContextValueDeprecated. The merged shape useCascader() returns.

Pure helpers. cascader-lib holds the functions the primitive is built on. Fifteen of them answer questions a hand-written row, trigger or chip list has to answer anyway, and re-deriving those by hand is how a custom surface ends up disagreeing with the panel beside it. The tree and selection functions take the CascaderIndex published on useCascaderState() as their first argument; the matching and formatting ones need no index at all.

import {
  collapseCascaderPath,
  findAmbiguousCascaderLabels,
  getCascaderPath,
} from "@/components/reui/cascader/cascader-lib"
FunctionPurpose
getCascaderPath(index, value)Ancestor chain, root first and the node itself last. Empty for an unknown value, which is the normal case while async data is still loading.
getCascaderChildren(index, parent?)One level's children. The root level for a nullish parent, so you never need the root sentinel key.
collectCascaderSubtree(index, value)Every LOADED node under value, that node first and depth first after. Loaded is the same caveat cascade carries.
isCascaderBranch(index, node)Known children, or a declared hasChildren.
getCascaderCount(index, node)The trailing count. An explicit count on the node wins over the number of loaded children.
isCascaderSelectable(index, node, selectable)Refuses the paging pseudo-node and a disabled node BEFORE consulting selectable, so neither can be overridden into selectability.
getCascaderSelectedDescendants(index, selected)Map<string, number>: how many selected nodes each value has below it, at any depth. A value counts once for each ancestor and never for itself.
getCascaderIndeterminate(index, selected)Set<string>: the values not selected themselves but with at least one selected descendant. That is the whole definition, derived here and never stored.
getCascaderCheckedValues(index, selected, strategy)A cascade closure condensed for reporting: "all" as-is, "parent" keeps a value only when its parent is not selected, "child" only the deepest selected frontier. Derived output - the stored value stays the full closure.
findAmbiguousCascaderLabels(nodes)Set<string>: the values whose label collides with another node in the same list. The rule CascaderChips applies per selection, and the one a hand-rolled chip list has to reproduce.
normalizeCascaderQuery(query)Trims and folds a query once, so the cost hoists out of a per-node loop.
matchesCascaderQuery(node, normalized)The default matcher: substring over the label plus any keywords. Expects an already normalized query, which is exactly what the filter prop receives.
foldCascaderText(text)toLocaleLowerCase, so the Turkish dotted and dotless I fold the way that locale expects. Both sides of a match go through it.
searchCascaderDeep(index, query, options?)Deep search over the index's depth-first order. options.within scopes it to one subtree, options.limit caps the results (200 by default), and options.matches swaps the matcher, defaulting to matchesCascaderQuery.
collapseCascaderPath(path, options?)The collapsing the trigger and the breadcrumb both feed, so a custom trigger reads a deep path the same way they do. options.maxSegments defaults to 3, options.collapse to "middle". Returns CascaderPathSegment[].

Four more.

ExportPurpose
getCascaderMorePropsgetCascaderMoreProps(node, loadStates) returns { loading, error, childrenLoading, childrenError }, all booleans, from the loadStates map on useCascaderState(). Every renderer that lays rows out itself has to hand those four down, because CascaderItem is memoised and must not read the volatile state context. One call answers for both row kinds, which are mutually exclusive: a PAGING row reports the state of the level it belongs to, a BRANCH row the state of the level it OWNS.
createCascaderHighlightStoreCreates a CascaderHighlightStore. Each Cascader makes its own, so you never need this to use the primitive - it is there for tests and for a custom root that drives the highlight itself.
useCascaderVirtualizerNot a context hook: it CREATES the virtualizer a windowed list drives, from { count, getScrollElement, estimateSize, overscan, getItemKey, activeIndex }, and reads no cascader context at all. A plain TanStack virtualizer plus the two things a combobox list needs on top of it - activeIndex is clamped against count on every read, and the highlighted row is kept mounted while it is scrolled out of the window so aria-activedescendant never points at a removed node. Typed by UseCascaderVirtualizerOptions and CascaderVirtualizer.
CASCADER_ROOT_KEYThe NUL-prefixed sentinel keying root children in index.childrenOf, so no real node value can collide with it. Worth knowing only if you read childrenOf by hand; getCascaderChildren(index) with no parent answers the same question without it.
import { getCascaderMoreProps } from "@/components/reui/cascader/cascader-item"
 
const { loadStates } = useCascaderState()
 
;<CascaderItem node={node} {...getCascaderMoreProps(node, loadStates)} />

Internal exports

The eleven modules export more than the two tiers above. Those remaining symbols exist ONLY because the registry ships one file per module and TypeScript needs cross-file access to them. They are not public API, they carry no compatibility promise, and they are listed here so you can recognise one when your editor offers it and reach for the documented equivalent instead.

  • Raw contexts - CascaderStateContext, CascaderActionsContext, CascaderRenderContext, CascaderHighlightContext. Read them through their hooks instead: the first two throw a named error rather than handing back undefined, and the other two carry a safe default so a useContext of your own cannot get a bare undefined either.
  • Class strings and sentinels - CASCADER_ACTION_CLASS, CASCADER_LIST_HEIGHT_CLASS, CASCADER_LIST_PAD_CLASS, CASCADER_ROWS_CLASS, CASCADER_SCROLL_CLASS, CASCADER_MORE_PREFIX, CASCADER_LABELS. The last is the English default bundle, which labels is shallow-merged over for you. CASCADER_ROOT_KEY is the one constant that is NOT internal - see Advanced.
  • Index and traversal engine - buildCascaderIndex, mergeCascaderIndex, flattenCascaderTree, filterCascaderLevel, applyCascadeSelection, getCascaderIndeterminateFrom, createCascaderMoreNode, isCascaderMoreNode, getCascaderMoreParent. Calling one of these yourself produces a second answer the panel does not share.
  • DOM and keyboard plumbing - getCascaderFooterStops, getCascaderTabTarget, isCascaderRtl.
  • Development-only diagnostics - warnCascaderOnce, resetCascaderWarnings, findCascaderDataIssues, CascaderDataIssues. See for what they report.
  • Label resolvers - resolveCascaderLabels, resolveCascaderSearchLabel. labels is resolved for you on the way into the context.
  • The async engine - useCascaderLoader, UseCascaderLoaderOptions, CascaderLoader, CascaderLoaderStore. The root's own loader. Consumers reach it through the getChildren, onSearch and resolveValue root props, and through loadMore / retryLevel / invalidateLevel on the actions context.

Keyboard

KeyAction
↓ / ↑Move between rows in the current level.
→Open the highlighted branch, when the caret is at the end of the query.
←Go back one level, when the caret is at the start of the query.
BackspaceGo back one level, when the query is empty.
EnterCommit the highlighted row, or open it if it is a branch.
Home / EndMove the CARET to the start or end of the query. They do not move the highlight: the field is a typeable role="combobox", which is the case Base UI's list navigation deliberately leaves to the text caret. ↑ from the first row and ↓ from the last are the ends of the list.
EscClose the popup - or, while a footer flyout is open, close only the flyout.
↓ past the last rowHands focus to the footer commands, when a footer is composed. An empty result list hands off immediately, which is when "Create ..." is the useful thing on screen.
↑ / ↓ in the footerWalk the commands. Either end returns focus to the search field. The list highlight cleared on the way in - one active row at a time - so ↑ resumes at the last row and ↓ continues at the first.
Tab / Shift+TabMove between the search field, any controls you put in the panel, and the footer commands.

Arrow keys only navigate levels when the caret is already at the relevant edge of the search field, so they never steal caret movement while you are editing a query. The level keys are logical, not physical: under dir="rtl" ← and → swap, in every mode. See right to left.

Tree mode keys

Branches expand in place rather than replacing the panel, so the level keys follow the APG tree pattern instead:

KeyAction
→Expand the highlighted branch. On a branch that is already expanded, move to its first child.
←Collapse the highlighted branch. On a leaf, or on a branch that is already collapsed, move to its parent.

Footer flyout

A CascaderSubmenu is a real menu, not a popover with buttons in it: role="menu", menuitem entries, and roving focus rather than a Tab stop per command. In the footer strip itself the vertical arrows move between commands - the flyout opens from the arrow that points at it, never from ↓, so the strip cannot stutter where the flyout row sits.

KeyAction
→Open the flyout from its footer row and focus the first entry. ← in RTL.
↓ / ↑Move between entries once it is open. Wraps at both ends.
Home / EndFirst or last entry.
Enter / SpaceRun the focused command.
←Close the flyout and return focus to the row that opened it. → in RTL.
EscClose the flyout, leaving the cascader open. A second Esc closes that.
TabClose the flyout and carry on from the footer row. A menu never holds Tab.
a-zTypeahead. Jumps to the next entry starting with what you type.

Opening with the pointer parks focus on the flyout rather than on an entry, so a click never paints a focus ring on a command nobody asked for; the arrow keys still work from there.

Tab is answered by CascaderPanel rather than left to the browser, and the reason is the scroll area between the field and the footer: its viewport makes ITSELF tabbable whenever the content overflows, so the footer sat one press away on a short level, two on a long one, and one more per column in mode="columns" - each extra stop an unnamed role="presentation" div that drew a focus ring around the whole list, which reads as "the footer is unreachable". The panel steps over that viewport and moves focus between the panel's real controls in DOM order instead, so the footer is one press away in every mode and in an embedded panel.

It never wraps and never traps: off either end there is no target, the key is left to the browser, and leaving the cascader is what dismisses it. A Tab your own onKeyDown has already default-prevented is left alone. The move itself is not announced - the control it lands on says what it is, and a live-region message on every Tab would be noise.

Accessibility

Structure

  • Built on Base UI's combobox, so the trigger and input carry the correct role, aria-expanded, aria-controls and aria-activedescendant wiring. An embedded panel (inline) supplies aria-expanded and aria-controls itself, because it is permanently expanded and Base UI omits both in that case.
  • The list's own role follows the mode: role="listbox" in drill and columns, role="tree" in mode="tree". In columns mode the container around the panes is a role="group" named by labels.columnsLabel, and inside it exactly ONE pane is the listbox - the deepest, active one, which is the only pane Base UI owns. Every pane behind it is a role="group" named by its parent. So a screen reader hears levels of one control rather than several competing lists, and there is never a second listbox for the arrow keys to be ambiguous about.
  • Every list and column is named after the level it is showing, falling back to labels.rootLevel at the root. The popup is named too, since Base UI gives it role="dialog" once the search input lives inside it.
  • The in-panel breadcrumb is a nav named by labels.breadcrumbLabel. Its ancestors are buttons that navigate; the last segment is the level currently being listed, so it is not pressable and carries aria-current="page".
  • Row ARIA is per mode. role="option" allows a far narrower set of attributes than role="treeitem", so the same row exposes different things depending on where it is rendered:
Attributedrillcolumns (active)columns (trail)tree
roleoptionoptionbuttontreeitem
aria-selectedyesyes-yes
aria-checkedmixed onlymixed only-mixed only
aria-setsize / aria-posinsetyesyes-yes
aria-level---yes
aria-expanded--the open rowbranches
aria-haspopupbranchesbranches--
aria-controls--the open row-
  • The chevron and the tree expander are pointer affordances, not controls: no role, no tabIndex, aria-hidden. A focusable element inside a role="option" row is a nested-interactive violation, and neither could take focus in any case, because focus stays in the search input. → is the keyboard path into a branch and ← the way back out. The tree row itself carries aria-expanded, so its expander announcing the same state again would be noise.

Names and announcements

  • A branch's accessible name spells out what the visual row only implies: "Person, 24 items, submenu" rather than "Person 24". The visible count and the chevron are aria-hidden, so nothing is announced twice. Once something inside the branch is selected the visible number switches to that count and takes the accent color, and the name says so in words - "Person, 24 items, 3 selected, submenu" - because a color is not an announcement.
  • CascaderStatus is the only live region. It announces level changes, a return to the root, tree expand and collapse, result counts, and the two presses whose effect is otherwise invisible: a pick refused past max (labels.maxReachedAnnouncement) and a cascade commit's fan-out over a subtree that is mostly off screen (labels.cascadeAnnouncement). It is built on Base UI's Combobox.Status so it inherits the initial text mutation that makes Safari and VoiceOver read the first announcement at all.
  • Result counts count MATCHES, not rows: the ancestor rows a filtered tree keeps as context and the paging row are excluded, and server search hits are counted as-is, since the server matched them on data the client cannot see. The count is also the one announcement that defers - a keystroke rewrites it, so it waits about 150ms of quiet while everything event-shaped (a level change, a load settling, a refusal) announces immediately.
  • A level announcement numbers its level one-based, matching the aria-level tree mode gives the same rows, so the spoken numbering and the ARIA numbering can never disagree by one.
  • The paging row is a real option rather than a DOM-only row, so it is arrow reachable and counted by aria-setsize. It is never selectable, and its retry affordance is plain text rather than a button.
  • The loading spinner on a branch row is aria-hidden. The load state is spoken once, by CascaderStatus, which announces loading, loadingMore and error ahead of any result count - so a level that is still fetching is never announced as having no results. searchingAnnouncement is announcement-only, named apart from loadingMore because a search is not the next page of anything.
  • The search input is described by a visually hidden labels.keyboardHint for the current mode AND writing direction - the level keys mirror in RTL, so the hint names the mirrored pair there - and it is the only thing that advertises the level keys; nothing on screen does.
  • The footer is a role="group" named by labels.actionsLabel, so its buttons do not read as more options, and a submenu trigger appends labels.submenuAffordance so it is not announced identically to a plain command. The footer is a deterministic Tab destination: CascaderPanel owns the panel's tab order and steps over the scroll area's viewport, which makes itself tabbable whenever a level overflows, so the number of presses between the search field and the commands does not change with the length of the list, the mode, or whether the panel is embedded. The flyout is a real popover with its own focus management, reachable by Tab from the footer row. See .
  • A partially selected branch under cascade carries aria-checked="mixed" - one of the four attributes role="option" allows on top of the globals, and one role="treeitem" takes too. It is only ever set when it is mixed: a plain selected row already says so with aria-selected, and two selection attributes on one row is noise. Columns-trail rows are role="button", which allows neither, so for those the state is spelled out in the accessible name via labels.partiallySelectedState.
  • The chips container is a named role="toolbar" WHENEVER it holds chips, and every remove button carries labels.removeChip(label). An icon-only remove button with no name is announced as "button", once per selection. Base UI drops the role on an empty selection, where there is nothing to arrow between and the container is showing its placeholder; the labels.chipsLabel name stays either way.
  • Disabled nodes are aria-disabled rather than removed from the accessibility tree.
  • The level keys mirror under dir="rtl". The check gutter and the check itself need no mirror: both are driven by --cascader-row-inset through logical properties. See .
  • Every string in the lists above comes from labels, so the accessible surface translates alongside the visible one. See for the full key list.

Deliberately not done

Each of these is a place where the obvious thing is the wrong thing, so none of them is an oversight to be fixed.

  • Focus is never moved into the list. No roving tabIndex, no focusable rows, no per-row tab stop. This is a combobox: the field keeps focus and the list is addressed through aria-activedescendant, which is also what lets typing keep working while the highlight moves.
  • CascaderSeparator is decorative. A listbox may not own a role="separator", and a run that needs separating for a screen reader needs a CascaderGroup, not a line.
  • CascaderEmpty has no live region of its own. It would be a second announcement of what CascaderStatus has already said, so an empty level is announced once rather than twice.
  • The paging row carries no button. Its retry is the row's own text, because a focusable element inside a role="option" row is a nested-interactive violation. The retry inside CascaderEmpty IS a real button, since that element sits outside the listbox - and the footer flyout is the one place a real menu with real focus management lives inside the popup, which is exactly why it is a popover rather than part of the list.
  • indicator={false} removes only the visual check. aria-selected is Base UI's and stays on every row in every mode.
"use client"

import { useState } from "react"
import {
  Cascader,
  CascaderContent,
  CascaderEmpty,
  CascaderList,
  CascaderPanel,
  CascaderStatus,
  CascaderTrigger,
} from "@/components/reui/cascader/cascader"
import { CascaderItems } from "@/components/reui/cascader/cascader-item"
import {
  CascaderBreadcrumb,
  CascaderInput,
  CascaderNav,
  CascaderValue,
} from "@/components/reui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/reui/cascader/cascader-types"

import { Button } from "@/components/ui/button"
import { AtSignIcon, CalendarClockIcon, HashIcon, SquareCheckIcon, TypeIcon, UserIcon, UsersIcon } from 'lucide-react'

const textIcon = (
  <TypeIcon  className="size-4" />
)
const hashIcon = (
  <HashIcon  className="size-4" />
)
const usersIcon = (
  <UsersIcon  className="size-4" />
)
const mailIcon = (
  <AtSignIcon  className="size-4" />
)
const checkIcon = (
  <SquareCheckIcon  className="size-4" />
)
const clockIcon = (
  <CalendarClockIcon  className="size-4" />
)
const userIcon = (
  <UserIcon  className="size-4" />
)

const attributes: CascaderNode[] = [
  { value: "record-id", label: "Record ID", icon: hashIcon },
  {
    value: "person",
    label: "Person",
    icon: usersIcon,
    count: 24,
    children: [
      { value: "person.name", label: "Name", icon: textIcon },
      {
        value: "person.email",
        label: "Email addresses",
        icon: mailIcon,
        keywords: ["mail"],
      },
      { value: "person.title", label: "Job title", icon: textIcon },
      {
        value: "person.company",
        label: "Company",
        icon: usersIcon,
        count: 8,
        children: [
          {
            value: "person.company.name",
            label: "Company name",
            icon: textIcon,
          },
          { value: "person.company.domain", label: "Domain", icon: textIcon },
          {
            value: "person.company.employees",
            label: "Employees",
            icon: hashIcon,
          },
        ],
      },
    ],
  },
  {
    value: "email",
    label: "Primary email address",
    icon: mailIcon,
    keywords: ["mail"],
  },
  { value: "user-id", label: "User ID", icon: textIcon },
  {
    value: "workspaces",
    label: "Workspaces",
    icon: usersIcon,
    count: 9,
    children: [
      { value: "workspaces.name", label: "Name", icon: textIcon },
      { value: "workspaces.plan", label: "Plan", icon: textIcon },
      { value: "workspaces.seats", label: "Seats", icon: hashIcon },
    ],
  },
  {
    value: "next-task",
    label: "Next due task",
    icon: checkIcon,
    count: 1,
    children: [{ value: "next-task.due", label: "Due date", icon: clockIcon }],
  },
  { value: "created-at", label: "Created at", icon: clockIcon },
  { value: "created-by", label: "Created by", icon: userIcon },
]

export function Pattern() {
  const [value, setValue] = useState("")

  return (
    <div className="flex w-full justify-center p-4">
      <Cascader items={attributes} value={value} onValueChange={setValue}>
        <CascaderTrigger
          aria-label="Attribute"
          render={
            <Button
              variant="outline"
              className="w-72 justify-between gap-2 font-normal"
            />
          }
        >
          <CascaderValue placeholder="Select an attribute" maxSegments={3} />
        </CascaderTrigger>

        <CascaderContent className="w-80">
          <CascaderPanel>
            <CascaderNav>
              <CascaderInput />
            </CascaderNav>
            <CascaderBreadcrumb />
            <CascaderEmpty />
            <CascaderList>
              <CascaderItems />
            </CascaderList>
            <CascaderStatus />
          </CascaderPanel>
        </CascaderContent>
      </Cascader>
    </div>
  )
}
"use client"

import { useState } from "react"
import {
  Cascader,
  CascaderContent,
  CascaderEmpty,
  CascaderList,
  CascaderPanel,
  CascaderStatus,
  CascaderTrigger,
  useCascaderSelection,
} from "@/components/reui/cascader/cascader"
import {
  CascaderAction,
  CascaderFooter,
} from "@/components/reui/cascader/cascader-footer"
import { CascaderItems } from "@/components/reui/cascader/cascader-item"
import {
  CascaderBreadcrumb,
  CascaderInput,
  CascaderNav,
  CascaderValue,
} from "@/components/reui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/reui/cascader/cascader-types"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ArrowLeftRightIcon, BanknoteIcon, CircleXIcon, ClipboardListIcon, CloudUploadIcon, CornerUpLeftIcon, CreditCardIcon, DownloadIcon, EyeIcon, FolderIcon, GlobeIcon, KeyIcon, LifeBuoyIcon, LinkIcon, PencilIcon, RefreshCwIcon, RepeatIcon, RocketIcon, RotateCcwIcon, ShieldCheckIcon, Trash2Icon, UserMinusIcon, UserPlusIcon, UserRoundCogIcon, UsersIcon, XIcon } from 'lucide-react'

/* -------------------------------------------------------------------------- */
/*                                 Area icons                                 */
/* -------------------------------------------------------------------------- */

/**
 * One icon per row, declared once here and referenced from the data below.
 *
 * The first level names an AREA of the product, so every root carries the thing
 * it governs: a card for billing, a globe for domains, a key for secrets. The
 * second level names an ACTION, so every leaf carries the verb instead. Handing
 * all eight areas the same shield and all twenty-four permissions the same key
 * made the icon column pure decoration - it repeated what the label already
 * said and told you nothing about the row you were about to tick.
 */
const billingIcon = (
  <CreditCardIcon  className="size-4" />
)
const membersIcon = (
  <UsersIcon  className="size-4" />
)
const projectsIcon = (
  <FolderIcon  className="size-4" />
)
const deploymentsIcon = (
  <RocketIcon  className="size-4" />
)
const domainsIcon = (
  <GlobeIcon  className="size-4" />
)
const secretsIcon = (
  <KeyIcon  className="size-4" />
)
const auditIcon = (
  <ClipboardListIcon  className="size-4" />
)
const supportIcon = (
  <LifeBuoyIcon  className="size-4" />
)

/* -------------------------------------------------------------------------- */
/*                                Action icons                                */
/* -------------------------------------------------------------------------- */

/**
 * `viewIcon` is the one glyph that repeats, and the repetition is the point:
 * every "View ..." permission is the same verb, so giving each of them its own
 * mark would invent a distinction the model does not have. Everything else is
 * spent on a verb that appears once.
 */
const viewIcon = (
  <EyeIcon  className="size-4" />
)
const editIcon = (
  <PencilIcon  className="size-4" />
)
const deleteIcon = (
  <Trash2Icon  className="size-4" />
)
const exportIcon = (
  <DownloadIcon  className="size-4" />
)
const inviteIcon = (
  <UserPlusIcon  className="size-4" />
)
const removeMemberIcon = (
  <UserMinusIcon  className="size-4" />
)
const roleIcon = (
  <UserRoundCogIcon  className="size-4" />
)
const subscriptionIcon = (
  <RepeatIcon  className="size-4" />
)
const refundIcon = (
  <BanknoteIcon  className="size-4" />
)
const transferIcon = (
  <ArrowLeftRightIcon  className="size-4" />
)
const promoteIcon = (
  <CloudUploadIcon  className="size-4" />
)
/**
 * The counter-clockwise arrow belongs HERE rather than in the footer: rolling a
 * deployment back really does restore a previous state, which is exactly what
 * the glyph promises.
 */
const rollbackIcon = (
  <RotateCcwIcon  className="size-4" />
)
const attachIcon = (
  <LinkIcon  className="size-4" />
)
const certificateIcon = (
  <ShieldCheckIcon  className="size-4" />
)
const rotateIcon = (
  <RefreshCwIcon  className="size-4" />
)
const replyIcon = (
  <CornerUpLeftIcon  className="size-4" />
)

/**
 * The footer command's mark, and the one icon in the file chosen against an
 * alternative rather than from the label.
 *
 * A counter-clockwise arrow is the other candidate, but it promises to RESTORE
 * something, and this command has nothing to restore: it empties the selection
 * and leaves you where you started. The circled X says "clear", which is what
 * actually happens, and it is the same mark the trigger's clear button carries
 * - the two entry points are one command offered twice, so they should not be
 * wearing different faces. It also survives the icon switcher intact: all five
 * sets draw an X in a circle, where the counter-clockwise family degrades into
 * an undo hook in two of them.
 */
const clearIcon = (
  <CircleXIcon  className="size-4" />
)

const permissions: CascaderNode[] = [
  {
    value: "billing",
    label: "Billing",
    icon: billingIcon,
    children: [
      { value: "billing.read", label: "View invoices", icon: viewIcon },
      {
        value: "billing.write",
        label: "Manage subscription",
        icon: subscriptionIcon,
      },
      { value: "billing.refund", label: "Issue refunds", icon: refundIcon },
      { value: "billing.tax", label: "Edit tax details", icon: editIcon },
    ],
  },
  {
    value: "members",
    label: "Members",
    icon: membersIcon,
    children: [
      { value: "members.read", label: "View members", icon: viewIcon },
      { value: "members.invite", label: "Invite members", icon: inviteIcon },
      {
        value: "members.remove",
        label: "Remove members",
        icon: removeMemberIcon,
      },
      { value: "members.roles", label: "Assign roles", icon: roleIcon },
    ],
  },
  {
    value: "projects",
    label: "Projects",
    icon: projectsIcon,
    children: [
      { value: "projects.read", label: "View projects", icon: viewIcon },
      { value: "projects.write", label: "Edit projects", icon: editIcon },
      {
        value: "projects.transfer",
        label: "Transfer projects",
        icon: transferIcon,
      },
      {
        value: "projects.delete",
        label: "Delete projects",
        icon: deleteIcon,
        disabled: true,
      },
    ],
  },
  {
    value: "deployments",
    label: "Deployments",
    icon: deploymentsIcon,
    children: [
      { value: "deployments.read", label: "View deployments", icon: viewIcon },
      {
        value: "deployments.ship",
        label: "Promote to production",
        icon: promoteIcon,
      },
      { value: "deployments.rollback", label: "Roll back", icon: rollbackIcon },
    ],
  },
  {
    value: "domains",
    label: "Domains",
    icon: domainsIcon,
    children: [
      { value: "domains.read", label: "View domains", icon: viewIcon },
      { value: "domains.attach", label: "Attach a domain", icon: attachIcon },
      {
        value: "domains.certs",
        label: "Manage certificates",
        icon: certificateIcon,
      },
    ],
  },
  {
    value: "secrets",
    label: "Secrets",
    icon: secretsIcon,
    children: [
      { value: "secrets.read", label: "Read secrets", icon: viewIcon },
      { value: "secrets.write", label: "Rotate secrets", icon: rotateIcon },
    ],
  },
  {
    value: "audit",
    label: "Audit log",
    icon: auditIcon,
    children: [
      { value: "audit.read", label: "Read the audit log", icon: viewIcon },
      {
        value: "audit.export",
        label: "Export the audit log",
        icon: exportIcon,
      },
    ],
  },
  {
    value: "support",
    label: "Support",
    icon: supportIcon,
    children: [
      { value: "support.read", label: "View tickets", icon: viewIcon },
      { value: "support.reply", label: "Reply to tickets", icon: replyIcon },
    ],
  },
]

/**
 * The clear as a footer COMMAND, reading the selection out of context instead of
 * being handed it. Nothing in here is specific to this example - composed into
 * any panel it clears that cascader - and `isEmpty` is what stops it offering to
 * empty an already empty selection.
 */
function ClearAction() {
  const { clear, isEmpty } = useCascaderSelection()

  return (
    <CascaderAction icon={clearIcon} disabled={isEmpty} onSelect={clear}>
      Clear selection
    </CascaderAction>
  )
}

/**
 * Multi-select. Rows render checkboxes, the popup stays open while you pick,
 * and `max` caps the selection. Deleting projects is disabled to show that a
 * blocked permission stays visible and announced rather than hidden.
 *
 * The clear control stands exactly where the chevron stood, and it is a SIBLING
 * of the trigger rather than something inside it. `CascaderTrigger` renders a
 * real `<button>`, so a button nested in it would be invalid HTML, would fail
 * the `nested-interactive` axe rule, and would open the popup on the way up
 * from its own click. Absolutely positioning it over the trigger's inline end
 * buys the same picture and none of that: the press lands on the clear button
 * and stops there.
 *
 * Two details make the swap read as one control rather than two. `showIcon`
 * takes the chevron away for exactly as long as the clear button is standing in
 * for it, so the two never stack. And `pe-8` reserves the room the chevron used
 * to occupy, so a five-permission summary truncates before it reaches the
 * button instead of sliding underneath it. Both insets are logical (`end-*`,
 * `pe-*`), so the whole arrangement mirrors in RTL.
 *
 * The clear is then offered a SECOND time in the footer, and the two are
 * additive rather than duplicates. The X is the one press out for someone who
 * can already see the summary; the footer row is the named one, in front of you
 * while the popup is open and a Tab away from the search field now that the
 * panel routes Tab past the scroll area, so it is a usable surface rather than a
 * decoration. `CascaderFooter` draws the rule
 * itself, with a `border-t` that sits exactly on the boundary between the list
 * and the footer. An explicit `CascaderSeparator` in here looked equivalent and
 * was not: it stacks its own block margin on top of BOTH containers' padding,
 * which measured 12px above the rule against 6px below, where the panel's own
 * rhythm is 4px after the search field and 0 between rows. The border needs no
 * arithmetic to be symmetric, because it is the boundary rather than a child of
 * one side of it.
 */
export function Pattern() {
  const [value, setValue] = useState<string[]>([])
  const hasSelection = value.length > 0

  return (
    <div className="flex w-full justify-center p-4">
      <Cascader
        multiple
        max={5}
        items={permissions}
        value={value}
        onValueChange={setValue}
      >
        <div className="relative w-72">
          <CascaderTrigger
            aria-label="Permissions"
            showIcon={!hasSelection}
            render={
              <Button
                variant="outline"
                className={cn(
                  "w-full justify-between gap-2 font-normal",
                  hasSelection && "pe-8"
                )}
              />
            }
          >
            <CascaderValue placeholder="Select permissions" />
          </CascaderTrigger>

          {hasSelection ? (
            <Button
              variant="ghost"
              size="icon-xs"
              aria-label="Clear all permissions"
              onClick={() => setValue([])}
              className="absolute end-1 top-1/2 -translate-y-1/2"
            >
              <XIcon />
            </Button>
          ) : null}
        </div>

        <CascaderContent className="w-80">
          <CascaderPanel>
            <CascaderNav>
              <CascaderInput />
            </CascaderNav>
            <CascaderBreadcrumb />
            <CascaderEmpty />
            <CascaderList>
              <CascaderItems />
            </CascaderList>

            {/* A SIBLING of the list, never a child of it: `CascaderList`'s own
                Enter handler clicks whatever it contains, so a command living
                inside the rows would fire on the keystroke that commits one. */}
            <CascaderFooter>
              <ClearAction />
            </CascaderFooter>

            <CascaderStatus />
          </CascaderPanel>
        </CascaderContent>
      </Cascader>
    </div>
  )
}
"use client"

import { useState, type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import {
  Cascader,
  CascaderContent,
  CascaderPanel,
  CascaderStatus,
  CascaderTrigger,
  useCascaderSelection,
} from "@/components/reui/cascader/cascader"
import { CascaderColumns } from "@/components/reui/cascader/cascader-columns"
import {
  CascaderInput,
  CascaderNav,
} from "@/components/reui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/reui/cascader/cascader-types"
import { IconTile } from "@/components/reui/icon-tile"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { BookIcon, CodeIcon, CompassIcon, FileTextIcon, FlaskConicalIcon, HeadphonesIcon, LayersIcon, LayoutTemplateIcon, LifeBuoyIcon, MegaphoneIcon, MessageCircleIcon, MicIcon, MonitorIcon, PaletteIcon, RocketIcon, ShieldCheckIcon, SparklesIcon, UsersIcon, VideoIcon, WrenchIcon } from 'lucide-react'

/* -------------------------------------------------------------------------- */
/*                                   Marks                                    */
/* -------------------------------------------------------------------------- */

/**
 * One tile per library, reused by every row in the first column.
 *
 * The hue is an IDENTIFIER, not a status: it tells "Engineering" from "Design
 * system" at a glance and says nothing about either. So the tile itself stays
 * neutral - `outline` is a plain bordered surface with no tint of its own - and
 * only the glyph carries the colour. Sixteen filled swatches down a column would
 * read as sixteen alerts.
 *
 * Three things about those colour classes, in order of how often they bite:
 *
 * 1. **They are literals.** Tailwind scans source TEXT, so a computed
 *    `text-${hue}-600` compiles to nothing and the column ships uncoloured.
 * 2. **Each is a light/dark PAIR.** A 600 that is comfortable on a white card
 *    goes muddy on a near-black popup, so the dark half steps up to a 400.
 * 3. **Each is `!`, at BOTH levels.** The shared combobox sheet paints the
 *    highlighted row with `data-highlighted:**:text-accent-foreground`, a
 *    DESCENDANT selector that reaches the `<path>` inside the svg. Since the
 *    path draws with `currentColor`, pinning the svg alone changes nothing -
 *    `text-*!` and `**:text-*!` have to be set together or the accent drains
 *    out of whichever row the pointer is on, which is the one row being looked
 *    at.
 *
 * With sixteen libraries the hues also have to survive being READ IN ORDER, so
 * neighbours in the list are neighbours nowhere on the wheel: no two adjacent
 * rows sit in the same family, which is what stops "teal, emerald, lime" from
 * arriving as one long green smear when the column scrolls past.
 *
 * The glyph carries no `size-*`. `IconTile` sizes its children through
 * `--icon-tile-icon-size`, and any `size-` class on the svg opts that child out
 * of the tile's scale.
 */
function LibraryTile({ children }: { children: ReactNode }) {
  return (
    <IconTile variant="outline" size="xs">
      {children}
    </IconTile>
  )
}

const libraryTiles = {
  start: (
    <LibraryTile>
      <RocketIcon  className="text-sky-600! **:text-sky-600! dark:text-sky-400! dark:**:text-sky-400!" />
    </LibraryTile>
  ),
  design: (
    <LibraryTile>
      <PaletteIcon  className="text-violet-600! **:text-violet-600! dark:text-violet-400! dark:**:text-violet-400!" />
    </LibraryTile>
  ),
  engineering: (
    <LibraryTile>
      <CodeIcon  className="text-indigo-600! **:text-indigo-600! dark:text-indigo-400! dark:**:text-indigo-400!" />
    </LibraryTile>
  ),
  podcast: (
    <LibraryTile>
      <MicIcon  className="text-rose-600! **:text-rose-600! dark:text-rose-400! dark:**:text-rose-400!" />
    </LibraryTile>
  ),
  customers: (
    <LibraryTile>
      <UsersIcon  className="text-emerald-600! **:text-emerald-600! dark:text-emerald-400! dark:**:text-emerald-400!" />
    </LibraryTile>
  ),
  webinars: (
    <LibraryTile>
      <MonitorIcon  className="text-amber-600! **:text-amber-600! dark:text-amber-400! dark:**:text-amber-400!" />
    </LibraryTile>
  ),
  releases: (
    <LibraryTile>
      <MegaphoneIcon  className="text-fuchsia-600! **:text-fuchsia-600! dark:text-fuchsia-400! dark:**:text-fuchsia-400!" />
    </LibraryTile>
  ),
  labs: (
    <LibraryTile>
      <SparklesIcon  className="text-cyan-600! **:text-cyan-600! dark:text-cyan-400! dark:**:text-cyan-400!" />
    </LibraryTile>
  ),
  courses: (
    <LibraryTile>
      <BookIcon  className="text-orange-600! **:text-orange-600! dark:text-orange-400! dark:**:text-orange-400!" />
    </LibraryTile>
  ),
  workshops: (
    <LibraryTile>
      <WrenchIcon  className="text-teal-600! **:text-teal-600! dark:text-teal-400! dark:**:text-teal-400!" />
    </LibraryTile>
  ),
  templates: (
    <LibraryTile>
      <LayoutTemplateIcon  className="text-purple-600! **:text-purple-600! dark:text-purple-400! dark:**:text-purple-400!" />
    </LibraryTile>
  ),
  security: (
    <LibraryTile>
      <ShieldCheckIcon  className="text-green-600! **:text-green-600! dark:text-green-400! dark:**:text-green-400!" />
    </LibraryTile>
  ),
  community: (
    <LibraryTile>
      <MessageCircleIcon  className="text-pink-600! **:text-pink-600! dark:text-pink-400! dark:**:text-pink-400!" />
    </LibraryTile>
  ),
  playbooks: (
    <LibraryTile>
      <CompassIcon  className="text-yellow-600! **:text-yellow-600! dark:text-yellow-400! dark:**:text-yellow-400!" />
    </LibraryTile>
  ),
  support: (
    <LibraryTile>
      <LifeBuoyIcon  className="text-blue-600! **:text-blue-600! dark:text-blue-400! dark:**:text-blue-400!" />
    </LibraryTile>
  ),
  research: (
    <LibraryTile>
      <FlaskConicalIcon  className="text-lime-600! **:text-lime-600! dark:text-lime-400! dark:**:text-lime-400!" />
    </LibraryTile>
  ),
}

/** The second column is one kind of thing all the way down, so it gets one mark. */
const collectionIcon = (
  <LayersIcon  className="size-4" />
)

/**
 * The leaf marks, and the one place in this file where a colour is deliberately
 * NOT pinned.
 *
 * These say what a row IS - watch it, listen to it, read it - and the SHAPE
 * already says it. Colour would be a second encoding of the same fact, so the
 * glyph inherits `text-muted-foreground` from the row's icon slot and is left to
 * follow the highlight like every other muted thing on the row. The trap in the
 * tiles above is only a trap when the colour carries meaning the shape does not.
 */
const kindIcons = {
  video: (
    <VideoIcon  className="size-4" />
  ),
  audio: (
    <HeadphonesIcon  className="size-4" />
  ),
  article: (
    <FileTextIcon  className="size-4" />
  ),
}

/**
 * The runtime chip. One component, both surfaces, one variant for every row.
 *
 * `info-outline` rather than plain `outline`: the plain one is `bg-transparent`
 * in light mode, so the row highlight washes straight through the chip and the
 * runtime lands on a tinted band instead of on its own surface. The semantic
 * outline variants are `bg-background`, an opaque plate the highlight cannot
 * reach, and they spend their colour on the text rather than on a fill.
 *
 * ONE variant across every row is the point. A ladder of colours by length
 * would invent a status where there is only a duration, and the two facts a row
 * already carries - what kind of thing it is, how long it takes - are spoken by
 * the leading glyph and by the number itself.
 *
 * `text-info-foreground!` re-states the variant's own colour so the highlighted
 * row cannot repaint it. Only ONE level of pin is needed here, unlike the
 * library tiles above: the chip holds text, not an svg, so there is no `<path>`
 * further down drawing itself in `currentColor`.
 *
 * Default `size`, not `sm`. At the default the chip is `h-5` with `text-xs`,
 * which is exactly the line box of a `text-sm` row, so the runtime reads at the
 * same weight as the title beside it without making a single row taller.
 */
function LengthBadge({
  children,
  className,
}: {
  children: ReactNode
  className?: string
}) {
  return (
    <Badge
      variant="info-outline"
      className={cn("text-info-foreground! shrink-0 tabular-nums", className)}
    >
      {children}
    </Badge>
  )
}

/* -------------------------------------------------------------------------- */
/*                                    Data                                    */
/* -------------------------------------------------------------------------- */

type MediaKind = keyof typeof kindIcons

interface Media {
  kind: MediaKind
  /** Runtime for video and audio, reading time for an article. */
  length: string
}

/**
 * `[title, kind, length]`. One line per item on purpose: a forty-four-episode
 * season written as forty-four objects is the same data spread over a hundred
 * and seventy lines, and the shape of a collection stops being readable at a
 * glance.
 */
type Row = [title: string, kind: MediaKind, length: string]

const slug = (name: string) =>
  name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")

/** One collection, with its items. The parent's value prefixes every child's. */
function collection(
  libraryValue: string,
  name: string,
  rows: Row[]
): CascaderNode<Media> {
  const value = `${libraryValue}.${slug(name)}`

  return {
    value,
    label: name,
    icon: collectionIcon,
    children: rows.map(([title, kind, length]) => ({
      value: `${value}.${slug(title)}`,
      label: title,
      icon: kindIcons[kind],
      data: { kind, length },
    })),
  }
}

const libraries: CascaderNode<Media>[] = [
  {
    value: "start",
    label: "Getting started",
    icon: libraryTiles.start,
    children: [
      collection("start", "Install and setup", [
        ["Install the CLI", "video", "6:12"],
        ["Your first component", "video", "8:45"],
        ["Theming in five minutes", "video", "5:30"],
        ["Framework adapters", "article", "7 min"],
        ["When installs go wrong", "article", "4 min"],
      ]),
      collection("start", "Core concepts", [
        ["Anatomy of a primitive", "video", "11:20"],
        ["Slots and data attributes", "article", "9 min"],
        ["Composition over props", "video", "14:05"],
        ["Controlled or uncontrolled", "article", "6 min"],
        ["Server and client boundaries", "video", "12:38"],
      ]),
      collection("start", "Migration guides", [
        ["Moving off a legacy kit", "video", "16:40"],
        ["Codemods in practice", "article", "8 min"],
        ["Mapping the old tokens", "article", "5 min"],
        ["Migration office hours", "audio", "42:18"],
      ]),
    ],
  },
  {
    value: "design",
    label: "Design system",
    icon: libraryTiles.design,
    children: [
      collection("design", "Foundations", [
        ["Colour tokens end to end", "video", "13:24"],
        ["A type scale that survives", "video", "9:58"],
        ["Spacing and rhythm", "article", "6 min"],
        ["Elevation without shadows", "article", "7 min"],
        ["Radius as a system", "video", "7:41"],
        ["Dark mode by contract", "video", "15:12"],
      ]),
      collection("design", "Components in depth", [
        ["Buttons are harder than that", "video", "18:30"],
        ["Forms that forgive", "video", "21:05"],
        ["Tables at scale", "video", "24:47"],
        ["Empty states worth reading", "article", "5 min"],
        ["Spending a motion budget", "article", "8 min"],
      ]),
      collection("design", "Critique sessions", [
        ["Redesigning the pricing page", "video", "46:12"],
        ["Dashboard teardown", "video", "38:55"],
        ["Onboarding critique", "audio", "51:30"],
        ["Icon set review", "video", "29:14"],
      ]),
    ],
  },
  {
    value: "engineering",
    label: "Engineering",
    icon: libraryTiles.engineering,
    children: [
      collection("engineering", "Deep dives", [
        ["Rendering ten thousand rows", "video", "27:16"],
        ["The virtualizer, line by line", "video", "33:02"],
        ["Focus management", "article", "12 min"],
        ["Portals and layers", "video", "19:48"],
        ["Hydration mismatches", "article", "9 min"],
        ["Putting the bundle on a diet", "video", "22:35"],
      ]),
      collection("engineering", "Performance clinic", [
        ["Profiling a slow page", "video", "31:20"],
        ["The real cost of :has()", "article", "11 min"],
        ["Memo, and when not to", "video", "17:44"],
        ["Streaming and Suspense", "video", "25:09"],
        ["Cache invalidation, again", "audio", "39:52"],
      ]),
      collection("engineering", "Accessibility", [
        ["Keyboard maps that work", "video", "20:11"],
        ["A screen reader run-through", "video", "26:38"],
        ["Contrast in practice", "article", "7 min"],
        ["Live regions, quietly", "article", "10 min"],
        ["Testing with axe", "video", "14:52"],
      ]),
    ],
  },
  {
    value: "podcast",
    label: "Podcast archive",
    icon: libraryTiles.podcast,
    children: [
      collection("podcast", "Season 5", [
        ["Systems that outlive their authors", "audio", "49:31"],
        ["The AI-shaped hole in the workflow", "audio", "55:12"],
        ["Small teams, large surfaces", "audio", "43:08"],
        ["What changed about the web", "audio", "51:47"],
        ["Pricing, two years later", "audio", "46:22"],
        ["The registry as a product", "audio", "58:19"],
        ["Designers who ship", "audio", "42:55"],
        ["Mid-season mailbag", "audio", "37:14"],
      ]),
      // The long one. Forty-four episodes is the column that makes the whole
      // layout argument: it cannot be read without scrolling, and scrolling it
      // must not move the two columns to its left.
      collection("podcast", "Season 4", [
        ["Designing for the one percent case", "audio", "48:12"],
        ["The registry model", "audio", "52:40"],
        ["Shipping on a Friday", "audio", "41:05"],
        ["What Figma cannot tell you", "audio", "57:22"],
        ["Naming things, again", "audio", "44:18"],
        ["Open source economics", "audio", "1:02:14"],
        ["The two-person design team", "audio", "39:47"],
        ["Migrating a decade of CSS", "audio", "55:03"],
        ["Accessibility as a default", "audio", "47:36"],
        ["When to fork a library", "audio", "43:29"],
        ["Type systems for designers", "audio", "50:58"],
        ["The cost of a config flag", "audio", "36:41"],
        ["Documentation nobody reads", "audio", "45:52"],
        ["Support as product research", "audio", "49:10"],
        ["Pricing a developer tool", "audio", "1:07:33"],
        ["Hiring for taste", "audio", "42:26"],
        ["The last five percent", "audio", "53:47"],
        ["Building in public", "audio", "46:19"],
        ["Killing a feature", "audio", "38:54"],
        ["A year of releases", "audio", "1:12:08"],
        ["Design reviews that end", "audio", "41:52"],
        ["The changelog as marketing", "audio", "37:26"],
        ["Estimating the unknowable", "audio", "44:09"],
        ["One repo or ten", "audio", "48:33"],
        ["What versioning teaches you", "audio", "39:41"],
        ["The interview that failed", "audio", "35:18"],
        ["Refactors nobody asked for", "audio", "52:04"],
        ["Reading other people's CSS", "audio", "43:37"],
        ["The support rota", "audio", "31:55"],
        ["Designing for the keyboard", "audio", "46:48"],
        ["A week without meetings", "audio", "29:12"],
        ["The demo that broke", "audio", "40:26"],
        ["Selling internal tools", "audio", "45:39"],
        ["Metrics we stopped tracking", "audio", "38:17"],
        ["The second product", "audio", "57:41"],
        ["Writing for engineers", "audio", "42:03"],
        ["When the roadmap slips", "audio", "36:29"],
        ["Contractors and continuity", "audio", "44:56"],
        ["The style guide graveyard", "audio", "33:44"],
        ["Shipping without a designer", "audio", "50:12"],
        ["Our worst incident", "audio", "1:04:38"],
        ["Answering the same question", "audio", "27:31"],
        ["The tooling we regret", "audio", "46:07"],
        ["Four seasons in", "audio", "1:09:24"],
      ]),
      collection("podcast", "Season 3", [
        ["Design tokens, three years on", "audio", "51:44"],
        ["The support inbox as a roadmap", "audio", "44:02"],
        ["Componentising a marketing site", "audio", "39:15"],
        ["Rewrites we regret", "audio", "58:30"],
        ["Working across time zones", "audio", "40:27"],
        ["What we got wrong about tables", "audio", "47:51"],
      ]),
      collection("podcast", "Season 2", [
        ["The first hundred components", "audio", "45:12"],
        ["Docs as the product", "audio", "39:48"],
        ["Choosing a licence", "audio", "51:33"],
        ["When users disagree", "audio", "43:21"],
        ["The support week from hell", "audio", "47:05"],
        ["A rewrite we did not do", "audio", "55:40"],
      ]),
      collection("podcast", "Season 1", [
        ["Why another library", "audio", "38:12"],
        ["The name we nearly used", "audio", "33:47"],
        ["Our first contributor", "audio", "41:19"],
        ["Design debt, day one", "audio", "44:52"],
        ["Shipping the first release", "audio", "49:26"],
        ["What we would redo", "audio", "52:38"],
      ]),
      collection("podcast", "Bonus interviews", [
        ["A maintainer's week", "audio", "33:18"],
        ["Notes from a design audit", "audio", "28:44"],
        ["Reading the changelog aloud", "audio", "22:36"],
        ["Show notes, annotated", "article", "6 min"],
      ]),
      collection("podcast", "Live recordings", [
        ["Live from the meetup", "audio", "58:44"],
        ["A recording with questions", "audio", "1:03:27"],
        ["The unedited take", "audio", "1:14:52"],
        ["Backstage notes", "article", "5 min"],
      ]),
      collection("podcast", "Listener questions", [
        ["Questions about theming", "audio", "31:22"],
        ["Questions about hiring", "audio", "28:47"],
        ["Questions about pricing", "audio", "34:16"],
        ["The ones we could not answer", "audio", "26:53"],
      ]),
      collection("podcast", "Guest hosts", [
        ["A designer takes the mic", "audio", "44:31"],
        ["An engineer takes the mic", "audio", "47:18"],
        ["A support lead takes the mic", "audio", "39:52"],
      ]),
      collection("podcast", "Show notes", [
        ["Season 5, annotated", "article", "7 min"],
        ["Season 4, annotated", "article", "12 min"],
        ["Transcript archive", "article", "4 min"],
      ]),
    ],
  },
  {
    value: "customers",
    label: "Customer stories",
    icon: libraryTiles.customers,
    children: [
      collection("customers", "Enterprise", [
        ["A bank rebuilds its console", "video", "23:40"],
        ["Rolling out to nine teams", "video", "18:12"],
        ["Compliance without friction", "article", "9 min"],
        ["Two design systems, one app", "audio", "44:55"],
      ]),
      collection("customers", "Startups", [
        ["Zero to launch in five weeks", "video", "15:26"],
        ["One engineer, forty screens", "video", "12:03"],
        ["Choosing boring on purpose", "article", "6 min"],
        ["The first hundred users", "audio", "37:41"],
      ]),
      collection("customers", "Agencies", [
        ["Reusing a kit across clients", "video", "19:57"],
        ["Handover that survives", "article", "8 min"],
        ["Pitching a system, not a page", "audio", "35:12"],
      ]),
    ],
  },
  {
    value: "webinars",
    label: "Webinars",
    icon: libraryTiles.webinars,
    children: [
      collection("webinars", "Live builds", [
        ["Building a settings page", "video", "58:20"],
        ["An analytics dashboard", "video", "1:04:37"],
        ["A checkout, end to end", "video", "1:11:49"],
        ["Search that feels instant", "video", "47:15"],
        ["A data grid from scratch", "video", "1:21:06"],
      ]),
      collection("webinars", "Office hours", [
        ["Ask me anything: theming", "video", "52:03"],
        ["Ask me anything: forms", "video", "49:28"],
        ["Ask me anything: performance", "video", "55:14"],
        ["Questions we keep getting", "article", "7 min"],
      ]),
      collection("webinars", "Partner sessions", [
        ["Deploying at the edge", "video", "41:32"],
        ["Auth without the tears", "video", "38:09"],
        ["Analytics you can trust", "video", "36:44"],
      ]),
    ],
  },
  {
    value: "releases",
    label: "Release notes",
    icon: libraryTiles.releases,
    children: [
      collection("releases", "2026 releases", [
        ["v9: the columns rewrite", "video", "9:12"],
        ["v8.4: motion primitives", "video", "6:48"],
        ["v8.2: the filters overhaul", "video", "7:55"],
        ["v8.0: what changed and why", "article", "11 min"],
        ["Release recap, quarter one", "audio", "26:33"],
      ]),
      collection("releases", "2025 releases", [
        ["v7: the theming pass", "video", "8:21"],
        ["v6.5: keyboard everywhere", "video", "5:39"],
        ["v6: the first data grid", "article", "10 min"],
        ["A year in changelogs", "audio", "31:07"],
      ]),
      collection("releases", "Deprecations", [
        ["Leaving the old icon API", "article", "5 min"],
        ["Retiring the legacy tokens", "article", "6 min"],
        ["How we deprecate", "video", "12:44"],
      ]),
    ],
  },
  {
    value: "labs",
    label: "Labs",
    icon: libraryTiles.labs,
    children: [
      collection("labs", "Prompting for UI", [
        ["Describing a layout precisely", "video", "16:08"],
        ["Prompts that survive a refactor", "article", "8 min"],
        ["Generating a theme", "video", "13:52"],
        ["Where generation stops", "audio", "34:26"],
      ]),
      collection("labs", "Agent workflows", [
        ["An agent that reads the registry", "video", "24:19"],
        ["Guardrails for generated code", "article", "12 min"],
        ["Reviewing what a model wrote", "video", "21:33"],
        ["Tooling notes", "article", "5 min"],
      ]),
      collection("labs", "Evaluations", [
        ["Scoring a generated screen", "video", "18:47"],
        ["Building a taste rubric", "article", "9 min"],
        ["What we measure, and why", "audio", "29:58"],
      ]),
    ],
  },
  {
    value: "courses",
    label: "Courses",
    icon: libraryTiles.courses,
    children: [
      collection("courses", "Beginner track", [
        ["What a component library is for", "video", "10:24"],
        ["Reading the docs", "article", "5 min"],
        ["Your first screen", "video", "17:52"],
        ["Layout without fighting it", "video", "14:31"],
        ["Forms, gently", "video", "19:06"],
      ]),
      collection("courses", "Intermediate track", [
        ["Composing three primitives", "video", "22:14"],
        ["State that survives a refactor", "article", "11 min"],
        ["Theming a whole app", "video", "26:48"],
        ["Testing what users do", "video", "20:37"],
      ]),
      collection("courses", "Advanced track", [
        ["Writing your own primitive", "video", "34:52"],
        ["Headless, but not hostile", "article", "13 min"],
        ["Publishing to a registry", "video", "28:19"],
        ["Maintaining a fork", "audio", "41:22"],
      ]),
      collection("courses", "Course clinics", [
        ["Homework review, week one", "video", "24:05"],
        ["Homework review, week two", "video", "22:48"],
        ["Common mistakes", "article", "8 min"],
      ]),
    ],
  },
  {
    value: "workshops",
    label: "Workshops",
    icon: libraryTiles.workshops,
    children: [
      collection("workshops", "Hands-on: theming", [
        ["Setting up the tokens", "video", "18:22"],
        ["Two brands, one build", "video", "25:14"],
        ["Worksheet and answers", "article", "9 min"],
      ]),
      collection("workshops", "Hands-on: data", [
        ["A grid you can maintain", "video", "31:47"],
        ["Server pagination, honestly", "video", "27:33"],
        ["Filters people can read", "video", "21:16"],
        ["Exercise notes", "article", "7 min"],
      ]),
      collection("workshops", "Hands-on: motion", [
        ["Timing that feels right", "video", "16:44"],
        ["Motion that respects settings", "article", "6 min"],
        ["Critique of the exercises", "audio", "33:05"],
      ]),
    ],
  },
  {
    value: "templates",
    label: "Templates",
    icon: libraryTiles.templates,
    children: [
      collection("templates", "Dashboards", [
        ["Tour of the admin template", "video", "19:38"],
        ["Wiring it to your data", "video", "24:12"],
        ["What to delete first", "article", "6 min"],
      ]),
      collection("templates", "Marketing sites", [
        ["The landing page template", "video", "15:47"],
        ["Blog and docs together", "video", "21:29"],
        ["Swapping the brand", "article", "5 min"],
      ]),
      collection("templates", "Application shells", [
        ["The auth flow, end to end", "video", "29:52"],
        ["Settings that scale", "video", "18:07"],
        ["Shell teardown", "audio", "36:14"],
      ]),
    ],
  },
  {
    value: "security",
    label: "Security notes",
    icon: libraryTiles.security,
    children: [
      collection("security", "Threat models", [
        ["Trust boundaries in a UI", "video", "23:11"],
        ["What a client cannot enforce", "article", "10 min"],
        ["Reviewing a third-party widget", "video", "17:26"],
      ]),
      collection("security", "Practices", [
        ["Handling tokens in the browser", "video", "20:44"],
        ["Content security policy, calmly", "article", "12 min"],
        ["Dependency hygiene", "video", "15:33"],
        ["Audit walkthrough", "audio", "38:47"],
      ]),
      collection("security", "Incident reading", [
        ["Anatomy of a supply chain hit", "article", "14 min"],
        ["The morning after a leak", "audio", "42:09"],
      ]),
    ],
  },
  {
    value: "community",
    label: "Community",
    icon: libraryTiles.community,
    children: [
      collection("community", "Show and tell", [
        ["Built in a weekend", "video", "12:36"],
        ["A design system for one", "video", "16:52"],
        ["The gallery, quarter one", "article", "5 min"],
      ]),
      collection("community", "Contributor guides", [
        ["Your first pull request", "video", "14:18"],
        ["How review works here", "article", "7 min"],
        ["Issue triage, live", "video", "26:41"],
      ]),
      collection("community", "Meetups", [
        ["Berlin, spring", "video", "47:22"],
        ["Remote meetup, June", "video", "51:08"],
        ["Lightning talks", "video", "33:56"],
      ]),
    ],
  },
  {
    value: "playbooks",
    label: "Playbooks",
    icon: libraryTiles.playbooks,
    children: [
      collection("playbooks", "Rollout", [
        ["Piloting with one team", "article", "9 min"],
        ["Winning over the sceptics", "audio", "34:41"],
        ["Measuring adoption", "video", "18:59"],
      ]),
      collection("playbooks", "Governance", [
        ["Who owns a component", "article", "8 min"],
        ["Requesting a new primitive", "video", "13:24"],
        ["Deprecating with notice", "article", "6 min"],
      ]),
      collection("playbooks", "Handbooks", [
        ["The design system handbook", "article", "21 min"],
        ["Engineering handbook", "article", "18 min"],
        ["Onboarding in a week", "video", "25:37"],
      ]),
    ],
  },
  {
    value: "support",
    label: "Support clinic",
    icon: libraryTiles.support,
    children: [
      collection("support", "Common issues", [
        ["Styles that never apply", "video", "11:42"],
        ["The hydration warning", "article", "6 min"],
        ["Why the popup is behind", "video", "9:17"],
        ["Fonts loading twice", "article", "4 min"],
      ]),
      collection("support", "Debug walkthroughs", [
        ["Reading a stack trace", "video", "22:53"],
        ["Bisecting a broken upgrade", "video", "19:31"],
        ["A live debugging session", "audio", "45:26"],
      ]),
      collection("support", "Ask the team", [
        ["Office hours, week 12", "audio", "39:14"],
        ["Office hours, week 13", "audio", "41:37"],
        ["Answers we reuse", "article", "7 min"],
      ]),
    ],
  },
  {
    value: "research",
    label: "Research",
    icon: libraryTiles.research,
    children: [
      collection("research", "Usability studies", [
        ["Five users, one form", "video", "28:44"],
        ["Testing a data grid", "video", "32:19"],
        ["What the recordings showed", "article", "11 min"],
      ]),
      collection("research", "Benchmarks", [
        ["Bundle size across kits", "article", "13 min"],
        ["Interaction latency, measured", "video", "24:07"],
        ["Method notes", "article", "8 min"],
      ]),
      collection("research", "Field notes", [
        ["A week with the CLI", "article", "9 min"],
        ["Watching a team migrate", "audio", "37:52"],
        ["Notes from support tickets", "article", "6 min"],
      ]),
    ],
  },
]

/* -------------------------------------------------------------------------- */
/*                                   Pattern                                  */
/* -------------------------------------------------------------------------- */

/**
 * Headless trigger: the selection is read as data and formatted freely.
 *
 * A media picker has to answer three questions at once - what was picked, where
 * it lives, and how long it is - and the hook hands over the resolved path, so
 * all three come out of one read with no lookup back into `libraries`. The
 * runtime keeps the same chip it wore in the row: the thing that identified an
 * item in the list is the thing that identifies it once chosen, down to the
 * variant, which is why both surfaces render `LengthBadge` rather than two
 * badges that would drift apart the first time one of them is tweaked.
 *
 * With no label above the control, the empty state is the only thing naming the
 * field, so it says what a pick DOES ("Select an item to feature") instead of
 * echoing a heading that is no longer there.
 *
 * `min-w-0` on both texts and `truncate` on each is what lets the title give
 * way before the collection name does. The chip is `shrink-0` so a long title
 * never squeezes a runtime into an ellipsis.
 */
function MediaValue() {
  const { first, firstPath, isEmpty } = useCascaderSelection<Media>()

  if (isEmpty || !first) {
    return (
      <span className="text-muted-foreground">Select an item to feature</span>
    )
  }

  const collectionNode = firstPath[1]

  return (
    <span className="flex min-w-0 flex-1 items-center gap-2">
      <span className="shrink-0">
        {first.data ? kindIcons[first.data.kind] : null}
      </span>
      <span className="min-w-0 truncate font-medium">{first.label}</span>
      <span className="text-muted-foreground min-w-0 truncate text-xs">
        {collectionNode?.label}
      </span>
      <LengthBadge className="ms-auto">{first.data?.length}</LengthBadge>
    </span>
  )
}

/**
 * Columns mode - Miller columns, the whole open trail side by side.
 *
 * A media library is the shape this layout was built for: the library, the
 * collection and the item stay on screen together, so you compare two seasons
 * without losing the library you came from, and stepping back is a glance
 * rather than a Back button. Arrow Left and Right move between columns, Up and
 * Down within one.
 *
 * Every column here is longer than the panel is tall, and that is the whole
 * demonstration. Sixteen libraries overflow the first column, the podcast
 * archive carries ten seasons and side collections in the second, and Season 4
 * runs to forty-four episodes in the third. Scroll any one of them and the
 * other two do not move: each pane owns its own thumb, so losing your place in
 * a long list of episodes never costs you the library you came from. A demo
 * where every column fits would show the layout and hide the reason for it.
 *
 * Each column earns its width. The first is identity: a neutral `IconTile` with
 * a coloured glyph, one hue per library. The second is structure: one repeated
 * stack mark, because a column of one kind of thing does not need sixteen
 * different marks to say so. The third is the payload: a type glyph on the
 * lead, the title, and the runtime as a trailing chip, all on ONE line.
 *
 * Branch rows do NOT get a chip. `CascaderItem` already draws a child count
 * next to the chevron, so a chip there would put two numbers on one row with
 * nothing to tell them apart. The count answers "how much is in here" and the
 * chip answers "how long is this", and only leaves have the second question.
 *
 * `columnWidth` runs a little over the primitive's 220px default rather than
 * well under it. The leaf row spends real width on a leading glyph and on a
 * trailing chip that now sits at the badge's default size - `text-xs`, not the
 * `sm` variant's 10px - and a runtime like "1:12:08" is seven glyphs wide at
 * that size. At the 180 this example used to pass, a title had about a hundred
 * pixels left and truncated after two words.
 *
 * `maxHeight` is a cap, not a height, and it moved up with the chip. At the
 * badge's default size the chip is `h-5`, which is exactly the line box of a
 * `text-sm` row - Nova and its siblings do not grow by a pixel - but it stands
 * a few pixels above the `text-xs` line box of the tighter styles, so their
 * rows do grow. Holding the old 260 would have quietly cost those styles a
 * visible row, so the cap moves with the row rather than the other way round.
 *
 * `w-auto min-w-0` on the content is load-bearing. `CascaderContent` carries
 * `min-w-(--anchor-width)` so a single-column popup lines up under its trigger,
 * and in columns mode that floor stops the popup shrinking to the width its
 * columns actually need. Clearing it lets the panel size to its content in both
 * directions.
 *
 * The wrapper pins itself to the TOP of the preview surface (`self-start`).
 * Both the docs frame and the catalog card centre their child vertically, and
 * a wide columns popup changes the measured height as panes open, so a centred
 * demo jumps while you navigate. `pt-6` keeps the top edge deliberate rather
 * than flush. `items-center` stays: in a column that is horizontal centring.
 */
export function Pattern() {
  const [value, setValue] = useState("")

  return (
    <div className="flex w-full flex-col items-center gap-3 self-start px-4 pt-6 pb-4">
      <div className="w-full max-w-sm">
        <Cascader
          mode="columns"
          items={libraries}
          value={value}
          onValueChange={setValue}
          renderLabel={(node, state) =>
            // Branches keep the default label. `customLabel ?? default` treats
            // null as "not handled", so opting out is a return rather than a
            // second copy of the default markup that would drift from it.
            state.branch ? null : (
              <span className="flex w-full min-w-0 items-center gap-2">
                <span className="min-w-0 flex-1 truncate text-start">
                  {node.label}
                </span>
                <LengthBadge>{node.data?.length}</LengthBadge>
              </span>
            )
          }
        >
          <CascaderTrigger
            aria-label="Featured media"
            render={
              <Button
                variant="outline"
                className="w-full justify-between gap-2 font-normal"
              />
            }
          >
            <MediaValue />
          </CascaderTrigger>

          <CascaderContent className="w-auto min-w-0">
            <CascaderPanel>
              <CascaderNav>
                <CascaderInput placeholder="Search this column..." />
              </CascaderNav>
              {/* Kept: three panes side by side have to agree on a height, or the
                  popup grows and shrinks as you move between columns of very
                  different lengths. Each pane still scrolls inside it. */}
              <CascaderColumns columnWidth={240} maxHeight={288} />
              <CascaderStatus />
            </CascaderPanel>
          </CascaderContent>
        </Cascader>
      </div>
    </div>
  )
}
"use client"

import { useState } from "react"
import {
  Cascader,
  CascaderContent,
  CascaderEmpty,
  CascaderList,
  CascaderPanel,
  CascaderStatus,
  CascaderTrigger,
  useCascaderSelection,
} from "@/components/reui/cascader/cascader"
import { CascaderItems } from "@/components/reui/cascader/cascader-item"
import {
  CascaderInput,
  CascaderNav,
} from "@/components/reui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/reui/cascader/cascader-types"

import { Button } from "@/components/ui/button"
import { CompassIcon, GlobeIcon } from 'lucide-react'

const globeIcon = (
  <GlobeIcon  className="size-4" />
)
const compassIcon = (
  <CompassIcon  className="size-4" />
)

/**
 * ISO 3166-1 alpha-2 to its regional indicator pair, so "DE" becomes the German
 * flag. Deriving it keeps the table below down to a code and a name per country
 * rather than sixty pasted emoji, and it leaves the code as the single source
 * of the flag: a typo cannot show the right flag beside the wrong country.
 */
function flagOf(iso2: string) {
  return String.fromCodePoint(
    ...[...iso2].map((letter) => letter.charCodeAt(0) + 127397)
  )
}

/**
 * The flag goes in the row's icon slot, not into the label. In a multi-select
 * tree the checkbox leads the row, and the icon is the very next column, so the
 * flag lands exactly where the globe and the compass sit one and two levels up
 * and every depth keeps one label column.
 *
 * Fixed width on purpose. Windows Chrome ships no regional indicator glyphs and
 * falls back to the two letters, whose width changes per pair, so without `w-5`
 * the country names in one level would each start on a slightly different
 * column. `aria-hidden` because the country name is already the row's text, and
 * announcing the flag as well would just read it twice.
 */
function flagIcon(iso2: string) {
  return (
    <span aria-hidden="true" className="w-5 text-center text-base leading-none">
      {flagOf(iso2)}
    </span>
  )
}

/** `[iso2, name]`. The flag is derived from the code, see `flagOf`. */
type CountryTuple = readonly [string, string]
/** `[slug, label, countries]`. */
type RegionTuple = readonly [string, string, readonly CountryTuple[]]
/** `[slug, label, regions]`. */
type ContinentTuple = readonly [string, string, readonly RegionTuple[]]

const CONTINENTS: readonly ContinentTuple[] = [
  [
    "europe",
    "Europe",
    [
      [
        "western",
        "Western Europe",
        [
          ["DE", "Germany"],
          ["FR", "France"],
          ["NL", "Netherlands"],
          ["BE", "Belgium"],
          ["AT", "Austria"],
          ["CH", "Switzerland"],
        ],
      ],
      [
        "northern",
        "Northern Europe",
        [
          ["GB", "United Kingdom"],
          ["IE", "Ireland"],
          ["SE", "Sweden"],
          ["NO", "Norway"],
          ["DK", "Denmark"],
          ["FI", "Finland"],
        ],
      ],
      [
        "southern",
        "Southern Europe",
        [
          ["IT", "Italy"],
          ["ES", "Spain"],
          ["PT", "Portugal"],
          ["GR", "Greece"],
        ],
      ],
      [
        "central",
        "Central Europe",
        [
          ["PL", "Poland"],
          ["CZ", "Czechia"],
          ["HU", "Hungary"],
          ["RO", "Romania"],
        ],
      ],
    ],
  ],
  [
    "americas",
    "Americas",
    [
      [
        "north",
        "North America",
        [
          ["US", "United States"],
          ["CA", "Canada"],
          ["MX", "Mexico"],
        ],
      ],
      [
        "south",
        "South America",
        [
          ["BR", "Brazil"],
          ["AR", "Argentina"],
          ["CL", "Chile"],
          ["CO", "Colombia"],
          ["PE", "Peru"],
        ],
      ],
      [
        "central",
        "Central America",
        [
          ["CR", "Costa Rica"],
          ["PA", "Panama"],
          ["GT", "Guatemala"],
          ["DO", "Dominican Republic"],
        ],
      ],
    ],
  ],
  [
    "apac",
    "Asia Pacific",
    [
      [
        "east",
        "East Asia",
        [
          ["JP", "Japan"],
          ["KR", "South Korea"],
          ["CN", "China"],
          ["TW", "Taiwan"],
          ["HK", "Hong Kong"],
        ],
      ],
      [
        "southeast",
        "Southeast Asia",
        [
          ["SG", "Singapore"],
          ["MY", "Malaysia"],
          ["TH", "Thailand"],
          ["VN", "Vietnam"],
          ["ID", "Indonesia"],
          ["PH", "Philippines"],
        ],
      ],
      [
        "south",
        "South Asia",
        [
          ["IN", "India"],
          ["PK", "Pakistan"],
          ["BD", "Bangladesh"],
          ["LK", "Sri Lanka"],
        ],
      ],
    ],
  ],
  [
    "mideast",
    "Middle East",
    [
      [
        "gulf",
        "Gulf States",
        [
          ["AE", "United Arab Emirates"],
          ["SA", "Saudi Arabia"],
          ["QA", "Qatar"],
          ["KW", "Kuwait"],
          ["BH", "Bahrain"],
          ["OM", "Oman"],
        ],
      ],
      [
        "levant",
        "Levant",
        [
          ["IL", "Israel"],
          ["JO", "Jordan"],
          ["LB", "Lebanon"],
        ],
      ],
    ],
  ],
  [
    "africa",
    "Africa",
    [
      [
        "north",
        "North Africa",
        [
          ["MA", "Morocco"],
          ["EG", "Egypt"],
          ["TN", "Tunisia"],
          ["DZ", "Algeria"],
        ],
      ],
      [
        "west",
        "West Africa",
        [
          ["NG", "Nigeria"],
          ["GH", "Ghana"],
          ["SN", "Senegal"],
          ["CI", "Cote d'Ivoire"],
        ],
      ],
      [
        "east",
        "East Africa",
        [
          ["KE", "Kenya"],
          ["TZ", "Tanzania"],
          ["ET", "Ethiopia"],
          ["UG", "Uganda"],
        ],
      ],
      [
        "south",
        "Southern Africa",
        [
          ["ZA", "South Africa"],
          ["BW", "Botswana"],
          ["NA", "Namibia"],
        ],
      ],
    ],
  ],
  [
    "oceania",
    "Oceania",
    [
      [
        "anz",
        "Australia and New Zealand",
        [
          ["AU", "Australia"],
          ["NZ", "New Zealand"],
        ],
      ],
      [
        "pacific",
        "Pacific Islands",
        [
          ["FJ", "Fiji"],
          ["PG", "Papua New Guinea"],
          ["NC", "New Caledonia"],
          ["PF", "French Polynesia"],
        ],
      ],
    ],
  ],
]

const zones: CascaderNode[] = CONTINENTS.map(
  ([continent, continentLabel, regions]) => ({
    value: continent,
    label: continentLabel,
    icon: globeIcon,
    children: regions.map(([region, regionLabel, countries]) => ({
      value: `${continent}.${region}`,
      label: regionLabel,
      icon: compassIcon,
      children: countries.map(([iso2, name]) => ({
        // Exactly three dot separated segments, and no segment carries a dot of
        // its own. That is what lets the summary below tell a country apart
        // from the region and the continent above it by counting separators.
        value: `${continent}.${region}.${iso2.toLowerCase()}`,
        label: name,
        icon: flagIcon(iso2),
        // So the search field finds Germany on "DE" as well as on the name.
        keywords: [iso2],
      })),
    })),
  })
)

/**
 * A headless trigger, because a cascade cannot be summarised by listing what is
 * in the value.
 *
 * Two things go wrong if you try. The count is inflated: ticking Western Europe
 * commits the region AND its six countries, so the honest number of shipping
 * destinations is the leaves alone. And the labels are the wrong ones: the six
 * countries under a fully committed region were never pressed individually, and
 * naming them back at the user hides the one press that actually happened.
 *
 * `useCascaderSelection` hands back one ancestor chain per selected node, which
 * is enough to fix both without parsing a single value string. A leaf is a chain
 * whose last node has no children. The sample keeps only the TOP of the
 * selection - a node whose parent is committed too is already spoken for by
 * that parent - so six countries collapse back into "Western Europe" and a lone
 * country still speaks for itself.
 *
 * ## What the line says, and why it is ONE size
 *
 * It used to read "6 countries" in the control's own type with the names beside
 * it in `text-xs` muted, which is the trigger equivalent of a footnote: the
 * user's actual choice was set in the smallest type on the control, under the
 * one fact they could have worked out themselves. So the sizes are gone and
 * the order is reversed. The line now names the SELECTION first, at the
 * control's own size, and closes with the leaf total.
 *
 * Both facts are needed and neither replaces the other. The names alone cannot
 * say how big the selection is once a region stands in for six countries; the
 * count alone cannot say which six. The deepest common ancestor was the other
 * candidate and it was rejected: it reads beautifully for one branch and
 * collapses to "everywhere" the moment a second continent is ticked, which is
 * exactly the selection worth describing.
 *
 * The "+N" is gone with the small type. A truncated list already says there is
 * more, and a "+4" sitting next to a "12 countries" was two numbers counting
 * different things a few pixels apart. So the names take `min-w-0 truncate` and
 * give way, and the count is `shrink-0` and survives any trigger width - the
 * middle dot travels with it so the line never ends on a dangling separator.
 */
function ZoneValue() {
  const { selected, paths, isEmpty } = useCascaderSelection()

  const committed = new Set(selected.map((node) => node.value))
  // A value the tree does not hold resolves to an EMPTY chain, so the guard
  // comes before anything reads the last node of one.
  const chains = paths.filter((path) => path.length > 0)

  // `isEmpty` covers the usual case; `chains` covers the one where every value
  // in the selection is a stale id the tree can no longer resolve, which would
  // otherwise render a confident "0 countries" beside nothing at all.
  if (isEmpty || chains.length === 0) {
    return (
      <span className="text-muted-foreground truncate">
        Select shipping destinations
      </span>
    )
  }

  const countries = chains.filter(
    (path) => !path[path.length - 1].children?.length
  ).length

  const covering = chains
    .filter((path) => {
      const parent = path[path.length - 2]
      return !parent || !committed.has(parent.value)
    })
    .map((path) => path[path.length - 1].label)

  return (
    <span className="flex min-w-0 flex-1 items-center gap-1.5 text-start">
      <span className="min-w-0 truncate">{covering.join(", ")}</span>
      <span className="text-muted-foreground shrink-0">
        · {countries} {countries === 1 ? "country" : "countries"}
      </span>
    </span>
  )
}

/**
 * A cascading tree in a POPOVER.
 *
 * Shipping zones are the case for the cascade: "everywhere in Western Europe"
 * is one press, not six, and dropping a single country has to demote the region
 * from fully selected to partial rather than leaving a checkbox that lies. The
 * comment deliberately avoids the shorter phrasing there: `verify-registry`
 * scans for `from "..."` to collect a file's imports, and a quoted word after
 * `from` inside a COMMENT is read as an undeclared dependency. Continent,
 * region, country is the shallowest tree where that is worth showing. At two
 * levels a branch is full or empty at a glance and the partial state carries
 * nothing.
 *
 * Three things make the selection work together. `cascade` propagates a commit
 * over the pressed node's subtree and reconciles its ancestors afterwards.
 * `selectable="any"` is what lets a branch be pressed at all - without it there
 * is nothing for a cascade to start from. And tree mode keeps the whole shape
 * visible, which is the only way a partial state means anything.
 *
 * The popup is the reason this exists as its own example rather than as a
 * variant of an inline one. A tree is the single mode whose height is under the
 * USER's control: every disclosure adds or removes rows while the panel is
 * already anchored to the trigger, so expanding Africa asks the popup to
 * re-measure on a press that had nothing to do with opening it. Get the bound
 * wrong and the last rows land off the bottom of the screen, or the popup flips
 * to the other side of the trigger mid-interaction.
 *
 * Which is the whole height contract: `CascaderList` takes NO `maxHeight` here.
 * Inline there is no positioner, nothing publishes `--available-height`, and an
 * explicit cap is the only bound in existence - which is why this example
 * carried `maxHeight={260}` while it was inline. Under `CascaderContent` the
 * positioner publishes the distance from the trigger to the edge of the
 * viewport, the list bounds itself at `min(--available-height, 24rem)`, and the
 * scroll area inside absorbs everything past that. The popup stops at the
 * viewport, and expanding a branch scrolls the rows rather than growing it.
 *
 * It opens with nothing picked and Western Europe already expanded, so the
 * first country ticked is also the first indeterminate region. The number on a
 * branch is the primitive's own: children until something inside is selected,
 * then the selected total in the accent colour, which is the second reading of
 * "some" for anyone who cannot see the box's third state.
 *
 * The heading over the control is gone. It said "Countries this store ships
 * to" above a trigger whose empty state already says "Select shipping
 * destinations" and whose filled state names what was picked, so it was the
 * same sentence twice with the field between them. A real form has a `Field`
 * and a `Label` doing accessible-name work; a one-control example has neither,
 * and a bare `<p>` is decoration that only pretends to.
 */
export function Pattern() {
  const [value, setValue] = useState<string[]>([])
  const [expanded, setExpanded] = useState<string[]>([
    "europe",
    "europe.western",
  ])

  return (
    <div className="flex w-full justify-center p-4">
      <div className="w-full max-w-sm">
        <Cascader
          multiple
          cascade
          selectable="any"
          mode="tree"
          items={zones}
          value={value}
          onValueChange={setValue}
          expanded={expanded}
          onExpandedChange={setExpanded}
        >
          <CascaderTrigger
            aria-label="Shipping destinations"
            render={
              <Button
                variant="outline"
                className="w-full justify-between gap-2 font-normal"
              />
            }
          >
            <ZoneValue />
          </CascaderTrigger>

          {/* WIDER THAN THE TRIGGER, on purpose, and the one measurement that
              fixes the deepest level.

              Where a country row's width goes, in nova, with the popup at the
              trigger's own 384px: 8px of list padding, then a 6px start inset
              plus 32px of indent (two levels at the row's default 16), a 6px
              end inset, an 18px expander slot the leaf reserves but cannot
              use, a 16px checkbox, a 20px flag and three 8px gaps. 130px of
              the 384 is gone before the country name starts, and in a narrow
              embed - a catalog card, a phone - the popup falls back to its own
              floor and the name is down to about 190px.

              `w-80` was that floor, and at this demo's width it was INERT:
              320px never beats the `min-w-(--anchor-width)` the popup already
              takes from a 384px trigger, so the class changed no pixel anyone
              ever saw. `CascaderContent` deliberately does not clamp to the
              anchor - a cascade is routinely wider than the control that opens
              it - so the fix is to name a width ABOVE the anchor. 416px puts
              every one of those 32px into the label column at every depth and
              raises the narrow-embed floor by the same amount. It stays
              bounded: `max-w-(--available-width)` still clamps it on a small
              viewport.

              Three alternatives were rejected. A smaller per-depth `indent` is
              the tempting one, but `CascaderItem` only takes it from a row you
              render yourself, and `CascaderItems`' tree-mode render prop hands
              back a node and an index - no `expanded`, no `aria-setsize` or
              `aria-posinset`. `depth` still resolves itself off the tree
              index, `expanded` has no such fallback, so every branch would
              draw a collapsed chevron and the tree would lose its position
              metadata. That is accessibility traded for 8px a level. Trimming
              the row has nothing left to take: the flag IS the icon slot, the
              name IS the label, and the empty expander slot and the leading
              checkbox are per-list decisions that keep one label column per
              depth. And widening the CONTROL to `max-w-md` buys the same
              pixels by changing what the example is about - a normal-width
              field with a big tree behind it. */}
          <CascaderContent className="w-[26rem]">
            <CascaderPanel>
              <CascaderNav>
                {/* Tree mode never drills, so there is no level to go back to. */}
                <CascaderInput showBack={false} />
              </CascaderNav>
              <CascaderEmpty />
              <CascaderList>
                <CascaderItems />
              </CascaderList>
              <CascaderStatus />
            </CascaderPanel>
          </CascaderContent>
        </Cascader>
      </div>
    </div>
  )
}
"use client"

import { useCallback, useState } from "react"
import { Badge, type BadgeProps } from "@/components/reui/badge"
import {
  Cascader,
  CascaderContent,
  CascaderEmpty,
  CascaderList,
  CascaderPanel,
  CascaderStatus,
  CascaderTrigger,
} from "@/components/reui/cascader/cascader"
import { CascaderItems } from "@/components/reui/cascader/cascader-item"
import {
  CascaderBreadcrumb,
  CascaderInput,
  CascaderNav,
  CascaderValue,
} from "@/components/reui/cascader/cascader-nav"
import type {
  CascaderLoadContext,
  CascaderLoadResult,
  CascaderNode,
} from "@/components/reui/cascader/cascader-types"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { FileTextIcon, FolderIcon } from 'lucide-react'

const folderIcon = (
  <FolderIcon  className="size-4" />
)
const fileIcon = (
  <FileTextIcon  className="size-4" />
)

/* -------------------------------------------------------------------------- */
/*                              A pretend backend                             */
/* -------------------------------------------------------------------------- */

/**
 * The one fact a row's trailing badge carries.
 *
 * Every level has a different one - a repository's kind, a release's channel,
 * an asset's size - and none of them repeat the numeric child count the row
 * already prints, so the badge always adds something the label cannot.
 */
interface RowMeta {
  badge: string
  /**
   * How loudly that fact should read. Set where the row is built, because only
   * the level that knows what the string MEANS can say whether it is the
   * default pick, a supported alternative, or a download worth a second look.
   */
  tone: Tone
}

type Tone = "primary" | "success" | "info" | "warning"

/**
 * Tone to chip.
 *
 * All four are SEMANTIC outline variants. Plain `outline` is `bg-transparent`
 * in light mode, so a highlighted row washed straight through the chip; these
 * are `bg-background`, an actual fill, and they carry the meaning in their text
 * colour rather than in a second element.
 *
 * `pin` repeats each variant's own text colour with `!`. A highlighted row
 * repaints every descendant with `text-accent-foreground`, which would grey the
 * chip out under the pointer, the way the primitive pins its own accent count.
 */
const TONE_BADGE: Record<
  Tone,
  { variant: BadgeProps["variant"]; pin: string }
> = {
  primary: { variant: "primary-outline", pin: "text-primary!" },
  success: { variant: "success-outline", pin: "text-success-foreground!" },
  info: { variant: "info-outline", pin: "text-info-foreground!" },
  warning: { variant: "warning-outline", pin: "text-warning-foreground!" },
}

/** Past this, the size is worth reading twice before you pick the row. */
const LARGE_ASSET_MB = 5

const REPOSITORIES = [
  { name: "reui", kind: "components" },
  { name: "keenicons", kind: "icons" },
  { name: "metronic", kind: "templates" },
  { name: "reui-blocks", kind: "blocks" },
  { name: "reui-icons", kind: "icons" },
  { name: "reui-mcp", kind: "mcp" },
  { name: "reui-templates", kind: "templates" },
  { name: "reui-cli", kind: "cli" },
]
const RELEASES_PER_REPOSITORY = 4
const ASSETS_PER_RELEASE = 20
const PAGE_SIZE = 8

/**
 * Nothing is known up front, and `items` still has to be given something.
 * Hoisted so the array keeps one identity across renders, and so `T` is
 * inferred as `RowMeta` from it - which is what types `node.data` inside
 * `renderLabel`.
 */
const NO_ITEMS: CascaderNode<RowMeta>[] = []

/** Every asset lives under `repository / release / file`, generated on demand. */
function releasesFor(repository: string) {
  return Array.from({ length: RELEASES_PER_REPOSITORY }, (_, i) => {
    const major = RELEASES_PER_REPOSITORY - i
    return {
      value: `${repository}/v${major}`,
      label: `v${major}.0`,
      icon: folderIcon,
      // Without `hasChildren` an unfetched branch renders as a selectable leaf:
      // the cascader has no other way to know a level exists before it is loaded.
      hasChildren: true,
      count: ASSETS_PER_RELEASE,
      // The newest release is the one you usually want, so it takes the brand
      // accent; the rest are supported, not stale, so they read as `success`
      // rather than as a greyed-out alternative.
      data:
        i === 0
          ? { badge: "latest", tone: "primary" as const }
          : { badge: "stable", tone: "success" as const },
    }
  }) satisfies CascaderNode<RowMeta>[]
}

function filesFor(release: string, offset: number) {
  const length = Math.min(PAGE_SIZE, ASSETS_PER_RELEASE - offset)
  return Array.from({ length }, (_, i) => {
    const n = offset + i + 1
    // Derived from the index rather than randomised. A paged row that showed a
    // different size every time it was fetched would read as data moving under
    // the user, which is the opposite of what this example is about.
    const megabytes = 0.6 + n * 0.37
    return {
      value: `${release}/asset-${n}`,
      label: `asset-${String(n).padStart(2, "0")}.tar.gz`,
      icon: fileIcon,
      // A size only earns a colour once it changes your mind about the row, so
      // the amber starts at the point a download stops being free.
      data: {
        badge: `${megabytes.toFixed(1)} MB`,
        tone:
          megabytes >= LARGE_ASSET_MB
            ? ("warning" as const)
            : ("info" as const),
      },
    }
  }) satisfies CascaderNode<RowMeta>[]
}

/**
 * Deliberately slow.
 *
 * The behaviour this example exists to show is what happens BETWEEN the press
 * and the new level, so a 150ms fake latency would hide it: the spinner would
 * flash for two frames and the panel would look like it navigated instantly.
 * 700ms is long enough to read the row you pressed, watch its chevron become a
 * spinner, and see that the level under it did not move until the children
 * landed. Real registries are slower than this.
 */
const LATENCY_MS = 700

const wait = (ms: number, signal: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const timer = setTimeout(resolve, ms)
    signal.addEventListener("abort", () => {
      clearTimeout(timer)
      reject(new DOMException("Aborted", "AbortError"))
    })
  })

/**
 * Async drill-down: load BEFORE you move.
 *
 * Nothing is known up front. The root level, each release and each page of
 * assets is fetched as it comes into view, and `getChildren` is asked for one
 * level at a time.
 *
 * The part worth watching is what a press does while the fetch is in flight.
 * The panel does NOT navigate and then show a loading screen. It stays on the
 * level you are reading and turns THAT row's chevron into a spinner in the same
 * 16px box, so nothing reflows, the rows around it stay readable, and a failed
 * request leaves you where you were with a retry on the row you pressed rather
 * than on an error screen for a level you never saw. The sub-level renders only
 * once its children exist.
 *
 * `getChildren` hands back a cursor when there is more, which turns the last
 * row of a level into a "Load more" affordance - a real, keyboard reachable
 * option rather than an invisible scroll sentinel.
 *
 * Loaded pages are cached until `loadKey` changes, so walking back up the tree
 * and down again costs nothing.
 *
 * Every row is one line: icon, label, trailing badge. `renderLabel` replaces
 * only the label block, so the count, the chevron and the spinner that takes
 * its place all keep working while the badge rides along beside them. A second
 * line under the label would push the loading affordance further from the text
 * it belongs to and halve the number of rows a level can show.
 *
 * That badge is a semantic outline variant, picked per row from `TONE_BADGE`.
 * The row it sits on turns `bg-accent` under the pointer, so the chip needs a
 * fill of its own and a pinned text colour to survive the highlight.
 */
export function Pattern() {
  const [value, setValue] = useState("")
  const [failNext, setFailNext] = useState(false)

  const getChildren = useCallback(
    async (
      node: CascaderNode<RowMeta> | null,
      context: CascaderLoadContext
    ): Promise<CascaderLoadResult<RowMeta>> => {
      await wait(LATENCY_MS, context.signal)

      if (failNext) {
        setFailNext(false)
        throw new Error("The registry is unreachable.")
      }

      // The root level: the repositories themselves.
      if (node === null) {
        return {
          items: REPOSITORIES.map((repository) => ({
            value: repository.name,
            label: repository.name,
            icon: folderIcon,
            hasChildren: true,
            count: RELEASES_PER_REPOSITORY,
            // A repository's kind is a classification, not a verdict on it, so
            // every root row reads at the same weight.
            data: { badge: repository.kind, tone: "info" as const },
          })),
        }
      }

      // A repository: its releases. One response, no paging.
      if (REPOSITORIES.some((repository) => repository.name === node.value)) {
        return { items: releasesFor(node.value) }
      }

      // A release: assets, eight at a time.
      const offset = context.cursor ? Number(context.cursor) : 0
      const items = filesFor(node.value, offset)
      const next = offset + items.length
      const more = next < ASSETS_PER_RELEASE
      return {
        items,
        nextCursor: more ? String(next) : undefined,
        hasMore: more,
      }
    },
    [failNext]
  )

  return (
    <div className="flex w-full flex-col items-center gap-3 p-4">
      <Cascader
        items={NO_ITEMS}
        getChildren={getChildren}
        value={value}
        onValueChange={setValue}
        // One line per row: the icon and the trailing affordances stay where
        // the default put them, and the badge sits between the label and them.
        // The paging row never reaches this - it renders its own body - so the
        // absent `data` there needs no special case.
        renderLabel={(node) => (
          <span className="flex w-full min-w-0 items-center gap-2">
            <span className="min-w-0 flex-1 truncate text-start">
              {node.label}
            </span>
            {node.data ? (
              <Badge
                variant={TONE_BADGE[node.data.tone].variant}
                className={cn("shrink-0", TONE_BADGE[node.data.tone].pin)}
              >
                {node.data.badge}
              </Badge>
            ) : null}
          </span>
        )}
      >
        <CascaderTrigger
          aria-label="Release asset"
          render={
            <Button
              variant="outline"
              className="w-80 justify-between gap-2 font-normal"
            />
          }
        >
          <CascaderValue placeholder="Select a release asset" />
        </CascaderTrigger>

        {/* The branch spinner is drawn by the primitive at `size-4`, the size
            of the chevron it replaces. Scoped down to 3.5 here, which is what
            the paging row's own spinner already uses, so the two loading
            states in one panel are the same weight. The 16px affordance box is
            untouched, so nothing reflows when the glyph swaps. */}
        <CascaderContent className="w-80 **:data-[slot=spinner]:size-3.5">
          <CascaderPanel>
            <CascaderNav>
              <CascaderInput />
            </CascaderNav>
            <CascaderBreadcrumb />
            {/* One element that swaps its children between loading, error and
                empty, so an async level never announces "No results found."
                on its way to being loaded. */}
            <CascaderEmpty />
            <CascaderList>
              <CascaderItems />
            </CascaderList>
            <CascaderStatus />
          </CascaderPanel>
        </CascaderContent>
      </Cascader>

      <p className="text-muted-foreground max-w-80 text-center text-xs text-balance">
        Each level is fetched on press, {LATENCY_MS}ms per request.
      </p>

      <Button
        variant="outline"
        size="sm"
        disabled={failNext}
        onClick={() => setFailNext(true)}
      >
        {failNext ? "Next request will fail" : "Fail the next request"}
      </Button>
    </div>
  )
}
"use client"

import { useState } from "react"
import {
  Cascader,
  CascaderContent,
  CascaderEmpty,
  CascaderList,
  CascaderPanel,
  CascaderStatus,
  CascaderTrigger,
} from "@/components/reui/cascader/cascader"
import {
  CascaderAction,
  CascaderFooter,
  CascaderSubmenu,
  CascaderSubmenuContent,
  CascaderSubmenuTrigger,
  useCascaderSubmenu,
} from "@/components/reui/cascader/cascader-footer"
import {
  CascaderGroup,
  CascaderItems,
  CascaderLabel,
  CascaderSeparator,
} from "@/components/reui/cascader/cascader-item"
import {
  CascaderBreadcrumb,
  CascaderInput,
  CascaderNav,
  CascaderValue,
} from "@/components/reui/cascader/cascader-nav"
import type { CascaderNode } from "@/components/reui/cascader/cascader-types"

import { Dropbox } from "@/components/ui/svgs/dropbox"
import { GithubDark } from "@/components/ui/svgs/githubDark"
import { GithubLight } from "@/components/ui/svgs/githubLight"
import { GoogleDrive } from "@/components/ui/svgs/googleDrive"
import { Redis } from "@/components/ui/svgs/redis"
import { Slack } from "@/components/ui/svgs/slack"
import { Stripe } from "@/components/ui/svgs/stripe"
import { Supabase } from "@/components/ui/svgs/supabase"
import { Button } from "@/components/ui/button"
import { AlignLeftIcon, BarChart3Icon, CalendarCheckIcon, CalendarDaysIcon, ClockIcon, CloudDownloadIcon, Columns3Icon, CreditCardIcon, EyeIcon, FileTextIcon, FlagIcon, FlameIcon, GitBranchIcon, HashIcon, LinkIcon, PackageIcon, PaletteIcon, RouteIcon, SlidersHorizontalIcon, SquarePenIcon, TrendingUpIcon, TruckIcon, TypeIcon, UserCheckIcon, UserIcon, UsersIcon, UsersRoundIcon } from 'lucide-react'

/* -------------------------------------------------------------------------- */
/*                                Group icons                                 */
/* -------------------------------------------------------------------------- */

/**
 * A property picker is read by TYPE before it is read by name, so the icon
 * column is where the type belongs.
 *
 * Each group carries the subject it collects and each leaf carries the shape of
 * the value behind it: text for a title, a person for an owner, a calendar for
 * a due date, a flag for a status, a card for a plan. Every icon is declared
 * once here and referenced below, so the data stays a list of properties rather
 * than a wall of JSX.
 */
const basicsIcon = (
  <FileTextIcon  className="size-4" />
)
const peopleIcon = (
  <UsersIcon  className="size-4" />
)
const datesIcon = (
  <CalendarDaysIcon  className="size-4" />
)
const workflowIcon = (
  <RouteIcon  className="size-4" />
)
const metricsIcon = (
  <BarChart3Icon  className="size-4" />
)
const linksIcon = (
  <LinkIcon  className="size-4" />
)
const billingIcon = (
  <CreditCardIcon  className="size-4" />
)

/* -------------------------------------------------------------------------- */
/*                              Property icons                                */
/* -------------------------------------------------------------------------- */

const textIcon = (
  <TypeIcon  className="size-4" />
)
const longTextIcon = (
  <AlignLeftIcon  className="size-4" />
)
const slugIcon = (
  <HashIcon  className="size-4" />
)
const personIcon = (
  <UserIcon  className="size-4" />
)
const approverIcon = (
  <UserCheckIcon  className="size-4" />
)
const watcherIcon = (
  <EyeIcon  className="size-4" />
)
const timestampIcon = (
  <ClockIcon  className="size-4" />
)
const deadlineIcon = (
  <CalendarCheckIcon  className="size-4" />
)
const shippedIcon = (
  <TruckIcon  className="size-4" />
)
const statusIcon = (
  <FlagIcon  className="size-4" />
)
const priorityIcon = (
  <FlameIcon  className="size-4" />
)
const stageIcon = (
  <Columns3Icon  className="size-4" />
)
const effortIcon = (
  <SlidersHorizontalIcon  className="size-4" />
)
const impactIcon = (
  <TrendingUpIcon  className="size-4" />
)
const repositoryIcon = (
  <GitBranchIcon  className="size-4" />
)
const designIcon = (
  <PaletteIcon  className="size-4" />
)
const planIcon = (
  <PackageIcon  className="size-4" />
)
const seatsIcon = (
  <UsersRoundIcon  className="size-4" />
)
const noteIcon = (
  <SquarePenIcon  className="size-4" />
)
const importIcon = (
  <CloudDownloadIcon  className="size-4" />
)

/**
 * Product marks, not icons.
 *
 * `CascaderAction` takes any ReactNode for `icon`, so each entry can carry the
 * logo of the product it names. That is the whole reason the flyout is a list
 * of SOURCES rather than a list of field types: seven marks are told apart at a
 * glance, where seven variations on a generic glyph are not. The full-colour
 * marks need no theme handling; GitHub's is one flat shape, so both files ship
 * and `dark:` picks between them at paint time.
 */
const slackLogo = <Slack className="size-4" aria-hidden="true" />
const githubLogo = (
  <>
    <GithubLight className="size-4 dark:hidden" aria-hidden="true" />
    <GithubDark className="hidden size-4 dark:block" aria-hidden="true" />
  </>
)
const driveLogo = <GoogleDrive className="size-4" aria-hidden="true" />
const dropboxLogo = <Dropbox className="size-4" aria-hidden="true" />
const supabaseLogo = <Supabase className="size-4" aria-hidden="true" />
const stripeLogo = <Stripe className="size-4" aria-hidden="true" />
const redisLogo = <Redis className="size-4" aria-hidden="true" />

const properties: CascaderNode[] = [
  {
    value: "basics",
    label: "Basics",
    icon: basicsIcon,
    children: [
      { value: "basics.title", label: "Title", icon: textIcon },
      { value: "basics.summary", label: "Summary", icon: longTextIcon },
      { value: "basics.slug", label: "Slug", icon: slugIcon },
    ],
  },
  {
    value: "people",
    label: "People",
    icon: peopleIcon,
    children: [
      { value: "people.owner", label: "Owner", icon: personIcon },
      { value: "people.reviewers", label: "Reviewers", icon: approverIcon },
      { value: "people.watchers", label: "Watchers", icon: watcherIcon },
    ],
  },
  {
    value: "dates",
    label: "Dates",
    icon: datesIcon,
    children: [
      { value: "dates.created", label: "Created at", icon: timestampIcon },
      { value: "dates.due", label: "Due date", icon: deadlineIcon },
      { value: "dates.shipped", label: "Shipped at", icon: shippedIcon },
    ],
  },
  {
    value: "workflow",
    label: "Workflow",
    icon: workflowIcon,
    children: [
      { value: "workflow.status", label: "Status", icon: statusIcon },
      { value: "workflow.priority", label: "Priority", icon: priorityIcon },
      { value: "workflow.stage", label: "Stage", icon: stageIcon },
    ],
  },
  {
    value: "metrics",
    label: "Metrics",
    icon: metricsIcon,
    children: [
      { value: "metrics.effort", label: "Effort", icon: effortIcon },
      { value: "metrics.impact", label: "Impact", icon: impactIcon },
    ],
  },
  {
    value: "links",
    label: "Links",
    icon: linksIcon,
    children: [
      { value: "links.repo", label: "Repository", icon: repositoryIcon },
      { value: "links.design", label: "Design file", icon: designIcon },
    ],
  },
  {
    value: "billing",
    label: "Billing",
    icon: billingIcon,
    children: [
      { value: "billing.plan", label: "Plan", icon: planIcon },
      { value: "billing.seats", label: "Seats", icon: seatsIcon },
    ],
  },
  { value: "notes", label: "Notes", icon: noteIcon },
]

/**
 * The flyout body.
 *
 * Its own component because `useCascaderSubmenu` has to be called from INSIDE
 * the submenu, and `close()` is what makes a command list behave like one: a
 * command runs and the list goes away, rather than sitting open as if the
 * entries were toggles.
 *
 * Seven entries is enough that a flat list stops reading as a list and starts
 * reading as a wall, so they are split into two named runs. `CascaderGroup`
 * rather than a `<div>` with a heading in it: the group is what carries the
 * name, so a screen reader says "Apps, group" before the four entries instead
 * of reading out a line of text that belongs to nothing. `CascaderSeparator`
 * draws the break for everyone else.
 */
function ImportSourceMenu({
  onImport,
}: {
  onImport: (source: string) => void
}) {
  const { close } = useCascaderSubmenu()

  const run = (source: string) => () => {
    onImport(source)
    close()
  }

  return (
    <>
      <CascaderGroup className="gap-0.5">
        <CascaderLabel>Apps</CascaderLabel>
        <CascaderAction icon={slackLogo} onSelect={run("Slack")}>
          Slack
        </CascaderAction>
        <CascaderAction icon={githubLogo} onSelect={run("GitHub")}>
          GitHub
        </CascaderAction>
        <CascaderAction icon={driveLogo} onSelect={run("Google Drive")}>
          Google Drive
        </CascaderAction>
        <CascaderAction icon={dropboxLogo} onSelect={run("Dropbox")}>
          Dropbox
        </CascaderAction>
      </CascaderGroup>

      <CascaderSeparator />

      <CascaderGroup className="gap-0.5">
        <CascaderLabel>Data sources</CascaderLabel>
        <CascaderAction icon={supabaseLogo} onSelect={run("Supabase")}>
          Supabase
        </CascaderAction>
        <CascaderAction icon={stripeLogo} onSelect={run("Stripe")}>
          Stripe
        </CascaderAction>
        <CascaderAction icon={redisLogo} onSelect={run("Redis")}>
          Redis
        </CascaderAction>
      </CascaderGroup>
    </>
  )
}

/**
 * A pinned footer, and a flyout that opens to the side.
 *
 * "The property I want is not in this list, it lives in another product" is a
 * normal outcome of a property picker, and the answer to it is a COMMAND rather
 * than another row. `CascaderFooter` pins its actions below the list, where
 * they stay put while the list scrolls, changes level, or filters down to
 * nothing - which is exactly the moment "Import properties from" is most
 * useful, and exactly the moment a row inside the list would have disappeared.
 *
 * `CascaderSubmenu` turns that row into a side-anchored flyout, so the seven
 * connected sources do not have to be seven footer rows. It is one level deep
 * on purpose: a footer is for commands, and a command list that nests is a menu
 * bar in disguise. One command is all this picker needs, and a footer holding a
 * single row is still worth having - it is the border and the pinning that make
 * the row read as an action rather than an eighth option.
 *
 * Nothing in the footer joins the option list. It is not in the arrow-key ring,
 * it is not filtered by the query, and it never becomes a selection - press
 * Escape once to close the flyout and again to close the cascader.
 *
 * The wrapper centres the demo horizontally (`w-full` + `items-center`, so the
 * trigger sits on the middle of the frame whichever surface renders it) and
 * pins it to the TOP (`self-start`). Both the docs frame and the catalog card
 * centre their child vertically, and this example grows a second line the
 * moment a source is imported, so a vertically centred demo would slide up
 * under the reader's cursor as it reports back. `pt-6` then keeps that top edge
 * off the frame.
 */
export function Pattern() {
  const [value, setValue] = useState("")
  const [log, setLog] = useState("")

  return (
    <div className="flex w-full flex-col items-center gap-3 self-start px-4 pt-6 pb-4">
      <Cascader items={properties} value={value} onValueChange={setValue}>
        <CascaderTrigger
          aria-label="Property"
          render={
            <Button
              variant="outline"
              className="w-72 justify-between gap-2 font-normal"
            />
          }
        >
          <CascaderValue placeholder="Select a property" />
        </CascaderTrigger>

        <CascaderContent className="w-72">
          <CascaderPanel>
            <CascaderNav>
              <CascaderInput />
            </CascaderNav>
            <CascaderBreadcrumb />
            <CascaderEmpty />
            <CascaderList>
              <CascaderItems />
            </CascaderList>

            {/* A SIBLING of the list, never a child of it: anything rendered
                inside the list would be clicked by the list's own Enter
                handler. */}
            <CascaderFooter>
              <CascaderSubmenu>
                <CascaderSubmenuTrigger icon={importIcon}>
                  Import properties from
                </CascaderSubmenuTrigger>
                <CascaderSubmenuContent>
                  <ImportSourceMenu
                    onImport={(source) => setLog(`Importing from ${source}`)}
                  />
                </CascaderSubmenuContent>
              </CascaderSubmenu>
            </CascaderFooter>

            <CascaderStatus />
          </CascaderPanel>
        </CascaderContent>
      </Cascader>

      <p className="text-muted-foreground min-h-4 text-xs" role="status">
        {log}
      </p>
    </div>
  )
}
CascaderGroup
CascaderLoadResult
useCascaderActions()
CascaderLoadContext
columns mode
tree mode
CascaderFooter
getCascaderCheckedValues
CascaderVirtualItems
CascaderVirtualItems
labels
forms
CascaderPanel
details
details
CascaderChips
getCascaderMoreProps
getCascaderMoreProps
CascaderChip
useCascaderAnchor()
CascaderColumnPanel
CascaderVirtualColumn
CascaderAction
development warnings
keyboard
right to left
labels