1069 lines
39 KiB
TypeScript
1069 lines
39 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import {
|
|
useCascaderActions,
|
|
useCascaderRender,
|
|
useCascaderState,
|
|
} from "@/components/reui/cascader/cascader-context"
|
|
import {
|
|
getCascaderCount,
|
|
getCascaderMoreParent,
|
|
getCascaderPath,
|
|
isCascaderMoreNode,
|
|
} from "@/components/reui/cascader/cascader-lib"
|
|
import type {
|
|
CascaderLoadState,
|
|
CascaderNode,
|
|
} from "@/components/reui/cascader/cascader-types"
|
|
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
|
import { mergeProps } from "@base-ui/react/merge-props"
|
|
import { useRender } from "@base-ui/react/use-render"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import { LoaderCircleIcon, MinusIcon, CheckIcon, RotateCwIcon, ChevronRightIcon } from "lucide-react"
|
|
|
|
// Base UI extends its synthetic events with a handler-veto escape hatch.
|
|
type VetoableEvent = { preventBaseUIHandler?: () => void }
|
|
|
|
/** A row press. The veto hook is optional: an `as="button"` row is outside Base
|
|
* UI's listbox and gets a plain React event. */
|
|
export type CascaderRowEvent = React.MouseEvent<HTMLElement> & VetoableEvent
|
|
|
|
// Each style's inline inset, as a variable: a row and its absolutely positioned
|
|
// check have to land on the same number, in LOGICAL properties. These eight
|
|
// numbers MIRROR the shared style sheets; keep them in step with
|
|
// `registry/styles/style-*.css`.
|
|
const ROW_INSET_CLASS =
|
|
"[--cascader-row-inset:6px]"
|
|
|
|
// Both insets equal, for a row with no check. `!` because a consumer may not
|
|
// import the sheets into `layer(base)`, where the class would win on order.
|
|
const ROW_FLUSH_CLASS =
|
|
"ps-[var(--cascader-row-inset,8px)]! pe-[var(--cascader-row-inset,8px)]!"
|
|
|
|
// The same start inset plus room for the check. A flat `pr-8` is wrong in five
|
|
// of the eight styles: 4px of slack in nova, 8px short in maia, luma and sera,
|
|
// and no gutter at all in mira, whose check then overlaps its own label.
|
|
const ROW_GUTTER_CLASS =
|
|
"ps-[var(--cascader-row-inset,8px)]! pe-[calc(var(--cascader-row-inset,8px)*2_+_16px)]!"
|
|
|
|
// The check's box, inset to match the row's. A physical `right-2` neither
|
|
// mirrors under RTL nor tracks the style's inset; `right-auto` comes with the
|
|
// logical `end-*`, because width + `left` + `right` is over-constrained.
|
|
const INDICATOR_CLASS =
|
|
"pointer-events-none absolute flex items-center justify-center size-4 end-[var(--cascader-row-inset,8px)]! rtl:right-auto!"
|
|
|
|
// The row's type, gap, radius and icon size per style. No horizontal padding:
|
|
// every row pairs this with `ROW_FLUSH_CLASS` or `ROW_GUTTER_CLASS`.
|
|
const ROW_THEME_CLASS =
|
|
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 rounded-md py-1"
|
|
|
|
/**
|
|
* The row's gap and type, per style, held apart from the rest of the theme.
|
|
*
|
|
* A row that pins its own gap or text size takes `ROW_SHELL_CLASS` and leaves
|
|
* these out, because a `style-<name>:` utility is emitted AFTER the plain one
|
|
* it would have to lose to. Not receiving the token beats overriding it with
|
|
* `!`, which a consumer then has to fight in turn.
|
|
*/
|
|
const ROW_GAP_CLASS =
|
|
"gap-2"
|
|
|
|
const ROW_TEXT_CLASS =
|
|
"text-sm"
|
|
|
|
const ROW_SHELL_CLASS = `${ROW_THEME_CLASS} relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 ${ROW_INSET_CLASS}`
|
|
|
|
const ROW_CLASS = `${ROW_SHELL_CLASS} ${ROW_GAP_CLASS} ${ROW_TEXT_CLASS}`
|
|
|
|
const CASCADER_ACTION_INSET_CLASS = `${ROW_INSET_CLASS} ${ROW_FLUSH_CLASS}`
|
|
|
|
/** A footer COMMAND: the row theme, so it cannot drift from the list, plus the
|
|
* hover and `focus-visible` fills a plain button never gets. Fills, never a
|
|
* ring: a ring under rows that all paint with a fill read as a different
|
|
* widget bolted on. `focus-visible`, not `focus`, so a pointer press does not
|
|
* leave a command painted as if it were still hovered. Disabled keys off
|
|
* `aria-disabled`, not `:disabled`, to keep it FOCUSABLE: a footer whose only
|
|
* row is disabled has no tab stop. */
|
|
export const CASCADER_ACTION_CLASS = `${ROW_SHELL_CLASS} ${ROW_TEXT_CLASS} ${ROW_FLUSH_CLASS} hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground cursor-pointer gap-2 transition-colors aria-disabled:pointer-events-none aria-disabled:opacity-50`
|
|
|
|
// The box around the affordances that are not the row. 20px around a 16px icon,
|
|
// the outer 2px cancelled at the call site; 24px would set the row height.
|
|
const AFFORDANCE_BOX_CLASS =
|
|
"flex size-5 shrink-0 items-center justify-center rounded-sm"
|
|
|
|
// What makes an affordance read as pressable on an already highlighted row. Not
|
|
// `hover:bg-accent`: the row is `bg-accent` under the pointer, so the chip would
|
|
// be invisible. `!` and `**:` for the `CHECKBOX_MARK_CLASS` reason.
|
|
const AFFORDANCE_HOVER_CLASS =
|
|
"transition-[background-color,box-shadow,color] hover:bg-background hover:text-foreground! hover:**:text-foreground! hover:shadow-xs"
|
|
|
|
// The selection MARK's colour, pinned so nothing can repaint it: every row
|
|
// carries a DESCENDANT highlight rule, and a mark is STATE. Pinned TWICE
|
|
// because icon sets draw with `fill`/`stroke="currentColor"`, which the
|
|
// `<path>` inherits and resolves against the PATH's own `color`. Both halves
|
|
// are asserted in `cascader-behavior.test.tsx`.
|
|
const CHECKBOX_MARK_CLASS =
|
|
"text-primary-foreground! **:text-primary-foreground!"
|
|
const INDICATOR_MARK_CLASS = "text-foreground! **:text-foreground!"
|
|
|
|
// Milliseconds the pointer must rest on a branch row before
|
|
// `expandTrigger="hover"` drills in: long enough that a pointer crossing the
|
|
// column on its way elsewhere opens nothing, short enough to read as immediate.
|
|
const CASCADER_HOVER_EXPAND_DELAY = 150
|
|
|
|
export interface CascaderItemProps extends Omit<
|
|
ComboboxPrimitive.Item.Props,
|
|
"value" | "children" | "className" | "style" | "onClick" | "onMouseUp"
|
|
> {
|
|
/** No state callback: the row merges `className` and `style` itself. */
|
|
className?: string
|
|
style?: React.CSSProperties
|
|
onClick?: (event: CascaderRowEvent) => void
|
|
onMouseUp?: (event: CascaderRowEvent) => void
|
|
node: CascaderNode
|
|
/** Explicit render index. Required while virtualized and DELIBERATELY IGNORED
|
|
* otherwise: it self-registers the row and breaks `aria-activedescendant`. */
|
|
index?: number
|
|
/** Indentation depth. Drives `--cascader-indent` in tree mode. */
|
|
depth?: number
|
|
/** Renders the ancestor chain under the label, for deep-search results. */
|
|
showPath?: boolean
|
|
/** `option` renders a real `Combobox.Item`; `button` renders identical markup
|
|
* outside the listbox, for the ancestor columns in columns mode. */
|
|
as?: "option" | "button"
|
|
/** Tree mode: this row is an expanded branch. */
|
|
expanded?: boolean
|
|
/** Indent each depth by this many pixels. Tree mode only. */
|
|
indent?: number
|
|
/** Whether the node has children. Defaults to the cascader's own answer, as do
|
|
* the three below: callers pass them because they already hold the level,
|
|
* which keeps a memoised row off the volatile context. */
|
|
branch?: boolean
|
|
/** Whether the node may be committed. Defaults to the cascader's own answer. */
|
|
selectable?: boolean
|
|
/** Whether the node is currently selected. Defaults to the cascader's answer. */
|
|
selected?: boolean
|
|
/** Some but not all of the loaded subtree selected. `cascade` only. */
|
|
indeterminate?: boolean
|
|
/** Selected nodes below this one, at any depth. Drives the trailing count. */
|
|
selectedCount?: number
|
|
/** Paging row only: a request for this level is in flight. */
|
|
loading?: boolean
|
|
/** Paging row only: this level's last request failed. */
|
|
error?: boolean
|
|
/** Branch row only: this node's OWN children are being fetched. The chevron
|
|
* (or, in tree mode, the expander) becomes a spinner IN PLACE, in the same
|
|
* 16px box: no skeleton row appears below, nothing reflows. */
|
|
childrenLoading?: boolean
|
|
/** Branch row only: the last fetch of this node's children failed. The same
|
|
* box becomes the retry. The press path is unchanged: it goes through
|
|
* `navigate`, which refires the failed level rather than moving. */
|
|
childrenError?: boolean
|
|
children?: React.ReactNode
|
|
}
|
|
|
|
/** One row. Branch presses navigate instead of selecting, vetoed here with
|
|
* `preventBaseUIHandler()` on `onClick` (Enter too) and on `onMouseUp`. */
|
|
const CascaderItem = React.memo(function CascaderItem({
|
|
node,
|
|
index,
|
|
depth,
|
|
showPath = false,
|
|
as = "option",
|
|
expanded,
|
|
indent = 16,
|
|
branch: branchProp,
|
|
selectable: selectableProp,
|
|
selected: selectedProp,
|
|
indeterminate: indeterminateProp,
|
|
selectedCount: selectedCountProp,
|
|
loading,
|
|
error,
|
|
childrenLoading = false,
|
|
childrenError = false,
|
|
className,
|
|
children,
|
|
onClick,
|
|
onMouseUp,
|
|
style,
|
|
...props
|
|
}: CascaderItemProps) {
|
|
// The ACTIONS context only: the state context republishes on every keystroke.
|
|
const {
|
|
index: treeIndex,
|
|
mode,
|
|
labels,
|
|
isBranch,
|
|
isSelectable,
|
|
isSelected,
|
|
isIndeterminate,
|
|
selectedDescendantCount,
|
|
multiple,
|
|
branchesSelectable,
|
|
indicator: indicatorEnabled,
|
|
expandTrigger = "click",
|
|
navigate,
|
|
navigateAt,
|
|
toggleExpanded,
|
|
commit,
|
|
virtualized,
|
|
loadMore,
|
|
retryLevel,
|
|
} = useCascaderActions()
|
|
// Render props come from an always-current context, or a closure goes stale.
|
|
const { renderItem, renderLabel } = useCascaderRender()
|
|
|
|
const branch = branchProp ?? isBranch(node)
|
|
const selectable = selectableProp ?? isSelectable(node)
|
|
const selected = selectedProp ?? isSelected(node)
|
|
// A selected node is never also partially selected (a dash in a filled box).
|
|
const indeterminate =
|
|
!selected && (indeterminateProp ?? isIndeterminate(node))
|
|
const count = branch ? getCascaderCount(treeIndex, node) : 0
|
|
const nodeDepth = depth ?? treeIndex.depthOf.get(node.value) ?? 0
|
|
|
|
// O(1): the cascader publishes a map of selected-descendant counts, because a
|
|
// row walking its own subtree is `rows x descendants`, paid every keystroke.
|
|
const selectedDescendants = branch
|
|
? (selectedCountProp ?? selectedDescendantCount(node))
|
|
: 0
|
|
// `multiple` only: with a single selection the trigger, the breadcrumb and
|
|
// the leaf's own check already say where it is, so swapping a branch's real
|
|
// child total ("24") for a permanent "1" would trade a useful number away.
|
|
const showsSelectedCount = multiple && selectedDescendants > 0
|
|
const trailingCount = showsSelectedCount ? selectedDescendants : count
|
|
// Shown selected or not: suppressing it under a checked branch read as data
|
|
// loss, and the row's TWO trailing columns mean the count never has to share
|
|
// a slot with the check.
|
|
const showCount = trailingCount > 0
|
|
|
|
// Where a multi-select tree puts its checkbox: at the HEAD of the row, since a
|
|
// trailing box does not follow the label stagger. Per-LIST, never per-row, or
|
|
// the labels below an unselectable row step back out.
|
|
const leadingCheckbox = mode === "tree" && multiple
|
|
|
|
// Whether this row draws a MARK, and so whether it reserves the gutter one
|
|
// needs. `|| multiple` is the scope of the opt-out: a checkbox is the
|
|
// selection CONTROL, so `indicator={false}` is a no-op in multi-select.
|
|
const showsIndicator = indicatorEnabled || multiple
|
|
|
|
const veto = React.useCallback((event: VetoableEvent) => {
|
|
event.preventBaseUIHandler?.()
|
|
}, [])
|
|
|
|
const handleClick = React.useCallback(
|
|
(event: CascaderRowEvent) => {
|
|
onClick?.(event)
|
|
if (node.disabled) return
|
|
if (branch && !selectable) {
|
|
veto(event)
|
|
navigate(node)
|
|
}
|
|
},
|
|
[onClick, node, branch, selectable, veto, navigate]
|
|
)
|
|
|
|
const handleMouseUp = React.useCallback(
|
|
(event: CascaderRowEvent) => {
|
|
onMouseUp?.(event)
|
|
if (node.disabled) return
|
|
if (branch && !selectable) veto(event)
|
|
},
|
|
[onMouseUp, node, branch, selectable, veto]
|
|
)
|
|
|
|
// The tree expander's own press. With `selectable="any"` the row press
|
|
// commits, and under `cascade` that selected a whole subtree while the row
|
|
// stayed collapsed, so expanding needs its own target. `stopPropagation`
|
|
// before the veto, so Base UI's handler never runs, and no role: nesting
|
|
// one in `role="option"` fails axe. `navigate` fetches BEFORE it opens.
|
|
const handleExpanderClick = React.useCallback(
|
|
(event: CascaderRowEvent) => {
|
|
event.stopPropagation()
|
|
veto(event)
|
|
if (node.disabled) return
|
|
if (expanded) toggleExpanded(node.value)
|
|
else navigate(node)
|
|
},
|
|
[veto, node, expanded, toggleExpanded, navigate]
|
|
)
|
|
|
|
const handleExpanderMouseUp = React.useCallback(
|
|
(event: CascaderRowEvent) => {
|
|
// Base UI treats a drag-select mouseup as a commit; this is not one.
|
|
event.stopPropagation()
|
|
veto(event)
|
|
},
|
|
[veto]
|
|
)
|
|
|
|
const handleChevronClick = React.useCallback(
|
|
(event: CascaderRowEvent) => {
|
|
event.stopPropagation()
|
|
veto(event)
|
|
if (node.disabled) return
|
|
// `navigate` falls through to `pushLevel`, which APPENDS: right in the
|
|
// deepest column, but from an ancestor column it duplicated the level and
|
|
// corrupted the path. `navigateAt` rebuilds the trail from this depth.
|
|
if (as === "button") navigateAt(node, depth ?? 0)
|
|
else navigate(node)
|
|
},
|
|
[veto, node, as, navigateAt, depth, navigate]
|
|
)
|
|
|
|
const handleChevronMouseUp = React.useCallback(
|
|
(event: CascaderRowEvent) => veto(event),
|
|
[veto]
|
|
)
|
|
|
|
// Keeps focus in the search field: Base UI prevents the mousedown default for
|
|
// option rows, and focusing a `tabindex="-1"` trail row kills every key.
|
|
const handleButtonMouseDown = React.useCallback(
|
|
(event: React.MouseEvent<HTMLElement>) => {
|
|
event.preventDefault()
|
|
},
|
|
[]
|
|
)
|
|
|
|
const handleButtonClick = React.useCallback(
|
|
(event: CascaderRowEvent) => {
|
|
onClick?.(event)
|
|
if (node.disabled) return
|
|
if (branch && !selectable) {
|
|
// Button rows only exist in the columns trail, where `depth` is the
|
|
// column's own depth, so navigating replaces the trail from here.
|
|
navigateAt(node, depth ?? 0)
|
|
return
|
|
}
|
|
if (selectable) commit(node)
|
|
},
|
|
[onClick, node, branch, selectable, navigateAt, depth, commit]
|
|
)
|
|
|
|
// The paging row's press, never a selection, whatever `selectable` says.
|
|
const handleMoreClick = React.useCallback(
|
|
(event: CascaderRowEvent) => {
|
|
onClick?.(event)
|
|
veto(event)
|
|
const parent = getCascaderMoreParent(node)
|
|
if (parent == null) return
|
|
if (error) retryLevel(parent)
|
|
else if (!loading) loadMore(parent)
|
|
},
|
|
[onClick, veto, node, error, loading, retryLevel, loadMore]
|
|
)
|
|
|
|
// `expandTrigger="hover"`: resting on a BRANCH row in the ACTIVE column drills
|
|
// in after a delay. The deepest column of columns mode only, and it never
|
|
// commits, so a pointer crossing the panel cannot change the value.
|
|
const hoverNavigates =
|
|
expandTrigger === "hover" &&
|
|
mode === "columns" &&
|
|
as === "option" &&
|
|
branch &&
|
|
!node.disabled
|
|
const hoverTimerRef = React.useRef<number | null>(null)
|
|
|
|
const cancelHoverNavigate = React.useCallback(() => {
|
|
if (hoverTimerRef.current == null) return
|
|
window.clearTimeout(hoverTimerRef.current)
|
|
hoverTimerRef.current = null
|
|
}, [])
|
|
|
|
const handleHoverPointerEnter = React.useCallback(() => {
|
|
cancelHoverNavigate()
|
|
hoverTimerRef.current = window.setTimeout(() => {
|
|
hoverTimerRef.current = null
|
|
navigate(node)
|
|
}, CASCADER_HOVER_EXPAND_DELAY)
|
|
}, [cancelHoverNavigate, navigate, node])
|
|
|
|
// Drilling in demotes this column to the trail WITHOUT unmounting the row, so
|
|
// a pending timer has to die or it fires `navigate` from an inactive column.
|
|
React.useEffect(() => {
|
|
if (!hoverNavigates) cancelHoverNavigate()
|
|
}, [hoverNavigates, cancelHoverNavigate])
|
|
React.useEffect(() => cancelHoverNavigate, [cancelHoverNavigate])
|
|
|
|
/* ------------------------------- paging row ------------------------------ */
|
|
|
|
if (isCascaderMoreNode(node)) {
|
|
const text = error
|
|
? labels.error
|
|
: loading
|
|
? node.count
|
|
? labels.loadingMore
|
|
: labels.loading
|
|
: labels.loadMore
|
|
|
|
const moreStyle =
|
|
mode === "tree" && nodeDepth > 0
|
|
? ({
|
|
...style,
|
|
"--cascader-indent": `${nodeDepth * indent}px`,
|
|
} as React.CSSProperties)
|
|
: style
|
|
|
|
const moreShared = {
|
|
"data-slot": "cascader-item",
|
|
"data-more": "",
|
|
"data-state": error ? "error" : loading ? "loading" : "idle",
|
|
"data-depth": nodeDepth,
|
|
style: moreStyle,
|
|
className: cn(
|
|
ROW_SHELL_CLASS,
|
|
ROW_FLUSH_CLASS,
|
|
"text-muted-foreground justify-center gap-1.5 text-xs",
|
|
!error && !loading && "hover:text-foreground",
|
|
mode === "tree" &&
|
|
"ps-[calc(var(--cascader-row-inset,8px)_+_var(--cascader-indent,0px))]!",
|
|
className
|
|
),
|
|
}
|
|
|
|
const moreBody = children ?? (
|
|
<>
|
|
{loading ? (
|
|
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden />
|
|
) : null}
|
|
<span>{text}</span>
|
|
{/* TEXT, not a button: a focusable element inside a `role="option"`
|
|
row is a `nested-interactive` violation. */}
|
|
{error ? (
|
|
<span className="text-foreground font-medium">{labels.retry}</span>
|
|
) : null}
|
|
</>
|
|
)
|
|
|
|
if (as === "button") {
|
|
const buttonProps = { ...(props as React.ComponentProps<"button">) }
|
|
delete buttonProps["aria-setsize"]
|
|
delete buttonProps["aria-posinset"]
|
|
delete buttonProps["aria-level"]
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
{...moreShared}
|
|
tabIndex={-1}
|
|
onClick={handleMoreClick}
|
|
{...buttonProps}
|
|
>
|
|
{moreBody}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ComboboxPrimitive.Item
|
|
{...moreShared}
|
|
value={node}
|
|
{...(virtualized && index != null ? { index } : null)}
|
|
{...(mode === "tree"
|
|
? { role: "treeitem" as const, "aria-level": nodeDepth + 1 }
|
|
: null)}
|
|
onClick={handleMoreClick}
|
|
onMouseUp={veto}
|
|
{...props}
|
|
>
|
|
{moreBody}
|
|
</ComboboxPrimitive.Item>
|
|
)
|
|
}
|
|
|
|
const itemState = {
|
|
branch,
|
|
selected,
|
|
disabled: !!node.disabled,
|
|
depth: nodeDepth,
|
|
count,
|
|
path: showPath ? getCascaderPath(treeIndex, node.value).slice(0, -1) : [],
|
|
}
|
|
|
|
const custom = renderItem?.(node, itemState)
|
|
const customLabel = renderLabel?.(node, itemState)
|
|
|
|
// What the markup cannot say: a branch would announce "Person 24", a naked
|
|
// number. Trail rows are outside the listbox, so they need words for state.
|
|
const srDetails = [
|
|
branch ? labels.itemCount(count) : null,
|
|
showsSelectedCount ? labels.selectedCount(selectedDescendants) : null,
|
|
branch && mode !== "tree" ? labels.branchAffordance : null,
|
|
as === "button" && selected ? labels.selectedState : null,
|
|
// Option rows say this with `aria-checked="mixed"`; `role="button"` allows
|
|
// neither that nor `aria-selected`, so a trail row says it in words.
|
|
as === "button" && indeterminate ? labels.partiallySelectedState : null,
|
|
]
|
|
.filter(Boolean)
|
|
.map((detail) => `, ${detail}`)
|
|
.join("")
|
|
|
|
// Extracted because tree mode renders it INLINE at the head of the row. It
|
|
// reads `data-selected` off the ROW, so it also works on the trail rows.
|
|
const checkbox = (
|
|
<span
|
|
data-slot="cascader-item-checkbox"
|
|
className="border-input in-data-[selected]:bg-primary in-data-[selected]:border-primary in-data-[selected]:text-primary-foreground in-data-[indeterminate]:bg-primary in-data-[indeterminate]:border-primary in-data-[indeterminate]:text-primary-foreground flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors"
|
|
>
|
|
{/* Colour declared ON THE ICON with `!`; see `CHECKBOX_MARK_CLASS`. */}
|
|
{indeterminate ? (
|
|
<MinusIcon data-slot="cascader-item-dash" className={cn(CHECKBOX_MARK_CLASS, "size-3")} />
|
|
) : (
|
|
<CheckIcon data-slot="cascader-item-tick" className={cn(
|
|
CHECKBOX_MARK_CLASS,
|
|
"size-3 opacity-0 in-data-[selected]:opacity-100"
|
|
)} />
|
|
)}
|
|
</span>
|
|
)
|
|
|
|
const indicator = (
|
|
<>
|
|
{multiple ? (
|
|
checkbox
|
|
) : (
|
|
<CheckIcon data-slot="cascader-item-check" className={cn("pointer-events-none", INDICATOR_MARK_CLASS)} />
|
|
)}
|
|
</>
|
|
)
|
|
|
|
const body = custom ?? children ?? (
|
|
<>
|
|
{/* Tree mode expands in place, so the branch marker leads the row. */}
|
|
{mode === "tree" && branch ? (
|
|
<span
|
|
data-slot="cascader-item-expander"
|
|
/* Pure affordance: the row itself carries `aria-expanded`. */
|
|
aria-hidden="true"
|
|
data-state={
|
|
childrenError ? "error" : childrenLoading ? "loading" : "idle"
|
|
}
|
|
onClick={handleExpanderClick}
|
|
onMouseUp={handleExpanderMouseUp}
|
|
className={cn(
|
|
"-ms-0.5",
|
|
childrenError ? "text-destructive" : "text-muted-foreground",
|
|
AFFORDANCE_BOX_CLASS,
|
|
AFFORDANCE_HOVER_CLASS,
|
|
childrenLoading && "pointer-events-none"
|
|
)}
|
|
>
|
|
{childrenLoading ? (
|
|
<Spinner aria-hidden="true" className="size-4" />
|
|
) : childrenError ? (
|
|
<RotateCwIcon className="size-4" />
|
|
) : (
|
|
<ChevronRightIcon className={cn(
|
|
"size-4 transition-transform",
|
|
/* Collapsed it points INTO the level, so it mirrors; expanded
|
|
it points DOWN, the same in both writing modes, so the mirror
|
|
has to come off or `scaleX(-1)` composed with `rotate(90deg)`
|
|
lands it pointing up. `rtl:rotate-90` beside `rotate-90` was
|
|
a no-op that only looked like it handled this. */
|
|
expanded ? "rotate-90" : "rtl:-scale-x-100"
|
|
)} />
|
|
)}
|
|
</span>
|
|
) : mode === "tree" ? (
|
|
// A LEAF's expander slot, reserved and empty: without it a leaf's label
|
|
// started 28px off its sibling branches' and read as one level up.
|
|
<span
|
|
data-slot="cascader-item-expander-spacer"
|
|
aria-hidden="true"
|
|
className={cn("-ms-0.5", AFFORDANCE_BOX_CLASS)}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Tree + multi-select: the box leads the row, and an unselectable row
|
|
still reserves its width. */}
|
|
{leadingCheckbox ? (
|
|
selectable ? (
|
|
checkbox
|
|
) : (
|
|
<span
|
|
data-slot="cascader-item-checkbox-spacer"
|
|
aria-hidden="true"
|
|
className="size-4 shrink-0"
|
|
/>
|
|
)
|
|
) : null}
|
|
|
|
{node.icon ? (
|
|
<span
|
|
data-slot="cascader-item-icon"
|
|
className="text-muted-foreground flex shrink-0 items-center justify-center"
|
|
>
|
|
{node.icon}
|
|
</span>
|
|
) : null}
|
|
|
|
{/* The row box is sized for pieces side by side; this one stacks, so it
|
|
restates the gap: 8px reads as a paragraph break, not a subtitle. */}
|
|
<span className="flex min-w-0 flex-1 flex-col items-start gap-0.5">
|
|
{customLabel ?? (
|
|
<>
|
|
<span className="w-full truncate text-start">{node.label}</span>
|
|
{node.description ? (
|
|
<span
|
|
data-slot="cascader-item-description"
|
|
className="text-muted-foreground w-full truncate text-start text-xs"
|
|
>
|
|
{node.description}
|
|
</span>
|
|
) : null}
|
|
</>
|
|
)}
|
|
{showPath ? <CascaderItemPath node={node} /> : null}
|
|
</span>
|
|
|
|
{/* The count is a bare number, and nothing else says a branch opens. */}
|
|
{srDetails ? (
|
|
<span data-slot="cascader-item-details" className="sr-only">
|
|
{srDetails}
|
|
</span>
|
|
) : null}
|
|
|
|
{/* Tree mode expands in place, so it has no drill-in chevron. */}
|
|
{branch && mode !== "tree" ? (
|
|
<span
|
|
data-slot="cascader-item-trailing"
|
|
aria-hidden="true"
|
|
className="text-muted-foreground ms-auto flex shrink-0 items-center gap-1 text-xs tabular-nums"
|
|
>
|
|
{showCount ? (
|
|
<span
|
|
data-slot="cascader-item-count"
|
|
/* The marker a consumer (and a test) reads to tell the two
|
|
numbers apart without matching on a colour class. */
|
|
{...(showsSelectedCount ? { "data-selected-count": "" } : null)}
|
|
className={cn(showsSelectedCount && "text-primary!")}
|
|
>
|
|
{trailingCount}
|
|
</span>
|
|
) : null}
|
|
{/* ONLY the chevron is the drill target: sharing a hit area with
|
|
the count made a number pressable. */}
|
|
<span
|
|
data-slot="cascader-item-chevron"
|
|
data-state={
|
|
childrenError ? "error" : childrenLoading ? "loading" : "idle"
|
|
}
|
|
onClick={selectable ? handleChevronClick : undefined}
|
|
onMouseUp={selectable ? handleChevronMouseUp : undefined}
|
|
onMouseDown={as === "button" ? handleButtonMouseDown : undefined}
|
|
className={cn(
|
|
/* Cancels the box's outer slack, so the icon keeps its column. */
|
|
"-me-0.5",
|
|
AFFORDANCE_BOX_CLASS,
|
|
selectable && AFFORDANCE_HOVER_CLASS,
|
|
childrenError && "text-destructive",
|
|
childrenLoading && "pointer-events-none"
|
|
)}
|
|
>
|
|
{childrenLoading ? (
|
|
<Spinner aria-hidden="true" className="size-4" />
|
|
) : childrenError ? (
|
|
<RotateCwIcon className="size-4" />
|
|
) : (
|
|
<ChevronRightIcon className="size-4 rtl:-scale-x-100" />
|
|
)}
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
|
|
{branch && mode === "tree" && showCount ? (
|
|
<span
|
|
data-slot="cascader-item-count"
|
|
{...(showsSelectedCount ? { "data-selected-count": "" } : null)}
|
|
/* The count is spoken by the sr-only details span instead. */
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"text-muted-foreground ms-auto shrink-0 text-xs tabular-nums",
|
|
showsSelectedCount && "text-primary!"
|
|
)}
|
|
>
|
|
{trailingCount}
|
|
</span>
|
|
) : null}
|
|
|
|
{/* No trailing indicator when a tree's box led the row, or when
|
|
`indicator={false}` opted out. See `showsIndicator`. */}
|
|
{selectable && !leadingCheckbox && showsIndicator ? (
|
|
as === "button" ? (
|
|
// No ItemIndicator outside Base UI: `data-selected` drives it.
|
|
(multiple || selected) && (
|
|
<span
|
|
data-slot="cascader-item-indicator"
|
|
className={INDICATOR_CLASS}
|
|
>
|
|
{indicator}
|
|
</span>
|
|
)
|
|
) : (
|
|
<ComboboxPrimitive.ItemIndicator
|
|
/* Kept mounted for a partial selection too: the row is not
|
|
selected, so Base UI would unmount the box the dash lives in. */
|
|
keepMounted={multiple || indeterminate}
|
|
render={
|
|
<span
|
|
data-slot="cascader-item-indicator"
|
|
className={INDICATOR_CLASS}
|
|
/>
|
|
}
|
|
>
|
|
{indicator}
|
|
</ComboboxPrimitive.ItemIndicator>
|
|
)
|
|
) : null}
|
|
</>
|
|
)
|
|
|
|
const rowStyle =
|
|
mode === "tree" && nodeDepth > 0
|
|
? ({
|
|
...style,
|
|
"--cascader-indent": `${nodeDepth * indent}px`,
|
|
} as React.CSSProperties)
|
|
: style
|
|
|
|
const shared = {
|
|
"data-slot": "cascader-item",
|
|
"data-branch": branch || undefined,
|
|
"data-selected": selected || undefined,
|
|
"data-indeterminate": indeterminate || undefined,
|
|
"data-depth": nodeDepth,
|
|
"data-expanded": expanded || undefined,
|
|
// No `aria-level`: `role="option"` allows only `aria-selected`,
|
|
// `aria-checked`, `aria-posinset` and `aria-setsize`, and a `role="button"`
|
|
// trail row allows none of the four. Only tree mode has a role that takes a
|
|
// level, and it adds one below.
|
|
style: rowStyle,
|
|
className: cn(
|
|
ROW_CLASS,
|
|
/* Three separate reasons to hand the check gutter back. A multi-select
|
|
tree's box leads the row, so the gutter would be 24px of dead space;
|
|
`indicator={false}` drops the check, and keeping the gutter would park
|
|
the count and the chevron one gutter in from an edge nothing sits on;
|
|
and for branches the question is per-LIST, not per-row, because the old
|
|
`branch && !selectable` put neighbouring chevrons on 24px and 6px in
|
|
nova. One `pe-*` is emitted either way, so nothing here depends on
|
|
class-merge order. */
|
|
leadingCheckbox || !showsIndicator || (branch && !branchesSelectable)
|
|
? ROW_FLUSH_CLASS
|
|
: ROW_GUTTER_CLASS,
|
|
/* ADDED to the style's own inset, never substituted: replacing it put
|
|
depth 0 at 12px and depth 1 at 16px in maia, luma and sera, a 4px step.
|
|
`_+_` because Tailwind's parser splits on whitespace. */
|
|
mode === "tree" &&
|
|
"ps-[calc(var(--cascader-row-inset,8px)_+_var(--cascader-indent,0px))]!",
|
|
className
|
|
),
|
|
}
|
|
|
|
if (as === "button") {
|
|
const buttonProps = { ...(props as React.ComponentProps<"button">) }
|
|
// `role="button"` allows none of these, so they are dropped, not passed.
|
|
delete buttonProps["aria-setsize"]
|
|
delete buttonProps["aria-posinset"]
|
|
delete buttonProps["aria-level"]
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
{...shared}
|
|
/* Focus stays in the search input; the trail uses ArrowLeft. */
|
|
tabIndex={-1}
|
|
disabled={node.disabled}
|
|
aria-disabled={node.disabled || undefined}
|
|
onClick={handleButtonClick}
|
|
onMouseDown={handleButtonMouseDown}
|
|
{...buttonProps}
|
|
>
|
|
{body}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ComboboxPrimitive.Item
|
|
{...shared}
|
|
value={node}
|
|
/* THE one gate for the explicit index: supply one outside virtualized mode
|
|
and the first arrow key leaves `aria-activedescendant` pointing at
|
|
nothing. */
|
|
{...(virtualized && index != null ? { index } : null)}
|
|
disabled={node.disabled}
|
|
/* Conditional spread, never `role={... : undefined}`: `mergeProps`
|
|
iterates own keys, so `undefined` would delete `role="option"`. */
|
|
{...(mode === "tree"
|
|
? {
|
|
role: "treeitem" as const,
|
|
"aria-level": nodeDepth + 1,
|
|
...(branch ? { "aria-expanded": !!expanded } : null),
|
|
}
|
|
: branch
|
|
? { "aria-haspopup": "listbox" as const }
|
|
: null)}
|
|
/* Only ever `mixed`: a selected row already says so with `aria-selected`,
|
|
and two selection attributes on one row is noise. */
|
|
{...(indeterminate ? { "aria-checked": "mixed" as const } : null)}
|
|
{...(hoverNavigates
|
|
? {
|
|
onPointerEnter: handleHoverPointerEnter,
|
|
onPointerLeave: cancelHoverNavigate,
|
|
}
|
|
: null)}
|
|
onClick={handleClick}
|
|
onMouseUp={handleMouseUp}
|
|
{...props}
|
|
>
|
|
{body}
|
|
</ComboboxPrimitive.Item>
|
|
)
|
|
})
|
|
|
|
/** Ancestor chain under a deep-search result: "Name" alone is ambiguous. */
|
|
function CascaderItemPath({ node }: { node: CascaderNode }) {
|
|
const { index, labels } = useCascaderActions()
|
|
const ancestors = getCascaderPath(index, node.value).slice(0, -1)
|
|
if (!ancestors.length) return null
|
|
|
|
return (
|
|
<span
|
|
data-slot="cascader-item-path"
|
|
className="text-muted-foreground flex w-full items-center gap-0.5 truncate text-start text-xs"
|
|
>
|
|
{ancestors.map((ancestor, i) => (
|
|
<React.Fragment key={ancestor.value}>
|
|
{i > 0 ? (
|
|
<span aria-hidden="true">{labels.pathSeparator}</span>
|
|
) : null}
|
|
<span className="truncate">{ancestor.label}</span>
|
|
</React.Fragment>
|
|
))}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/** Every load-derived prop a row needs. A PAGING row reports the level it
|
|
* belongs to, a BRANCH row the level it OWNS; mutually exclusive, so one call
|
|
* answers both. Exported because a memoised row cannot read them itself. */
|
|
export function getCascaderMoreProps(
|
|
node: CascaderNode,
|
|
loadStates: ReadonlyMap<string, CascaderLoadState>
|
|
): {
|
|
loading: boolean
|
|
error: boolean
|
|
childrenLoading: boolean
|
|
childrenError: boolean
|
|
} {
|
|
const parent = getCascaderMoreParent(node)
|
|
if (parent == null) {
|
|
const own = loadStates.get(node.value)
|
|
return {
|
|
loading: false,
|
|
error: false,
|
|
childrenLoading: !!own?.loading,
|
|
childrenError: !!own?.error,
|
|
}
|
|
}
|
|
const state = loadStates.get(parent)
|
|
return {
|
|
loading: !!state?.loading,
|
|
error: !!state?.error,
|
|
childrenLoading: false,
|
|
childrenError: false,
|
|
}
|
|
}
|
|
|
|
export interface CascaderItemsProps {
|
|
/** Replaces the default row for every item. */
|
|
children?: (node: CascaderNode, index: number) => React.ReactNode
|
|
}
|
|
|
|
/** Whatever the active view is: the current level, the deepest column, or the
|
|
* flattened tree rows. Lives here so `cascader.tsx` never imports a row
|
|
* component, which would cycle with the context. `index` is deliberately not
|
|
* passed; see `CascaderItemProps`. */
|
|
function CascaderItems({ children }: CascaderItemsProps) {
|
|
const { mode, isBranch, isSelectable, isSelected, isIndeterminate } =
|
|
useCascaderActions()
|
|
const { renderedItems, deepResults, treeRows, loadStates } =
|
|
useCascaderState()
|
|
const showPath = deepResults !== null
|
|
|
|
// Answered once here, as plain booleans, so `React.memo` can skip a row.
|
|
if (mode === "tree") {
|
|
return (
|
|
<>
|
|
{treeRows.map((row, i) =>
|
|
children ? (
|
|
<React.Fragment key={row.node.value}>
|
|
{children(row.node, i)}
|
|
</React.Fragment>
|
|
) : (
|
|
<CascaderItem
|
|
key={row.node.value}
|
|
node={row.node}
|
|
depth={row.depth}
|
|
expanded={row.expanded}
|
|
branch={row.branch}
|
|
selectable={isSelectable(row.node)}
|
|
selected={isSelected(row.node)}
|
|
indeterminate={isIndeterminate(row.node)}
|
|
{...getCascaderMoreProps(row.node, loadStates)}
|
|
aria-setsize={row.setSize}
|
|
aria-posinset={row.posInSet}
|
|
/>
|
|
)
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{renderedItems.map((node, i) =>
|
|
children ? (
|
|
<React.Fragment key={node.value}>{children(node, i)}</React.Fragment>
|
|
) : (
|
|
<CascaderItem
|
|
key={node.value}
|
|
node={node}
|
|
showPath={showPath}
|
|
branch={isBranch(node)}
|
|
selectable={isSelectable(node)}
|
|
selected={isSelected(node)}
|
|
indeterminate={isIndeterminate(node)}
|
|
{...getCascaderMoreProps(node, loadStates)}
|
|
aria-setsize={renderedItems.length}
|
|
aria-posinset={i + 1}
|
|
/>
|
|
)
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Group / Label / Separator */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
// Whether a `CascaderLabel` has a group to name: Base UI's `Combobox.GroupLabel`
|
|
// THROWS when there is none, and a heading is useful outside a group.
|
|
const CascaderGroupContext = React.createContext(false)
|
|
|
|
// A heading over a run of rows. All eight styles are spelled out because the
|
|
// type is not uniform (sera is uppercase), and the inset is the row's own.
|
|
const CASCADER_LABEL_CLASS = `text-muted-foreground text-xs py-1.5 ${CASCADER_ACTION_INSET_CLASS} block truncate`
|
|
|
|
// A rule between two runs. The inline margin is not the combobox separator's:
|
|
// that cancels the container's padding with a per-style number, wrong in a
|
|
// footer and 4px too much in lyra. `--cascader-list-pad` reaches the edge.
|
|
const CASCADER_SEPARATOR_CLASS =
|
|
"h-px bg-border my-1 mx-[calc(var(--cascader-list-pad,4px)*-1)]! shrink-0"
|
|
|
|
export interface CascaderGroupProps extends Omit<
|
|
ComboboxPrimitive.Group.Props,
|
|
"className"
|
|
> {
|
|
/** Base UI also accepts a state callback here; the group has no state. */
|
|
className?: string
|
|
}
|
|
|
|
/** A run of related rows, named by the `CascaderLabel` inside it: the group is
|
|
* `role="group"` and Base UI points its `aria-labelledby` at the label, where a
|
|
* heading alone inside a listbox is dropped from the accessibility tree.
|
|
* Grouping is composition, not data: `CascaderItems` renders one flat run per
|
|
* level, so its indices line up with `listRef`. */
|
|
function CascaderGroup({ className, ...props }: CascaderGroupProps) {
|
|
return (
|
|
<CascaderGroupContext.Provider value={true}>
|
|
<ComboboxPrimitive.Group
|
|
data-slot="cascader-group"
|
|
className={cn("flex flex-col", className)}
|
|
{...props}
|
|
/>
|
|
</CascaderGroupContext.Provider>
|
|
)
|
|
}
|
|
|
|
export interface CascaderLabelProps extends Omit<
|
|
ComboboxPrimitive.GroupLabel.Props,
|
|
"className" | "style"
|
|
> {
|
|
/** No state callback; outside a group this is not a Base UI part at all. */
|
|
className?: string
|
|
style?: React.CSSProperties
|
|
}
|
|
|
|
/** Inside a group: the real thing, with the id association Base UI wires up. */
|
|
function CascaderGroupLabel({ className, ...props }: CascaderLabelProps) {
|
|
return (
|
|
<ComboboxPrimitive.GroupLabel
|
|
data-slot="cascader-label"
|
|
className={cn(CASCADER_LABEL_CLASS, className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Outside a group: the same heading as a plain element. A separate component
|
|
// rather than a branch, so the hook count cannot change when a label moves in.
|
|
function CascaderPlainLabel({ className, ...props }: CascaderLabelProps) {
|
|
const defaultProps = {
|
|
"data-slot": "cascader-label",
|
|
className: cn(CASCADER_LABEL_CLASS, className),
|
|
}
|
|
|
|
return useRender({
|
|
defaultTagName: "div",
|
|
render: props.render,
|
|
props: mergeProps<"div">(defaultProps, props),
|
|
})
|
|
}
|
|
|
|
/** A heading. Inside `CascaderGroup` it is the group's accessible name; outside
|
|
* one (the footer flyout is a menu, not a listbox) it is decoration only. */
|
|
function CascaderLabel(props: CascaderLabelProps) {
|
|
const grouped = React.useContext(CascaderGroupContext)
|
|
return grouped ? (
|
|
<CascaderGroupLabel {...props} />
|
|
) : (
|
|
<CascaderPlainLabel {...props} />
|
|
)
|
|
}
|
|
|
|
export interface CascaderSeparatorProps extends Omit<
|
|
ComboboxPrimitive.Separator.Props,
|
|
"className"
|
|
> {
|
|
/** Base UI also accepts a state callback here; only the orientation varies. */
|
|
className?: string
|
|
}
|
|
|
|
/** Decorative on purpose: Base UI's separator is `role="separator"`, which a
|
|
* `listbox` may not own, and a run that needs separating for a screen reader
|
|
* needs a `CascaderGroup`, not a line. A consumer can pass the role back. */
|
|
function CascaderSeparator({ className, ...props }: CascaderSeparatorProps) {
|
|
return (
|
|
<ComboboxPrimitive.Separator
|
|
data-slot="cascader-separator"
|
|
role="presentation"
|
|
aria-hidden="true"
|
|
className={cn(CASCADER_SEPARATOR_CLASS, className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export {
|
|
CascaderGroup,
|
|
CascaderItem,
|
|
CascaderItems,
|
|
CascaderLabel,
|
|
CascaderSeparator,
|
|
} |