Base UI 的 Slider 装进 shadcn 的 base-nova 皮肤。一个值一个拇指:defaultValue={40}
渲染一个,defaultValue={[25, 75]} 渲染两个,同一个组件两种形态。
它是封装层自己的组合,不是原样转出,就为了那句话能成立 —— vendored 那份按
Array.isArray(value) 数拇指,标量走不进这条判断,于是退回 [min, max]:最常见的
单值滑块因此长出两个叠在同一位置的拇指,多一个 Tab 停靠点,屏幕阅读器也多播报
一个滑块,而这个控件只有一个值。这里的拇指数按真正在用的那个值算
(value ?? defaultValue,用 ?? 不用 || —— 0 是个正经的值,falsy 判断会把它
漏进默认分支、数错拇指)。
有一条边界:非受控时 Base UI 只在挂载那一次读 defaultValue,所以挂载之后再改它的
形状(标量 ↔ 数组,或换个长度),拇指数会动而 Base UI 那份值不动,叠在一起的那对
就回来了。挂载后改 defaultValue 本来就是 React 会告警的误用;受控的 value 一路跟得住。
使用
import { Field, FieldDescription, FieldTitle, Slider } from '@gedatou/cadenza-ui'<Field>
<FieldTitle id="volume">主音量</FieldTitle>
<Slider aria-labelledby="volume" defaultValue={40} name="volume" />
</Field>标签
Slider 的根渲染的是 <div role="group">,不是一个可 label 的元素;真正带
role="slider" 的,是每个拇指里那个视觉隐藏的 <input type="range">。所以标签这条线
和 Checkbox 那套 htmlFor → id 不一样:
有可见文字就用 aria-labelledby。 配 FieldTitle 正合适:
它是个 div(不是 label),本来就是给「没有单一控件可指」的编组用的,而 role="group"
的滑块正是这种。
FieldLabel htmlFor 在这里没有落点,别指它:原生 <label for> 只认可 label 的元素,
指向根那个 div 等于没指;而封装层自己渲染拇指,没有给外部塞 id 的口子 —— 就算有,
Base UI 的 Slider.Thumb 的 id 也是落在拇指那个 div 上,里面 input 的 id 是内部
生成的,猜不到。
压根没有可见文字时(工具条里的一根音量条)才写 aria-label。
全库四条标签通道的总表在 Field,滑块是唯一
「接不上 htmlFor」的那条。
区间
数组里放两个值,就是一个区间:
渲染前值会被 clamp 到 [min, max] 并按升序排列。
正好两个值时,Base UI 给两个拇指默认的 aria-valuetext 是 "180 start range" /
"680 end range"。
要它们始终隔开一点,用 minStepsBetweenValues(单位是步,不是值:
step={20} minStepsBetweenValues={1} 就是至少差 20)。
多个拇指
数组里有几个值就有几个拇指,两个以上同理:
拇指编号从 0 起,data-index 写在每个拇指上。三个以上时 Base UI 不再给
aria-valuetext(除非你传了 format),屏幕阅读器读的就是 aria-valuenow 那个数字本身
—— start range / end range 那套只在正好两个值时出现。
两个拇指撞上时的行为由 thumbCollisionBehavior 决定:'push'(默认,推着走)、
'swap'(交换位置)、'none'(顶住不动,多出来的位移丢弃)。
泛型
签名是 Slider<Value extends number | readonly number[] = number>,泛型跟着你传的
值走:
<Slider defaultValue={40} onValueChange={next => setLevel(next)} /> // next: number
<Slider defaultValue={[25, 75]} onValueChange={next => setRange(next)} /> // next: number[]
<Slider onValueChange={next => setLevel(next)} /> // next: number也就是说回调拿到的不是 number | readonly number[],不用在每个调用点先窄化一次再用。
SliderProps 同样带这个泛型(默认 number)。
方向
orientation="vertical" 把轨道立起来:
data-orientation 在根、控件、轨道、指示条、拇指上一起翻成 vertical,封装层的默认样式
跟着换轴:轨道变竖条,控件拿 block-full 撑满外面给的高度,再用 min-block-40 兜一个
最小高度 —— 不给高度也不至于塌成 0。
受控
受控三件套是 value / defaultValue / onValueChange,和别处一样:给 value 就是受控,
给 defaultValue 就是非受控,两个都不给按 min 起步。
要挂请求的不是 onValueChange,是它旁边那个 onValueCommitted —— 见下。
拖动中与落定
两个回调不是一回事:
「落定」的时机分两条路:
- 指针(
'drag'/'track-press')—— 松手时才落定,拖一整趟只算一次。 - 键盘(
'keyboard')—— 每按一下当场就落定一次。
上游把这条写死在契约里:值没变、或者这次变更被 cancel() 了,就不会为它触发落定。
两个回调的 reason 取值集合相同:
区间滑块想知道动的是哪个拇指时,SliderChangeEventDetails 上还有一个
activeThumbIndex(编号同 data-index);落定那份没有这个字段。
昂贵的那件事挂 onValueCommitted。 一趟拖动会把 onValueChange 打成几十上百次,
拿它发请求就是几十上百个请求。
表单
序列化是 Base UI 的:给了 name,每个拇指里那个 <input type="range"> 都带上这个
name 参与提交。单值滑块提交一条;区间滑块同名提交两条,读的时候用
FormData.getAll(name):
new FormData(form).getAll('price') // ['180', '680']form 指定拥有这些 input 的表单 id,控件渲染在 <form> 之外时用。
禁用
disabled 让控件忽略用户交互,整条一起变淡 —— 这一下是 Slider.Control 上的
data-disabled:opacity-50 做的,轨道、指示条、拇指跟着一起半透明。
上游把 disabled:pointer-events-none disabled:opacity-50 写在拇指上,那两条在这里是
死的:拇指是个 div,:disabled 只会命中嵌在它里面那个隐藏的 <input type="range">,
永远匹配不到拇指本身。封装层因此没有照抄这两条。
光标变成 not-allowed,来源见光标。
拇指贴边
封装层把 thumbAlignment 从 Base UI 的 'center' 改成了 'edge':值走到两端时整个拇指
内缩在控件里,而不是一半探到轨道外面去。要回原来的行为,自己传 thumbAlignment="center"。
光标
拇指是 cursor-grab,拖动中翻成 grabbing;轨道保持箭头。全库其它可点控件走的是
pointer(那条策略在 styles.css 里,按 tag 与 role 命中),滑块有意不在其中 ——
CSS 里 pointer 的语义是「指示一个链接」,grab / grabbing 才是「可抓取 / 正在抓取」,
而拇指是拖的不是点的。上游 shadcn、Base UI 与 Radix 的官方示例对滑块一条 cursor 都不写,
原生 <input type="range"> 在 Chrome 也是箭头 —— 这一条是封装层自己加的。
拖动态挂在 data-dragging 而不是 :active 上:拖拽会活过拇指上的那一下按压,指针移开
拇指时 :active 就断了。同一个属性也挂在控件上,所以拖快了指针甩出拇指,光标依然是
grabbing。
禁用时是 not-allowed,来自全库那条禁用规则 —— 它不分层,所以压得过
cursor-grab 这个 utility,不需要在滑块这边再写一遍。
要换成别的光标,走 data-slot 选择器(className 落在根上,够不到拇指):
[data-slot='slider-thumb'] { cursor: pointer; }状态与 className
className 通到的是 Base UI 的 slot,所以函数形态成立:(state) => string | undefined。
那个 state 就是 SliderState:
SliderState 里另有一组故意不落成 data-* 的:values / activeThumbIndex /
min / max / step / minStepsBetweenValues —— 数值进属性选择器没有意义,要用就在
className 的函数形态里读。
还有 Field 那一组(valid / touched / dirty / filled / focused),和
Checkbox 同样的注脚:它们只在 Base UI 的 Field.Root 里
才会动,本库的 Field 是纯 DOM 布局,不是它。
从外面改样式
根以下的部件全部由封装层自己渲染,没有对外的组合位(写进 children 的东西不会出现)。
要改样式就用 data-slot 选择器:
键盘交互
焦点落在拇指上(每个拇指一个 Tab 停靠点)之后:
键盘每按一下都当场落定一次(reason 是 'keyboard'),和拖动只在松手时落定一次不同 ——
见拖动中与落定。
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
四个类型一并导出:SliderProps(带 Value 泛型)/ SliderState /
SliderChangeEventDetails / SliderCommitEventDetails。