wui
组件

Cascader

用于浏览和选择层级值的多列选择器。

基础示例

Loading…
pnpm dlx wui@latest add @wui/cascader
components/ui/cascader.tsx
"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,大规模无结构数据则适合可搜索的命令菜单。

属性

PropTypeDefaultDescription
options *CascaderOption[]层级选项树。
valuestring[]受控模式下的选中值路径。
defaultValuestring[][]非受控模式下的初始值路径。
onValueChange((value: string[], options: CascaderOption[]) => void)当前路径发生变化时调用。
placeholderstring请选择地区未选中完整路径时展示的占位文本。@default "请选择地区"
separatorReactNode/选中标签之间的分隔符。@default " / "
closeOnSelectbooleantrue选中叶子节点后是否关闭面板。@default true
panelLabelstring级联选项选项面板的无障碍标签。@default "级联选项"
contentClassNamestring应用于浮层面板的额外类名。
renderValue((options: CascaderOption[]) => ReactNode)自定义触发器的选中值渲染。
disabledboolean

事件

  • onValueChange(value, options) 会在任一级发生变化后触发,并返回稳定值路径与已解析的选项对象。
  • onOpenChange 由组件内部的触发器管理;默认选中叶子节点后关闭面板。
  • 标准按钮的聚焦和键盘激活事件会透传给触发器。

扩展用法

设置 closeOnSelect={false} 可在选择叶子节点后保持面板打开。通过 contentClassName 调整面板宽度而不影响触发器;当选中路径需要不同分隔符时,传入自定义 separator