"use client" import * as React from "react" import { useCascaderActions, useCascaderRender, useCascaderState, } from "@/components/reui/cascader/cascader-context" import { getCascaderCount, getCascaderMoreParent, getCascaderPath, isCascaderMoreNode, } from "@/components/reui/cascader/cascader-lib" import type { CascaderLoadState, CascaderNode, } from "@/components/reui/cascader/cascader-types" import { Combobox as ComboboxPrimitive } from "@base-ui/react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { cn } from "@/lib/utils" import { Spinner } from "@/components/ui/spinner" import { LoaderCircleIcon, MinusIcon, CheckIcon, RotateCwIcon, ChevronRightIcon } from "lucide-react" // Base UI extends its synthetic events with a handler-veto escape hatch. type VetoableEvent = { preventBaseUIHandler?: () => void } /** A row press. The veto hook is optional: an `as="button"` row is outside Base * UI's listbox and gets a plain React event. */ export type CascaderRowEvent = React.MouseEvent & VetoableEvent // Each style's inline inset, as a variable: a row and its absolutely positioned // check have to land on the same number, in LOGICAL properties. These eight // numbers MIRROR the shared style sheets; keep them in step with // `registry/styles/style-*.css`. const ROW_INSET_CLASS = "[--cascader-row-inset:6px]" // Both insets equal, for a row with no check. `!` because a consumer may not // import the sheets into `layer(base)`, where the class would win on order. const ROW_FLUSH_CLASS = "ps-[var(--cascader-row-inset,8px)]! pe-[var(--cascader-row-inset,8px)]!" // The same start inset plus room for the check. A flat `pr-8` is wrong in five // of the eight styles: 4px of slack in nova, 8px short in maia, luma and sera, // and no gutter at all in mira, whose check then overlaps its own label. const ROW_GUTTER_CLASS = "ps-[var(--cascader-row-inset,8px)]! pe-[calc(var(--cascader-row-inset,8px)*2_+_16px)]!" // The check's box, inset to match the row's. A physical `right-2` neither // mirrors under RTL nor tracks the style's inset; `right-auto` comes with the // logical `end-*`, because width + `left` + `right` is over-constrained. const INDICATOR_CLASS = "pointer-events-none absolute flex items-center justify-center size-4 end-[var(--cascader-row-inset,8px)]! rtl:right-auto!" // The row's type, gap, radius and icon size per style. No horizontal padding: // every row pairs this with `ROW_FLUSH_CLASS` or `ROW_GUTTER_CLASS`. const ROW_THEME_CLASS = "data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 rounded-md py-1" /** * The row's gap and type, per style, held apart from the rest of the theme. * * A row that pins its own gap or text size takes `ROW_SHELL_CLASS` and leaves * these out, because a `style-:` utility is emitted AFTER the plain one * it would have to lose to. Not receiving the token beats overriding it with * `!`, which a consumer then has to fight in turn. */ const ROW_GAP_CLASS = "gap-2" const ROW_TEXT_CLASS = "text-sm" const ROW_SHELL_CLASS = `${ROW_THEME_CLASS} relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 ${ROW_INSET_CLASS}` const ROW_CLASS = `${ROW_SHELL_CLASS} ${ROW_GAP_CLASS} ${ROW_TEXT_CLASS}` const CASCADER_ACTION_INSET_CLASS = `${ROW_INSET_CLASS} ${ROW_FLUSH_CLASS}` /** A footer COMMAND: the row theme, so it cannot drift from the list, plus the * hover and `focus-visible` fills a plain button never gets. Fills, never a * ring: a ring under rows that all paint with a fill read as a different * widget bolted on. `focus-visible`, not `focus`, so a pointer press does not * leave a command painted as if it were still hovered. Disabled keys off * `aria-disabled`, not `:disabled`, to keep it FOCUSABLE: a footer whose only * row is disabled has no tab stop. */ export const CASCADER_ACTION_CLASS = `${ROW_SHELL_CLASS} ${ROW_TEXT_CLASS} ${ROW_FLUSH_CLASS} hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground cursor-pointer gap-2 transition-colors aria-disabled:pointer-events-none aria-disabled:opacity-50` // The box around the affordances that are not the row. 20px around a 16px icon, // the outer 2px cancelled at the call site; 24px would set the row height. const AFFORDANCE_BOX_CLASS = "flex size-5 shrink-0 items-center justify-center rounded-sm" // What makes an affordance read as pressable on an already highlighted row. Not // `hover:bg-accent`: the row is `bg-accent` under the pointer, so the chip would // be invisible. `!` and `**:` for the `CHECKBOX_MARK_CLASS` reason. const AFFORDANCE_HOVER_CLASS = "transition-[background-color,box-shadow,color] hover:bg-background hover:text-foreground! hover:**:text-foreground! hover:shadow-xs" // The selection MARK's colour, pinned so nothing can repaint it: every row // carries a DESCENDANT highlight rule, and a mark is STATE. Pinned TWICE // because icon sets draw with `fill`/`stroke="currentColor"`, which the // `` inherits and resolves against the PATH's own `color`. Both halves // are asserted in `cascader-behavior.test.tsx`. const CHECKBOX_MARK_CLASS = "text-primary-foreground! **:text-primary-foreground!" const INDICATOR_MARK_CLASS = "text-foreground! **:text-foreground!" // Milliseconds the pointer must rest on a branch row before // `expandTrigger="hover"` drills in: long enough that a pointer crossing the // column on its way elsewhere opens nothing, short enough to read as immediate. const CASCADER_HOVER_EXPAND_DELAY = 150 export interface CascaderItemProps extends Omit< ComboboxPrimitive.Item.Props, "value" | "children" | "className" | "style" | "onClick" | "onMouseUp" > { /** No state callback: the row merges `className` and `style` itself. */ className?: string style?: React.CSSProperties onClick?: (event: CascaderRowEvent) => void onMouseUp?: (event: CascaderRowEvent) => void node: CascaderNode /** Explicit render index. Required while virtualized and DELIBERATELY IGNORED * otherwise: it self-registers the row and breaks `aria-activedescendant`. */ index?: number /** Indentation depth. Drives `--cascader-indent` in tree mode. */ depth?: number /** Renders the ancestor chain under the label, for deep-search results. */ showPath?: boolean /** `option` renders a real `Combobox.Item`; `button` renders identical markup * outside the listbox, for the ancestor columns in columns mode. */ as?: "option" | "button" /** Tree mode: this row is an expanded branch. */ expanded?: boolean /** Indent each depth by this many pixels. Tree mode only. */ indent?: number /** Whether the node has children. Defaults to the cascader's own answer, as do * the three below: callers pass them because they already hold the level, * which keeps a memoised row off the volatile context. */ branch?: boolean /** Whether the node may be committed. Defaults to the cascader's own answer. */ selectable?: boolean /** Whether the node is currently selected. Defaults to the cascader's answer. */ selected?: boolean /** Some but not all of the loaded subtree selected. `cascade` only. */ indeterminate?: boolean /** Selected nodes below this one, at any depth. Drives the trailing count. */ selectedCount?: number /** Paging row only: a request for this level is in flight. */ loading?: boolean /** Paging row only: this level's last request failed. */ error?: boolean /** Branch row only: this node's OWN children are being fetched. The chevron * (or, in tree mode, the expander) becomes a spinner IN PLACE, in the same * 16px box: no skeleton row appears below, nothing reflows. */ childrenLoading?: boolean /** Branch row only: the last fetch of this node's children failed. The same * box becomes the retry. The press path is unchanged: it goes through * `navigate`, which refires the failed level rather than moving. */ childrenError?: boolean children?: React.ReactNode } /** One row. Branch presses navigate instead of selecting, vetoed here with * `preventBaseUIHandler()` on `onClick` (Enter too) and on `onMouseUp`. */ const CascaderItem = React.memo(function CascaderItem({ node, index, depth, showPath = false, as = "option", expanded, indent = 16, branch: branchProp, selectable: selectableProp, selected: selectedProp, indeterminate: indeterminateProp, selectedCount: selectedCountProp, loading, error, childrenLoading = false, childrenError = false, className, children, onClick, onMouseUp, style, ...props }: CascaderItemProps) { // The ACTIONS context only: the state context republishes on every keystroke. const { index: treeIndex, mode, labels, isBranch, isSelectable, isSelected, isIndeterminate, selectedDescendantCount, multiple, branchesSelectable, indicator: indicatorEnabled, expandTrigger = "click", navigate, navigateAt, toggleExpanded, commit, virtualized, loadMore, retryLevel, } = useCascaderActions() // Render props come from an always-current context, or a closure goes stale. const { renderItem, renderLabel } = useCascaderRender() const branch = branchProp ?? isBranch(node) const selectable = selectableProp ?? isSelectable(node) const selected = selectedProp ?? isSelected(node) // A selected node is never also partially selected (a dash in a filled box). const indeterminate = !selected && (indeterminateProp ?? isIndeterminate(node)) const count = branch ? getCascaderCount(treeIndex, node) : 0 const nodeDepth = depth ?? treeIndex.depthOf.get(node.value) ?? 0 // O(1): the cascader publishes a map of selected-descendant counts, because a // row walking its own subtree is `rows x descendants`, paid every keystroke. const selectedDescendants = branch ? (selectedCountProp ?? selectedDescendantCount(node)) : 0 // `multiple` only: with a single selection the trigger, the breadcrumb and // the leaf's own check already say where it is, so swapping a branch's real // child total ("24") for a permanent "1" would trade a useful number away. const showsSelectedCount = multiple && selectedDescendants > 0 const trailingCount = showsSelectedCount ? selectedDescendants : count // Shown selected or not: suppressing it under a checked branch read as data // loss, and the row's TWO trailing columns mean the count never has to share // a slot with the check. const showCount = trailingCount > 0 // Where a multi-select tree puts its checkbox: at the HEAD of the row, since a // trailing box does not follow the label stagger. Per-LIST, never per-row, or // the labels below an unselectable row step back out. const leadingCheckbox = mode === "tree" && multiple // Whether this row draws a MARK, and so whether it reserves the gutter one // needs. `|| multiple` is the scope of the opt-out: a checkbox is the // selection CONTROL, so `indicator={false}` is a no-op in multi-select. const showsIndicator = indicatorEnabled || multiple const veto = React.useCallback((event: VetoableEvent) => { event.preventBaseUIHandler?.() }, []) const handleClick = React.useCallback( (event: CascaderRowEvent) => { onClick?.(event) if (node.disabled) return if (branch && !selectable) { veto(event) navigate(node) } }, [onClick, node, branch, selectable, veto, navigate] ) const handleMouseUp = React.useCallback( (event: CascaderRowEvent) => { onMouseUp?.(event) if (node.disabled) return if (branch && !selectable) veto(event) }, [onMouseUp, node, branch, selectable, veto] ) // The tree expander's own press. With `selectable="any"` the row press // commits, and under `cascade` that selected a whole subtree while the row // stayed collapsed, so expanding needs its own target. `stopPropagation` // before the veto, so Base UI's handler never runs, and no role: nesting // one in `role="option"` fails axe. `navigate` fetches BEFORE it opens. const handleExpanderClick = React.useCallback( (event: CascaderRowEvent) => { event.stopPropagation() veto(event) if (node.disabled) return if (expanded) toggleExpanded(node.value) else navigate(node) }, [veto, node, expanded, toggleExpanded, navigate] ) const handleExpanderMouseUp = React.useCallback( (event: CascaderRowEvent) => { // Base UI treats a drag-select mouseup as a commit; this is not one. event.stopPropagation() veto(event) }, [veto] ) const handleChevronClick = React.useCallback( (event: CascaderRowEvent) => { event.stopPropagation() veto(event) if (node.disabled) return // `navigate` falls through to `pushLevel`, which APPENDS: right in the // deepest column, but from an ancestor column it duplicated the level and // corrupted the path. `navigateAt` rebuilds the trail from this depth. if (as === "button") navigateAt(node, depth ?? 0) else navigate(node) }, [veto, node, as, navigateAt, depth, navigate] ) const handleChevronMouseUp = React.useCallback( (event: CascaderRowEvent) => veto(event), [veto] ) // Keeps focus in the search field: Base UI prevents the mousedown default for // option rows, and focusing a `tabindex="-1"` trail row kills every key. const handleButtonMouseDown = React.useCallback( (event: React.MouseEvent) => { event.preventDefault() }, [] ) const handleButtonClick = React.useCallback( (event: CascaderRowEvent) => { onClick?.(event) if (node.disabled) return if (branch && !selectable) { // Button rows only exist in the columns trail, where `depth` is the // column's own depth, so navigating replaces the trail from here. navigateAt(node, depth ?? 0) return } if (selectable) commit(node) }, [onClick, node, branch, selectable, navigateAt, depth, commit] ) // The paging row's press, never a selection, whatever `selectable` says. const handleMoreClick = React.useCallback( (event: CascaderRowEvent) => { onClick?.(event) veto(event) const parent = getCascaderMoreParent(node) if (parent == null) return if (error) retryLevel(parent) else if (!loading) loadMore(parent) }, [onClick, veto, node, error, loading, retryLevel, loadMore] ) // `expandTrigger="hover"`: resting on a BRANCH row in the ACTIVE column drills // in after a delay. The deepest column of columns mode only, and it never // commits, so a pointer crossing the panel cannot change the value. const hoverNavigates = expandTrigger === "hover" && mode === "columns" && as === "option" && branch && !node.disabled const hoverTimerRef = React.useRef(null) const cancelHoverNavigate = React.useCallback(() => { if (hoverTimerRef.current == null) return window.clearTimeout(hoverTimerRef.current) hoverTimerRef.current = null }, []) const handleHoverPointerEnter = React.useCallback(() => { cancelHoverNavigate() hoverTimerRef.current = window.setTimeout(() => { hoverTimerRef.current = null navigate(node) }, CASCADER_HOVER_EXPAND_DELAY) }, [cancelHoverNavigate, navigate, node]) // Drilling in demotes this column to the trail WITHOUT unmounting the row, so // a pending timer has to die or it fires `navigate` from an inactive column. React.useEffect(() => { if (!hoverNavigates) cancelHoverNavigate() }, [hoverNavigates, cancelHoverNavigate]) React.useEffect(() => cancelHoverNavigate, [cancelHoverNavigate]) /* ------------------------------- paging row ------------------------------ */ if (isCascaderMoreNode(node)) { const text = error ? labels.error : loading ? node.count ? labels.loadingMore : labels.loading : labels.loadMore const moreStyle = mode === "tree" && nodeDepth > 0 ? ({ ...style, "--cascader-indent": `${nodeDepth * indent}px`, } as React.CSSProperties) : style const moreShared = { "data-slot": "cascader-item", "data-more": "", "data-state": error ? "error" : loading ? "loading" : "idle", "data-depth": nodeDepth, style: moreStyle, className: cn( ROW_SHELL_CLASS, ROW_FLUSH_CLASS, "text-muted-foreground justify-center gap-1.5 text-xs", !error && !loading && "hover:text-foreground", mode === "tree" && "ps-[calc(var(--cascader-row-inset,8px)_+_var(--cascader-indent,0px))]!", className ), } const moreBody = children ?? ( <> {loading ? ( ) : null} {text} {/* TEXT, not a button: a focusable element inside a `role="option"` row is a `nested-interactive` violation. */} {error ? ( {labels.retry} ) : null} ) if (as === "button") { const buttonProps = { ...(props as React.ComponentProps<"button">) } delete buttonProps["aria-setsize"] delete buttonProps["aria-posinset"] delete buttonProps["aria-level"] return ( ) } return ( {moreBody} ) } const itemState = { branch, selected, disabled: !!node.disabled, depth: nodeDepth, count, path: showPath ? getCascaderPath(treeIndex, node.value).slice(0, -1) : [], } const custom = renderItem?.(node, itemState) const customLabel = renderLabel?.(node, itemState) // What the markup cannot say: a branch would announce "Person 24", a naked // number. Trail rows are outside the listbox, so they need words for state. const srDetails = [ branch ? labels.itemCount(count) : null, showsSelectedCount ? labels.selectedCount(selectedDescendants) : null, branch && mode !== "tree" ? labels.branchAffordance : null, as === "button" && selected ? labels.selectedState : null, // Option rows say this with `aria-checked="mixed"`; `role="button"` allows // neither that nor `aria-selected`, so a trail row says it in words. as === "button" && indeterminate ? labels.partiallySelectedState : null, ] .filter(Boolean) .map((detail) => `, ${detail}`) .join("") // Extracted because tree mode renders it INLINE at the head of the row. It // reads `data-selected` off the ROW, so it also works on the trail rows. const checkbox = ( {/* Colour declared ON THE ICON with `!`; see `CHECKBOX_MARK_CLASS`. */} {indeterminate ? ( ) : ( )} ) const indicator = ( <> {multiple ? ( checkbox ) : ( )} ) const body = custom ?? children ?? ( <> {/* Tree mode expands in place, so the branch marker leads the row. */} {mode === "tree" && branch ? ( ) : mode === "tree" ? ( // A LEAF's expander slot, reserved and empty: without it a leaf's label // started 28px off its sibling branches' and read as one level up.