组件
级联选择 Cascader
用于在多层级树状数据结构中逐级展开、浏览并选取特定路径的分栏级联选择器。
基础用法
最简单的级联选择器。点击输入框展开分栏浮层,逐层点击选择目标路径:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/cascader安装基础依赖与动效库
pnpm add radix-ui motion lucide-react clsx tailwind-merge复制组件源码到
components/ui/cascader.tsx"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
SearchIcon,
} from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
export interface CascaderOption {
/** 选中路径中保存的稳定值。 */
value: string
/** 展示给用户的文本。 */
label: React.ReactNode
/** 在下一列展示的子选项。 */
children?: CascaderOption[]
/** 禁止选择此选项。 */
disabled?: boolean
/** 参与本地搜索的额外关键词,例如拼音或首字母。 */
keywords?: string[]
}
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
/** 在面板顶部显示路径搜索框。@default false */
searchable?: boolean
/** 搜索框占位文本。@default "搜索选项" */
searchPlaceholder?: string
/** 自定义路径匹配逻辑。 */
filterOption?: (query: string, path: CascaderOption[]) => boolean
}
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 flattenLeafPaths(
options: CascaderOption[],
parentPath: CascaderOption[] = []
) {
const paths: CascaderOption[][] = []
for (const option of options) {
const path = [...parentPath, option]
if (option.children?.length)
paths.push(...flattenLeafPaths(option.children, path))
else paths.push(path)
}
return paths
}
function defaultFilterOption(query: string, path: CascaderOption[]) {
const terms = query.trim().toLocaleLowerCase().split(/\s+/)
const searchableText = path
.flatMap((option) => [
typeof option.label === "string" ? option.label : "",
...(option.keywords ?? []),
])
.join(" ")
.toLocaleLowerCase()
return terms.every((term) => {
if (searchableText.includes(term)) return true
let cursor = 0
for (const character of searchableText) {
if (character === term[cursor]) cursor += 1
if (cursor === term.length) return true
}
return false
})
}
/** 用于层级值的紧凑多列选择器。 */
function Cascader({
className,
options,
value,
defaultValue = [],
onValueChange,
placeholder = "请选择地区",
separator = " / ",
closeOnSelect = true,
panelLabel = "级联选项",
contentClassName,
renderValue,
searchable = false,
searchPlaceholder = "搜索选项",
filterOption = defaultFilterOption,
disabled,
...props
}: CascaderProps) {
const reduceMotion = useReducedMotion()
const layoutId = React.useId()
const [open, setOpen] = React.useState(false)
const [query, setQuery] = React.useState("")
const searchInputRef = React.useRef<HTMLInputElement>(null)
const panelRef = React.useRef<HTMLDivElement>(null)
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: { key: string; options: CascaderOption[] }[] = [
{ key: "root", options },
]
for (const option of selectedOptions) {
if (option.children?.length)
result.push({ key: option.value, options: option.children })
else break
}
return result
}, [options, selectedOptions])
const searchResults = React.useMemo(() => {
if (!searchable || !query.trim()) return []
return flattenLeafPaths(options).filter((path) => filterOption(query, path))
}, [filterOption, options, query, searchable])
const spring = reduceMotion
? { duration: 0 }
: ({ type: "spring", stiffness: 520, damping: 38, mass: 0.7 } as const)
function changeOpen(nextOpen: boolean) {
setOpen(nextOpen)
if (!nextOpen) setQuery("")
}
function commitPath(path: CascaderOption[]) {
const nextValues = path.map((option) => option.value)
if (value === undefined) setInternalValue(nextValues)
onValueChange?.(nextValues, path)
if (closeOnSelect) changeOpen(false)
}
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) changeOpen(false)
}
function columnButtons(depth: number) {
return Array.from(
panelRef.current?.querySelectorAll<HTMLButtonElement>(
`[data-depth="${depth}"] button:not(:disabled)`
) ?? []
)
}
function focusColumn(depth: number) {
const buttons = columnButtons(depth)
const target =
buttons.find((button) => button.getAttribute("aria-selected") === "true") ??
buttons[0]
target?.focus()
}
function moveWithin(
event: React.KeyboardEvent<HTMLButtonElement>,
buttons: HTMLButtonElement[]
) {
const index = buttons.indexOf(event.currentTarget)
const next =
event.key === "ArrowDown"
? buttons[Math.min(index + 1, buttons.length - 1)]
: event.key === "ArrowUp"
? buttons[Math.max(index - 1, 0)]
: event.key === "Home"
? buttons[0]
: event.key === "End"
? buttons.at(-1)
: undefined
if (!next) return false
event.preventDefault()
next.focus()
return true
}
function handleOptionKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
option: CascaderOption,
depth: number
) {
if (moveWithin(event, columnButtons(depth))) return
if (event.key === "ArrowRight" && option.children?.length) {
event.preventDefault()
selectOption(option, depth)
requestAnimationFrame(() => columnButtons(depth + 1)[0]?.focus())
} else if (event.key === "ArrowLeft" && depth > 0) {
event.preventDefault()
focusColumn(depth - 1)
}
}
return (
<PopoverPrimitive.Root open={open} onOpenChange={changeOpen}>
<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 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 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="dialog"
aria-expanded={open}
{...props}
>
<span className="relative flex min-w-0 flex-1 items-center overflow-hidden">
{selectedOptions.length
? (renderValue?.(selectedOptions) ?? (
<AnimatePresence initial={false} mode="popLayout">
{selectedOptions.map((option, index) => (
<motion.span
key={`${index}-${option.value}`}
layout={!reduceMotion}
className="flex min-w-0 items-center"
initial={reduceMotion ? false : { opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
exit={reduceMotion ? undefined : { opacity: 0 }}
transition={spring}
>
{index > 0 ? (
<span className="text-muted-foreground/60 mx-1.5 shrink-0">
{separator}
</span>
) : null}
<span className="truncate">{option.label}</span>
</motion.span>
))}
</AnimatePresence>
))
: placeholder}
</span>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
transition={spring}
className="text-muted-foreground"
>
<ChevronDownIcon className="size-4" />
</motion.span>
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<AnimatePresence>
{open ? (
<PopoverPrimitive.Content
forceMount
asChild
align="start"
sideOffset={6}
onOpenAutoFocus={(event) => {
event.preventDefault()
if (searchable) searchInputRef.current?.focus()
else focusColumn(columns.length - 1)
}}
>
<motion.div
ref={panelRef}
data-slot="cascader-content"
aria-label={panelLabel}
style={{
transformOrigin:
"var(--radix-popover-content-transform-origin)",
}}
initial={reduceMotion ? false : { opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={
reduceMotion
? undefined
: { opacity: 0, scale: 0.97, transition: { duration: 0.12 } }
}
transition={spring}
className={cn(
"bg-popover text-popover-foreground z-50 flex max-h-[min(24rem,var(--radix-popover-content-available-height))] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-lg border shadow-md outline-none",
contentClassName
)}
>
{searchable ? (
<div className="border-border/70 border-b p-1.5">
<Input
ref={searchInputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "ArrowDown") return
event.preventDefault()
panelRef.current
?.querySelector<HTMLButtonElement>(
'[data-slot="cascader-search-results"] button:not(:disabled), [data-depth="0"] button:not(:disabled)'
)
?.focus()
}}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
size="sm"
startContent={<SearchIcon />}
/>
</div>
) : null}
<div className="flex min-h-0 overflow-x-auto">
{query.trim() ? (
<motion.div
data-slot="cascader-search-results"
role="listbox"
aria-label="搜索结果"
initial={reduceMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
className="max-h-72 w-80 max-w-[calc(100vw-3rem)] overflow-y-auto overscroll-contain p-1"
>
{searchResults.length ? (
searchResults.map((path) => (
<button
key={path.map((option) => option.value).join("/")}
type="button"
role="option"
aria-selected={path.every(
(option, index) =>
selectedValues[index] === option.value
)}
disabled={path.some((option) => option.disabled)}
onClick={() => commitPath(path)}
onKeyDown={(event) => {
const buttons = Array.from(
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)"
) ?? []
)
if (
event.key === "ArrowUp" &&
buttons[0] === event.currentTarget
) {
event.preventDefault()
searchInputRef.current?.focus()
return
}
moveWithin(event, buttons)
}}
className="hover:bg-accent focus-visible:bg-accent aria-selected:text-primary flex min-h-9 w-full items-center rounded-md px-2.5 py-2 text-left text-sm outline-none transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<span className="min-w-0 truncate">
{path.map((option, index) => (
<React.Fragment key={option.value}>
{index > 0 ? (
<span className="text-muted-foreground/60 mx-1.5">
{separator}
</span>
) : null}
<span>{option.label}</span>
</React.Fragment>
))}
</span>
</button>
))
) : (
<p className="text-muted-foreground px-3 py-8 text-center text-sm">
没有匹配的选项
</p>
)}
</motion.div>
) : (
<AnimatePresence initial={false} mode="popLayout">
{columns.map((column, depth) => (
<motion.div
key={`${depth}-${column.key}`}
data-slot="cascader-column"
data-depth={depth}
role="listbox"
aria-label={`第 ${depth + 1} 级`}
initial={reduceMotion ? false : { opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={
reduceMotion
? undefined
: { opacity: 0, x: 6, transition: { duration: 0.12 } }
}
transition={spring}
className={cn(
"max-h-80 w-44 shrink-0 overflow-y-auto overscroll-contain scroll-py-1 p-1",
depth > 0 && "border-border/70 border-l"
)}
>
{column.options.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)}
onKeyDown={(event) =>
handleOptionKeyDown(event, option, depth)
}
className={cn(
"group/option hover:bg-accent/60 focus-visible:ring-ring/40 relative isolate flex min-h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm outline-none transition-colors duration-150 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-40",
active &&
"text-accent-foreground font-medium hover:bg-transparent"
)}
>
{active ? (
<motion.span
aria-hidden
layoutId={`${layoutId}-${depth}`}
className="bg-accent absolute inset-0 z-[-1] rounded-md"
transition={spring}
/>
) : null}
<span className="min-w-0 flex-1 truncate">
{option.label}
</span>
<span className="text-muted-foreground flex size-4 items-center justify-center">
{active && isLeaf ? (
<motion.span
className="flex"
initial={
reduceMotion
? false
: { scale: 0.4, opacity: 0 }
}
animate={{ scale: 1, opacity: 1 }}
transition={{
type: "spring",
stiffness: 560,
damping: 28,
mass: 0.6,
}}
>
<CheckIcon className="text-primary size-4" />
</motion.span>
) : !isLeaf ? (
<ChevronRightIcon
className={cn(
"size-4 transition-transform duration-200 group-hover/option:translate-x-0.5",
active &&
"text-foreground translate-x-0.5"
)}
/>
) : null}
</span>
</button>
)
})}
</motion.div>
))}
</AnimatePresence>
)}
</div>
</motion.div>
</PopoverPrimitive.Content>
) : null}
</AnimatePresence>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
)
}
export { Cascader }
属性 Props
Cascader 支持以下配置属性,并继承原生 <button> 的 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| options | CascaderOption[] | — | 多层级选项树形数据源,每个节点包含 value、label、children 等属性。 |
| value | string[] | — | 受控模式下的选中值路径数组(例如 `['china', 'zhejiang', 'hangzhou']`)。 |
| defaultValue | string[] | [] | 非受控模式下的初始选中值路径数组。 |
| onValueChange | (value: string[], options: CascaderOption[]) => void | — | 选中路径变化时触发的回调函数,返回选中的值数组与完整节点对象路径。 |
| placeholder | string | "请选择地区" | 未选中任何有效路径时输入框内展示的占位文本。 |
| separator | React.ReactNode | " / " | 选中路径多级标签之间的连接分隔符。 |
| searchable | boolean | false | 是否在下拉浮层顶部显示路径搜索过滤输入框。 |
| searchPlaceholder | string | "搜索选项" | 搜索过滤输入框的占位提示文本。 |
| filterOption | (query: string, path: CascaderOption[]) => boolean | — | 自定义搜索过滤算法,接收当前检索词和完整叶子路径节点数组。 |
| closeOnSelect | boolean | true | 选中叶子节点(最末级节点)后是否自动关闭下拉选择面板。 |
| renderValue | (options: CascaderOption[]) => React.ReactNode | — | 自定义触发器输入框内已选路径的渲染函数。 |
| panelLabel | string | "级联选项" | 下拉选项浮层容器的无障碍无声标签(aria-label)。 |
| disabled | boolean | false | 是否完全禁用级联选择器交互。 |
| contentClassName | string | — | 应用于下拉弹出层浮动面板容器的额外 CSS 类名。 |
| className | string | — | 应用于级联选择器触发按钮本身的额外 CSS 类名。 |
CascaderOption 数据结构
options 数组中各节点的类型定义如下:
export interface CascaderOption {
/** 节点的唯一稳定标识值 */
value: string
/** 节点在界面上展示的文本或 React 元素 */
label: React.ReactNode
/** 下一级子选项列表,若为空或未定义则视为叶子节点 */
children?: CascaderOption[]
/** 是否禁用当前节点的选择 */
disabled?: boolean
/** 参与本地搜索匹配的额外关键词(如拼音首字母、英文代码、业务别名等) */
keywords?: string[]
}事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onValueChange | (value: string[], options: CascaderOption[]) => void | — | 当用户在任一级点击切换节点或从搜索结果中选定路径时触发。 |
| onFocus | (event: React.FocusEvent<HTMLButtonElement>) => void | — | 触发器按钮获取焦点时触发。 |
| onBlur | (event: React.FocusEvent<HTMLButtonElement>) => void | — | 触发器按钮失去焦点时触发。 |
| onKeyDown | (event: React.KeyboardEvent<HTMLButtonElement>) => void | — | 在触发器或子项获得焦点时按下键盘按键触发。 |
使用场景与设计规范
Cascader 适用于数据具有清晰明确的父子层级链路、且需要用户按层级顺序逐级深入浏览选择的业务场景。
- Cascader vs TreeSelect vs Select:
- Cascader(级联选择):横向多列分栏展开,用户能直观看到从顶层到底层的整条链路(如:国家 → 省份 → 城市 → 区县),选中结果通常是一条完整链路路径。
- TreeSelect(树选择):垂直折叠树状结构,适合从具有包含关系但层级深度不定、重点在于最终选择单个节点的场景(如:组织部门架构树、文件目录)。
- Select / Combobox(下拉选择 / 组合框):单层扁平列表,适合选项之间无从属依赖关系的枚举值选择。
- 搜索增强设计:对于层级深或选项量大的数据,建议开启
searchable,并在数据项中配置keywords(如拼音缩写hz、bj),用户无需一层层展开即可一步直达叶子选项。 - 自定义路径呈现:对于商品多级类目或复杂业务标签,可使用
renderValue呈现为徽章(Badge)或带状态标记的精简标签,提升信息密度。
场景示例
拼音与关键词模糊搜索
开启 searchable 后,面板顶部会出现搜索框。组件内置了拼音和多关键词的分词模糊匹配算法,选择搜索结果会直接定位并高亮整条路径:
Loading…
组织架构与多级部门
在企业管理系统中,通过自定义连接分隔符 separator=" → ",清晰呈现部门从中心到小组的层级关系:
Loading…
自定义选中值渲染
通过 renderValue 可以接管触发框内部的内容排版,例如使用不同层级的徽章(Badge)组合高亮末级类目:
Loading…
节点禁用与整控件禁用
无论是整颗树中的某些分支(如维护中的可用区),还是整个控件自身处于只读状态,都可以优雅支持:
Loading…
无障碍与交互 Accessibility
- ARIA 规范:触发按钮挂载
aria-haspopup="dialog"及aria-expanded;每一列是带“第 N 级”标签的role="listbox",选项使用role="option"与aria-selected反映当前路径。 - 键盘导航:
- Space / Enter:展开弹层,焦点自动落在最深一级的已选项上(开启搜索时聚焦搜索框)。
- ↑ / ↓、Home / End:在当前列内移动焦点。
- →:展开当前项的下一级并聚焦其第一项;←:返回上一级。
- 搜索框中按 ↓ 进入结果列表,在第一项按 ↑ 回到搜索框。
- Esc:关闭弹出层并将焦点恢复至触发按钮。
- 动效:弹层从触发器方向缩放展开;切换父级时下一列从右侧滑入替换旧列;每列的选中背景在选项间滑动;触发器中的路径分段依次淡入。全部兼容
prefers-reduced-motion。