"use client" import * as React from "react" import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns" import { useCascaderActions, useCascaderHighlight, useCascaderState, } from "@/components/reui/cascader/cascader-context" import type { CascaderColumn } from "@/components/reui/cascader/cascader-context" import { CascaderItem, CascaderItems, getCascaderMoreProps, } from "@/components/reui/cascader/cascader-item" import { defaultRangeExtractor, useVirtualizer, type Range, type Virtualizer, } from "@tanstack/react-virtual" /** * Windowing for the cascader, in its own file so the primitive's own install * never pulls `@tanstack/react-virtual` in. Base UI forces three rules: * * 1. A windowed row MUST carry an explicit `index`; the fallback * `findItemIndex` is O(n) per row and returns `-1` on the frame a level swap * renders empty, dangling `aria-activedescendant`. * 2. That index is DESTRUCTIVE while unvirtualized (`useCompositeListItem` * skips registration, `CompositeList` truncates `elementsRef` to zero and * the first arrow key highlights nothing), so rows stay plain until the root * flips `virtualized`, gated inside `CascaderItem`. * 3. No `Combobox.Collection` and no FUNCTION CHILD on `Combobox.List`, which * implicitly wraps one in a Collection: a Collection renders every filtered * item. */ /* -------------------------------------------------------------------------- */ /* Virtualizer */ /* -------------------------------------------------------------------------- */ export interface UseCascaderVirtualizerOptions { count: number getScrollElement: () => HTMLElement | null estimateSize?: number overscan?: number /** Key rows by node value, never by index: a tree expand shifts every index. */ getItemKey: (index: number) => string | number /** Render index of the highlighted row, or `-1`. Pinned into the window. */ activeIndex?: number } export type CascaderVirtualizer = Virtualizer /** * The cascader's virtualizer: TanStack plus the highlight pinning and * scroll-into-view a combobox list needs. `activeIndex` is clamped here, not by * the caller: emitted from a layout effect, it can be one commit ahead of a * level that just shrank and would otherwise scroll to a row that is gone. */ export function useCascaderVirtualizer({ count, getScrollElement, estimateSize = 32, overscan = 8, getItemKey, activeIndex = -1, }: UseCascaderVirtualizerOptions): CascaderVirtualizer { const active = activeIndex >= 0 && activeIndex < count ? activeIndex : -1 // Keeps the highlighted row mounted so `aria-activedescendant` never dangles. const rangeExtractor = React.useCallback( (range: Range) => { const indices = new Set(defaultRangeExtractor(range)) if (active !== -1) indices.add(active) return Array.from(indices).sort((a, b) => a - b) }, [active] ) const measureEstimate = React.useCallback(() => estimateSize, [estimateSize]) // React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one. // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count, getScrollElement, estimateSize: measureEstimate, overscan, getItemKey, rangeExtractor, }) // Base UI's scroll-into-view no-ops on a windowed-out row (empty `listRef`). React.useEffect(() => { if (active === -1) return virtualizer.scrollToIndex(active, { align: "auto" }) }, [active, count, virtualizer]) return virtualizer } /* -------------------------------------------------------------------------- */ /* Geometry */ /* -------------------------------------------------------------------------- */ interface CascaderVirtualGutter { block: number inline: number } const NO_GUTTER: CascaderVirtualGutter = { block: 0, inline: 0 } /** * The ROWS' BOX's own padding, never the scrollport's (separate element, reads * a flat zero). Measured because every style sets its own: a windowed row is * positioned against this box's PADDING box while the scroll-height spacer is * an in-flow child of its CONTENT box, so without it rows sit flush. */ function useCascaderVirtualGutter( contentElement: HTMLElement | null ): CascaderVirtualGutter { const [gutter, setGutter] = React.useState(NO_GUTTER) React.useLayoutEffect(() => { if (!contentElement) return const measure = () => { const styles = getComputedStyle(contentElement) const block = Number.parseFloat(styles.paddingTop) || 0 const inline = Number.parseFloat(styles.paddingInlineStart || styles.paddingLeft) || 0 setGutter((previous) => previous.block === block && previous.inline === inline ? previous : { block, inline } ) } const observer = new ResizeObserver(measure) observer.observe(contentElement) return () => observer.disconnect() }, [contentElement]) return gutter } /** Logical insets, not `left` / `width`, so RTL needs no second code path. */ function cascaderVirtualRowStyle( start: number, gutter: CascaderVirtualGutter ): React.CSSProperties { return { position: "absolute", top: 0, insetInlineStart: gutter.inline, insetInlineEnd: gutter.inline, transform: `translateY(${start + gutter.block}px)`, } } /** * The element that actually SCROLLS: the `ScrollArea` viewport above the rows' * box, not the rows' box itself, which never overflows, so a virtualizer aimed * at it would read a full `clientHeight` and render every row. `closest` rather * than `parentElement`, plus a fallback to the content element, keeps a * hand-written `overflow-y-auto` container working. */ function cascaderScrollElement( content: HTMLElement | null ): HTMLElement | null { if (!content) return null return ( content.closest('[data-slot="scroll-area-viewport"]') ?? content ) } /** * `role="presentation"` keeps the listbox owning nothing but options, and this * node's parent IS the rows' box, saving a ref threaded through `CascaderList`. */ function CascaderVirtualSpacer({ height, onContentElement, }: { height: number onContentElement: (element: HTMLElement | null) => void }) { return (
onContentElement(node?.parentElement ?? null)} role="presentation" data-slot="cascader-virtual-spacer" style={{ height }} /> ) } /* -------------------------------------------------------------------------- */ /* Rows */ /* -------------------------------------------------------------------------- */ export interface CascaderVirtualItemsProps { /** Row height before measurement. Defaults to the root `estimateRowSize`. */ estimateSize?: number /** Rows rendered beyond each edge. Defaults to the root `overscan`. */ overscan?: number } /** * Windowed replacement for `CascaderItems`, dropped inside `CascaderList`. The * registration is a LAYOUT effect because the root derives `virtualized` from * it and the flip has to land before the browser paints. Until the root has * seen it, this renders exactly what `CascaderItems` does, so no frame carries * indexed rows while Base UI still owns the composite list. */ function CascaderVirtualItems(props: CascaderVirtualItemsProps) { const { virtualized, registerVirtualRenderer } = useCascaderActions() React.useLayoutEffect( () => registerVirtualRenderer(), [registerVirtualRenderer] ) if (!virtualized) return return } function CascaderVirtualRows({ estimateSize, overscan, }: CascaderVirtualItemsProps) { const { estimateRowSize, overscan: rootOverscan, mode, isBranch, isSelectable, isSelected, isIndeterminate, } = useCascaderActions() const { renderedItems, treeRows, deepResults, loadStates } = useCascaderState() // Re-renders on every highlight change, and has to: the pinned row and the // scroll target are both derived from it. const highlight = useCascaderHighlight() const [contentElement, setContentElement] = React.useState(null) const gutter = useCascaderVirtualGutter(contentElement) const tree = mode === "tree" const count = tree ? treeRows.length : renderedItems.length const showPath = !tree && deepResults !== null const getItemKey = React.useCallback( (index: number) => (tree ? treeRows[index]?.node.value : renderedItems[index]?.value) ?? index, [tree, treeRows, renderedItems] ) const virtualizer = useCascaderVirtualizer({ count, getScrollElement: () => cascaderScrollElement(contentElement), estimateSize: estimateSize ?? estimateRowSize, overscan: overscan ?? rootOverscan, getItemKey, activeIndex: highlight.index, }) return ( <> {virtualizer.getVirtualItems().map((row) => { const flat = tree ? treeRows[row.index] : undefined const node = tree ? flat?.node : renderedItems[row.index] // For one commit after a query narrows the level, the window can name // a row it no longer has; the count recomputes on the same tick. if (!node) return null return ( ) })} ) } /* -------------------------------------------------------------------------- */ /* Column */ /* -------------------------------------------------------------------------- */ export interface CascaderVirtualColumnProps extends CascaderVirtualItemsProps { column: CascaderColumn } /** * Windowed replacement for one Miller column, passed through the * `CascaderColumns` render slot. One virtualizer PER COLUMN, since columns * scroll independently, and only the deepest is Base UI's listbox: the trail * behind is `as="button"` rows outside Base UI, so it windows on its own row * count rather than waiting for the root to flip. */ function CascaderVirtualColumn({ column, estimateSize, overscan, }: CascaderVirtualColumnProps) { const { virtualized, registerVirtualRenderer, virtualize, virtualizeThreshold, } = useCascaderActions() React.useLayoutEffect( () => registerVirtualRenderer(), [registerVirtualRenderer] ) const windowed = column.active ? virtualized : (virtualize ?? column.items.length >= virtualizeThreshold) if (!windowed) return return ( {column.active ? ( ) : ( )} ) } /** Only the active column subscribes to the highlight; a trail column must not. */ function CascaderVirtualActiveColumnRows(props: CascaderVirtualColumnProps) { const highlight = useCascaderHighlight() return } function CascaderVirtualColumnRows({ column, estimateSize, overscan, activeIndex, }: CascaderVirtualColumnProps & { activeIndex: number }) { const { estimateRowSize, overscan: rootOverscan, baseId, isBranch, isSelectable, isSelected, isIndeterminate, } = useCascaderActions() const { loadStates } = useCascaderState() const [contentElement, setContentElement] = React.useState(null) const gutter = useCascaderVirtualGutter(contentElement) const items = column.items const getItemKey = React.useCallback( (index: number) => items[index]?.value ?? index, [items] ) const virtualizer = useCascaderVirtualizer({ count: items.length, getScrollElement: () => cascaderScrollElement(contentElement), estimateSize: estimateSize ?? estimateRowSize, overscan: overscan ?? rootOverscan, getItemKey, activeIndex, }) return ( <> {virtualizer.getVirtualItems().map((row) => { const node = items[row.index] if (!node) return null const open = node.value === column.activeValue return ( ) })} ) } export { CascaderVirtualColumn, CascaderVirtualItems }