组件列表

Select 选择器

UI

复合 Select,用 Motion 做面板展开和缝隙动画;圆角由 className 控制。面板关闭后选项仍挂载,触发器不会掉回占位符。

roadmap

5 groups · local search done

本地搜索已落地。下面按建议实现顺序排列,不改变现有 searchable API。

  1. 01

    远程搜索

    已预留 API

    onSearch + filter={false} 已可把列表交给外部。组件内仍不发请求。

    • 防抖避免每个按键都打接口;可做 searchDebounce,或由调用方在 onSearch 里做。
    • Loading请求中展示加载态,与「无匹配」区分。
    • 请求竞态只采用最后一次 query 的结果,过期响应丢弃或 abort。
    • 失败态网络错误时的文案,避免被空列表误当成没有数据。
  2. 02

    键盘浏览

    未开始

    现有 Select 也没有方向键选中。搜索 Combobox 同样还没补。

    • 方向键高亮上下移动当前项,面板滚动跟随。
    • Enter 选中选中高亮项;单选关面板,多选保持打开。
    • aria-activedescendant把当前高亮项同步给辅助技术。
  3. 03

    多选 Tag 关闭

    未开始

    现在只能 Backspace 删最后一个,或再打开列表点掉。

    • Tag 上的 ×删除后焦点回到搜索框,避免光标丢失。
  4. 04

    空态 / 加载插槽

    未开始

    无匹配目前写在 Content 内部,远程 loading 还没有对应节点。

    • SelectEmpty可组合的空态,替换写死的「无匹配项」。
    • SelectLoading远程搜索转圈或骨架。
  5. 05

    其它

    未开始

    不影响本地搜索主路径,有需要再做。

    • 虚拟列表选项极多时只渲染可视区域。
    • 源码快照custom-components.json 里的查看源码仍可能是旧实现。
defaultValue / onValueChange

内部自己管选中值;变化时仍会回调。

onValueChange → apple

SelectValue placeholder

未选中时显示占位文案,选中后换成选项 label。

value / onValueChange

传入 value 即为受控。外部按钮也能改选中项。

value = apple

open / onOpenChange

面板开关受控。可用外部按钮强制打开或关闭。

open = false

disabled

根组件禁用:触发器不可点。

SelectItem disabled

芒果不可选,其它项正常。禁用项仍会登记 label。

SelectItem children

children 不是纯字符串时,触发器回退显示 value。

multiple

点选项切换,面板保持打开。新选中的 Tag 会弹出,已有 Tag 让位。

onValueChange → apple

searchable

Trigger 变成输入框。点字段打开面板,输入过滤选项;选中后关闭并恢复 label。

onValueChange → (未选)

searchable + multiple

Tag 和输入框在同一格。选中后清空搜索词、焦点留在输入框,可继续搜。

apple

onValueChange → apple

SelectItem label

自定义节点没有字符串 children,用 label 作为触发器文案和搜索文本。

defaultOpen

非受控的初始打开状态。刷新后默认展开。

  • open + onOpenChange(堆叠)

    面板绝对定位会互相挡住。父级记下当前打开的那一个。

    opened = null

    使用示例
    import { useState } from 'react'
    import {
      Select,
      SelectTrigger,
      SelectValue,
      SelectContent,
      SelectItem,
    } from '@components/ui/Select'
    
    // defaultValue:非受控初始选中值
    // onValueChange:选中变化时回调(受控 / 非受控都会触发)
    <Select defaultValue="apple" onValueChange={console.log}>
      <SelectTrigger>
        <SelectValue placeholder="选择水果" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">苹果</SelectItem>
        <SelectItem value="pear">梨</SelectItem>
        <SelectItem value="banana">香蕉</SelectItem>
      </SelectContent>
    </Select>
    
    // SelectValue placeholder:尚未选中时的占位文案
    <Select>
      <SelectTrigger>
        <SelectValue placeholder="选择水果" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">苹果</SelectItem>
      </SelectContent>
    </Select>
    
    // value + onValueChange:选中值受控,外部也可以改
    function ControlledValue() {
      const [value, setValue] = useState('apple')
      return (
        <>
          <button type="button" onClick={() => setValue('banana')}>设为香蕉</button>
          <Select value={value} onValueChange={setValue}>
            <SelectTrigger>
              <SelectValue placeholder="选择水果" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="apple">苹果</SelectItem>
              <SelectItem value="banana">香蕉</SelectItem>
            </SelectContent>
          </Select>
        </>
      )
    }
    
    // open + onOpenChange:面板开关受控
    function ControlledOpen() {
      const [open, setOpen] = useState(false)
      return (
        <>
          <button type="button" onClick={() => setOpen((v) => !v)}>
            {open ? '关闭' : '打开'}
          </button>
          <Select defaultValue="pear" open={open} onOpenChange={setOpen}>
            <SelectTrigger>
              <SelectValue placeholder="选择水果" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="pear">梨</SelectItem>
              <SelectItem value="mango">芒果</SelectItem>
            </SelectContent>
          </Select>
        </>
      )
    }
    
    // defaultOpen:非受控,挂载时面板是打开的
    <Select defaultValue="apple" defaultOpen>
      <SelectTrigger>
        <SelectValue placeholder="选择水果" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">苹果</SelectItem>
      </SelectContent>
    </Select>
    
    // disabled:禁用整个选择器
    <Select defaultValue="pear" disabled>
      <SelectTrigger>
        <SelectValue placeholder="选择水果" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="pear">梨</SelectItem>
      </SelectContent>
    </Select>
    
    // SelectItem disabled:禁用某一项(仍会登记 label)
    <Select defaultValue="apple">
      <SelectTrigger>
        <SelectValue placeholder="选择水果" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="apple">苹果</SelectItem>
        <SelectItem value="mango" disabled>芒果</SelectItem>
      </SelectContent>
    </Select>
    
    // children 不是字符串时,触发器文案回退为 value
    <Select defaultValue="pear">
      <SelectTrigger>
        <SelectValue placeholder="选择" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="pear">
          <span>🍐 梨</span>
        </SelectItem>
      </SelectContent>
    </Select>
    
    // 堆叠多个 Select:用 open 保证同一时刻只开一个面板
    function ExclusiveOpen() {
      const [opened, setOpened] = useState(null)
      return (
        <>
          <Select
            defaultValue="sh"
            open={opened === 'city'}
            onOpenChange={(next) => setOpened(next ? 'city' : null)}
          >
            <SelectTrigger>
              <SelectValue placeholder="城市" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="sh">上海</SelectItem>
              <SelectItem value="bj">北京</SelectItem>
            </SelectContent>
          </Select>
          <Select
            defaultValue="apple"
            open={opened === 'fruit'}
            onOpenChange={(next) => setOpened(next ? 'fruit' : null)}
          >
            <SelectTrigger>
              <SelectValue placeholder="水果" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="apple">苹果</SelectItem>
            </SelectContent>
          </Select>
        </>
      )
    }
    
    组件源码
    // ===== index.ts =====
    /**
     * 复合 Select:根组件持有状态,子组件通过上下文协作。
     *
     * 组合顺序通常为:
     * `Select` → `SelectTrigger`(内嵌 `SelectValue`)+ `SelectContent`(内嵌若干 `SelectItem`)。
     *
     * 从本目录导入即可,不必深入各个文件:
     * `import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@components/ui/Select'`
     */
    
    export { Select } from './Select'
    export { SelectTrigger } from './SelectTrigger'
    export { SelectValue } from './SelectValue'
    export { SelectContent } from './SelectContent'
    export { SelectItem } from './SelectItem'
    
    export type {
      SelectProps,
      SelectTriggerProps,
      SelectValueProps,
      SelectContentProps,
      SelectItemProps,
    } from './types'
    
    // ===== types.ts =====
    import type { HTMLAttributes, ReactNode } from 'react'
    
    /**
     * 下拉面板相对触发器的摆放方向。
     *
     * - `bottom`:面板从触发器下方展开(默认)。
     * - `top`:视口下方空间不足、上方更宽裕时,由 `SelectContent` 翻转到触发器上方。
     *
     * 放置方向会同时影响:
     * 1. 面板的绝对定位锚点(`top-full` / `bottom-full`)
     * 2. 缝隙(margin)打开的一侧
     * 3. `data-placement`,供 className 按方向覆盖圆角等样式
     */
    export type Placement = 'bottom' | 'top'
    
    /**
     * Select 内部共享状态。
     *
     * 子组件不直接持有选中值 / 开关,全部通过该上下文协作:
     * - `SelectTrigger` 负责开关;圆角由 className 控制
     * - `SelectValue` 根据 `labelFor` 显示当前项文案
     * - `SelectItem` 注册文案、提交选中值
     * - `SelectContent` 测量高度、决定翻转方向
     */
    export interface SelectContextValue {
      /** 当前选中值;未选中时为 `undefined`。 */
      value?: string
      /** 面板是否展开。 */
      open: boolean
      /** 更新展开状态。受控模式下只通知外部,不写内部 state。 */
      setOpen: (open: boolean) => void
      /** 选中一项:更新值并关闭面板。 */
      select: (value: string) => void
      /**
       * 选项挂载时把自己的 `value → label` 登记进 Map。
       * 面板关闭后选项仍保持挂载,登记不会丢失,触发器才不会回落到占位符。
       */
      register: (value: string, label: string) => void
      /** 选项卸载时从 Map 中移除对应条目。 */
      unregister: (value: string) => void
      /** 用选中值查出展示文案。值为空时返回 `undefined`。 */
      labelFor: (value?: string) => string | undefined
      /**
       * 是否遵循系统「减少动态效果」。
       * 为 `true` 时各子组件将过渡时长压到 0 或极短淡入淡出。
       */
      reduce: boolean
      /** 触发器 DOM id,用于 `aria-labelledby` / `aria-controls` 配对。 */
      triggerId: string
      /** 列表 DOM id,触发器用 `aria-controls` 指向它。 */
      listId: string
      /** 根级禁用:触发器不可点,选项仍可渲染。 */
      disabled: boolean
      /** 当前面板放置方向。 */
      placement: Placement
      /** 由 `SelectContent` 在打开时根据视口剩余空间写入。 */
      setPlacement?: (placement: Placement) => void
    }
    
    /**
     * 根组件属性。
     *
     * 值与打开状态各自支持受控 / 非受控:
     * - 传入 `value` → 选中值受控
     * - 传入 `open` → 面板开关受控
     *
     * 多个 Select 纵向堆叠时,面板是绝对定位的,同时打开会互相遮挡。
     * 这时应由父级持有 `open`,保证同一时刻只有一个面板展开。
     */
    export interface SelectProps extends HTMLAttributes<HTMLDivElement> {
      /** 受控选中值。 */
      value?: string
      /** 非受控初始选中值。 */
      defaultValue?: string
      /** 选中值变化时回调。受控与非受控都会触发。 */
      onValueChange?: (value: string) => void
      /**
       * 受控的面板打开状态。
       * 堆叠布局可由父级持有该状态,避免两个绝对定位面板互相覆盖。
       */
      open?: boolean
      /** 非受控的初始打开状态。默认 `false`。 */
      defaultOpen?: boolean
      /**
       * 面板打开或关闭时触发。
       * 堆叠选择器需要据此决定哪个邻居要画在上面。
       */
      onOpenChange?: (open: boolean) => void
      /** 禁用整个选择器。 */
      disabled?: boolean
      className?: string
      children: ReactNode
    }
    
    export interface SelectTriggerProps {
      className?: string
      children: ReactNode
    }
    
    export interface SelectValueProps {
      /** 尚未选中时显示的占位文案。缺省为 `"Select"`。 */
      placeholder?: string
      className?: string
    }
    
    export interface SelectContentProps {
      className?: string
      children: ReactNode
    }
    
    export interface SelectItemProps {
      /** 选项的机器可读值,选中后写入上下文。 */
      value: string
      /** 禁用该项:不可点击,仍会注册 label。 */
      disabled?: boolean
      className?: string
      /**
       * 展示内容。若为纯字符串,会同时作为触发器上的 label;
       * 否则退回使用 `value` 作为 label。
       */
      children: ReactNode
    }
    
    // ===== context.tsx =====
    import { createContext, useContext } from 'react'
    import type { SelectContextValue } from './types'
    
    /**
     * Select 复合组件的内部上下文。
     * 不对外导出 Provider;只由根 `Select` 写入,子组件通过 `useSelectContext` 读取。
     */
    export const SelectContext = createContext<SelectContextValue | null>(null)
    
    /**
     * 读取 Select 上下文。
     *
     * @param component 调用方组件名,用于拼进错误信息,方便定位「在根组件外使用了子组件」。
     * @throws 未包裹在 `<Select>` 内时抛错。
     */
    export function useSelectContext(component: string): SelectContextValue {
      const context = useContext(SelectContext)
      if (!context) {
        throw new Error(`${component} 必须放在 <Select> 内部使用`)
      }
      return context
    }
    
    // ===== motion.ts =====
    import type { Transition, Variants } from 'motion/react'
    
    /** 瞬时过渡:翻转放置方向时,远侧属性不应跟着动,用 duration: 0 锁死当前值。 */
    export const initialTransition: Transition = {
      duration: 0,
    }
    
    /**
     * 触发器右侧 Chevron 的旋转弹簧。
     * 与面板展开编排同步,观感接近 bouncy-accordion:略带回弹,但时长控制在 0.4s。
     */
    export const chevronTransition: Transition = {
      type: 'spring',
      duration: 0.4,
      bounce: 0.3,
    }
    
    /**
     * 选项列表的编排变体。
     * `staggerChildren` 让每一项依次入场;`delayChildren` 等面板高度弹簧先走一小段再开始。
     */
    export const listVariants: Variants = {
      hidden: {},
      show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } },
    }
    
    /**
     * 单个选项的入场:轻微上移 + 透明度 + 模糊。
     * 关闭时回到 hidden;面板本身用 height 裁切,所以项即使仍挂载也看不见。
     */
    export const itemVariants: Variants = {
      hidden: { opacity: 0, y: -6, filter: 'blur(3px)' },
      show: { opacity: 1, y: 0, filter: 'blur(0px)' },
    }
    
    /** 面板打开后,近侧(朝向触发器)拉开的缝隙(px)。 */
    export const NEAR_GAP = 8
    
    /** 判断翻转时,下方至少要多留出的安全边距(px)。 */
    export const FLIP_SAFE_GAP = 16
    
    /** 面板近侧缝隙弹簧:打开带回弹,关闭更快、几乎不弹。 */
    export function gapTransition(open: boolean): Transition {
      return open
        ? { type: 'spring', duration: 0.6, bounce: 0.5, delay: 0.12 }
        : { type: 'spring', duration: 0.3, bounce: 0.1 }
    }
    
    // ===== Select.tsx =====
    import { useState, useCallback, useMemo, useId, useRef, useEffect } from 'react'
    import { useReducedMotion } from 'motion/react'
    import { cn } from '@components/lib/utils'
    import { SelectContext } from './context'
    import type { Placement, SelectProps } from './types'
    import { useMap } from '@hooks/useMap'
    
    /**
     * Select 根组件:持有选中值、开关、选项标签表与放置方向。
     *
     * ## 受控 / 非受控
     * - `value` 有值 → 选中值受控,内部 `internal` 不再更新展示,只靠外部回流。
     * - `open` 有值 → 开关受控,`setOpen` 只调用 `onOpenChange`。
     *
     * ## 标签登记
     * `SelectItem` 挂载时 `register(value, label)`,卸载时 `unregister`。
     * 面板关闭并不卸载选项(见 `SelectContent`),所以触发器关闭后仍能显示当前 label。
     *
     * ## 点击外部 / Esc
     * 打开期间在 `window` 上监听 `keydown` 与 `pointerdown`:
     * Esc 或点击根节点以外区域时关闭。监听器在关闭后立即拆除。
     *
     * @example
     * ```tsx
     * <Select defaultValue="apple" onValueChange={console.log}>
     *   <SelectTrigger>
     *     <SelectValue placeholder="选择水果" />
     *   </SelectTrigger>
     *   <SelectContent>
     *     <SelectItem value="apple">苹果</SelectItem>
     *     <SelectItem value="pear">梨</SelectItem>
     *   </SelectContent>
     * </Select>
     * ```
     */
    export function Select({
      value,
      defaultValue,
      onValueChange,
      open: openProp,
      defaultOpen = false,
      onOpenChange,
      disabled = false,
      className,
      children,
    }: SelectProps) {
      /** 系统开启「减少动态效果」时为 true,子组件据此跳过弹簧。 */
      const reduce = useReducedMotion() ?? false
      /** 同一页面多个 Select 并存时,用 React id 保证 trigger / list 的 aria 配对唯一。 */
      const baseId = useId()
      const rootRef = useRef<HTMLDivElement>(null)
    
      const [internalOpen, setInternalOpen] = useState(defaultOpen)
      const [internal, setInternal] = useState(defaultValue)
      const [labels, { set: setLabel, remove: removeLabel }] = useMap<string, string>()
      const [placement, setPlacement] = useState<Placement>('bottom')
    
      const controlled = value !== undefined
      const current = controlled ? value : internal
    
      const openControlled = openProp !== undefined
      const open = openControlled ? openProp : internalOpen
    
      const setOpen = useCallback(
        (next: boolean) => {
          if (!openControlled) setInternalOpen(next)
          onOpenChange?.(next)
        },
        [onOpenChange, openControlled],
      )
    
      const select = useCallback(
        (next: string) => {
          if (!controlled) setInternal(next)
          onValueChange?.(next)
          setOpen(false)
        },
        [controlled, onValueChange, setOpen],
      )
    
      /** 依赖具体方法而不是整个 actions 对象,避免对象换引用导致选项反复登记。 */
      const register = useCallback(
        (v: string, label: string) => {
          setLabel(v, label)
        },
        [setLabel],
      )
    
      const unregister = useCallback(
        (v: string) => {
          removeLabel(v)
        },
        [removeLabel],
      )
    
      useEffect(() => {
        if (!open) return
    
        const onKey = (e: KeyboardEvent) => {
          if (e.key === 'Escape') setOpen(false)
        }
    
        const onPointer = (e: PointerEvent) => {
          if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
            setOpen(false)
          }
        }
    
        window.addEventListener('keydown', onKey)
        window.addEventListener('pointerdown', onPointer)
    
        return () => {
          window.removeEventListener('keydown', onKey)
          window.removeEventListener('pointerdown', onPointer)
        }
      }, [open, setOpen])
    
      const ctx = useMemo(
        () => ({
          value: current,
          open,
          setOpen,
          select,
          register,
          unregister,
          labelFor: (v?: string) => (v === undefined ? undefined : labels.get(v)),
          reduce,
          triggerId: `${baseId}-trigger`,
          listId: `${baseId}-list`,
          disabled,
          placement,
          setPlacement,
        }),
        [
          current,
          open,
          setOpen,
          select,
          register,
          unregister,
          labels,
          reduce,
          baseId,
          disabled,
          placement,
        ],
      )
    
      return (
        <SelectContext value={ctx}>
          <div ref={rootRef} className={cn('relative', className)}>
            {children}
          </div>
        </SelectContext>
      )
    }
    
    // ===== SelectTrigger.tsx =====
    import { ChevronDown } from 'lucide-react'
    import { motion } from 'motion/react'
    import { cn } from '@components/lib/utils'
    import { useSelectContext } from './context'
    import { chevronTransition } from './motion'
    import type { SelectTriggerProps } from './types'
    
    /**
     * 打开 / 关闭面板的按钮。
     *
     * ## 无障碍
     * - `aria-haspopup="listbox"` + `aria-expanded` 声明这是列表框触发器
     * - `aria-controls` 指向 `SelectContent` 的 `listId`
     *
     * ## 圆角
     * 默认 `rounded-xl`,由 className 覆盖(例如 `rounded-lg` / `rounded-2xl`)。
     * 开合只动 Chevron,不写 inline `border-radius`,避免盖掉 Tailwind。
     * 需要按打开方向改近侧圆角时,用 `data-open` / `data-placement` 自己写选择器。
     */
    export function SelectTrigger({ className, children }: SelectTriggerProps) {
      const ctx = useSelectContext('SelectTrigger')
    
      return (
        <button
          type="button"
          id={ctx.triggerId}
          disabled={ctx.disabled}
          data-open={ctx.open}
          data-placement={ctx.placement}
          aria-haspopup="listbox"
          aria-expanded={ctx.open}
          aria-controls={ctx.listId}
          onClick={() => ctx.setOpen(!ctx.open)}
          className={cn(
            'relative z-10 flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors',
            'hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20',
            'disabled:pointer-events-none disabled:opacity-50',
            className,
          )}
        >
          {children}
          <motion.span
            aria-hidden={true}
            animate={{ rotate: ctx.open ? 180 : 0 }}
            transition={ctx.reduce ? { duration: 0 } : chevronTransition}
            className="text-muted-foreground"
          >
            <ChevronDown className="w-4 h-4" />
          </motion.span>
        </button>
      )
    }
    
    // ===== SelectValue.tsx =====
    import { cn } from '@components/lib/utils'
    import { useSelectContext } from './context'
    import type { SelectValueProps } from './types'
    
    /**
     * 触发器内的当前值展示。
     *
     * 文案来源:`SelectItem` 挂载时通过 `register` 写入的 label Map。
     * 尚未选中(或对应项尚未注册)时显示 `placeholder`,再缺省则 `"Select"`。
     *
     * 有值用前景色,占位符用 muted,避免「看起来像已选中」。
     */
    export function SelectValue({ placeholder, className }: SelectValueProps) {
      const ctx = useSelectContext('SelectValue')
      const label = ctx.labelFor(ctx.value)
    
      return (
        <span className={cn(label ? 'text-foreground' : 'text-muted-foreground', className)}>
          {label ?? placeholder ?? 'Select'}
        </span>
      )
    }
    
    // ===== SelectContent.tsx =====
    import { useLayoutEffect, useRef, useState } from 'react'
    import { motion } from 'motion/react'
    import { EASE_OUT } from '@components/lib/select-ease'
    import { cn } from '@components/lib/utils'
    import { useSelectContext } from './context'
    import {
      FLIP_SAFE_GAP,
      NEAR_GAP,
      gapTransition,
      initialTransition,
      listVariants,
    } from './motion'
    import type { SelectContentProps } from './types'
    
    /**
     * 选项面板:绝对定位在触发器上方或下方,用 height 弹簧做展开 / 收起。
     *
     * ## 为何关闭后仍挂载 children
     * 打开只动画面板高度,不卸载 `SelectItem`。否则 `unregister` 会清掉 label,
     * 触发器上的 `SelectValue` 会在关闭瞬间掉回占位符。
     *
     * ## 翻转
     * 打开时量一次视口:下方放不下(高度 + 16px 安全距)且上方更宽裕 → `placement: 'top'`。
     *
     * ## 近侧 / 远侧
     * 朝向触发器的一侧叫近侧:打开时缝隙从 0→8。远侧 margin 为 0。
     * 两侧 margin 每次都写全,避免 `placement` 翻转后留下旧边距。
     * 圆角只走 className(默认 `rounded-xl`),不写 inline `border-radius`。
     *
     * ## 减少动态效果
     * 只做透明度和高度的短过渡,不做缝隙弹簧。
     */
    export function SelectContent({ className, children }: SelectContentProps) {
      const ctx = useSelectContext('SelectContent')
      const innerRef = useRef<HTMLDivElement>(null)
      const [height, setHeight] = useState(0)
      const open = ctx.open
      const { setPlacement } = ctx
    
      useLayoutEffect(() => {
        const node = innerRef.current
        if (!node) return
    
        const measure = () => setHeight(node.offsetHeight)
        measure()
    
        const observer = new ResizeObserver(measure)
        observer.observe(node)
        return () => observer.disconnect()
      }, [])
    
      useLayoutEffect(() => {
        if (!open) return
        const trigger = document.getElementById(ctx.triggerId)
        const node = innerRef.current
        if (!trigger || !node) return
    
        const rect = trigger.getBoundingClientRect()
        const h = node.offsetHeight
        const below = window.innerHeight - rect.bottom
        const above = rect.top
        setPlacement?.(below < h + FLIP_SAFE_GAP && above > below ? 'top' : 'bottom')
      }, [open, ctx.triggerId, setPlacement])
    
      const isTop = ctx.placement === 'top'
      const nearGap = open ? NEAR_GAP : 0
      const gapT = gapTransition(open)
    
      const animate = ctx.reduce
        ? { opacity: open ? 1 : 0, height: open ? height : 0 }
        : {
            opacity: open ? 1 : 0,
            height: open ? height : 0,
            marginTop: isTop ? 0 : nearGap,
            marginBottom: isTop ? nearGap : 0,
          }
    
      const transition = ctx.reduce
        ? { duration: 0.12 }
        : {
            opacity: open ? { duration: 0.18 } : { duration: 0.16, delay: 0.12 },
            height: open
              ? { type: 'spring', duration: 0.42, bounce: 0.14 }
              : { duration: 0.26, ease: EASE_OUT, delay: 0.14 },
            marginTop: isTop ? initialTransition : gapT,
            marginBottom: isTop ? gapT : initialTransition,
          }
    
      return (
        <motion.div
          id={ctx.listId}
          role="listbox"
          aria-labelledby={ctx.triggerId}
          aria-hidden={!open}
          inert={!open}
          data-open={open}
          data-placement={ctx.placement}
          initial={false}
          animate={animate}
          transition={transition}
          style={{
            transformOrigin: isTop ? 'bottom' : 'top',
            overflow: 'hidden',
            pointerEvents: open ? 'auto' : 'none',
          }}
          className={cn(
            'absolute left-0 right-0 z-20 rounded-xl border border-border bg-background shadow-lg',
            isTop ? 'bottom-full' : 'top-full',
            className,
          )}
        >
          <motion.div
            ref={innerRef}
            variants={ctx.reduce ? undefined : listVariants}
            initial={false}
            animate={open ? 'show' : 'hidden'}
            className="p-1"
          >
            {children}
          </motion.div>
        </motion.div>
      )
    }
    
    // ===== SelectItem.tsx =====
    import { useLayoutEffect } from 'react'
    import { Check } from 'lucide-react'
    import { motion } from 'motion/react'
    import { cn } from '@components/lib/utils'
    import { useSelectContext } from './context'
    import { itemVariants } from './motion'
    import type { SelectItemProps } from './types'
    
    /**
     * 列表中的一项。
     *
     * ## 标签登记
     * 在 layout effect 里把 `value → label` 登记到根组件。
     * `children` 为字符串时用它做 label(触发器展示更友好);否则用 `value`。
     *
     * 依赖里只列 `register` / `unregister` 引用(根组件用 `useCallback` 固定),
     * 避免把整个 context 对象放进依赖导致每次父渲染都重新登记。
     *
     * ## 无障碍
     * 外层是 `motion.li` 以配合 listbox;真正可聚焦的是内部 `role="option"` 按钮。
     * 选中项展示勾选图标,并用 `aria-selected` 同步给辅助技术。
     */
    export function SelectItem({ value, disabled, className, children }: SelectItemProps) {
      const ctx = useSelectContext('SelectItem')
      const selected = ctx.value === value
      const label = typeof children === 'string' ? children : value
    
      useLayoutEffect(() => {
        ctx.register(value, label)
        return () => {
          ctx.unregister(value)
        }
      }, [ctx.register, ctx.unregister, value, label])
    
      return (
        <motion.li variants={ctx.reduce ? undefined : itemVariants}>
          <button
            type="button"
            role="option"
            aria-selected={selected}
            disabled={disabled}
            onClick={() => ctx.select(value)}
            className={cn(
              'flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors',
              selected
                ? 'bg-muted text-foreground'
                : 'text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted',
              'disabled:pointer-events-none disabled:opacity-50',
              className,
            )}
          >
            {children}
            {selected && <Check className="w-4 h-4" />}
          </button>
        </motion.li>
      )
    }

    API

    Select

    根组件。持有选中值、面板开关、选项标签表和放置方向。

    属性 类型 默认值 说明
    value string | string[] 受控选中值。multiple 时为 string[],传入 [] 表示受控且未选。
    defaultValue string | string[] 非受控初始值。multiple 时为 string[]。
    onValueChange (value: string | string[]) => void 选中变化回调。单选为 string,multiple 时为 string[]。
    open boolean 受控的面板打开状态。堆叠多个 Select 时由父级持有,避免面板互相遮挡。
    defaultOpen boolean false 非受控的初始打开状态。仅在未传 open 时生效。
    onOpenChange (open: boolean) => void 面板打开或关闭时回调。受控与非受控都会触发。
    disabled boolean false 禁用整个选择器。触发器不可点,选项仍会渲染并登记 label。
    className string 根节点 class。根节点是 relative 定位容器。
    children ReactNode 必填 通常为 SelectTrigger 与 SelectContent。
    multiple boolean false 为 true 时多选。value / defaultValue / onValueChange 变为 string[];点选项切换且不关面板。
    searchable boolean false 为 true 时 Trigger 变成 combobox:点输入框打开面板并过滤选项。关面板或(多选)选中后清空搜索词。
    searchValue string 受控搜索词。传入后内部不再自己更新 query。
    defaultSearchValue string "" 非受控的初始搜索词。仅在未传 searchValue 时生效。
    onSearch (query: string) => void 搜索词变化时回调。远程搜索可在这里拉数,并设 filter={false}。
    filter boolean | ((query: string, item: { value: string; label: string }) => boolean) true 本地过滤。false 时不隐藏选项(留给远程);传入函数则替换默认的 label/value includes。

    SelectTrigger

    打开 / 关闭面板。默认为 button;searchable 时改为容器,combobox 角色在输入框上。圆角用 className 覆盖。

    属性 类型 默认值 说明
    className string 触发器按钮 class。可覆盖默认 rounded-xl;开合不会写 inline 圆角。
    children ReactNode 必填 通常放 SelectValue,右侧会自动渲染 Chevron。

    SelectValue

    触发器内的当前值。单选为纯文本;多选时每个 label 包一层 SelectTag。searchable 时在同一处渲染输入框,placeholder 成为 input 的占位。

    属性 类型 默认值 说明
    placeholder string "Select" 尚未选中(或对应项尚未登记)时显示的占位文案。searchable 时写在输入框上;已有选中值时不显示。
    className string 容器 class。未选中时是占位文案;单选是纯文本,多选是 Tag 列表。

    SelectContent

    选项面板。关闭后仍挂载 children,避免触发器掉回占位符;视口不够时翻到上方。可搜索时列表限高滚动,无匹配时显示空态。

    属性 类型 默认值 说明
    className string 面板容器 class。可覆盖默认 rounded-xl;高度与缝隙仍由 Motion 驱动。
    children ReactNode 必填 若干 SelectItem。不要在关闭时条件卸载,否则 label 会丢失。

    SelectItem

    列表中的一项。挂载时把 value → label 登记到根组件。

    属性 类型 默认值 说明
    value string 必填 机器可读值。选中后写入上下文,并作为非字符串 children 时的回退文案。
    disabled boolean false 禁用该项:不可点击,仍会登记 label。
    className string 选项按钮 class。
    children ReactNode 必填 列表中的展示内容。纯字符串会同时作为触发器 label 与搜索文本;否则触发器显示 value,可用 label / textValue 覆盖。
    label string 触发器文案。不传则用字符串 children,再否则 value。
    textValue string 仅用于本地搜索,不改触发器展示。适合 children 是图标或自定义节点的项。