组件
Cascader
用于浏览和选择层级值的多列选择器。
基础示例
Loading…
pnpm dlx wui@latest add @wui/cascader"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
export interface CascaderOption {
/** 选中路径中保存的稳定值。 */
value: string
/** 展示给用户的文本。 */
label: React.ReactNode
/** 在下一列展示的子选项。 */
children?: CascaderOption[]
/** 禁止选择此选项。 */
disabled?: boolean
}
export interface CascaderProps extends Omit<
React.ComponentProps<"button">,
"value" | "defaultValue" | "onChange"
> {
/** 层级选项树。 */
options: CascaderOption[]
/** 受控模式下的选中值路径。 */
value?: string[]
/** 非受控模式下的初始值路径。 */
defaultValue?: string[]
/** 当前路径发生变化时调用。 */
onValueChange?: (value: string[], options: CascaderOption[]) => void
/** 未选中完整路径时展示的占位文本。@default "请选择地区" */
placeholder?: string
/** 选中标签之间的分隔符。@default " / " */
separator?: React.ReactNode
/** 选中叶子节点后是否关闭面板。@default true */
closeOnSelect?: boolean
/** 选项面板的无障碍标签。@default "级联选项" */
panelLabel?: string
/** 应用于浮层面板的额外类名。 */
contentClassName?: string
/** 自定义触发器的选中值渲染。 */
renderValue?: (options: CascaderOption[]) => React.ReactNode
}
function resolvePath(options: CascaderOption[], values: string[]) {
const resolved: CascaderOption[] = []
let level = options
for (const value of values) {
const option = level.find((item) => item.value === value)
if (!option) break
resolved.push(option)
level = option.children ?? []
}
return resolved
}
/** 用于层级值的紧凑多列选择器。 */
function Cascader({
className,
options,
value,
defaultValue = [],
onValueChange,
placeholder = "请选择地区",
separator = " / ",
closeOnSelect = true,
panelLabel = "级联选项",
contentClassName,
renderValue,
disabled,
...props
}: CascaderProps) {
const reduceMotion = useReducedMotion()
const [open, setOpen] = React.useState(false)
const [internalValue, setInternalValue] = React.useState(defaultValue)
const selectedValues = value ?? internalValue
const selectedOptions = React.useMemo(
() => resolvePath(options, selectedValues),
[options, selectedValues]
)
const columns = React.useMemo(() => {
const result: CascaderOption[][] = [options]
for (const option of selectedOptions) {
if (option.children?.length) result.push(option.children)
else break
}
return result
}, [options, selectedOptions])
function selectOption(option: CascaderOption, depth: number) {
if (option.disabled) return
const nextValues = [...selectedValues.slice(0, depth), option.value]
const nextOptions = resolvePath(options, nextValues)
if (value === undefined) setInternalValue(nextValues)
onValueChange?.(nextValues, nextOptions)
if (!option.children?.length && closeOnSelect) setOpen(false)
}
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
data-slot="cascader"
data-placeholder={!selectedOptions.length || undefined}
className={cn(
"border-input bg-background shadow-xs hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-ring/30 data-[placeholder=true]:text-muted-foreground group flex h-10 w-full min-w-56 items-center gap-2 rounded-md border px-3.5 text-left text-sm outline-none transition-[border-color,box-shadow,background-color] duration-200 ease-out focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50",
className
)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
{...props}
>
<span className="flex min-w-0 flex-1 items-center truncate">
{selectedOptions.length
? renderValue?.(selectedOptions) ??
selectedOptions.map((option, index) => (
<React.Fragment key={option.value}>
{index > 0 ? (
<span className="text-muted-foreground/60 mx-1.5">
{separator}
</span>
) : null}
<span className="truncate">{option.label}</span>
</React.Fragment>
))
: placeholder}
</span>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 32 }
}
className="text-muted-foreground"
>
<ChevronDownIcon className="size-4" />
</motion.span>
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<AnimatePresence>
{open ? (
<PopoverPrimitive.Content
forceMount
asChild
align="start"
sideOffset={7}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<motion.div
data-slot="cascader-content"
role="listbox"
aria-label={panelLabel}
initial={
reduceMotion ? false : { opacity: 0, y: -6, scale: 0.98 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={
reduceMotion ? undefined : { opacity: 0, y: -3, scale: 0.985 }
}
transition={
reduceMotion
? { duration: 0 }
: {
type: "spring",
stiffness: 520,
damping: 38,
mass: 0.72,
}
}
className={cn(
"bg-popover text-popover-foreground z-50 flex max-w-[calc(100vw-2rem)] overflow-x-auto rounded-xl border p-1.5 shadow-lg outline-none",
contentClassName
)}
>
<AnimatePresence initial={false} mode="popLayout">
{columns.map((column, depth) => (
<motion.div
key={depth}
data-slot="cascader-column"
aria-label={`Level ${depth + 1}`}
initial={reduceMotion ? false : { opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={reduceMotion ? undefined : { opacity: 0, x: 8 }}
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 500, damping: 38 }
}
className={cn(
"w-44 shrink-0 p-1",
depth > 0 && "border-border/70 border-l"
)}
>
{column.map((option) => {
const active = selectedValues[depth] === option.value
const isLeaf = !option.children?.length
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={active}
disabled={option.disabled}
onClick={() => selectOption(option, depth)}
className={cn(
"group/option hover:bg-accent focus-visible:bg-accent relative flex min-h-9 w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm outline-none transition-colors duration-150 disabled:pointer-events-none disabled:opacity-40",
active && "bg-accent text-accent-foreground"
)}
>
{active ? (
<motion.span
layoutId={`cascader-active-${depth}`}
className="bg-accent absolute inset-0 rounded-lg"
transition={{
type: "spring",
stiffness: 520,
damping: 38,
}}
/>
) : null}
<span className="relative z-10 min-w-0 flex-1 truncate">
{option.label}
</span>
<span className="text-muted-foreground relative z-10 flex size-4 items-center justify-center">
{active && isLeaf ? (
<motion.span
initial={
reduceMotion
? false
: { scale: 0.5, opacity: 0 }
}
animate={{ scale: 1, opacity: 1 }}
>
<CheckIcon className="text-foreground size-4" />
</motion.span>
) : !isLeaf ? (
<ChevronRightIcon className="size-4 transition-transform group-hover/option:translate-x-0.5" />
) : null}
</span>
</button>
)
})}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</PopoverPrimitive.Content>
) : null}
</AnimatePresence>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
)
}
export { Cascader }
适用场景
当选项具有明确层级时使用 Cascader,例如省份 / 城市、部门 / 团队或分类 / 子分类。每次选择都会展开下一列,帮助用户在深入选择时保持上下文。单层选项使用 Select,大规模无结构数据则适合可搜索的命令菜单。
属性
| Prop | Type | Default | Description |
|---|---|---|---|
| options * | CascaderOption[] | — | 层级选项树。 |
| value | string[] | — | 受控模式下的选中值路径。 |
| defaultValue | string[] | [] | 非受控模式下的初始值路径。 |
| onValueChange | ((value: string[], options: CascaderOption[]) => void) | — | 当前路径发生变化时调用。 |
| placeholder | string | 请选择地区 | 未选中完整路径时展示的占位文本。@default "请选择地区" |
| separator | ReactNode | / | 选中标签之间的分隔符。@default " / " |
| closeOnSelect | boolean | true | 选中叶子节点后是否关闭面板。@default true |
| panelLabel | string | 级联选项 | 选项面板的无障碍标签。@default "级联选项" |
| contentClassName | string | — | 应用于浮层面板的额外类名。 |
| renderValue | ((options: CascaderOption[]) => ReactNode) | — | 自定义触发器的选中值渲染。 |
| disabled | boolean | — |
事件
onValueChange(value, options)会在任一级发生变化后触发,并返回稳定值路径与已解析的选项对象。onOpenChange由组件内部的触发器管理;默认选中叶子节点后关闭面板。- 标准按钮的聚焦和键盘激活事件会透传给触发器。
扩展用法
设置 closeOnSelect={false} 可在选择叶子节点后保持面板打开。通过 contentClassName 调整面板宽度而不影响触发器;当选中路径需要不同分隔符时,传入自定义 separator。