"use client" import * as React from "react" import type { CascaderActionItem, CascaderChangeReason, CascaderFlatNode, CascaderIndex, CascaderLabels, CascaderLoadState, CascaderMode, CascaderNode, CascaderSearchScope, } from "@/components/reui/cascader/cascader-types" /** * Four contexts, not one: actions (config and callbacks, near-stable), state * (every keystroke), render (`renderItem` identity) and highlight (every arrow * key and pointer move). One combined context re-rendered every row on every * keystroke, which is what made `React.memo` on the row worth nothing. */ /* -------------------------------------------------------------------------- */ /* Shared types */ /* -------------------------------------------------------------------------- */ export interface CascaderColumn { parent: CascaderNode | null items: CascaderNode[] depth: number /** The node in this column that is drilled into, if any. */ activeValue: string | null /** Whether this is the deepest column, the one Base UI owns. */ active: boolean } export interface CascaderItemState { branch: boolean selected: boolean disabled: boolean depth: number count: number /** Ancestor chain, root first. Populated for deep-search rows. */ path: CascaderNode[] } /* -------------------------------------------------------------------------- */ /* State */ /* -------------------------------------------------------------------------- */ /** * Everything derived from the query, the path and the selection. One keystroke * rebuilds most of it, so never subscribe to it from a row. */ export interface CascaderStateContextValue { /** Same `useMemo` identity as the actions context's `index`. */ index: CascaderIndex path: string[] expanded: ReadonlySet query: string currentParent: CascaderNode | null /** Rows for the current level, already filtered. */ levelItems: CascaderNode[] deepResults: CascaderNode[] | null /** What `Combobox.Root` is currently rendering, in render order. */ renderedItems: CascaderNode[] columns: CascaderColumn[] treeRows: CascaderFlatNode[] selectedValues: string[] /** Selected nodes below each value, at any depth. Absent means zero. */ selectedDescendants: ReadonlyMap /** * Per level, keyed by parent value or `CASCADER_ROOT_KEY`. MEMBERSHIP is the * discriminator: no entry means never fetched, an entry with no `loading`, no * `error` and no children means fetched and genuinely empty. */ loadStates: ReadonlyMap /** Async search, `null` when idle. Separate: a search belongs to no level. */ searchState: CascaderLoadState | null announcement: string } const CascaderStateContext = React.createContext< CascaderStateContextValue | undefined >(undefined) /** * One provider serves every `T`, so the context holds an erased `unknown` value * and this cast restores it. The primitive never inspects the payload. */ export function useCascaderState(): CascaderStateContextValue { const context = React.useContext(CascaderStateContext) if (!context) { throw new Error("useCascaderState must be used within a Cascader") } return context as unknown as CascaderStateContextValue } /* -------------------------------------------------------------------------- */ /* Actions */ /* -------------------------------------------------------------------------- */ /** * Config and callbacks, slow enough that a memoised row can subscribe: the * mutators are `[]`-dep callbacks over a latest-props ref. The three predicates * are the exception, read DURING RENDER where a ref written in an effect would * return the previous commit's answer, so each is memoised on its own input. */ export interface CascaderActionsContextValue { index: CascaderIndex mode: CascaderMode multiple: boolean /** Multi-select only: a commit propagates over the LOADED subtree. */ cascade: boolean /** Whether a BRANCH is committable. Per-LIST: the check gutter is a COLUMN. */ branchesSelectable: boolean /** Draws the SINGLE-SELECT check and its gutter. Ignored in multi-select. */ indicator: boolean expandTrigger?: "click" | "hover" actions: CascaderActionItem[] searchScope: CascaderSearchScope maxHeight?: number | string inline: boolean invalid: boolean /** * Id prefix; columns are `${baseId}-column-${depth}`. The SCHEME is contract: * the filters primitive's `FilterMenuPinKeeper` restores its highlight across * a live re-pin through `${baseId}-column-0` and the `cascader-item` slot. */ baseId: string labels: CascaderLabels /** * Whether rows are WINDOWED. Also handed to `Combobox.Root`, which is what * makes an explicit row `index` legal: forwarding one while this is false * makes `aria-activedescendant` resolve to nothing. Latched per level. */ virtualized: boolean /** Mounts a windowing renderer, returns its unregister. LAYOUT effect only. */ registerVirtualRenderer: () => () => void /** The root's `virtualize` prop. `undefined` means "decide by count". */ virtualize?: boolean virtualizeThreshold: number estimateRowSize: number overscan: number /** Tells "this level is empty" from "not fetched yet" before a load state. */ hasLoader: boolean /** Next page of a level. Latched: a page with nothing new is not re-asked. */ loadMore: (parentKey: string) => void retryLevel: (parentKey: string) => void /** * Evicts one level's async cache, `null` for the root, so membership reads * never-loaded. A level that is on screen when evicted refetches at once. */ invalidateLevel: (value: string | null) => void /** Index as of the last commit. Use in the stable callbacks, not `index`. */ getIndex: () => CascaderIndex getState: () => CascaderStateContextValue /** Highlighted row or null. A getter: the highlight moves per arrow key. */ getHighlighted: () => CascaderNode | null setPath: (next: string[] | ((prev: string[]) => string[])) => void pushLevel: (value: string) => void popLevel: () => void goToDepth: (depth: number) => void toggleExpanded: (value: string) => void /** * Registers a footer submenu as open or closed. `Combobox` has no * `FloatingTree`, so one Escape would dismiss the flyout AND the cascader; * the root's `onOpenChange` guard cancels the close while any is open, from a * ref so it reads as of that event without re-rendering the root. */ setFlyoutOpen: (key: string, open: boolean) => void hasOpenFlyout: () => boolean setQuery: (next: string) => void /** * Replaces the selection. `onValueChange` diffs it against the current one * for its node and reason; pass `reason` only when the caller knows better. */ setSelection: (values: string[], reason?: CascaderChangeReason) => void /** Commits a node, for rows outside the listbox such as ancestor columns. */ commit: (node: CascaderNode) => void navigate: (node: CascaderNode) => void /** Into `node` as a child of `depth`, replacing anything deeper. */ navigateAt: (node: CascaderNode, depth: number) => void /** Never undefined: falls back to a remembered label, then a synthetic node. */ resolveNode: (value: string) => CascaderNode isBranch: (node: CascaderNode) => boolean isSelectable: (node: CascaderNode) => boolean isSelected: (node: CascaderNode) => boolean /** Always `false` without `cascade`: partial selection needs propagation. */ isIndeterminate: (node: CascaderNode) => boolean /** * O(1) read of `selectedDescendants`; a memoised row may not subscribe. * Unlike `isIndeterminate` this answers in every mode. */ selectedDescendantCount: (node: CascaderNode) => number } const CascaderActionsContext = React.createContext< CascaderActionsContextValue | undefined >(undefined) export function useCascaderActions< T = unknown, >(): CascaderActionsContextValue { const context = React.useContext(CascaderActionsContext) if (!context) { throw new Error("useCascaderActions must be used within a Cascader") } return context as unknown as CascaderActionsContextValue } /* -------------------------------------------------------------------------- */ /* Render props */ /* -------------------------------------------------------------------------- */ /** * Their own context, republished every render. They cannot ride on the actions * context: an inline closure read off a memoised object is whichever closure * that object captured, so the row would call a stale prop over stale state. */ export interface CascaderRenderContextValue { renderItem?: ( node: CascaderNode, state: CascaderItemState ) => React.ReactNode renderLabel?: ( node: CascaderNode, state: CascaderItemState ) => React.ReactNode } const CascaderRenderContext = React.createContext( {} ) export function useCascaderRender< T = unknown, >(): CascaderRenderContextValue { return React.useContext( CascaderRenderContext ) as CascaderRenderContextValue } /* -------------------------------------------------------------------------- */ /* Highlight store */ /* -------------------------------------------------------------------------- */ export interface CascaderHighlight { index: number value: string | null } /** * An external store, deliberately NOT React state: `onItemHighlighted` fires on * every arrow key AND every pointer move over the list, so `setState` would * re-render the whole root at mousemove rate. A store re-renders subscribers * only, which inside the primitive is the virtualizer. */ export interface CascaderHighlightStore { subscribe: (onStoreChange: () => void) => () => void getSnapshot: () => CascaderHighlight set: (next: CascaderHighlight) => void } const NO_HIGHLIGHT: CascaderHighlight = { index: -1, value: null } export function createCascaderHighlightStore(): CascaderHighlightStore { let snapshot: CascaderHighlight = NO_HIGHLIGHT const listeners = new Set<() => void>() return { subscribe(onStoreChange) { listeners.add(onStoreChange) return () => { listeners.delete(onStoreChange) } }, // The SAME object until something changes; `useSyncExternalStore` needs it. getSnapshot() { return snapshot }, set(next) { if (next.index === snapshot.index && next.value === snapshot.value) return snapshot = next for (const listener of listeners) listener() }, } } /** Shared and permanently empty, so the hook degrades outside a `Cascader`. */ const FALLBACK_HIGHLIGHT_STORE = createCascaderHighlightStore() const CascaderHighlightContext = React.createContext( FALLBACK_HIGHLIGHT_STORE ) /** Subscribes to the highlight. Re-renders ONLY the calling component. */ export function useCascaderHighlight(): CascaderHighlight { const store = React.useContext(CascaderHighlightContext) return React.useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot ) } export { CascaderActionsContext, CascaderHighlightContext, CascaderRenderContext, CascaderStateContext, }