"use client" import * as React from "react" import { useCascaderActions, useCascaderState, } from "@/components/reui/cascader/cascader-context" import type { CascaderColumn } from "@/components/reui/cascader/cascader-context" import { CascaderItem, getCascaderMoreProps, } from "@/components/reui/cascader/cascader-item" import { CASCADER_LIST_HEIGHT_CLASS, CASCADER_LIST_PAD_CLASS, CASCADER_ROOT_KEY, CASCADER_ROWS_CLASS, CASCADER_SCROLL_CLASS, warnCascaderOnce, } from "@/components/reui/cascader/cascader-lib" import { Combobox as ComboboxPrimitive } from "@base-ui/react" import { cn } from "@/lib/utils" import { ScrollArea } from "@/components/ui/scroll-area" import { LoaderCircleIcon } from "lucide-react" export interface CascaderColumnsProps extends Omit< React.ComponentProps<"div">, "children" > { /** Width of each column. */ columnWidth?: number | string /** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */ maxHeight?: number | string /** Replaces the default panel; the seam a windowed column plugs into. */ children?: (column: CascaderColumn) => React.ReactNode } /** * Miller columns: the open trail side by side, one panel per level. Only the * DEEPEST column is a real listbox (Base UI owns exactly one list); the trail * behind is plain buttons, which keeps one state machine instead of a second, * 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight. */ function CascaderColumns({ className, columnWidth = 220, maxHeight: maxHeightProp, children, ...props }: CascaderColumnsProps) { const { maxHeight, mode, labels } = useCascaderActions() const { columns } = useCascaderState() // Before the early return, so the hook count is the same in both modes. React.useEffect(() => { if (process.env.NODE_ENV === "production") return if (mode === "columns") return warnCascaderOnce( `columns-outside-columns-mode:${mode}`, `\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.` ) }, [mode]) if (mode !== "columns") return null const height = maxHeightProp ?? maxHeight const toCss = (value: number | string) => typeof value === "number" ? `${value}px` : value return (
{columns.map((column) => children ? ( {children(column)} ) : ( ) )}
) } /** * One column's box. `columnWidth` is the width of the LIST, not the box: under * `border-box` the 1px inline-start divider on every column but the first would * come out of the rows, so bordered columns are widened by that pixel. The box * is also the BOUND, the same `min(--available-height, cap)` the single list * uses, and the `ScrollArea` inside scrolls, so every column shows a thumb. */ const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}` export interface CascaderColumnPanelProps { column: CascaderColumn /** Replaces the panel's rows; the empty state still wins on an empty column. */ children?: React.ReactNode /** Containing block for the windowed column's absolutely positioned rows. */ virtualized?: boolean } function CascaderColumnPanel({ column, children, virtualized, }: CascaderColumnPanelProps) { const { labels, baseId, isBranch, isSelectable, isSelected, isIndeterminate, retryLevel, } = useCascaderActions() const { loadStates } = useCascaderState() // Keyed per level, not one global flag: columns load and land independently. const columnKey = column.parent?.value ?? CASCADER_ROOT_KEY const loadState = loadStates.get(columnKey) let emptyBody: React.ReactNode = labels.empty if (loadState?.error) { emptyBody = ( <> {labels.error}{" "} ) } else if (loadState?.loading) { emptyBody = ( {labels.loading} ) } const rows = column.items.length === 0 ? (

{emptyBody}

) : ( (children ?? column.items.map((node, i) => { const open = node.value === column.activeValue return ( ) })) ) const shared = { "data-slot": "cascader-column", "data-active": column.active || undefined, "data-depth": column.depth, // Addressable so the opening trail row can point `aria-controls` here, and // named even at the root, which has no parent label to borrow. id: `${baseId}-column-${column.depth}`, "aria-label": column.parent?.label ?? labels.rootLevel, // Conditional spread, never an explicit `undefined`: the active column is a // Base UI element, and its `mergeProps` iterates own keys. ...(virtualized ? { "data-virtualized": true } : null), } // A windowed row is absolutely positioned, so the ROWS' box is the containing // block, not the scrollport: it carries the padding the geometry is measured // against. const rowsClass = cn(CASCADER_ROWS_CLASS, virtualized && "relative") // The active column IS the Combobox list: only rows inside `Combobox.List` // reach the CompositeList, arrow-key navigation and `aria-activedescendant`. const body = column.active ? ( {rows} ) : ( // A named `group`, not a second listbox competing with the active column.
{rows}
) return (
{body}
) } export { CascaderColumnPanel, CascaderColumns }