"use client" import * as React from "react" import { useCascaderLoader, useCascaderLoadState, } from "@/components/reui/cascader/cascader-async" import type { CascaderGetChildren, CascaderOnSearch, CascaderResolveValue, } from "@/components/reui/cascader/cascader-async" import { CascaderActionsContext, CascaderHighlightContext, CascaderRenderContext, CascaderStateContext, createCascaderHighlightStore, useCascaderActions, useCascaderHighlight, useCascaderRender, useCascaderState, } from "@/components/reui/cascader/cascader-context" import type { CascaderActionsContextValue, CascaderColumn, CascaderHighlight, CascaderHighlightStore, CascaderItemState, CascaderRenderContextValue, CascaderStateContextValue, } from "@/components/reui/cascader/cascader-context" import { resolveCascaderLabels } from "@/components/reui/cascader/cascader-i18n" import { applyCascadeSelection, buildCascaderIndex, CASCADER_LIST_HEIGHT_CLASS, CASCADER_LIST_PAD_CLASS, CASCADER_ROOT_KEY, CASCADER_ROWS_CLASS, CASCADER_SCROLL_CLASS, collapseCascaderPath, createCascaderMoreNode, filterCascaderLevel, findAmbiguousCascaderLabels, findCascaderDataIssues, flattenCascaderTree, getCascaderCheckedValues, getCascaderChildren, getCascaderCount, getCascaderIndeterminateFrom, getCascaderPath, getCascaderSelectedDescendants, getCascaderTabTarget, isCascaderBranch, isCascaderMoreNode, isCascaderSelectable, matchesCascaderQuery, mergeCascaderIndex, normalizeCascaderQuery, searchCascaderDeep, warnCascaderOnce, } from "@/components/reui/cascader/cascader-lib" import type { CascaderCheckedStrategy } from "@/components/reui/cascader/cascader-lib" import type { CascaderActionItem, CascaderChangeDetails, CascaderChangeReason, CascaderFlatNode, CascaderIndex, CascaderLabels, CascaderLoadState, CascaderMode, CascaderNode, CascaderSearchScope, CascaderSelectable, } 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 { ScrollArea } from "@/components/ui/scroll-area" import { ChevronDownIcon, XIcon } from "lucide-react" /** Stable `filteredItems` for a level swap; a fixed identity cannot loop. */ const EMPTY: CascaderNode[] = [] /** Stable empty array for the `actions` prop, so a cascader with no footer * does not republish the actions context on every render. */ const EMPTY_ACTIONS: CascaderActionItem[] = [] // Word joiner. A polite region reads only MUTATIONS, so alternate it. const ANNOUNCE_MARKER = "\u2060" // Delay before a query result-count reaches the live region: polite // announcements queue, and re-announcing per keystroke plays back stale // counts. Only the count defers; every event-shaped one stays immediate. const ANNOUNCE_DEBOUNCE = 150 /* -------------------------------------------------------------------------- */ /* State */ /* -------------------------------------------------------------------------- */ // Minimal controlled/uncontrolled resolver, hand-rolled because the repo has // no shared `useControllableState`; the root needs it five times. function useControllable( controlled: V | undefined, defaultValue: V, onChange?: (value: V) => void, /** Prop name, used only by the development-time switching warning. */ devName?: string ): [V, (next: V | ((prev: V) => V)) => void] { const [uncontrolled, setUncontrolled] = React.useState(defaultValue) const isControlled = controlled !== undefined const value = isControlled ? controlled : uncontrolled // Switching controlled/uncontrolled mid-life strands half the state. const wasControlled = React.useRef(isControlled) React.useEffect(() => { if (process.env.NODE_ENV === "production") return if (wasControlled.current === isControlled || !devName) return const from = wasControlled.current ? "controlled" : "uncontrolled" const to = isControlled ? "controlled" : "uncontrolled" wasControlled.current = isControlled warnCascaderOnce( `controlled-switch:${devName}`, `\`${devName}\` switched from ${from} to ${to}. Decide once: pass \`${devName}\` for the whole life of the component, or pass only the default and let the cascader own it.` ) }, [isControlled, devName]) // Written in an EFFECT: a discarded render would still have mutated a ref. const latest = React.useRef({ value, isControlled, onChange }) React.useEffect(() => { latest.current = { value, isControlled, onChange } }) // The last PROP seen: it differs from the optimistic advance exactly when a // controlled parent declines a change and re-renders nothing. const lastProp = React.useRef(controlled) React.useEffect(() => { lastProp.current = controlled }) const set = React.useCallback((next: V | ((prev: V) => V)) => { const current = latest.current.value const resolved = typeof next === "function" ? (next as (prev: V) => V)(current) : next if (Object.is(resolved, current)) { // Dedup against the last PROP, not the optimistic advance: a parent that // declines re-renders nothing, so deduping on the advance left the popup // un-dismissable from the second Escape on. if ( !latest.current.isControlled || Object.is(resolved, lastProp.current) ) { return } } // Advance now so two updates in ONE batch compose, rather than the second // reading a stale value and its `Object.is` dedup dropping it. latest.current = { ...latest.current, value: resolved } if (!latest.current.isControlled) setUncontrolled(resolved) latest.current.onChange?.(resolved) }, []) return [value, set] } /* -------------------------------------------------------------------------- */ /* Shallow stability */ /* -------------------------------------------------------------------------- */ function shallowEqualRecords(a: object, b: object): boolean { const left = a as Record const right = b as Record const keys = Object.keys(left) if (keys.length !== Object.keys(right).length) return false for (const key of keys) { if (!Object.is(left[key], right[key])) return false } return true } function shallowEqualItemLists( a: readonly object[], b: readonly object[] ): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i] && !shallowEqualRecords(a[i], b[i])) return false } return true } // Reuses the previous value while the next merely says the same thing: an // inline `labels`/`actions` otherwise republishes the actions context, and // with it every row. Adjust-state-during-render, not a ref. function useShallowStable( value: V, equal: (a: V & object, b: V & object) => boolean ): V { const [stable, setStable] = React.useState(value) if (Object.is(value, stable)) return stable const comparable = typeof value === "object" && value !== null && typeof stable === "object" && stable !== null if (comparable && equal(value as V & object, stable as V & object)) { return stable } setStable(value) return value } /* -------------------------------------------------------------------------- */ /* Context */ /* -------------------------------------------------------------------------- */ /** The pre-split shape, merged, so `useCascader()` returns what it always did. */ export interface CascaderContextValue extends CascaderActionsContextValue, CascaderStateContextValue {} /** * The cascader's internals, typed for the caller's own item payload. * @deprecated Reads BOTH halves, so a caller re-renders on every keystroke. * Prefer `useCascaderActions()` or `useCascaderState()`. Kept for compat. */ export function useCascader(): CascaderContextValue { const actions = useCascaderActions() const state = useCascaderState() return React.useMemo(() => ({ ...actions, ...state }), [actions, state]) } /* -------------------------------------------------------------------------- */ /* Headless selection */ /* -------------------------------------------------------------------------- */ export interface CascaderSelection { /** The selected nodes, resolved. Empty when nothing is selected. */ selected: CascaderNode[] /** Ancestor chain per selected node, root first, node last. */ paths: CascaderNode[][] /** The single selection, or the first one in `multiple` mode. */ first: CascaderNode | null /** Ancestor chain of `first`. */ firstPath: CascaderNode[] count: number isEmpty: boolean multiple: boolean remove: (value: string) => void clear: () => void } /** Headless access to the selection, for a trigger that is not * `CascaderValue`. Pass the payload explicitly to get `node.data` typed. */ export function useCascaderSelection(): CascaderSelection { const { multiple, setSelection } = useCascaderActions() const { index, selectedValues } = useCascaderState() return React.useMemo(() => { const selected = selectedValues .map((value) => index.byValue.get(value)) .filter(Boolean) as CascaderNode[] const paths = selectedValues.map((value) => getCascaderPath(index, value)) return { selected, paths, first: selected[0] ?? null, firstPath: paths[0] ?? [], count: selectedValues.length, isEmpty: selectedValues.length === 0, multiple, remove: (value: string) => setSelection(selectedValues.filter((entry) => entry !== value)), // Explicit reason: emptying on purpose is not "deselect the last node". clear: () => setSelection([], "clear"), } }, [index, selectedValues, multiple, setSelection]) } /* -------------------------------------------------------------------------- */ /* Root */ /* -------------------------------------------------------------------------- */ /** * Why the navigation path changed. * - `"drill"`: a branch was entered (row press, level arrow, deep-search hit). * - `"back"`: one level up (back control, Backspace on an empty query). * - `"breadcrumb"`: a jump to an exact depth in the trail. * - `"reveal"`: the reopen navigated to the level holding the selection. * - `"external"`: `setPath` called from outside the primitive's own flows. */ export type CascaderPathChangeReason = | "drill" | "back" | "breadcrumb" | "reveal" | "external" /** Everything independent of the selection mode; the rest is on the two arms. */ export interface CascaderBaseProps { /** Nested tree, or a flat list when `getParent` is supplied. */ items: CascaderNode[] /** Opt in to flat adjacency input by returning each node's parent value. */ getParent?: (node: CascaderNode) => string | null | undefined /** * Fetches one level on demand; `node` is `null` for the root. Return an * array, or a `CascaderLoadResult` when the level pages. Pages cache apart * from `items`; mark unfetched branches `hasChildren` or they read as leaves. */ getChildren?: CascaderGetChildren /** * Server-side search, debounced by `searchDebounce`; replaces the local scan * while the query is set. Results are detached: resolvable by value, in no * level. Ignored in `mode="tree"`, where a hit belongs to no visible branch. */ onSearch?: CascaderOnSearch /** Milliseconds of quiet before `onSearch` fires. */ searchDebounce?: number /** * Resolves a value the loader has never seen into its ancestor chain, root * first, so the trigger can path a server-supplied selection. Does NOT mark * those levels loaded: opening one still fetches. */ resolveValue?: CascaderResolveValue /** Cache key. Changing it drops every page and aborts anything in flight. */ loadKey?: unknown /** Prefetch a highlighted branch's children after a pause. Off by default. */ prefetch?: boolean /** * Called when a load fails. `context.parent` is the level (`null` for root); * `context.reason` is `"level"`, `"more"`, `"retry"`, `"prefetch"`, * `"search"` or `"resolve"`. Never for an aborted or superseded request. */ onLoadError?: ( error: unknown, context: { parent: string | null; reason: string } ) => void /** Which nodes may be committed. Defaults to leaves only. */ selectable?: CascaderSelectable /** * Whether the built-in SINGLE-SELECT check is drawn. Defaults to true. * `false` also gives back the inline-end gutter every style reserves for it; * `data-selected` and `aria-selected` stay. A no-op under `multiple`, where * the checkbox is the selection CONTROL rather than a decoration. */ indicator?: boolean /** * Footer ACTIONS: commands pinned below the list, never rows in it, and never * touching the data tree. A childless `` draws these; an * action with `items` opens a side-anchored flyout rather than firing. */ actions?: CascaderActionItem[] /** * Parent/child cascading. Multi-select only, off by default, and it needs * committable branches (`selectable="any"` or a predicate). A commit selects * or deselects every SELECTABLE node in the LOADED subtree and reconciles * ancestors. LOADED is the semantic, not a hedge: a commit must not fan out * into a request per level, so children arriving later arrive unchecked. */ cascade?: boolean mode?: CascaderMode /** How a branch opens from the pointer in columns mode's ACTIVE column. * `"click"` is the default, and neither value ever commits a selection. */ expandTrigger?: "click" | "hover" path?: string[] defaultPath?: string[] /** The second argument says WHY. An existing `(path) => void` still works. */ onPathChange?: ( path: string[], details: { reason: CascaderPathChangeReason } ) => void expanded?: string[] defaultExpanded?: string[] onExpandedChange?: (expanded: string[]) => void open?: boolean defaultOpen?: boolean /** Forwards Base UI's reason; a leaf commit's close says `"item-press"`. */ onOpenChange?: (open: boolean, details: { reason: string }) => void /** * Whether a SINGLE-SELECT leaf commit closes the popup. Defaults to true. * `false` is for a compare-and-repick flow; Escape, outside press and the * trigger still close. Ignored under `multiple`, which never closes. */ closeOnSelect?: boolean inputValue?: string defaultInputValue?: string onInputValueChange?: (value: string) => void searchScope?: CascaderSearchScope /** Custom matcher, replacing label + keywords substring matching. */ filter?: (node: CascaderNode, normalizedQuery: string) => boolean /** On open, navigate to the level holding the selection. Default true. */ revealSelected?: boolean maxHeight?: number | string /** * Window the rendered rows. `undefined` decides per level from * `virtualizeThreshold`; `true`/`false` force it. Also opt-in at the markup * level, so the base install pulls in no virtualization library. */ virtualize?: boolean /** Row count at which windowing turns itself on. */ virtualizeThreshold?: number /** Row height handed to the virtualizer before a row has been measured. */ estimateRowSize?: number /** Rows rendered beyond each edge of the viewport. */ overscan?: number labels?: Partial /** Replaces the entire row, affordances included. */ renderItem?: ( node: CascaderNode, state: CascaderItemState ) => React.ReactNode /** Replaces only the label block; icon, count, chevron and check stay. */ renderLabel?: ( node: CascaderNode, state: CascaderItemState ) => React.ReactNode disabled?: boolean /** Native form field name. Submits the selected value(s). */ name?: string /** Id of the owning form, for a cascader rendered outside it. */ form?: string /** Id of the control. Pairs the field with a `