2944 lines
102 KiB
TypeScript
2944 lines
102 KiB
TypeScript
"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<never>[] = []
|
|
|
|
/** 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<V>(
|
|
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<string, unknown>
|
|
const right = b as Record<string, unknown>
|
|
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<V>(
|
|
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<T = unknown>
|
|
extends CascaderActionsContextValue<T>, CascaderStateContextValue<T> {}
|
|
|
|
/**
|
|
* 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<T = unknown>(): CascaderContextValue<T> {
|
|
const actions = useCascaderActions<T>()
|
|
const state = useCascaderState<T>()
|
|
|
|
return React.useMemo(() => ({ ...actions, ...state }), [actions, state])
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Headless selection */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface CascaderSelection<T = unknown> {
|
|
/** The selected nodes, resolved. Empty when nothing is selected. */
|
|
selected: CascaderNode<T>[]
|
|
/** Ancestor chain per selected node, root first, node last. */
|
|
paths: CascaderNode<T>[][]
|
|
/** The single selection, or the first one in `multiple` mode. */
|
|
first: CascaderNode<T> | null
|
|
/** Ancestor chain of `first`. */
|
|
firstPath: CascaderNode<T>[]
|
|
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<T = unknown>(): CascaderSelection<T> {
|
|
const { multiple, setSelection } = useCascaderActions<T>()
|
|
const { index, selectedValues } = useCascaderState<T>()
|
|
|
|
return React.useMemo(() => {
|
|
const selected = selectedValues
|
|
.map((value) => index.byValue.get(value))
|
|
.filter(Boolean) as CascaderNode<T>[]
|
|
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<T = unknown> {
|
|
/** Nested tree, or a flat list when `getParent` is supplied. */
|
|
items: CascaderNode<T>[]
|
|
/** Opt in to flat adjacency input by returning each node's parent value. */
|
|
getParent?: (node: CascaderNode<T>) => 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<T>
|
|
/**
|
|
* 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<T>
|
|
/** 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<T>
|
|
/** 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<T>
|
|
|
|
/**
|
|
* 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 `<CascaderFooter />` 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<T>, 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<CascaderLabels>
|
|
/** Replaces the entire row, affordances included. */
|
|
renderItem?: (
|
|
node: CascaderNode<T>,
|
|
state: CascaderItemState<T>
|
|
) => React.ReactNode
|
|
/** Replaces only the label block; icon, count, chevron and check stay. */
|
|
renderLabel?: (
|
|
node: CascaderNode<T>,
|
|
state: CascaderItemState<T>
|
|
) => 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 `<label htmlFor>`. */
|
|
id?: string
|
|
/** Marks the field required for native form validation. */
|
|
required?: boolean
|
|
/** The value can be read and submitted, but not changed. */
|
|
readOnly?: boolean
|
|
/** Marks the field invalid: `aria-invalid` and `data-invalid` on the trigger,
|
|
* the chips and the input, which every style's error treatment reads. */
|
|
invalid?: boolean
|
|
/** Ref to the hidden input Base UI submits with, for form-library focus. */
|
|
inputRef?: React.Ref<HTMLInputElement>
|
|
/** Renders without a floating popup, for an embedded `CascaderPanel`. */
|
|
inline?: boolean
|
|
|
|
children?: React.ReactNode
|
|
}
|
|
|
|
export interface CascaderSingleProps<T = unknown> extends CascaderBaseProps<T> {
|
|
multiple?: false
|
|
value?: string
|
|
defaultValue?: string
|
|
onValueChange?: (value: string, details: CascaderChangeDetails<T>) => void
|
|
/** `never` so passing it is a compile error, not a silent no-op. */
|
|
max?: never
|
|
}
|
|
|
|
/** Multi-select props. Enables checkbox rows and chip display. */
|
|
export interface CascaderMultipleProps<
|
|
T = unknown,
|
|
> extends CascaderBaseProps<T> {
|
|
multiple: true
|
|
value?: string[]
|
|
defaultValue?: string[]
|
|
onValueChange?: (value: string[], details: CascaderChangeDetails<T>) => void
|
|
/** Cap on selections. Further picks are refused, the existing ones survive. */
|
|
max?: number
|
|
}
|
|
|
|
/** `multiple` discriminates: `value`, `onValueChange` and `max` narrow with it. */
|
|
export type CascaderProps<T = unknown> =
|
|
| CascaderSingleProps<T>
|
|
| CascaderMultipleProps<T>
|
|
|
|
/** Normalizes to an array. `""` is "no selection", not a node: it is the
|
|
* uncontrolled default, and resolving it would render a blank chip. */
|
|
function toArray(value: string | string[] | undefined): string[] {
|
|
if (value == null) return []
|
|
if (Array.isArray(value)) return value.filter((entry) => entry !== "")
|
|
return value === "" ? [] : [value]
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Development warnings */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
interface CascaderDevOptions<T> {
|
|
items: CascaderNode<T>[]
|
|
getParent?: (node: CascaderNode<T>) => string | null | undefined
|
|
mode: CascaderMode
|
|
multiple: boolean
|
|
cascade: boolean
|
|
indicator: boolean
|
|
selectable: CascaderSelectable<T>
|
|
searchScope: CascaderSearchScope
|
|
max: number | undefined
|
|
value: string | string[] | undefined
|
|
defaultValue: string | string[] | undefined
|
|
hasExpanded: boolean
|
|
hasPath: boolean
|
|
hasOnSearch: boolean
|
|
}
|
|
|
|
/** Misconfigurations that are SILENT by design. Once each, never in prod. */
|
|
function useCascaderDevWarnings<T>(options: CascaderDevOptions<T>) {
|
|
const {
|
|
items,
|
|
getParent,
|
|
mode,
|
|
multiple,
|
|
cascade,
|
|
indicator,
|
|
selectable,
|
|
searchScope,
|
|
max,
|
|
value,
|
|
defaultValue,
|
|
hasExpanded,
|
|
hasPath,
|
|
hasOnSearch,
|
|
} = options
|
|
|
|
// O(n) over the raw input, so the memo is skipped outright in production.
|
|
const issues = React.useMemo(
|
|
() =>
|
|
process.env.NODE_ENV === "production"
|
|
? null
|
|
: findCascaderDataIssues(items, getParent),
|
|
[items, getParent]
|
|
)
|
|
|
|
React.useEffect(() => {
|
|
if (process.env.NODE_ENV === "production") return
|
|
|
|
for (const duplicate of issues?.duplicates ?? []) {
|
|
warnCascaderOnce(
|
|
`duplicate-value:${duplicate}`,
|
|
`Duplicate node value ${JSON.stringify(duplicate)}. \`value\` is the selection key, so only the first occurrence is indexed and the rest never render.`
|
|
)
|
|
}
|
|
|
|
for (const cycle of issues?.cycles ?? []) {
|
|
warnCascaderOnce(
|
|
`cycle:${cycle}`,
|
|
`\`getParent\` puts ${JSON.stringify(cycle)} on a cycle. The depth walk is cycle-guarded so nothing hangs, but every depth on that chain is clamped rather than derived.`
|
|
)
|
|
}
|
|
}, [issues])
|
|
|
|
React.useEffect(() => {
|
|
if (process.env.NODE_ENV === "production") return
|
|
|
|
for (const [prop, current] of [
|
|
["value", value],
|
|
["defaultValue", defaultValue],
|
|
] as const) {
|
|
if (current == null) continue
|
|
if (multiple && !Array.isArray(current)) {
|
|
warnCascaderOnce(
|
|
`value-shape:multiple:${prop}`,
|
|
`\`multiple\` is set but \`${prop}\` is a string. Multi-select reads and writes a \`string[]\`; a string is treated as no selection at all.`
|
|
)
|
|
} else if (!multiple && Array.isArray(current)) {
|
|
warnCascaderOnce(
|
|
`value-shape:single:${prop}`,
|
|
`\`${prop}\` is an array but \`multiple\` is not set. Single-select reads and writes a \`string\`; only the first entry would survive a commit.`
|
|
)
|
|
}
|
|
}
|
|
|
|
if (cascade && !multiple) {
|
|
warnCascaderOnce(
|
|
"cascade-without-multiple",
|
|
"`cascade` does nothing without `multiple`: a single selection has no subtree to propagate over."
|
|
)
|
|
}
|
|
|
|
if (cascade && selectable === "leaf") {
|
|
warnCascaderOnce(
|
|
"cascade-with-leaf-selectable",
|
|
'`cascade` does nothing while `selectable="leaf"`: no branch can be committed, so no commit ever has a subtree under it. Pass `selectable="any"` or a predicate that accepts branches.'
|
|
)
|
|
}
|
|
|
|
if (!indicator && multiple) {
|
|
warnCascaderOnce(
|
|
"indicator-false-with-multiple",
|
|
"`indicator={false}` does nothing with `multiple`: the checkbox is the selection control rather than a decoration, so it and its gutter stay. Mark selection your own way from `data-selected` on the row, and drop the prop."
|
|
)
|
|
}
|
|
|
|
if (max != null && !multiple) {
|
|
warnCascaderOnce(
|
|
"max-without-multiple",
|
|
"`max` does nothing without `multiple`: there is only ever one selection to cap."
|
|
)
|
|
}
|
|
|
|
if (hasExpanded && mode !== "tree") {
|
|
warnCascaderOnce(
|
|
`expanded-outside-tree:${mode}`,
|
|
`\`expanded\` only does something in \`mode="tree"\`, and this cascader is in \`mode="${mode}"\`. Drill and columns navigate with \`path\`.`
|
|
)
|
|
}
|
|
|
|
if (hasPath && mode === "tree") {
|
|
warnCascaderOnce(
|
|
"path-in-tree",
|
|
'`path` does nothing in `mode="tree"`: branches expand in place rather than replacing the level. Use `expanded`.'
|
|
)
|
|
}
|
|
|
|
if (searchScope === "deep" && mode === "tree") {
|
|
warnCascaderOnce(
|
|
"deep-search-in-tree",
|
|
'`searchScope="deep"` does nothing in `mode="tree"`: a tree query already matches at any depth and auto-expands the ancestors of every hit.'
|
|
)
|
|
}
|
|
|
|
if (hasOnSearch && mode === "tree") {
|
|
warnCascaderOnce(
|
|
"onsearch-in-tree",
|
|
'`onSearch` does nothing in `mode="tree"`: a tree query filters the loaded tree in place, and a server hit belongs to no visible branch, so its results would never render. The request is not fired. Filter the tree locally, or use drill or columns for server search.'
|
|
)
|
|
}
|
|
}, [
|
|
mode,
|
|
multiple,
|
|
cascade,
|
|
indicator,
|
|
selectable,
|
|
searchScope,
|
|
max,
|
|
value,
|
|
defaultValue,
|
|
hasExpanded,
|
|
hasPath,
|
|
hasOnSearch,
|
|
])
|
|
}
|
|
|
|
/** What the `[]`-dep callbacks would otherwise close over. One ref, one
|
|
* effect, so "the last committed render" is defined in exactly one place. */
|
|
interface CascaderLatest<T> {
|
|
index: CascaderIndex<T>
|
|
state: CascaderStateContextValue<T>
|
|
mode: CascaderMode
|
|
multiple: boolean
|
|
cascade: boolean
|
|
max: number | undefined
|
|
labels: CascaderLabels
|
|
closeOnSelect: boolean
|
|
selectedValues: string[]
|
|
deepResults: CascaderNode<T>[] | null
|
|
onValueChange:
|
|
| ((value: string | string[], details: CascaderChangeDetails<T>) => void)
|
|
| undefined
|
|
resolveNode: (value: string) => CascaderNode<T>
|
|
isSelectable: (node: CascaderNode<T>) => boolean
|
|
needsChildren: (node: CascaderNode<T>) => boolean
|
|
expanded: ReadonlySet<string>
|
|
}
|
|
|
|
/** A navigation waiting on a fetch. The `kind`s differ: a drill appends, a
|
|
* columns press replaces the trail from `depth`, a tree press expands. */
|
|
interface CascaderPendingNavigation {
|
|
value: string
|
|
kind: "push" | "at" | "expand"
|
|
/** `kind: "at"` only. The depth the pressed row belongs to. */
|
|
depth: number
|
|
}
|
|
|
|
/** No default on `T`: inferred from `items`, so `node.data` arrives typed. */
|
|
function Cascader<T>({
|
|
items,
|
|
getParent,
|
|
getChildren,
|
|
onSearch,
|
|
searchDebounce,
|
|
resolveValue,
|
|
loadKey,
|
|
prefetch,
|
|
onLoadError,
|
|
value: valueProp,
|
|
defaultValue,
|
|
onValueChange: onValueChangeProp,
|
|
multiple = false,
|
|
selectable = "leaf",
|
|
indicator = true,
|
|
actions,
|
|
cascade = false,
|
|
max,
|
|
mode = "drill",
|
|
expandTrigger,
|
|
path: pathProp,
|
|
defaultPath,
|
|
onPathChange,
|
|
expanded: expandedProp,
|
|
defaultExpanded,
|
|
onExpandedChange,
|
|
open: openProp,
|
|
defaultOpen = false,
|
|
onOpenChange,
|
|
closeOnSelect = true,
|
|
inputValue: inputValueProp,
|
|
defaultInputValue = "",
|
|
onInputValueChange,
|
|
searchScope = "level",
|
|
filter,
|
|
revealSelected = true,
|
|
maxHeight,
|
|
virtualize,
|
|
virtualizeThreshold = 100,
|
|
estimateRowSize = 32,
|
|
overscan = 8,
|
|
labels: labelsProp,
|
|
renderItem,
|
|
renderLabel,
|
|
disabled,
|
|
name,
|
|
form,
|
|
id,
|
|
required,
|
|
readOnly,
|
|
invalid,
|
|
inputRef,
|
|
inline,
|
|
children,
|
|
}: CascaderProps<T>) {
|
|
const baseIndex = React.useMemo(
|
|
() => buildCascaderIndex(items, getParent),
|
|
// `getParent` is stable by design: an inline accessor must not rebuild a
|
|
// 50k-node index.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[items]
|
|
)
|
|
|
|
const baseId = React.useId()
|
|
|
|
// Shallow-stabilized first: an inline `labels` would republish the context.
|
|
const stableLabelsProp = useShallowStable(labelsProp, shallowEqualRecords)
|
|
|
|
const labels = React.useMemo(
|
|
() => resolveCascaderLabels(stableLabelsProp),
|
|
[stableLabelsProp]
|
|
)
|
|
|
|
useCascaderDevWarnings({
|
|
items,
|
|
getParent,
|
|
mode,
|
|
multiple,
|
|
cascade,
|
|
indicator,
|
|
selectable,
|
|
searchScope,
|
|
max,
|
|
value: valueProp,
|
|
defaultValue,
|
|
hasExpanded:
|
|
expandedProp !== undefined ||
|
|
defaultExpanded !== undefined ||
|
|
onExpandedChange !== undefined,
|
|
hasPath:
|
|
pathProp !== undefined ||
|
|
defaultPath !== undefined ||
|
|
onPathChange !== undefined,
|
|
hasOnSearch: onSearch !== undefined,
|
|
})
|
|
|
|
// Public props are discriminated, internals are not: one assertion.
|
|
const onValueChange = onValueChangeProp as
|
|
| ((value: string | string[], details: CascaderChangeDetails<T>) => void)
|
|
| undefined
|
|
|
|
// No `onChange`: `onValueChange` takes a second argument, so `emitSelection`
|
|
// invokes it rather than the state hook, which only knows the value.
|
|
const [value, setValue] = useControllable<string | string[]>(
|
|
valueProp,
|
|
defaultValue ?? (multiple ? [] : ""),
|
|
undefined,
|
|
"value"
|
|
)
|
|
// Why the current path (or open) change is happening. Refs, not arguments:
|
|
// `useControllable` only passes a value, and its onChange runs synchronously
|
|
// inside the setter, so a ref written just before is exact. The path ref
|
|
// resets to `"external"`, which is what the consumer's `setPath` reports.
|
|
const pathReasonRef = React.useRef<CascaderPathChangeReason>("external")
|
|
const openReasonRef = React.useRef<string>("none")
|
|
|
|
const [path, setPath] = useControllable<string[]>(
|
|
pathProp,
|
|
defaultPath ?? [],
|
|
(next) => onPathChange?.(next, { reason: pathReasonRef.current }),
|
|
"path"
|
|
)
|
|
const [expandedList, setExpandedList] = useControllable<string[]>(
|
|
expandedProp,
|
|
defaultExpanded ?? [],
|
|
onExpandedChange,
|
|
"expanded"
|
|
)
|
|
const [open, setOpen] = useControllable<boolean>(
|
|
openProp,
|
|
defaultOpen,
|
|
(next) => onOpenChange?.(next, { reason: openReasonRef.current }),
|
|
"open"
|
|
)
|
|
|
|
/** The internal `setPath`: names its reason, then hands back to "external". */
|
|
const setPathWithReason = React.useCallback(
|
|
(
|
|
next: string[] | ((prev: string[]) => string[]),
|
|
reason: CascaderPathChangeReason
|
|
) => {
|
|
pathReasonRef.current = reason
|
|
try {
|
|
setPath(next)
|
|
} finally {
|
|
pathReasonRef.current = "external"
|
|
}
|
|
},
|
|
[setPath]
|
|
)
|
|
const [query, setQuery] = useControllable<string>(
|
|
inputValueProp,
|
|
defaultInputValue,
|
|
onInputValueChange,
|
|
"inputValue"
|
|
)
|
|
|
|
const expanded = React.useMemo(() => new Set(expandedList), [expandedList])
|
|
const selectedValues = React.useMemo(() => toArray(value), [value])
|
|
|
|
/* --------------------------------- async -------------------------------- */
|
|
|
|
// On-screen levels, so the ones worth fetching. Columns shows the whole
|
|
// trail at once, which is why the loader is per-level, not one flag.
|
|
const levels = React.useMemo(() => {
|
|
if (mode === "tree") return [CASCADER_ROOT_KEY, ...expandedList]
|
|
if (mode === "columns") return [CASCADER_ROOT_KEY, ...path]
|
|
return [path.length ? path[path.length - 1] : CASCADER_ROOT_KEY]
|
|
}, [mode, path, expandedList])
|
|
|
|
// A SIBLING of the index build, never a step inside it: the build stays pure
|
|
// in `items`, the loader owns the pages, the merge is pure in both, and that
|
|
// is what makes loaded data survive an `items` change.
|
|
const loader = useCascaderLoader<T>({
|
|
base: baseIndex,
|
|
getChildren,
|
|
// Gated, not merely unrendered: a tree hit belongs to no visible branch.
|
|
onSearch: mode === "tree" ? undefined : onSearch,
|
|
resolveValue,
|
|
searchDebounce,
|
|
loadKey,
|
|
prefetch,
|
|
onLoadError,
|
|
// An inline cascader is never "open", but its panel is live.
|
|
enabled: open || !!inline,
|
|
query,
|
|
levels,
|
|
path,
|
|
values: selectedValues,
|
|
})
|
|
|
|
const loadStates = loader.states
|
|
|
|
// Destructured here, not at the actions memo, because `navigate` sits above
|
|
// it. All three are `[]`-dep inside the loader, so identity is stable.
|
|
const { ensureLevel, retryLevel: retryLoaderLevel, invalidateLevel } = loader
|
|
|
|
/**
|
|
* Whether pressing this branch has to FETCH first: in flight, failed, or
|
|
* never asked. `baseIndex`, NOT the merged `index`: `resolveValue` writes an
|
|
* ancestor chain into `pages` without touching `states`, so the merged index
|
|
* shows children for levels never requested. Must agree with `ensureLevel`'s
|
|
* own guards or a press parks a navigation nothing will ever settle.
|
|
*/
|
|
const needsChildren = React.useCallback(
|
|
(node: CascaderNode<T>) => {
|
|
if (!loader.active) return false
|
|
const state = loadStates.get(node.value)
|
|
if (state) return state.loading || state.error
|
|
return !baseIndex.childrenOf.has(node.value)
|
|
},
|
|
[loader.active, loadStates, baseIndex]
|
|
)
|
|
|
|
// Keyed on the two maps the merge reads, NOT the store: `withLoadState`
|
|
// returns a fresh store over the SAME maps, so keying on it rebuilt the
|
|
// whole index on every loading flip that changed no data.
|
|
const index = React.useMemo(
|
|
() =>
|
|
mergeCascaderIndex(baseIndex, loader.store.pages, loader.store.detached),
|
|
[baseIndex, loader.store.pages, loader.store.detached]
|
|
)
|
|
|
|
/** Whether a level needs the paging pseudo-row. A never-fetched level gets
|
|
* nothing; that is the empty state's job. Tree has no per-level empty state,
|
|
* so there the row is also the branch's loading and error surface. */
|
|
const needsMoreRow = React.useCallback(
|
|
(key: string, childCount: number) => {
|
|
const state = loadStates.get(key)
|
|
if (!state) return false
|
|
if (childCount > 0) return state.hasMore || state.loading || state.error
|
|
return mode === "tree" && (state.loading || state.error)
|
|
},
|
|
[loadStates, mode]
|
|
)
|
|
|
|
// Remembers every selected node, so the trigger keeps a label after it
|
|
// leaves the loaded set (or was never in it).
|
|
const [labelCache, setLabelCache] = React.useState(() => new Map<string, CascaderNode<T>>())
|
|
|
|
// Populated during render, since the trigger reads it while rendering.
|
|
// Idempotent and prop-derived, so a discarded render is harmless.
|
|
if (selectedValues.some(selected => {
|
|
const node = index.byValue.get(selected)
|
|
return node && labelCache.get(selected) !== node
|
|
})) {
|
|
const nextCache = new Map(labelCache)
|
|
for (const selected of selectedValues) {
|
|
const node = index.byValue.get(selected)
|
|
if (node) nextCache.set(selected, node)
|
|
}
|
|
setLabelCache(nextCache)
|
|
}
|
|
|
|
const resolveNode = React.useCallback(
|
|
(nodeValue: string): CascaderNode<T> =>
|
|
index.byValue.get(nodeValue) ??
|
|
labelCache.get(nodeValue) ?? {
|
|
value: nodeValue,
|
|
label: nodeValue,
|
|
},
|
|
[index, labelCache]
|
|
)
|
|
|
|
/* ------------------------------ derived view ----------------------------- */
|
|
|
|
const currentParentValue = path.length ? path[path.length - 1] : null
|
|
const currentParent = currentParentValue
|
|
? (index.byValue.get(currentParentValue) ?? null)
|
|
: null
|
|
|
|
const currentLevelKey = currentParentValue ?? CASCADER_ROOT_KEY
|
|
|
|
const isDeepSearching =
|
|
searchScope === "deep" && query.trim().length > 0 && mode !== "tree"
|
|
|
|
const localDeepResults = React.useMemo(() => {
|
|
if (!isDeepSearching) return null
|
|
return searchCascaderDeep(index, query, {
|
|
within: currentParentValue,
|
|
matches: filter,
|
|
})
|
|
}, [isDeepSearching, index, query, currentParentValue, filter])
|
|
|
|
// Server search wins over the local scan, or each hit shows up twice.
|
|
const deepResults = loader.searchResults ?? localDeepResults
|
|
|
|
const levelItems = React.useMemo(() => {
|
|
const level = getCascaderChildren(index, currentParentValue)
|
|
return filterCascaderLevel(level, query, filter)
|
|
}, [index, currentParentValue, query, filter])
|
|
|
|
/** The active level's rows plus the paging pseudo-row, appended HERE because
|
|
* every index Base UI hands out indexes this array: a DOM row missing from
|
|
* it shifts the highlight and every windowed row by one. */
|
|
const levelRendered = React.useMemo(() => {
|
|
if (deepResults) return deepResults
|
|
if (!needsMoreRow(currentLevelKey, levelItems.length)) return levelItems
|
|
return [
|
|
...levelItems,
|
|
createCascaderMoreNode<T>(currentLevelKey, levelItems.length),
|
|
]
|
|
}, [deepResults, levelItems, currentLevelKey, needsMoreRow])
|
|
|
|
// Columns renders the whole trail, but only the deepest column is
|
|
// interactive through Base UI, so `renderedItems` stays what drill uses.
|
|
const columns = React.useMemo<CascaderColumn<T>[]>(() => {
|
|
if (mode !== "columns") return []
|
|
const trail: CascaderColumn<T>[] = []
|
|
for (let depth = 0; depth <= path.length; depth += 1) {
|
|
const parentValue = depth === 0 ? null : path[depth - 1]
|
|
const parent = parentValue
|
|
? (index.byValue.get(parentValue) ?? null)
|
|
: null
|
|
const isActive = depth === path.length
|
|
let columnItems: CascaderNode<T>[]
|
|
if (isActive) {
|
|
columnItems = levelRendered
|
|
} else {
|
|
// A trail column pages on its own, without losing the columns right of it.
|
|
const key = parentValue ?? CASCADER_ROOT_KEY
|
|
const children = getCascaderChildren(index, parentValue)
|
|
columnItems = needsMoreRow(key, children.length)
|
|
? [...children, createCascaderMoreNode<T>(key, children.length)]
|
|
: children
|
|
}
|
|
trail.push({
|
|
parent,
|
|
depth,
|
|
activeValue: path[depth] ?? null,
|
|
active: isActive,
|
|
// Only the active column is filtered: a query narrows what you type
|
|
// into, never the trail behind it.
|
|
items: columnItems,
|
|
})
|
|
}
|
|
return trail
|
|
}, [mode, path, index, levelRendered, needsMoreRow])
|
|
|
|
// Tree level keys needing a paging pseudo-row. With no per-level empty
|
|
// state, this is also where a still-loading branch says so.
|
|
const treeSentinels = React.useMemo(() => {
|
|
if (mode !== "tree") return undefined
|
|
const keys = new Set<string>()
|
|
const consider = (key: string) => {
|
|
if (needsMoreRow(key, index.childrenOf.get(key)?.length ?? 0)) {
|
|
keys.add(key)
|
|
}
|
|
}
|
|
consider(CASCADER_ROOT_KEY)
|
|
for (const value of expanded) consider(value)
|
|
return keys.size ? keys : undefined
|
|
}, [mode, index, expanded, needsMoreRow])
|
|
|
|
const treeRows = React.useMemo<CascaderFlatNode<T>[]>(() => {
|
|
if (mode !== "tree") return []
|
|
const rows = flattenCascaderTree(index, expanded, treeSentinels)
|
|
// Same normalizer as the level filter, so the two cannot disagree.
|
|
const normalized = normalizeCascaderQuery(query)
|
|
if (!normalized) return rows
|
|
// Matches every node, not just visible rows, and keeps the ancestors, or a
|
|
// hit inside a collapsed branch disappears. Same matcher as every other
|
|
// path, so keyword-only hits do not vanish in tree mode alone.
|
|
const keep = new Set<string>()
|
|
for (const node of index.all) {
|
|
if (
|
|
!(filter
|
|
? filter(node, normalized)
|
|
: matchesCascaderQuery(node, normalized))
|
|
) {
|
|
continue
|
|
}
|
|
for (const ancestor of getCascaderPath(index, node.value)) {
|
|
keep.add(ancestor.value)
|
|
}
|
|
}
|
|
// No sentinels while filtering: that page would not have the query applied.
|
|
return flattenCascaderTree(index, new Set([...expanded, ...keep])).filter(
|
|
(row) => keep.has(row.node.value)
|
|
)
|
|
}, [mode, index, expanded, treeSentinels, query, filter])
|
|
|
|
// What `Combobox.Root` receives. Tree flattens to the same linear list, so
|
|
// selection and highlighting need no per-mode case.
|
|
const renderedItems = React.useMemo(
|
|
() => (mode === "tree" ? treeRows.map((row) => row.node) : levelRendered),
|
|
[mode, treeRows, levelRendered]
|
|
)
|
|
|
|
/* ---------------------------- level swap reset --------------------------- */
|
|
|
|
/** Base UI never resets the highlighted index on an `items` change and has
|
|
* no imperative setter; its one automatic reset is an out-of-range clamp, so
|
|
* a single empty frame trips it. The frame rides `filteredItems`, so the
|
|
* `items` identity stays put. */
|
|
const levelKey = currentParentValue ?? ""
|
|
|
|
// STATE, not a ref: a replayed render would see a ref advanced and skip it.
|
|
const [swap, setSwap] = React.useState({ key: levelKey, active: false })
|
|
|
|
if (swap.key !== levelKey) {
|
|
setSwap({ key: levelKey, active: true })
|
|
}
|
|
const swapping = swap.active && swap.key === levelKey
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (!swap.active) return
|
|
const frame = requestAnimationFrame(() => setSwap(prev => ({ ...prev, active: false })))
|
|
return () => cancelAnimationFrame(frame)
|
|
}, [swap.active])
|
|
|
|
/* ----------------------------- virtualization ---------------------------- */
|
|
|
|
// A COUNT: columns mounts one per column, and StrictMode remounts effects.
|
|
const [virtualRenderers, setVirtualRenderers] = React.useState(0)
|
|
|
|
const registerVirtualRenderer = React.useCallback(() => {
|
|
setVirtualRenderers((count) => count + 1)
|
|
return () => setVirtualRenderers((count) => Math.max(0, count - 1))
|
|
}, [])
|
|
|
|
const hasVirtualRenderer = virtualRenderers > 0
|
|
const wantsVirtual = virtualize ?? renderedItems.length >= virtualizeThreshold
|
|
|
|
// LATCHED per level: otherwise a query narrowing a 5,000 row level past the
|
|
// threshold tears the virtualizer down and rebuilds it on backspace, losing
|
|
// every measurement. State, not a ref, like `swap`.
|
|
const [virtualLatch, setVirtualLatch] = React.useState({
|
|
key: levelKey,
|
|
on: false,
|
|
})
|
|
|
|
const virtualized =
|
|
hasVirtualRenderer &&
|
|
(virtualLatch.key === levelKey
|
|
? virtualLatch.on || wantsVirtual
|
|
: wantsVirtual)
|
|
|
|
if (!hasVirtualRenderer) {
|
|
// Nothing is windowing, so nothing may stay latched.
|
|
if (virtualLatch.on || virtualLatch.key !== levelKey) {
|
|
setVirtualLatch({ key: levelKey, on: false })
|
|
}
|
|
} else if (virtualLatch.key !== levelKey) {
|
|
setVirtualLatch({ key: levelKey, on: virtualized })
|
|
} else if (virtualized && !virtualLatch.on) {
|
|
setVirtualLatch({ key: levelKey, on: true })
|
|
}
|
|
|
|
/**
|
|
* One forced re-render, one commit AFTER windowing turns on. Base UI sizes
|
|
* `listRef` from `filteredItems` in a layout effect keyed on that array's
|
|
* identity, but `virtualized` reaches the list a commit later and the
|
|
* `CompositeList` teardown REPLACES `listRef` with an empty array nothing
|
|
* re-sizes, so ArrowDown stops at the last WINDOWED row. A passive effect is
|
|
* late enough: the teardown is scheduled from a layout effect.
|
|
*/
|
|
const [virtualSyncs, setVirtualSyncs] = React.useState(0)
|
|
|
|
React.useEffect(() => {
|
|
if (!virtualized) return
|
|
const frame = requestAnimationFrame(() => setVirtualSyncs(count => count + 1))
|
|
return () => cancelAnimationFrame(frame)
|
|
}, [virtualized])
|
|
|
|
React.useEffect(() => {
|
|
if (process.env.NODE_ENV === "production") return undefined
|
|
if (virtualize !== true || hasVirtualRenderer) return undefined
|
|
// Deferred a tick: the renderer registers from a layout effect, so a
|
|
// synchronous check would report every correctly wired cascader.
|
|
const timeout = setTimeout(() => {
|
|
console.error(
|
|
"[Cascader] `virtualize` is true but no windowing renderer is mounted. " +
|
|
"Render <CascaderVirtualItems /> inside <CascaderList>, or " +
|
|
"<CascaderVirtualColumn /> through the <CascaderColumns> slot."
|
|
)
|
|
}, 0)
|
|
return () => clearTimeout(timeout)
|
|
}, [virtualize, hasVirtualRenderer])
|
|
|
|
/* ------------------------------ navigation ------------------------------ */
|
|
|
|
/** One signal per navigation, feeding the live region, bumped EXACTLY once
|
|
* per action. `seq` alternates the invisible marker so two identical
|
|
* announcements are still two mutations; `toggled` names the tree branch. */
|
|
const [announceSignal, setAnnounceSignal] = React.useState<{
|
|
seq: number
|
|
toggled: string | null
|
|
/** A one-off verbatim message: a `max` refusal, a cascade fan-out. STATE,
|
|
* so an unrelated render cannot clear it mid-read. */
|
|
notice: string | null
|
|
}>({ seq: 0, toggled: null, notice: null })
|
|
|
|
const bumpAnnouncement = React.useCallback((toggled: string | null) => {
|
|
setAnnounceSignal((prev) => ({ seq: prev.seq + 1, toggled, notice: null }))
|
|
}, [])
|
|
|
|
/** Speaks for interactions that change nothing on screen: a refusal returns
|
|
* before any state moves, a cascade sweep is invisible from the row pressed.
|
|
* The seq bump keeps two identical refusals two mutations. */
|
|
const announceNotice = React.useCallback((notice: string) => {
|
|
setAnnounceSignal((prev) => ({ seq: prev.seq + 1, toggled: null, notice }))
|
|
}, [])
|
|
|
|
/**
|
|
* The navigation a press asked for and a fetch has not answered yet. Load
|
|
* BEFORE you move: drilling in first threw away the list the user was
|
|
* reading and, on failure, stranded them on an error screen for a level they
|
|
* had never seen. Holding here keeps the old rows and puts the progress on
|
|
* the pressed row, whose chevron `CascaderItem` swaps for a spinner.
|
|
*/
|
|
const [pendingNavigation, setPendingNavigation] =
|
|
React.useState<CascaderPendingNavigation | null>(null)
|
|
|
|
/** Asks for a branch's children and parks the navigation until they land.
|
|
* BOTH entry points are called unconditionally: `retryLevel` no-ops unless
|
|
* the level errored, `ensureLevel` no-ops if it has state, so a repeat press
|
|
* is free and the readiness decision stays inside the loader. */
|
|
const requestChildren = React.useCallback(
|
|
(node: CascaderNode<T>, pending: CascaderPendingNavigation) => {
|
|
setPendingNavigation(pending)
|
|
retryLoaderLevel(node.value)
|
|
ensureLevel(node.value, "level")
|
|
// Otherwise the panel is silent from the press until the level swaps.
|
|
bumpAnnouncement(null)
|
|
},
|
|
[retryLoaderLevel, ensureLevel, bumpAnnouncement]
|
|
)
|
|
|
|
const pushLevel = React.useCallback(
|
|
(nodeValue: string) => {
|
|
setPathWithReason((prev) => [...prev, nodeValue], "drill")
|
|
setQuery("")
|
|
bumpAnnouncement(null)
|
|
},
|
|
[setPathWithReason, setQuery, bumpAnnouncement]
|
|
)
|
|
|
|
const popLevel = React.useCallback(() => {
|
|
setPathWithReason(
|
|
(prev) => (prev.length ? prev.slice(0, -1) : prev),
|
|
"back"
|
|
)
|
|
setQuery("")
|
|
// Going BACK abandons a held navigation, or an in-flight branch drills in
|
|
// the moment it lands, long after the user moved on.
|
|
setPendingNavigation(null)
|
|
bumpAnnouncement(null)
|
|
}, [setPathWithReason, setQuery, bumpAnnouncement])
|
|
|
|
const goToDepth = React.useCallback(
|
|
(depth: number) => {
|
|
setPathWithReason(
|
|
(prev) => prev.slice(0, Math.max(0, depth)),
|
|
"breadcrumb"
|
|
)
|
|
setQuery("")
|
|
setPendingNavigation(null)
|
|
bumpAnnouncement(null)
|
|
},
|
|
[setPathWithReason, setQuery, bumpAnnouncement]
|
|
)
|
|
|
|
const toggleExpanded = React.useCallback(
|
|
(nodeValue: string) => {
|
|
setExpandedList((prev) =>
|
|
prev.includes(nodeValue)
|
|
? prev.filter((v) => v !== nodeValue)
|
|
: [...prev, nodeValue]
|
|
)
|
|
bumpAnnouncement(nodeValue)
|
|
},
|
|
[setExpandedList, bumpAnnouncement]
|
|
)
|
|
|
|
const isBranch = React.useCallback(
|
|
(node: CascaderNode<T>) => isCascaderBranch(index, node),
|
|
[index]
|
|
)
|
|
|
|
const isSelectable = React.useCallback(
|
|
(node: CascaderNode<T>) => isCascaderSelectable(index, node, selectable),
|
|
[index, selectable]
|
|
)
|
|
|
|
// Whether a branch can be committed AT ALL here. `"leaf"` is the only
|
|
// setting that answers no in advance; a predicate is opaque, so the check
|
|
// gutter is reserved uniformly rather than per row.
|
|
const branchesSelectable = selectable !== "leaf"
|
|
|
|
// Open flyouts, by key. A ref, not state: `handleOpenChange` needs the
|
|
// answer as of the Escape being handled, and opening a footer menu must not
|
|
// re-render the root.
|
|
const openFlyoutsRef = React.useRef<Set<string>>(new Set())
|
|
|
|
const setFlyoutOpen = React.useCallback((key: string, open: boolean) => {
|
|
if (open) openFlyoutsRef.current.add(key)
|
|
else openFlyoutsRef.current.delete(key)
|
|
}, [])
|
|
|
|
const hasOpenFlyout = React.useCallback(
|
|
() => openFlyoutsRef.current.size > 0,
|
|
[]
|
|
)
|
|
|
|
// Shallow-stabilized like `labels`: an inline array republished the context.
|
|
const stableActions = useShallowStable(actions, shallowEqualItemLists)
|
|
|
|
const resolvedActions = React.useMemo<CascaderActionItem[]>(
|
|
() => stableActions ?? EMPTY_ACTIONS,
|
|
[stableActions]
|
|
)
|
|
|
|
// A Set, not an `includes` scan: `isSelected` runs once per rendered row.
|
|
const selectedSet = React.useMemo(
|
|
() => new Set(selectedValues),
|
|
[selectedValues]
|
|
)
|
|
|
|
const isSelected = React.useCallback(
|
|
(node: CascaderNode<T>) => selectedSet.has(node.value),
|
|
[selectedSet]
|
|
)
|
|
|
|
/** Selected-descendant counts, walked ONCE per selection change and never
|
|
* stored, so the flat value array stays the only source of truth. The walk
|
|
* is `selections x depth`, up from each selection rather than down from each
|
|
* row, which keeps the count and `isIndeterminate` O(1). */
|
|
const selectedDescendants = React.useMemo(
|
|
() => getCascaderSelectedDescendants(index, selectedValues),
|
|
[index, selectedValues]
|
|
)
|
|
|
|
const selectedDescendantCount = React.useCallback(
|
|
(node: CascaderNode<T>) => selectedDescendants.get(node.value) ?? 0,
|
|
[selectedDescendants]
|
|
)
|
|
|
|
// Partial selections read off the SAME map, so the dash and the number are
|
|
// two readings of one traversal and cannot drift apart.
|
|
const indeterminateSet = React.useMemo(() => {
|
|
if (!cascade || !multiple) return null
|
|
return getCascaderIndeterminateFrom(selectedDescendants, selectedValues)
|
|
}, [cascade, multiple, selectedDescendants, selectedValues])
|
|
|
|
const isIndeterminate = React.useCallback(
|
|
(node: CascaderNode<T>) => indeterminateSet?.has(node.value) ?? false,
|
|
[indeterminateSet]
|
|
)
|
|
|
|
// Base UI owns the highlight; mirrored so handlers can read it unsubscribed.
|
|
const highlightedRef = React.useRef<CascaderNode<T> | null>(null)
|
|
const getHighlighted = React.useCallback(() => highlightedRef.current, [])
|
|
|
|
// The same highlight as an external store, for the consumer that must
|
|
// RE-RENDER on it. `useState`, not `useMemo`: React may drop a memo, and a
|
|
// second store would silently orphan every subscriber.
|
|
const [highlightStore] = React.useState<CascaderHighlightStore>(
|
|
createCascaderHighlightStore
|
|
)
|
|
|
|
/* ------------------------------ announcement ---------------------------- */
|
|
|
|
const currentLoadState = React.useMemo<CascaderLoadState | null>(
|
|
() => loadStates.get(currentLevelKey) ?? null,
|
|
[loadStates, currentLevelKey]
|
|
)
|
|
const searchLoadState = loader.searchState
|
|
|
|
/** What the query announcement COUNTS: matches, not rows, since the list
|
|
* also carries a tree query's ancestor context and the Load-more row. Server
|
|
* hits count as-is; re-judging them locally would drop what it matched on
|
|
* data the client cannot see. */
|
|
const announcedMatches = React.useMemo(() => {
|
|
if (deepResults) return deepResults.length
|
|
const normalized = normalizeCascaderQuery(query)
|
|
if (!normalized) return 0
|
|
const matches = filter ?? matchesCascaderQuery
|
|
return renderedItems.filter(
|
|
(node) => !isCascaderMoreNode(node) && matches(node, normalized)
|
|
).length
|
|
}, [deepResults, renderedItems, query, filter])
|
|
|
|
// The live-region text plus whether it may be DEFERRED; see
|
|
// `ANNOUNCE_DEBOUNCE`. The committed string is state, set below.
|
|
const announcementTarget = React.useMemo(() => {
|
|
// An INLINE cascader is never "open", so `open` alone silenced its tree.
|
|
if (!open && !inline) return { text: "", defer: false }
|
|
|
|
const total = renderedItems.length
|
|
const marker = ANNOUNCE_MARKER.repeat(announceSignal.seq % 2)
|
|
|
|
// A notice outranks everything below: it exists because nothing else on
|
|
// screen changed. It rides the signal, so the next navigation replaces it.
|
|
if (announceSignal.notice) {
|
|
return { text: `${announceSignal.notice}${marker}`, defer: false }
|
|
}
|
|
|
|
// A tree toggle changes neither level nor query, so it rides the signal.
|
|
const toggled =
|
|
mode === "tree" && announceSignal.toggled
|
|
? (index.byValue.get(announceSignal.toggled) ?? null)
|
|
: null
|
|
|
|
// Async states outrank everything below, or a fetching level announces "0
|
|
// results" and contradicts itself. A HELD navigation outranks even those:
|
|
// nothing on screen has moved yet, so "loading" is the only truth.
|
|
const pendingState = pendingNavigation
|
|
? (loadStates.get(pendingNavigation.value) ?? null)
|
|
: null
|
|
const levelState = query.trim() ? searchLoadState : currentLoadState
|
|
let text: string
|
|
let defer = false
|
|
if (pendingState?.error) {
|
|
text = labels.error
|
|
} else if (pendingNavigation) {
|
|
// Always the FIRST-page string: the rows on screen belong to the level
|
|
// being left behind, so their count says nothing about this request.
|
|
text = labels.loading
|
|
} else if (levelState?.error) {
|
|
text = labels.error
|
|
} else if (levelState?.loading) {
|
|
// With a query set the in-flight load IS the server search, and calling
|
|
// that "Loading more" misreads what is being waited on.
|
|
text = query.trim()
|
|
? labels.searchingAnnouncement
|
|
: total > 0
|
|
? labels.loadingMore
|
|
: labels.loading
|
|
} else if (query.trim()) {
|
|
// An empty result set is the more useful thing to say than "0 results".
|
|
text =
|
|
announcedMatches === 0
|
|
? labels.empty
|
|
: labels.resultsAnnouncement(announcedMatches)
|
|
// The ONE deferred branch: only a stream of keystrokes needs coalescing.
|
|
defer = true
|
|
} else if (toggled) {
|
|
text = expanded.has(toggled.value)
|
|
? labels.expandedAnnouncement(
|
|
toggled.label,
|
|
getCascaderCount(index, toggled)
|
|
)
|
|
: labels.collapsedAnnouncement(toggled.label)
|
|
} else if (currentParent) {
|
|
// `path.length + 1` matches tree mode's `aria-level`: the rows are
|
|
// `currentParent`'s CHILDREN, one level below it.
|
|
text = labels.levelAnnouncement(
|
|
currentParent.label,
|
|
path.length + 1,
|
|
total
|
|
)
|
|
} else {
|
|
// Returning to the root would otherwise announce nothing at all.
|
|
text = labels.rootAnnouncement(total)
|
|
}
|
|
|
|
return { text: `${text}${marker}`, defer }
|
|
}, [
|
|
open,
|
|
inline,
|
|
mode,
|
|
index,
|
|
expanded,
|
|
announceSignal,
|
|
query,
|
|
labels,
|
|
renderedItems.length,
|
|
announcedMatches,
|
|
currentParent,
|
|
path.length,
|
|
currentLoadState,
|
|
searchLoadState,
|
|
pendingNavigation,
|
|
loadStates,
|
|
])
|
|
|
|
/** The COMMITTED announcement. Immediate texts commit during render (the
|
|
* pattern `swap` uses), so an async settle announces in the very commit that
|
|
* shows the rows. Only the deferred query count uses the timer below. */
|
|
const [announced, setAnnounced] = React.useState(announcementTarget)
|
|
|
|
if (!announcementTarget.defer && announced.text !== announcementTarget.text) {
|
|
// Terminates: after the set the condition is false on the re-render.
|
|
setAnnounced(announcementTarget)
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
if (!announcementTarget.defer) return undefined
|
|
const timer = setTimeout(
|
|
() => setAnnounced(announcementTarget),
|
|
ANNOUNCE_DEBOUNCE
|
|
)
|
|
return () => clearTimeout(timer)
|
|
}, [announcementTarget])
|
|
|
|
const announcement = announced.text
|
|
|
|
/* --------------------------------- state -------------------------------- */
|
|
|
|
// The volatile half: rebuilt by a keystroke, hence split from the actions.
|
|
const stateValue = React.useMemo<CascaderStateContextValue<T>>(
|
|
() => ({
|
|
index,
|
|
path,
|
|
expanded,
|
|
query,
|
|
currentParent,
|
|
levelItems,
|
|
deepResults,
|
|
renderedItems,
|
|
columns,
|
|
treeRows,
|
|
selectedValues,
|
|
selectedDescendants,
|
|
loadStates,
|
|
searchState: searchLoadState,
|
|
announcement,
|
|
}),
|
|
[
|
|
index,
|
|
path,
|
|
expanded,
|
|
query,
|
|
currentParent,
|
|
levelItems,
|
|
deepResults,
|
|
renderedItems,
|
|
columns,
|
|
treeRows,
|
|
selectedValues,
|
|
selectedDescendants,
|
|
loadStates,
|
|
searchLoadState,
|
|
announcement,
|
|
]
|
|
)
|
|
|
|
/* ------------------------------ latest props ---------------------------- */
|
|
|
|
/**
|
|
* Latest committed props and derived state, WRITTEN IN AN EFFECT. That is
|
|
* what keeps the callbacks below `[]`-dep and stable for the life of the
|
|
* cascader; writing during render is unsafe under concurrent rendering. The
|
|
* cost: a read sees the PREVIOUS commit until the effect runs, so ONLY event
|
|
* handlers may read it. Anything used during render is memoised instead.
|
|
*/
|
|
const latest = React.useRef<CascaderLatest<T>>({
|
|
index,
|
|
state: stateValue,
|
|
mode,
|
|
multiple,
|
|
cascade,
|
|
max,
|
|
labels,
|
|
closeOnSelect,
|
|
selectedValues,
|
|
deepResults,
|
|
onValueChange,
|
|
resolveNode,
|
|
isSelectable,
|
|
needsChildren,
|
|
expanded,
|
|
})
|
|
|
|
React.useEffect(() => {
|
|
latest.current = {
|
|
index,
|
|
state: stateValue,
|
|
mode,
|
|
multiple,
|
|
cascade,
|
|
max,
|
|
labels,
|
|
closeOnSelect,
|
|
selectedValues,
|
|
deepResults,
|
|
onValueChange,
|
|
resolveNode,
|
|
isSelectable,
|
|
needsChildren,
|
|
expanded,
|
|
}
|
|
})
|
|
|
|
const getIndex = React.useCallback(() => latest.current.index, [])
|
|
const getState = React.useCallback(() => latest.current.state, [])
|
|
|
|
const goToLevelAt = React.useCallback(
|
|
(nodeValue: string, depth: number) => {
|
|
setPathWithReason(
|
|
(prev) => [...prev.slice(0, Math.max(0, depth)), nodeValue],
|
|
"drill"
|
|
)
|
|
setQuery("")
|
|
bumpAnnouncement(null)
|
|
},
|
|
[setPathWithReason, setQuery, bumpAnnouncement]
|
|
)
|
|
|
|
const navigateAt = React.useCallback(
|
|
(node: CascaderNode<T>, depth: number) => {
|
|
if (latest.current.mode === "tree") {
|
|
toggleExpanded(node.value)
|
|
return
|
|
}
|
|
if (latest.current.needsChildren(node)) {
|
|
requestChildren(node, { value: node.value, kind: "at", depth })
|
|
return
|
|
}
|
|
goToLevelAt(node.value, depth)
|
|
},
|
|
[toggleExpanded, goToLevelAt, requestChildren]
|
|
)
|
|
|
|
const navigate = React.useCallback(
|
|
(node: CascaderNode<T>) => {
|
|
const {
|
|
mode: currentMode,
|
|
deepResults: currentDeepResults,
|
|
index: currentIndex,
|
|
expanded: currentExpanded,
|
|
needsChildren: pending,
|
|
} = latest.current
|
|
|
|
if (currentMode === "tree") {
|
|
// Collapsing never needs data. Opening waits WITHOUT expanding, so
|
|
// the branch stays shut with a spinner rather than opening onto no rows.
|
|
if (!currentExpanded.has(node.value) && pending(node)) {
|
|
requestChildren(node, {
|
|
value: node.value,
|
|
kind: "expand",
|
|
depth: 0,
|
|
})
|
|
return
|
|
}
|
|
toggleExpanded(node.value)
|
|
return
|
|
}
|
|
|
|
// A deep-search hit sits at any depth, so drilling in rebuilds the trail
|
|
// rather than appending to wherever the user was.
|
|
if (currentDeepResults) {
|
|
if (pending(node)) {
|
|
const ancestors = getCascaderPath(currentIndex, node.value)
|
|
requestChildren(node, {
|
|
value: node.value,
|
|
kind: "at",
|
|
// The hit's own depth, so the settle rebuilds the same trail.
|
|
depth: Math.max(0, ancestors.length - 1),
|
|
})
|
|
return
|
|
}
|
|
const ancestors = getCascaderPath(currentIndex, node.value)
|
|
setPathWithReason(
|
|
ancestors.map((entry) => entry.value),
|
|
"drill"
|
|
)
|
|
setQuery("")
|
|
bumpAnnouncement(null)
|
|
return
|
|
}
|
|
|
|
if (pending(node)) {
|
|
requestChildren(node, { value: node.value, kind: "push", depth: 0 })
|
|
return
|
|
}
|
|
pushLevel(node.value)
|
|
},
|
|
[
|
|
toggleExpanded,
|
|
setPathWithReason,
|
|
setQuery,
|
|
pushLevel,
|
|
bumpAnnouncement,
|
|
requestChildren,
|
|
]
|
|
)
|
|
|
|
/**
|
|
* The other half of `requestChildren`: the fetch settled, so the held
|
|
* navigation happens or is dropped. Reads the load STATE, not a promise:
|
|
* only the loader knows a request was superseded, aborted or retried, and no
|
|
* entry at all means it has not reached the store yet. On failure nothing
|
|
* moves and the intent is KEPT, which is what makes the row's retry
|
|
* affordance mean something; only `popLevel`, `goToDepth` and a close drop it.
|
|
*/
|
|
React.useEffect(() => {
|
|
if (!pendingNavigation) return
|
|
const state = loadStates.get(pendingNavigation.value)
|
|
if (!state || state.loading || state.error) return
|
|
|
|
if (!open && !inline) return
|
|
// Navigation notifies the controlled parent; dispatch only after this
|
|
// commit and discard the intent if the popup/context changes first.
|
|
let cancelled = false
|
|
queueMicrotask(() => {
|
|
if (cancelled) return
|
|
setPendingNavigation(null)
|
|
if (pendingNavigation.kind === "expand") toggleExpanded(pendingNavigation.value)
|
|
else if (pendingNavigation.kind === "at") goToLevelAt(pendingNavigation.value, pendingNavigation.depth)
|
|
else pushLevel(pendingNavigation.value)
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [pendingNavigation, loadStates, toggleExpanded, goToLevelAt, pushLevel, open, inline])
|
|
|
|
// A popup that closes mid-flight must not drill in when the answer arrives;
|
|
// the loader aborts the request, this drops the intent.
|
|
if (!open && !inline && pendingNavigation !== null) setPendingNavigation(null)
|
|
|
|
/* ------------------------------- selection ------------------------------ */
|
|
|
|
// The one place the selection changes, so `onValueChange` reports the same
|
|
// shape whatever caused it: a row press, drag-select, or the headless hooks.
|
|
const emitSelection = React.useCallback(
|
|
(
|
|
nextValues: string[],
|
|
node: CascaderNode<T> | null,
|
|
reason: CascaderChangeReason
|
|
) => {
|
|
const {
|
|
selectedValues: current,
|
|
multiple: currentMultiple,
|
|
onValueChange: emit,
|
|
index: currentIndex,
|
|
resolveNode: resolve,
|
|
} = latest.current
|
|
|
|
// Nothing changed. The dedup lives here now, not in `useControllable`.
|
|
const unchanged =
|
|
nextValues.length === current.length &&
|
|
nextValues.every((entry, i) => entry === current[i])
|
|
if (unchanged) return
|
|
|
|
const next = currentMultiple ? nextValues : (nextValues[0] ?? "")
|
|
setValue(next)
|
|
|
|
if (!emit) return
|
|
|
|
const chain = node ? getCascaderPath(currentIndex, node.value) : []
|
|
emit(next, {
|
|
node,
|
|
// A node absent from `items` has no chain, so fall back to the node
|
|
// rather than report an empty path for a change that had one.
|
|
path: node ? (chain.length ? chain : [node]) : [],
|
|
// `resolveNode`, not `index.byValue`: a selected value missing from
|
|
// `items` must still come back rather than be dropped.
|
|
nodes: nextValues.map(resolve),
|
|
reason,
|
|
})
|
|
},
|
|
[setValue]
|
|
)
|
|
|
|
const setSelection = React.useCallback(
|
|
(values: string[], reason?: CascaderChangeReason) => {
|
|
const { selectedValues: current, resolveNode: resolve } = latest.current
|
|
const before = new Set(current)
|
|
const after = new Set(values)
|
|
const added = values.find((entry) => !before.has(entry))
|
|
const removed = current.find((entry) => !after.has(entry))
|
|
|
|
if (reason === "clear") {
|
|
emitSelection(values, null, "clear")
|
|
return
|
|
}
|
|
if (added) {
|
|
emitSelection(values, resolve(added), reason ?? "select")
|
|
return
|
|
}
|
|
if (removed) {
|
|
emitSelection(values, resolve(removed), reason ?? "deselect")
|
|
return
|
|
}
|
|
// Same set: a no-op, but routed so an explicit `reason` still applies.
|
|
emitSelection(values, null, reason ?? "select")
|
|
},
|
|
[emitSelection]
|
|
)
|
|
|
|
const commit = React.useCallback(
|
|
(node: CascaderNode<T>) => {
|
|
const {
|
|
multiple: currentMultiple,
|
|
cascade: currentCascade,
|
|
selectedValues: current,
|
|
max: currentMax,
|
|
index: currentIndex,
|
|
isSelectable: currentIsSelectable,
|
|
labels: currentLabels,
|
|
closeOnSelect: currentCloseOnSelect,
|
|
} = latest.current
|
|
|
|
if (!currentMultiple) {
|
|
emitSelection([node.value], node, "select")
|
|
if (currentCloseOnSelect) {
|
|
openReasonRef.current = "item-press"
|
|
setOpen(false)
|
|
} else {
|
|
// The popup stays up, so clear the query the way a multiple commit
|
|
// does; the next pick then starts from the full level.
|
|
setQuery("")
|
|
}
|
|
return
|
|
}
|
|
|
|
if (currentCascade) {
|
|
const selecting = !current.includes(node.value)
|
|
const next = applyCascadeSelection(
|
|
currentIndex,
|
|
current,
|
|
node.value,
|
|
selecting,
|
|
currentIsSelectable
|
|
)
|
|
// `max` caps the whole gesture rather than truncating it: an arbitrary
|
|
// prefix of a subtree is a selection nobody asked for. The refusal
|
|
// changes nothing on screen, so the live region is its only witness.
|
|
if (
|
|
selecting &&
|
|
currentMax != null &&
|
|
next.length > currentMax &&
|
|
next.length > current.length
|
|
) {
|
|
announceNotice(currentLabels.maxReachedAnnouncement(currentMax))
|
|
return
|
|
}
|
|
emitSelection(next, node, selecting ? "select" : "deselect")
|
|
// How many values the closure swept along is invisible from the one
|
|
// row pressed, so the sweep says its number out loud, itself excluded.
|
|
const before = new Set(current)
|
|
const after = new Set(next)
|
|
let swept = 0
|
|
for (const entry of selecting ? next : current) {
|
|
if (entry === node.value) continue
|
|
if (selecting ? !before.has(entry) : !after.has(entry)) swept += 1
|
|
}
|
|
if (swept > 0) {
|
|
announceNotice(
|
|
currentLabels.cascadeAnnouncement(
|
|
node.label ?? node.value,
|
|
swept,
|
|
selecting
|
|
)
|
|
)
|
|
}
|
|
setQuery("")
|
|
return
|
|
}
|
|
|
|
if (current.includes(node.value)) {
|
|
emitSelection(
|
|
current.filter((v) => v !== node.value),
|
|
node,
|
|
"deselect"
|
|
)
|
|
} else if (currentMax == null || current.length < currentMax) {
|
|
emitSelection([...current, node.value], node, "select")
|
|
} else {
|
|
// The plain-multiple twin of the cascade refusal above: just as silent.
|
|
announceNotice(currentLabels.maxReachedAnnouncement(currentMax))
|
|
}
|
|
setQuery("")
|
|
},
|
|
[emitSelection, setOpen, setQuery, announceNotice]
|
|
)
|
|
|
|
/** Safety net for paths that bypass the row-level veto (the drag-select
|
|
* mouseup). `details.cancel()` suppresses the commit and the close together:
|
|
* Base UI shares one event-details object between them. */
|
|
const handleComboboxValueChange = React.useCallback(
|
|
(
|
|
next: CascaderNode<T> | CascaderNode<T>[] | null,
|
|
details: ComboboxPrimitive.Root.ChangeEventDetails
|
|
) => {
|
|
if (!multiple) {
|
|
const node = Array.isArray(next) ? (next[0] ?? null) : next
|
|
// The paging row is a real option, so Base UI reports it as a
|
|
// selection. Same safety net branches get, for paths a row cannot veto.
|
|
if (node && isCascaderMoreNode(node)) {
|
|
details.cancel()
|
|
return
|
|
}
|
|
if (node && !isSelectable(node) && isBranch(node)) {
|
|
details.cancel()
|
|
navigate(node)
|
|
return
|
|
}
|
|
if (node) {
|
|
// `closeOnSelect={false}` must also stop Base UI's OWN close; one
|
|
// shared details object, so cancelling suppresses both, and our value
|
|
// is controlled, so Base UI's half had nothing to keep.
|
|
if (!closeOnSelect) details.cancel()
|
|
commit(node)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Take Base UI's whole array as the answer and inspect only what was
|
|
// ADDED. Deriving the toggle from the tail works for an add but on a
|
|
// remove drops the wrong node, leaving the last selection unclearable.
|
|
const nextNodes = Array.isArray(next) ? next : []
|
|
const before = new Set(selectedValues)
|
|
const added = nextNodes.find((node) => !before.has(node.value))
|
|
|
|
if (added && isCascaderMoreNode(added)) {
|
|
details.cancel()
|
|
return
|
|
}
|
|
|
|
if (added && !isSelectable(added) && isBranch(added)) {
|
|
details.cancel()
|
|
navigate(added)
|
|
return
|
|
}
|
|
|
|
// Cascading reads Base UI's array only as the REPORT of which row was
|
|
// pressed: it differs by exactly one value either way, and the real
|
|
// commit covers a whole subtree.
|
|
if (cascade) {
|
|
const nextValues = new Set(nextNodes.map((node) => node.value))
|
|
const removed = selectedValues.find((entry) => !nextValues.has(entry))
|
|
const toggled = added ?? (removed != null ? resolveNode(removed) : null)
|
|
if (toggled) commit(toggled)
|
|
return
|
|
}
|
|
|
|
if (
|
|
max != null &&
|
|
nextNodes.length > selectedValues.length &&
|
|
nextNodes.length > max
|
|
) {
|
|
details.cancel()
|
|
// The third silent refusal site: cancelling reverts the press with no
|
|
// state change, so the live region is the only witness.
|
|
announceNotice(labels.maxReachedAnnouncement(max))
|
|
return
|
|
}
|
|
|
|
setSelection(nextNodes.map((node) => node.value))
|
|
setQuery("")
|
|
},
|
|
[
|
|
multiple,
|
|
selectedValues,
|
|
max,
|
|
cascade,
|
|
closeOnSelect,
|
|
labels,
|
|
announceNotice,
|
|
isSelectable,
|
|
isBranch,
|
|
navigate,
|
|
commit,
|
|
resolveNode,
|
|
setSelection,
|
|
setQuery,
|
|
]
|
|
)
|
|
|
|
const handleOpenChange = React.useCallback(
|
|
(nextOpen: boolean, details: ComboboxPrimitive.Root.ChangeEventDetails) => {
|
|
if (details.isCanceled) return
|
|
|
|
// Escape closes ONE popup at a time. `Combobox` builds no `FloatingTree`,
|
|
// so one Escape reaches the flyout and the cascader; the flyout clears
|
|
// its registration in an effect, so the set holds for exactly this event.
|
|
if (!nextOpen && details.reason === "escape-key" && hasOpenFlyout()) {
|
|
details.cancel()
|
|
return
|
|
}
|
|
|
|
// Captured for the wrapped `onOpenChange`, which runs synchronously
|
|
// inside `setOpen`, so a ref written just before the call is exact.
|
|
openReasonRef.current = details.reason ?? "none"
|
|
setOpen(nextOpen)
|
|
|
|
if (!nextOpen) {
|
|
setQuery("")
|
|
return
|
|
}
|
|
|
|
// Opening onto the level holding the selection is the difference
|
|
// between "edit this" and "start over".
|
|
if (revealSelected) {
|
|
setQuery("")
|
|
const first = toArray(value)[0]
|
|
setPathWithReason(
|
|
first
|
|
? getCascaderPath(index, first)
|
|
.slice(0, -1)
|
|
.map((node) => node.value)
|
|
: [],
|
|
"reveal"
|
|
)
|
|
}
|
|
},
|
|
[
|
|
setOpen,
|
|
setQuery,
|
|
revealSelected,
|
|
value,
|
|
index,
|
|
setPathWithReason,
|
|
hasOpenFlyout,
|
|
]
|
|
)
|
|
|
|
/* -------------------------------- context ------------------------------- */
|
|
|
|
// NOT memoised further: render props must be the current closure, and only
|
|
// rows consume this. See `CascaderRenderContextValue`.
|
|
const renderContext = React.useMemo(
|
|
() => ({ renderItem, renderLabel }),
|
|
[renderItem, renderLabel]
|
|
)
|
|
|
|
/** The stable half: nothing in these deps moves on a keystroke, a level
|
|
* change or a highlight, which is what makes `React.memo` on `CascaderItem`
|
|
* hold while the user types. Destructured off `loader` so the memo depends
|
|
* on its `[]`-dep callbacks, not the object it republishes per load. */
|
|
const { active: hasLoader, loadMore, retryLevel, prefetchNode } = loader
|
|
|
|
const actionsValue = React.useMemo<CascaderActionsContextValue<T>>(
|
|
() => ({
|
|
index,
|
|
mode,
|
|
multiple,
|
|
cascade,
|
|
branchesSelectable,
|
|
indicator,
|
|
expandTrigger,
|
|
actions: resolvedActions,
|
|
searchScope,
|
|
maxHeight,
|
|
inline: !!inline,
|
|
invalid: !!invalid,
|
|
baseId,
|
|
labels,
|
|
virtualized,
|
|
registerVirtualRenderer,
|
|
virtualize,
|
|
virtualizeThreshold,
|
|
estimateRowSize,
|
|
overscan,
|
|
hasLoader,
|
|
loadMore,
|
|
retryLevel,
|
|
invalidateLevel,
|
|
getIndex,
|
|
getState,
|
|
getHighlighted,
|
|
setPath,
|
|
pushLevel,
|
|
popLevel,
|
|
goToDepth,
|
|
toggleExpanded,
|
|
setFlyoutOpen,
|
|
hasOpenFlyout,
|
|
setQuery,
|
|
setSelection,
|
|
commit,
|
|
navigate,
|
|
navigateAt,
|
|
resolveNode,
|
|
isBranch,
|
|
isSelectable,
|
|
isSelected,
|
|
isIndeterminate,
|
|
selectedDescendantCount,
|
|
}),
|
|
[
|
|
index,
|
|
mode,
|
|
multiple,
|
|
cascade,
|
|
branchesSelectable,
|
|
indicator,
|
|
expandTrigger,
|
|
resolvedActions,
|
|
searchScope,
|
|
maxHeight,
|
|
inline,
|
|
invalid,
|
|
baseId,
|
|
labels,
|
|
virtualized,
|
|
registerVirtualRenderer,
|
|
virtualize,
|
|
virtualizeThreshold,
|
|
estimateRowSize,
|
|
overscan,
|
|
hasLoader,
|
|
loadMore,
|
|
retryLevel,
|
|
invalidateLevel,
|
|
getIndex,
|
|
getState,
|
|
getHighlighted,
|
|
setPath,
|
|
pushLevel,
|
|
popLevel,
|
|
goToDepth,
|
|
toggleExpanded,
|
|
setFlyoutOpen,
|
|
hasOpenFlyout,
|
|
setQuery,
|
|
setSelection,
|
|
commit,
|
|
navigate,
|
|
navigateAt,
|
|
resolveNode,
|
|
isBranch,
|
|
isSelectable,
|
|
isSelected,
|
|
isIndeterminate,
|
|
selectedDescendantCount,
|
|
]
|
|
)
|
|
|
|
// A fresh array identity each time windowing turns on, and once more after:
|
|
// that identity is the ONLY thing that re-runs Base UI's `listRef` sizing
|
|
// effect. See `virtualSyncs`.
|
|
const comboboxItems = React.useMemo(
|
|
() => (virtualized ? renderedItems.slice() : renderedItems),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[renderedItems, virtualized, virtualSyncs]
|
|
)
|
|
|
|
const comboboxValue = React.useMemo(() => {
|
|
if (multiple) return selectedValues.map(resolveNode)
|
|
return selectedValues.length ? resolveNode(selectedValues[0]) : null
|
|
}, [multiple, selectedValues, resolveNode])
|
|
|
|
return (
|
|
// Providers store values with the item generic erased; the hooks re-apply
|
|
// the caller's own `T` on the way back out.
|
|
<CascaderActionsContext.Provider
|
|
value={actionsValue as unknown as CascaderActionsContextValue}
|
|
>
|
|
<CascaderStateContext.Provider
|
|
value={stateValue as unknown as CascaderStateContextValue}
|
|
>
|
|
<CascaderHighlightContext.Provider value={highlightStore}>
|
|
<CascaderRenderContext.Provider
|
|
value={renderContext as unknown as CascaderRenderContextValue}
|
|
>
|
|
<ComboboxPrimitive.Root
|
|
items={renderedItems}
|
|
/* The empty frame that resets the highlight. See the comment above. */
|
|
filteredItems={swapping ? EMPTY : comboboxItems}
|
|
/* Filtering is ours: level vs deep search scope cannot be
|
|
expressed through Base UI's single matcher. */
|
|
filter={null}
|
|
value={comboboxValue}
|
|
onValueChange={handleComboboxValueChange}
|
|
open={open}
|
|
onOpenChange={handleOpenChange}
|
|
inputValue={query}
|
|
onInputValueChange={(next: string) => setQuery(next)}
|
|
multiple={multiple}
|
|
disabled={disabled}
|
|
/* Form wiring, straight to Base UI's hidden input. Conditional
|
|
spreads: an explicit `undefined` on a controlled prop reads as
|
|
"deleted" rather than "not supplied". */
|
|
name={name}
|
|
{...(form != null ? { form } : null)}
|
|
{...(id != null ? { id } : null)}
|
|
{...(required != null ? { required } : null)}
|
|
{...(readOnly != null ? { readOnly } : null)}
|
|
{...(inputRef ? { inputRef } : null)}
|
|
inline={inline}
|
|
/* Stops Base UI rendering its composite list, which is what makes
|
|
an explicit row `index` legal. `items`/`filteredItems` stay full:
|
|
they size `listRef`, or ArrowDown stops at the last RENDERED row. */
|
|
virtualized={virtualized}
|
|
itemToStringValue={(item: CascaderNode<T>) => item?.value ?? ""}
|
|
itemToStringLabel={(item: CascaderNode<T>) => item?.label ?? ""}
|
|
isItemEqualToValue={(a: CascaderNode<T>, b: CascaderNode<T>) =>
|
|
a?.value === b?.value
|
|
}
|
|
// Fires on every arrow key AND every pointer move, so it must
|
|
// never call setState. The ref serves keyboard handlers, the
|
|
// de-duping store anything that has to re-render.
|
|
onItemHighlighted={(
|
|
item: CascaderNode<T> | undefined,
|
|
details: ComboboxPrimitive.Root.HighlightEventDetails
|
|
) => {
|
|
highlightedRef.current = item ?? null
|
|
highlightStore.set({
|
|
index: details.index,
|
|
value: item?.value ?? null,
|
|
})
|
|
// Schedules a TIMEOUT and returns: inside a layout effect, a
|
|
// synchronous setState would cascade before paint.
|
|
prefetchNode(item)
|
|
}}
|
|
>
|
|
{children}
|
|
</ComboboxPrimitive.Root>
|
|
</CascaderRenderContext.Provider>
|
|
</CascaderHighlightContext.Provider>
|
|
</CascaderStateContext.Provider>
|
|
</CascaderActionsContext.Provider>
|
|
)
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Trigger */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
/** `Trigger.Props` stops short of `ref`, so it is intersected in, as the
|
|
* shadcn combobox wrapper also does: a form library needs a ref here, and
|
|
* `onBlur`, the "touched" signal, lands here too. */
|
|
export interface CascaderTriggerProps
|
|
extends
|
|
ComboboxPrimitive.Trigger.Props,
|
|
Pick<React.ComponentPropsWithRef<"button">, "ref"> {
|
|
/** Hides the trailing chevron, for a trigger that supplies its own. */
|
|
showIcon?: boolean
|
|
}
|
|
|
|
function CascaderTrigger({
|
|
className,
|
|
children,
|
|
showIcon = true,
|
|
ref,
|
|
...props
|
|
}: CascaderTriggerProps) {
|
|
const { invalid } = useCascaderActions()
|
|
|
|
// `ref` is the one prop `mergeProps` does not merge, so chain it by hand.
|
|
const triggerRef = React.useRef<HTMLButtonElement | null>(null)
|
|
const setTrigger = React.useCallback(
|
|
(node: HTMLButtonElement | null) => {
|
|
triggerRef.current = node
|
|
if (typeof ref === "function") ref(node)
|
|
else if (ref) ref.current = node
|
|
},
|
|
[ref]
|
|
)
|
|
|
|
/** 18 of 19 audited examples shipped an unnamed combobox: the trigger's
|
|
* contents are the field's VALUE, so a screen reader never hears its NAME.
|
|
* Checked in the DOM, since the name may arrive as a `<label for>`. */
|
|
React.useEffect(() => {
|
|
if (process.env.NODE_ENV === "production") return
|
|
const element = triggerRef.current
|
|
if (!element) return
|
|
const named =
|
|
element.hasAttribute("aria-label") ||
|
|
element.hasAttribute("aria-labelledby") ||
|
|
element.closest("label") !== null ||
|
|
(element.id !== "" &&
|
|
element.ownerDocument.querySelector(
|
|
`label[for="${CSS.escape(element.id)}"]`
|
|
) !== null)
|
|
if (named) return
|
|
warnCascaderOnce(
|
|
"trigger-unnamed",
|
|
"`CascaderTrigger` has no accessible name. Its contents are the field's VALUE - they change with the selection - so a screen reader hears what is picked but never what the field is for. Pass `aria-label` or `aria-labelledby`, or reference the trigger's `id` from a `<label>`."
|
|
)
|
|
})
|
|
|
|
return (
|
|
<ComboboxPrimitive.Trigger
|
|
ref={setTrigger}
|
|
data-slot="cascader-trigger"
|
|
/* Conditional spread: an explicit `undefined` would DELETE whatever a
|
|
`Field` wrapper had already put here. */
|
|
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
|
|
className={cn(TRIGGER_ICON_FALLBACK_CLASS, className)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
{showIcon ? (
|
|
<ChevronDownIcon className={`${TRIGGER_ICON_CLASS} pointer-events-none shrink-0`} />
|
|
) : null}
|
|
</ComboboxPrimitive.Trigger>
|
|
)
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Resolved theme */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
/* The eight registry styles each state a combobox surface differently, so the
|
|
* classes below spell every one out with `style-<name>:` variants. Nothing
|
|
* depends on ReUI's theme CSS; `build-registry` flattens one in on install. */
|
|
|
|
/** Icon size for a combobox surface: 14px in mira and sera, 16px elsewhere. */
|
|
const TRIGGER_ICON_CLASS =
|
|
"text-muted-foreground size-4"
|
|
|
|
/** The same ladder, applied only to trigger children that carry no size. */
|
|
const TRIGGER_ICON_FALLBACK_CLASS =
|
|
"[&_svg:not([class*='size-'])]:size-4"
|
|
|
|
/** The chips field: a form-control surface, so it carries the invalid states. */
|
|
const CHIPS_CLASS =
|
|
"flex flex-wrap items-center border bg-clip-padding dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 bg-transparent px-2.5 text-sm focus-within:ring-3 has-aria-invalid:ring-3 min-h-8 gap-1 rounded-lg py-1 transition-colors has-data-[slot=combobox-chip]:px-1"
|
|
|
|
const CHIP_CLASS =
|
|
"text-foreground flex w-fit items-center justify-center gap-1 font-medium whitespace-nowrap bg-muted rounded-sm px-1.5 text-xs has-data-[slot=combobox-chip-remove]:pr-0 h-[calc(--spacing(5.25))]"
|
|
|
|
const CHIP_REMOVE_CLASS =
|
|
"opacity-50 hover:opacity-100 -ml-1"
|
|
|
|
/** The floating panel's surface. The shared popup's `InputGroup` sizing is
|
|
* left out (mirrored in `cascader-nav.tsx`), as are its `max-h-72` and
|
|
* `min-w-*`: `CascaderContent` sets both from the positioner's variables. */
|
|
const CONTENT_SURFACE_CLASS =
|
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2"
|
|
|
|
const EMPTY_CLASS =
|
|
"text-muted-foreground hidden w-full justify-center py-2 text-center group-data-empty/combobox-content:flex text-sm"
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Chips */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
/** Ref for the chips container: they replace the trigger, so the popup has to
|
|
* be anchored to them. Hand it to `CascaderContent`'s `anchor`. */
|
|
export function useCascaderAnchor() {
|
|
return React.useRef<HTMLDivElement | null>(null)
|
|
}
|
|
|
|
export interface CascaderChipsProps
|
|
extends
|
|
Omit<ComboboxPrimitive.Chips.Props, "children">,
|
|
Pick<React.ComponentPropsWithRef<"div">, "ref"> {
|
|
/** Shown in place of the chips when nothing is selected. */
|
|
placeholder?: React.ReactNode
|
|
/**
|
|
* How a `cascade` selection is condensed into chips. DISPLAY ONLY: the
|
|
* stored value stays the full closure, which keeps a row's checked state an
|
|
* O(1) lookup. `"all"` (default) is one chip per value, `"parent"` collapses
|
|
* a fully selected branch, `"child"` chips only the deepest frontier.
|
|
* Removing a condensed chip removes its whole closure and reconciles.
|
|
*/
|
|
strategy?: CascaderCheckedStrategy
|
|
/** Replaces the chip list, receiving the resolved selection IN SELECTION
|
|
* ORDER. Keep that order: Base UI removes a chip by sibling position. */
|
|
children?: React.ReactNode | ((nodes: CascaderNode[]) => React.ReactNode)
|
|
}
|
|
|
|
/**
|
|
* The multi-select trigger surface: one removable chip per selection, where
|
|
* `CascaderValue` would collapse to "3 selected". A component rather than a
|
|
* recipe because of three easy misses: the popup anchored to the chips, an
|
|
* accessible name on every remove button, and labels that disambiguate when
|
|
* two branches use the same one.
|
|
*
|
|
* ```tsx
|
|
* const anchor = useCascaderAnchor()
|
|
* <Cascader multiple items={items}>
|
|
* <CascaderChips ref={anchor} placeholder="Select attributes" />
|
|
* <CascaderContent anchor={anchor}>...</CascaderContent>
|
|
* </Cascader>
|
|
* ```
|
|
*/
|
|
function CascaderChips({
|
|
className,
|
|
children,
|
|
placeholder,
|
|
strategy = "all",
|
|
...props
|
|
}: CascaderChipsProps) {
|
|
const {
|
|
labels,
|
|
multiple,
|
|
invalid,
|
|
resolveNode,
|
|
index,
|
|
isSelectable,
|
|
setSelection,
|
|
} = useCascaderActions()
|
|
const { selectedValues } = useCascaderState()
|
|
|
|
React.useEffect(() => {
|
|
if (process.env.NODE_ENV === "production") return
|
|
if (multiple) return
|
|
warnCascaderOnce(
|
|
"chips-without-multiple",
|
|
"`CascaderChips` renders one chip per selection and there is only ever one without `multiple`. Use `CascaderValue` for a single-select trigger."
|
|
)
|
|
}, [multiple])
|
|
|
|
// DERIVED display: the selection stays the full closure, the chips condense.
|
|
const displayedValues =
|
|
strategy === "all"
|
|
? selectedValues
|
|
: getCascaderCheckedValues(index, selectedValues, strategy)
|
|
|
|
// Not memoised: one entry per SELECTION, not per row, so the pass is short.
|
|
const nodes = displayedValues.map(resolveNode)
|
|
const ambiguous = findAmbiguousCascaderLabels(nodes)
|
|
|
|
// Removal for a CONDENSED chip: Base UI's `ChipRemove` maps a press onto the
|
|
// STORED array, which is wrong once the chips are fewer, so this removes the
|
|
// chip's whole subtree closure instead.
|
|
const removeClosure = (node: CascaderNode) => {
|
|
setSelection(
|
|
applyCascadeSelection(
|
|
index,
|
|
selectedValues,
|
|
node.value,
|
|
false,
|
|
isSelectable
|
|
),
|
|
"deselect"
|
|
)
|
|
}
|
|
|
|
const content = typeof children === "function" ? children(nodes) : children
|
|
|
|
return (
|
|
<ComboboxPrimitive.Chips
|
|
data-slot="cascader-chips"
|
|
/* An unnamed `role="toolbar"` announces as just "toolbar". */
|
|
aria-label={labels.chipsLabel}
|
|
/* Conditional spread: the error treatment is keyed on
|
|
`has-aria-invalid:`, so the attribute must be absent, not empty. */
|
|
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
|
|
className={cn(CHIPS_CLASS, className)}
|
|
{...props}
|
|
>
|
|
{content ??
|
|
(nodes.length === 0 ? (
|
|
<span
|
|
data-slot="cascader-chips-placeholder"
|
|
className="text-muted-foreground truncate"
|
|
>
|
|
{placeholder}
|
|
</span>
|
|
) : (
|
|
nodes.map((node) => (
|
|
<CascaderChip
|
|
key={node.value}
|
|
node={node}
|
|
showPath={ambiguous.has(node.value)}
|
|
/* Only a condensed chip needs the remover; the default shape
|
|
keeps Base UI's positional removal. */
|
|
{...(strategy !== "all"
|
|
? { onRemove: () => removeClosure(node) }
|
|
: null)}
|
|
/>
|
|
))
|
|
))}
|
|
</ComboboxPrimitive.Chips>
|
|
)
|
|
}
|
|
|
|
export interface CascaderChipProps extends Omit<
|
|
ComboboxPrimitive.Chip.Props,
|
|
"children"
|
|
> {
|
|
node: CascaderNode
|
|
/** Prefix the label with its ancestor trail. */
|
|
showPath?: boolean
|
|
/** Trail segments to show, the node itself included. */
|
|
maxSegments?: number
|
|
/** Hides the remove button, for a read-only chip. */
|
|
showRemove?: boolean
|
|
/** Replaces Base UI's positional removal for this chip (remove button and
|
|
* Backspace/Delete). Supplied for a `strategy`-condensed chip, whose
|
|
* position no longer maps onto the stored array. */
|
|
onRemove?: () => void
|
|
/** Replaces the chip's label. The remove button is still rendered. */
|
|
children?: React.ReactNode
|
|
}
|
|
|
|
/** Base UI hands its chip handlers events carrying the veto hook. */
|
|
type CascaderChipKeyEvent = Parameters<
|
|
NonNullable<ComboboxPrimitive.Chip.Props["onKeyDown"]>
|
|
>[0]
|
|
|
|
type CascaderChipRemoveClickEvent = Parameters<
|
|
NonNullable<ComboboxPrimitive.ChipRemove.Props["onClick"]>
|
|
>[0]
|
|
|
|
/** One chip. Removal goes through Base UI's `ChipRemove`, which reports the
|
|
* shortened selection to the root, so it lands in the same `setSelection` as
|
|
* every other deselection and `cascade` applies to it. */
|
|
function CascaderChip({
|
|
className,
|
|
node,
|
|
showPath = false,
|
|
maxSegments = 2,
|
|
showRemove = true,
|
|
onRemove,
|
|
children,
|
|
...props
|
|
}: CascaderChipProps) {
|
|
const { labels, index } = useCascaderActions()
|
|
|
|
const chain = showPath ? getCascaderPath(index, node.value) : []
|
|
const segments = collapseCascaderPath(chain, {
|
|
maxSegments,
|
|
collapse: "start",
|
|
})
|
|
|
|
const label = segments.length
|
|
? segments
|
|
.map((segment) => (segment.type === "node" ? segment.node.label : "…"))
|
|
.join(` ${labels.pathSeparator} `)
|
|
: node.label
|
|
|
|
return (
|
|
<ComboboxPrimitive.Chip
|
|
data-slot="cascader-chip"
|
|
/* Keyboard half of `onRemove`: Base UI's chip removes ITSELF by position
|
|
on Backspace/Delete, the arithmetic a condensed chip must not use. The
|
|
veto runs first, so the closure removal replaces it. */
|
|
{...(onRemove
|
|
? {
|
|
onKeyDown: (event: CascaderChipKeyEvent) => {
|
|
if (event.key !== "Backspace" && event.key !== "Delete") return
|
|
event.preventBaseUIHandler()
|
|
onRemove()
|
|
},
|
|
}
|
|
: null)}
|
|
className={cn(
|
|
CHIP_CLASS,
|
|
"has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children ?? <span className="truncate">{label}</span>}
|
|
{showRemove ? (
|
|
<ComboboxPrimitive.ChipRemove
|
|
data-slot="cascader-chip-remove"
|
|
/* Named after what the chip DISPLAYS, not the bare node label, or
|
|
two disambiguated chips both announce "Remove Created at". */
|
|
aria-label={labels.removeChip(label)}
|
|
/* The pointer half of `onRemove`, same veto, same reason. */
|
|
{...(onRemove
|
|
? {
|
|
onClick: (event: CascaderChipRemoveClickEvent) => {
|
|
event.preventBaseUIHandler()
|
|
onRemove()
|
|
},
|
|
}
|
|
: null)}
|
|
className={CHIP_REMOVE_CLASS}
|
|
>
|
|
<XIcon className="pointer-events-none" />
|
|
</ComboboxPrimitive.ChipRemove>
|
|
) : null}
|
|
</ComboboxPrimitive.Chip>
|
|
)
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Content */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface CascaderContentProps
|
|
extends
|
|
ComboboxPrimitive.Popup.Props,
|
|
/** Positioner surface forwarded as-is, so an unusual anchor does not force
|
|
* a rebuild of the content stack. No `trackAnchor`: Base UI 1.5.0 has
|
|
* none. */
|
|
Pick<
|
|
ComboboxPrimitive.Positioner.Props,
|
|
| "side"
|
|
| "align"
|
|
| "sideOffset"
|
|
| "alignOffset"
|
|
| "anchor"
|
|
| "collisionBoundary"
|
|
| "collisionPadding"
|
|
| "sticky"
|
|
| "positionMethod"
|
|
>,
|
|
/** Where the portal mounts, for a shadow root or a scoped stacking context. */
|
|
Pick<ComboboxPrimitive.Portal.Props, "container"> {}
|
|
|
|
/** Portal + Positioner + floating panel. Unlike shadcn's `ComboboxContent` it
|
|
* does NOT clamp the popup to the anchor width (a cascader panel is routinely
|
|
* wider), but it keeps `group/combobox-content`, which `CascaderEmpty` needs. */
|
|
function CascaderContent({
|
|
className,
|
|
side = "bottom",
|
|
sideOffset = 6,
|
|
align = "start",
|
|
alignOffset = 0,
|
|
anchor,
|
|
collisionBoundary,
|
|
collisionPadding,
|
|
sticky,
|
|
positionMethod,
|
|
container,
|
|
ref,
|
|
...props
|
|
}: CascaderContentProps) {
|
|
const { labels } = useCascaderActions()
|
|
const popupRef = React.useRef<HTMLDivElement | null>(null)
|
|
|
|
// `initialFocus` needs the popup element, and `ref` is the one prop
|
|
// `mergeProps` does NOT merge, so it is chained by hand rather than spread.
|
|
const setPopup = React.useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
popupRef.current = node
|
|
if (typeof ref === "function") ref(node)
|
|
else if (ref) ref.current = node
|
|
},
|
|
[ref]
|
|
)
|
|
|
|
/**
|
|
* Focus goes to the search field, where the level keys and
|
|
* `aria-activedescendant` live. Base UI's own default agrees but is COMPUTED
|
|
* FROM THE FIRST RENDER, when the field has not mounted, and collapses to
|
|
* "do not move focus": measured, a KEYBOARD open left focus on the trigger
|
|
* and cost two Tabs to reach the footer where a pointer open cost one.
|
|
*
|
|
* `initialFocus` rather than an effect, because anything scheduled alongside
|
|
* Base UI's focus manager is a race; the field is resolved INSIDE the
|
|
* callback, which runs after the layout effects. Touch is handed back to
|
|
* Base UI on purpose - focusing the popup keeps the Android keyboard shut.
|
|
*/
|
|
const initialFocus = React.useCallback((openType: string) => {
|
|
const popup = popupRef.current
|
|
if (!popup) return true
|
|
if (openType === "touch") return popup
|
|
return (
|
|
popup.querySelector<HTMLElement>('[data-slot="cascader-input"]') ?? true
|
|
)
|
|
}, [])
|
|
|
|
return (
|
|
// Conditional spreads throughout: `mergeProps` iterates own keys, so an
|
|
// explicit `undefined` reads as "deleted" rather than "not supplied".
|
|
<ComboboxPrimitive.Portal
|
|
{...(container !== undefined ? { container } : null)}
|
|
>
|
|
<ComboboxPrimitive.Positioner
|
|
side={side}
|
|
sideOffset={sideOffset}
|
|
align={align}
|
|
alignOffset={alignOffset}
|
|
anchor={anchor}
|
|
{...(collisionBoundary !== undefined ? { collisionBoundary } : null)}
|
|
{...(collisionPadding !== undefined ? { collisionPadding } : null)}
|
|
{...(sticky !== undefined ? { sticky } : null)}
|
|
{...(positionMethod !== undefined ? { positionMethod } : null)}
|
|
className="isolate z-50"
|
|
>
|
|
<ComboboxPrimitive.Popup
|
|
ref={setPopup}
|
|
data-slot="cascader-content"
|
|
/* Marks a menu surface for the docs design-system picker. An
|
|
attribute, not a class, so the panel's look stays self-contained. */
|
|
data-menu-target=""
|
|
/* The input inside makes this a `role="dialog"`, unnamed by default. */
|
|
aria-label={labels.panelLabel}
|
|
initialFocus={initialFocus}
|
|
className={cn(
|
|
CONTENT_SURFACE_CLASS,
|
|
"group/combobox-content relative flex max-h-(--available-height) max-w-(--available-width) min-w-(--anchor-width) origin-(--transform-origin) flex-col",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
</ComboboxPrimitive.Positioner>
|
|
</ComboboxPrimitive.Portal>
|
|
)
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Panel */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export type CascaderPanelProps = useRender.ComponentProps<"div">
|
|
|
|
/**
|
|
* The nav + list(s) + footer container, with no positioning of its own: inside
|
|
* `CascaderContent` for the popup case, alone for the embedded one.
|
|
*
|
|
* `min-h-0` and `max-h-full` are load-bearing. A flex child without `min-h-0`
|
|
* refuses to shrink below its content, which is the usual reason a scroll area
|
|
* never scrolls - worst embedded, where no positioner publishes
|
|
* `--available-height`. It also owns the panel's TAB ORDER: the scroll area
|
|
* makes itself a tab stop whenever the level overflows, stranding the footer,
|
|
* and the panel is the only ancestor of both ends of that move in BOTH shapes.
|
|
*/
|
|
function CascaderPanel({ className, ...props }: CascaderPanelProps) {
|
|
const { mode } = useCascaderActions()
|
|
|
|
const handleKeyDown = React.useCallback(
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (event.key !== "Tab" || event.defaultPrevented) return
|
|
// A browser-level move (Ctrl+Tab switches tabs) is never ours.
|
|
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
|
|
const panel = event.currentTarget
|
|
const target = event.target as HTMLElement | null
|
|
if (!target || !panel.contains(target)) return
|
|
|
|
const next = getCascaderTabTarget(panel, target, event.shiftKey)
|
|
// Off either end: left to the browser, since focus is never trapped.
|
|
if (!next) return
|
|
|
|
event.preventDefault()
|
|
next.focus()
|
|
},
|
|
[]
|
|
)
|
|
|
|
const defaultProps = {
|
|
"data-slot": "cascader-panel",
|
|
"data-mode": mode,
|
|
// In `defaultProps`, not around `props.onKeyDown`: `mergeProps` chains
|
|
// right to left, so a consumer's handler runs FIRST and can drop this one.
|
|
onKeyDown: handleKeyDown,
|
|
className: cn("flex max-h-full min-h-0 w-full flex-col", className),
|
|
}
|
|
|
|
return useRender({
|
|
defaultTagName: "div",
|
|
render: props.render,
|
|
props: mergeProps<"div">(defaultProps, props),
|
|
})
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* List */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface CascaderListProps extends Omit<
|
|
ComboboxPrimitive.List.Props,
|
|
"style"
|
|
> {
|
|
/** Applied to the OUTER shell, which owns the height, so an inline
|
|
* `--cascader-max-height` lands where the bound can read it. Narrowed from
|
|
* Base UI's `style`: the shell is a plain `<div>` with no list state. */
|
|
style?: React.CSSProperties
|
|
/** Caps this level's height; the panel still shrinks to fit. Default 24rem. */
|
|
maxHeight?: number | string
|
|
}
|
|
|
|
/**
|
|
* Scroll container for one level: pattern 3 of the house scroll reference,
|
|
* viewport-bounded popup scroll. The height is `min()` of `--available-height`
|
|
* (the positioner's space between anchor and viewport edge) and `maxHeight`,
|
|
* so neither can be ignored; a fixed pixel cap alone still reserved 288px for
|
|
* a panel opened 200px above the fold and spilled off screen, and wasted room
|
|
* in a tall window. The four classes are shared with `CascaderColumns` and
|
|
* live in `cascader-lib.tsx`.
|
|
*/
|
|
function CascaderList({
|
|
className,
|
|
style,
|
|
maxHeight: maxHeightProp,
|
|
...props
|
|
}: CascaderListProps) {
|
|
const { maxHeight, mode, labels, baseId, virtualized } = useCascaderActions()
|
|
const { currentParent } = useCascaderState()
|
|
const resolved = maxHeightProp ?? maxHeight
|
|
|
|
return (
|
|
<div
|
|
data-slot="cascader-list-shell"
|
|
style={
|
|
resolved != null
|
|
? ({
|
|
...style,
|
|
"--cascader-max-height":
|
|
typeof resolved === "number" ? `${resolved}px` : resolved,
|
|
} as React.CSSProperties)
|
|
: style
|
|
}
|
|
className={cn(
|
|
"relative flex max-h-full min-h-0",
|
|
CASCADER_LIST_PAD_CLASS
|
|
)}
|
|
>
|
|
<div
|
|
data-slot="cascader-list-bounds"
|
|
className={cn(
|
|
"flex w-full min-w-0 flex-col overscroll-contain",
|
|
CASCADER_LIST_HEIGHT_CLASS
|
|
)}
|
|
>
|
|
<ScrollArea className={CASCADER_SCROLL_CLASS}>
|
|
<ComboboxPrimitive.List
|
|
data-slot="cascader-list"
|
|
/* Base UI names the list nothing, so every mode shipped an unnamed
|
|
listbox. The level's parent is the name; the root borrows one. */
|
|
aria-label={currentParent?.label ?? labels.rootLevel}
|
|
/* Replaces Base UI's floating id. `aria-controls` reads the live
|
|
id, so the columns trail gets a predictable target. */
|
|
id={`${baseId}-column-0`}
|
|
/* NEVER a ternary ending in `undefined`: `mergeProps` iterates own
|
|
keys, so that DELETES Base UI's `role="listbox"`. */
|
|
{...(mode === "tree" ? { role: "tree" as const } : null)}
|
|
{...(virtualized ? { "data-virtualized": true } : null)}
|
|
className={cn(
|
|
CASCADER_ROWS_CLASS,
|
|
/* 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. */
|
|
virtualized && "relative",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Empty / Loading */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
export interface CascaderEmptyProps extends ComboboxPrimitive.Empty.Props {
|
|
/** @deprecated Ignored: the loading surface no longer draws skeletons. They
|
|
* promised a shape the level might not have and cost a second layout; the
|
|
* progress lives on the branch row instead. Kept so call sites compile. */
|
|
skeletonRows?: number
|
|
}
|
|
|
|
/** The empty, loading and error surface, in ONE element that never unmounts.
|
|
* `Combobox.Empty` renders whenever `filteredItems.length === 0`, as true of a
|
|
* fetching level as of an empty one, so swapping siblings would announce "No
|
|
* results found." over every async level. Swap the CHILDREN. */
|
|
function CascaderEmpty({
|
|
className,
|
|
children,
|
|
// Accepted and ignored; destructured so it is never spread onto the DOM.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
skeletonRows,
|
|
...props
|
|
}: CascaderEmptyProps) {
|
|
const { labels, retryLevel } = useCascaderActions()
|
|
const { query, path, searchState } = useCascaderState()
|
|
// The level whose emptiness is on screen; tree always shows the root.
|
|
const levelKey = path.length ? path[path.length - 1] : CASCADER_ROOT_KEY
|
|
const loadState = useCascaderLoadState(levelKey)
|
|
const state = query.trim() ? (searchState ?? loadState) : loadState
|
|
|
|
let body: React.ReactNode = children ?? labels.empty
|
|
|
|
if (state?.error) {
|
|
body = (
|
|
<span
|
|
data-slot="cascader-error"
|
|
className="flex flex-col items-center gap-1.5"
|
|
>
|
|
<span>{labels.error}</span>
|
|
<button
|
|
type="button"
|
|
data-slot="cascader-retry"
|
|
/* A real button because this lives OUTSIDE the listbox; a focusable
|
|
element inside a `role="option"` is `nested-interactive`. */
|
|
onClick={() => retryLevel(levelKey)}
|
|
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 rounded-md px-2 py-0.5 font-medium outline-hidden transition-colors focus-visible:ring-2"
|
|
>
|
|
{labels.retry}
|
|
</button>
|
|
</span>
|
|
)
|
|
} else if (state?.loading) {
|
|
body = (
|
|
<span
|
|
data-slot="cascader-loading"
|
|
className="flex w-full items-center justify-center"
|
|
>
|
|
{labels.loading}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ComboboxPrimitive.Empty
|
|
data-slot="cascader-empty"
|
|
data-state={state?.error ? "error" : state?.loading ? "loading" : "empty"}
|
|
className={cn(EMPTY_CLASS, className)}
|
|
/* The ONE place an explicit `undefined` is right: it DELETES Base UI's
|
|
live region, which is wanted. `CascaderStatus` is the single one, and
|
|
leaving this one spoke "No results found." on every level swap, which
|
|
renders one deliberately empty frame to reset the highlight. */
|
|
role={undefined}
|
|
aria-live={undefined}
|
|
aria-atomic={undefined}
|
|
{...props}
|
|
>
|
|
{body}
|
|
</ComboboxPrimitive.Empty>
|
|
)
|
|
}
|
|
|
|
export type CascaderStatusProps = ComboboxPrimitive.Status.Props
|
|
|
|
/**
|
|
* Polite live region for the current level and result count, which drill-down
|
|
* otherwise hides in a visual breadcrumb. Built on `Combobox.Status` for its
|
|
* initial-mutation marker: Safari and VoiceOver need a text change ~200ms
|
|
* after mount before reading a polite region at all.
|
|
*/
|
|
function CascaderStatus({
|
|
className,
|
|
children,
|
|
...props
|
|
}: CascaderStatusProps) {
|
|
const { announcement } = useCascaderState()
|
|
|
|
return (
|
|
<ComboboxPrimitive.Status
|
|
data-slot="cascader-status"
|
|
className={cn("sr-only", className)}
|
|
{...props}
|
|
>
|
|
{children ?? announcement}
|
|
</ComboboxPrimitive.Status>
|
|
)
|
|
}
|
|
|
|
export {
|
|
Cascader,
|
|
CascaderTrigger,
|
|
CascaderChip,
|
|
CascaderChips,
|
|
CascaderContent,
|
|
CascaderPanel,
|
|
CascaderList,
|
|
CascaderEmpty,
|
|
CascaderStatus,
|
|
}
|
|
|
|
/** Re-exported from `cascader-context.tsx`: existing import paths keep
|
|
* working, and the row component avoids an import cycle through this file. */
|
|
export {
|
|
createCascaderHighlightStore,
|
|
useCascaderActions,
|
|
useCascaderHighlight,
|
|
useCascaderRender,
|
|
useCascaderState,
|
|
}
|
|
|
|
export type {
|
|
CascaderActionsContextValue,
|
|
CascaderColumn,
|
|
CascaderHighlight,
|
|
CascaderHighlightStore,
|
|
CascaderItemState,
|
|
CascaderRenderContextValue,
|
|
CascaderStateContextValue,
|
|
}
|