wui
组件

级联选择 Cascader

用于在多层级树状数据结构中逐级展开、浏览并选取特定路径的分栏级联选择器。

第三方依赖 · radix-ui第三方依赖 · lucide-react第三方依赖 · motion

基础用法

最简单的级联选择器。点击输入框展开分栏浮层,逐层点击选择目标路径:

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
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 属性:

属性类型默认值说明
optionsCascaderOption[]—多层级选项树形数据源,每个节点包含 value、label、children 等属性。
valuestring[]—受控模式下的选中值路径数组(例如 `['china', 'zhejiang', 'hangzhou']`)。
defaultValuestring[][]非受控模式下的初始选中值路径数组。
onValueChange(value: string[], options: CascaderOption[]) => void—选中路径变化时触发的回调函数,返回选中的值数组与完整节点对象路径。
placeholderstring"请选择地区"未选中任何有效路径时输入框内展示的占位文本。
separatorReact.ReactNode" / "选中路径多级标签之间的连接分隔符。
searchablebooleanfalse是否在下拉浮层顶部显示路径搜索过滤输入框。
searchPlaceholderstring"搜索选项"搜索过滤输入框的占位提示文本。
filterOption(query: string, path: CascaderOption[]) => boolean—自定义搜索过滤算法,接收当前检索词和完整叶子路径节点数组。
closeOnSelectbooleantrue选中叶子节点(最末级节点)后是否自动关闭下拉选择面板。
renderValue(options: CascaderOption[]) => React.ReactNode—自定义触发器输入框内已选路径的渲染函数。
panelLabelstring"级联选项"下拉选项浮层容器的无障碍无声标签(aria-label)。
disabledbooleanfalse是否完全禁用级联选择器交互。
contentClassNamestring—应用于下拉弹出层浮动面板容器的额外 CSS 类名。
classNamestring—应用于级联选择器触发按钮本身的额外 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。