Select 选择器
UI复合 Select,用 Motion 做面板展开和缝隙动画;圆角由 className 控制。面板关闭后选项仍挂载,触发器不会掉回占位符。
roadmap
5 groups · local search done
本地搜索已落地。下面按建议实现顺序排列,不改变现有 searchable API。
- 01
远程搜索
已预留 APIonSearch + filter={false} 已可把列表交给外部。组件内仍不发请求。
- 防抖 — 避免每个按键都打接口;可做 searchDebounce,或由调用方在 onSearch 里做。
- Loading — 请求中展示加载态,与「无匹配」区分。
- 请求竞态 — 只采用最后一次 query 的结果,过期响应丢弃或 abort。
- 失败态 — 网络错误时的文案,避免被空列表误当成没有数据。
- 02
键盘浏览
未开始现有 Select 也没有方向键选中。搜索 Combobox 同样还没补。
- 方向键高亮 — 上下移动当前项,面板滚动跟随。
- Enter 选中 — 选中高亮项;单选关面板,多选保持打开。
- aria-activedescendant — 把当前高亮项同步给辅助技术。
- 03
多选 Tag 关闭
未开始现在只能 Backspace 删最后一个,或再打开列表点掉。
- Tag 上的 × — 删除后焦点回到搜索框,避免光标丢失。
- 04
空态 / 加载插槽
未开始无匹配目前写在 Content 内部,远程 loading 还没有对应节点。
- SelectEmpty — 可组合的空态,替换写死的「无匹配项」。
- SelectLoading — 远程搜索转圈或骨架。
- 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 childrenchildren 不是纯字符串时,触发器回退显示 value。
multiple点选项切换,面板保持打开。新选中的 Tag 会弹出,已有 Tag 让位。
onValueChange → apple
searchableTrigger 变成输入框。点字段打开面板,输入过滤选项;选中后关闭并恢复 label。
onValueChange → (未选)
searchable + multipleTag 和输入框在同一格。选中后清空搜索词、焦点留在输入框,可继续搜。
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 是图标或自定义节点的项。 |