Cadenza
EN

滑块 —— 一个值一个拇指,Value 泛型不退化,onValueCommitted 才是该挂请求的那个

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 那套 htmlForid 不一样:

写法谁拿到名字
aria-labelledby 指向标签元素的 id根那个 group 每个拇指里的 input(Base UI 会往下传)
aria-label 写在根上只有根那个 group;拇指的 input 拿不到
FieldLabel htmlFor谁都没有 —— 见下

有可见文字就用 aria-labelledbyFieldTitle 正合适: 它是个 div(不是 label),本来就是给「没有单一控件可指」的编组用的,而 role="group" 的滑块正是这种。

FieldLabel htmlFor 在这里没有落点,别指它:原生 <label for> 只认可 label 的元素, 指向根那个 div 等于没指;而封装层自己渲染拇指,没有给外部塞 id 的口子 —— 就算有, Base UI 的 Slider.Thumbid 也是落在拇指那个 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 —— 见下。

拖动中与落定

两个回调不是一回事

回调什么时候响第二参能不能拦
onValueChange值每变一次SliderChangeEventDetails能。cancel() 一调,这次变更就不落到组件内部那份状态上,onValueCommitted 也不会跟着响
onValueCommitted这一下交互落定后SliderCommitEventDetails不能 —— 它是通知(generic event),压根没有 cancel()

「落定」的时机分两条路:

  • 指针'drag' / 'track-press')—— 松手时才落定,拖一整趟只算一次。
  • 键盘'keyboard')—— 每按一下当场就落定一次。

上游把这条写死在契约里:值没变、或者这次变更被 cancel() 了,就不会为它触发落定。

两个回调的 reason 取值集合相同:

reason来自
'drag'拖拽拇指
'track-press'按在轨道上
'keyboard'方向键、PageUp / PageDown、Home / End
'input-change'隐藏的 range input 自己发出 change(表单集成、辅助技术直接改值)
'none'没有具体交互来源

区间滑块想知道动的是哪个拇指时,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

data-*SliderState 里的名字出现时机
data-orientationorientation一直有值:horizontal / vertical。根与所有部件上都有
data-draggingdragging正在拖拽拇指
data-disableddisableddisabled

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 选择器:

data-slot是什么
slider根,<div role="group">
slider-control负责命中与拖拽的那层(Base UI 的 Slider.Control),拇指排在它里面
slider-track轨道,那条底槽
slider-range已选中那一截(Base UI 的 Slider.Indicator
slider-thumb拇指,一个值一个

键盘交互

焦点落在拇指上(每个拇指一个 Tab 停靠点)之后:

按键效果
/ 加一个 step
/ 减一个 step
Shift + 方向键改用 largeStep(默认 10
PageUp / PageDown同样走 largeStep,不用按 Shift
Home跳到 min;区间里是「前一个拇指 + minStepsBetweenValues × step
End跳到 max;区间里是「后一个拇指 − minStepsBetweenValues × step

键盘每按一下都当场落定一次(reason'keyboard'),和拖动只在松手时落定一次不同 —— 见拖动中与落定

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className

Prop类型默认值说明
defaultValueValuemin(也就是 0非受控初始值。number 一个拇指,数组一个值一个拇指
valueValue受控值
onValueChange(value: Value extends number ? number : Value, eventDetails: SliderChangeEventDetails) => void值变化回调,可 cancel()
onValueCommitted(value: Value extends number ? number : Value, eventDetails: SliderCommitEventDetails) => void落定回调,通知,没有 cancel()
minnumber0最小值
maxnumber100最大值。开发模式下 min >= max 会告警
stepnumber1步长,以 min 为原点吸附
largeStepnumber10Shift + 方向键、PageUp / PageDown 的步长
minStepsBetweenValuesnumber0区间里两个拇指之间至少隔几步
thumbCollisionBehavior'push' | 'swap' | 'none''push'两个拇指撞上时推着走 / 交换 / 顶住不动
orientation'horizontal' | 'vertical''horizontal'方向
disabledbooleanfalse忽略用户交互
namestring表单字段名,每个拇指的 input 都用它
formstring拥有这些 input 的表单 id,控件渲染在表单之外时用
formatIntl.NumberFormatOptions播报用的数字格式,进拇指的 aria-valuetext
localeIntl.LocalesArgumentformat 的 locale,不给就跟运行时
thumbAlignment'center' | 'edge' | 'edge-client-only''edge'封装层改过默认值,见拇指贴边
aria-labelledbystring指向标签元素的 id,会往下传给每个拇指的 input
renderReactElement | (props, state) => ReactElement换掉根渲染的元素
classNamestring | ((state: SliderState) => string | undefined)根的类名。排在封装层那组默认之后,冲突时你的赢
其余Base UI Slider.Root 的 props(div 的原生属性 + ref透传。children 除外 —— 部件由封装层渲染

四个类型一并导出:SliderProps(带 Value 泛型)/ SliderState / SliderChangeEventDetails / SliderCommitEventDetails