元素
Slider
滑块沿轨道选取一个值(音量、亮度),或两个值组成的范围,每个值一个滑块(价格区间)。它负责行为:按压轨道时,较近的滑块跳到按下的位置;拖动滑块时它跟随指针或手指;数值按
step 对齐;每个滑块都是键盘焦点的一站,可以用方向键移动,并且对屏幕阅读器是带自己数值的可调节元素。外观由应用决定:Slider
没有自己的颜色、尺寸、圆角或动效。它由轨道、轨道里的指示条、指示条上的范围和滑块,以及之后的子元素(标签、文字形式的数值)组成,每个部件都由应用设置样式。
import { type JSX, mount, signal, Text, View } from "nsh";
import { Slider, type SliderValue } from "nsh/base";
import { desktop, Log, Panel, size, sliderFill, sliderLook, sliderValueClass, sliderValueStyle, Split, ui } from "./palette";
// The look is the shared layer's `sliderLook` and `sliderFill` (BJORK UI's
// slider in the palette's tones): an 8-point rail, a fill that deepens at its
// start and fades into the thumb at the thumb, a 16 × 8 pill thumb that
// brightens under the pointer and while dragged, the keyboard's focus ring
// around the rail, and 35% when disabled. The spread holds only what never
// changes; the fill follows the value as a prop of its own. The demo shows a slider with its value column, a
// bare one, a range, a disabled one and vertical bands, each named above it.
const show = (v: SliderValue) => (typeof v === "number" ? `${v}` : `${v[0]}–${v[1]}`);
const clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds) % 60).padStart(2, "0")}`;
const LENGTH = 225;
function Row(props: { label: string; value?: string; children: JSX.Element }) {
return (
<View class={desktop ? "gap-2 px-3 py-3" : "gap-2 px-4 py-4"}>
<View class="flex-row items-baseline gap-3">
<Text style={{ flex: 1, fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
<Text style={{ fontSize: size.label, color: ui().muted, fontVariant: ["tabular-nums"] }}>{props.value ?? ""}</Text>
</View>
{props.children}
</View>
);
}
function App() {
const strength = signal(64);
const position = signal(84);
const price = signal<[number, number]>([200, 800]);
const bands = [signal(0), signal(3), signal(-2)];
const events = signal<string[]>([]);
const log = (name: string) => (next: SliderValue) => events.set([`${name} → ${show(next)}`, ...events()].slice(0, 5));
const line = () => <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>slider</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Drag a thumb or click the track, or Tab to a thumb and use the arrow keys" : "Drag a thumb, or tap the track"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="value">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
strength {strength()} · position {position()} · price [{price()[0]}, {price()[1]}]
</Text>
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
bands {bands.map((b) => b()).join(" / ")}
</Text>
</Panel>
<Log title="onChangeEnd(value)" lines={events()} minHeight={92} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
<Row label="Option strength">
<Slider
value={strength()}
onChange={(next) => strength.set(next as number)}
onChangeEnd={log("strength")}
accessibilityLabel="Option strength"
rangeStyle={sliderFill(0, strength() / 100)}
{...sliderLook({ value: true })}
>
<Text class={sliderValueClass} style={sliderValueStyle()}>{`${strength()}%`}</Text>
</Slider>
</Row>
{line()}
<Row label="Playback position" value={`${clock(position())} / ${clock(LENGTH)}`}>
<Slider
value={position()}
max={LENGTH}
onChange={(next) => position.set(next as number)}
onChangeEnd={log("position")}
accessibilityLabel="Playback position"
rangeStyle={sliderFill(0, position() / LENGTH)}
{...sliderLook()}
/>
</Row>
{line()}
<Row label="Price" value={`$${price()[0]} – $${price()[1]}`}>
<Slider
value={price()}
min={0}
max={1000}
step={10}
onChange={(next) => price.set(next as [number, number])}
onChangeEnd={log("price")}
startAccessibilityLabel="Minimum price"
accessibilityLabel="Maximum price"
rangeStyle={sliderFill(price()[0] / 1000, price()[1] / 1000)}
{...sliderLook()}
/>
</Row>
{line()}
<Row label="Locked intensity">
<Slider value={72} disabled accessibilityLabel="Locked intensity" rangeStyle={sliderFill(0, 0.72)} {...sliderLook({ value: true })}>
<Text class={sliderValueClass} style={sliderValueStyle()}>72%</Text>
</Slider>
</Row>
{line()}
<Row label="Equalizer" value="dB">
<View class="flex-row justify-around pt-1">
{["Bass", "Mid", "Treble"].map((name, i) => (
<View class="items-center gap-2">
<Slider
value={bands[i]()}
min={-12}
max={12}
axis="vertical"
onChange={(next) => bands[i].set(next as number)}
onChangeEnd={log(name.toLowerCase())}
accessibilityLabel={name}
rangeStyle={sliderFill(0, (bands[i]() + 12) / 24, true)}
{...sliderLook({ vertical: true })}
/>
<Text style={{ fontSize: size.detail, color: ui().muted, fontVariant: ["tabular-nums"] }}>
{`${name} ${bands[i]() > 0 ? "+" : ""}${bands[i]()}`}
</Text>
</View>
))}
</View>
</Row>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{SliderValue, slider, theme};
use nsh_core::prelude::*;
fn strength(level: Signal<SliderValue>) -> impl IntoElement {
let percent = move || format!("{}%", level.get().end());
slider()
.value(move || level.get())
.accessibility_label("选项强度")
.on_change(move |next: &SliderValue| level.set(*next))
// The value in a column after the rail.
.flex_row()
.items_center()
.gap_2()
// An 8-point rail; the thumbs travel inside it.
.track_style(|s| s.h(px(8.0)).flex_1().px(px(8.0)).rounded_full().bg(move || theme().muted))
.indicator_style(|s| s.h_full())
// The fill, from the rail's edge, fading into the thumb's tone.
.range_style(|s| {
s.ml(-px(8.0))
.rounded_full()
.bg_linear_to_r()
.gradient_from(move || theme().primary)
.gradient_to(move || theme().muted_foreground)
})
// A 16 x 8 pill sitting in the rail.
.thumb_style(|s| {
s.w(px(16.0))
.h(px(8.0))
.rounded_full()
.bg(move || theme().muted_foreground)
.hover(|s| s.brightness(1.1))
.active(|s| s.brightness(1.25))
})
.child(
text(percent)
.w(px(42.0))
.text_right()
.text_xs()
.tabular_nums()
.text_color(move || theme().muted_foreground),
)
}import { mount, signal, Text } from "nsh";
import { Slider } from "nsh/base";
function App() {
const strength = signal(64);
return (
<Slider
value={strength()}
onChange={(next) => strength.set(next as number)}
accessibilityLabel="选项强度"
class="flex-row items-center gap-2"
trackClass="h-2 flex-1 px-2 rounded-full bg-muted focus-within:ring-2 focus-within:ring-primary/36 focus-within:ring-offset-2 focus-within:ring-offset-background disabled:opacity-35"
indicatorClass="h-full"
rangeClass="-ml-2 rounded-full bg-linear-to-r from-primary via-primary to-muted-foreground"
thumbClass="h-2 w-4 rounded-full bg-muted-foreground transition hover:brightness-110 active:brightness-125 focus:outline-none disabled:brightness-100 motion-reduce:transition-none"
thumbStyle={{ boxShadow: "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)" }}
>
<Text class="w-[42px] text-right font-mono text-[11px] text-muted-foreground">{`${strength()}%`}</Text>
</Slider>
);
}
mount(App);Slider 来自 nsh/base,在 Rust 中是 nsh-base crate。在 Rust 中,slider()
创建一个滑块,每个 prop 有一个 setter(.value(..)、.thumb_style(..)),每个事件有一个监听方法(.on_change(..)),滑块本身用带类型的样式方法设置,标签用
.child(..) 添加。它的值是 SliderValue:Single(f64) 或 Range(f64, f64)。
#值
值是一个数字,或两个滑块的范围 [start, end];值的形状决定有几个滑块。应用用 value 持有它,并设置 onChange 报告的值,和 nsh/base
的每个控件一样;也可以用 defaultValue 把值交给滑块自己保存,修改照样会报告给应用。
const price = signal<[number, number]>([200, 800]);
<Slider
value={price()}
onChange={(next) => price.set(next as [number, number])}
onChangeEnd={(next) => save(next)}
min={0}
max={1000}
step={10}
accessibilityLabel="最高价格"
startAccessibilityLabel="最低价格"
/>;
onChange报告用户要的每个新值:按压轨道时、拖动的每一步、每次按键。每次报告都是一个新值。onChangeEnd报告修改结束时的值,只报告一次:按压或拖动设置过值之后指针抬起时,以及每次按键或屏幕阅读器的步进之后(每一次都是完整的修改)。在这里保存或发送数值。- 数值按
step对齐,从min起算(min={1} step={2}得到 1、3、5……),并保持在min和max之间;max总能取到,所以范围不是步长整数倍时最后一步更短。step={0}可取任意值。 - 范围的两个滑块不会交叉:拖动的滑块停在另一个滑块处。从同一个值上的两个滑块开始拖动时,移动能朝指针方向走的那个。
- 显示的值是应用的值,限制在
min和max之间(范围的两端按顺序),不在步长网格上的值显示在它所在的位置;用户下一次修改时对齐。
#Props
| Prop | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
value | .value(..) | number | [number, number] | 无 | 值(受控):一个数字,或带两个滑块的范围。应用设置 onChange 报告的值。 |
defaultValue | .default_value(..) | number | [number, number] | 0 | 未设置 value 时的初始值;之后由滑块自己保存。 |
min | .min(..) | number | 0 | 最小值。 |
max | .max(..) | number | 100 | 最大值;小于 min 时取 min。 |
step | .step(..) | number | 1 | 数值对齐的步长,从 min 起算;0 可取任意值。 |
scale | .scale(..) | "linear" | "logarithmic" | "linear" | 数值沿轨道的分布:均匀,或按数量级(从 1 到 1000,三分之一处是 10)。对数刻度需要 min 大于 0;min 更小时为线性。 |
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | 轨道的方向:min 在左边,或在底部。创建滑块时读取。 |
disabled | .disabled(..) | boolean | false | 指针、键盘和屏幕阅读器都不能修改;disabled: 类作用于滑块及其部件。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 屏幕阅读器为滑块(范围时为终点的滑块)读出的内容。 |
startAccessibilityLabel | .start_accessibility_label(..) | string | accessibilityLabel | 屏幕阅读器为范围起点的滑块读出的内容。 |
trackClass、trackStyle | .track_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 轨道:接收按压和拖动的区域。 |
indicatorClass、indicatorStyle | .indicator_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 指示条:映射数值的滑轨。 |
rangeClass、rangeStyle | .range_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 范围:从 min 到滑块(范围时在两个滑块之间),由滑块放置。 |
thumbClass、thumbStyle | .thumb_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 每个滑块,以它的值为中心。 |
children | .child(..) | JSX.Element | 无 | 轨道之后的内容:标签、文字形式的数值。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 滑块本身:包住轨道和标签的列(或行)。 |
#事件
| 事件 | Rust | 参数 | 何时 |
|---|---|---|---|
onChange | .on_change(|value: &SliderValue| ..) | number | [number, number] | 用户要一个新值:按压轨道、拖动的一步、按键、屏幕阅读器的增加或减少。 |
onChangeEnd | .on_change_end(|value: &SliderValue| ..) | number | [number, number] | 修改结束:指针抬起,一次按键或屏幕阅读器的步进完成。每次修改一次。 |
#部件
| 部件 | 位于 | 样式 |
|---|---|---|
| 滑块控件 | class、style | |
| 轨道 | 滑块控件内,第一个 | trackClass、trackStyle |
| 指示条 | 轨道内 | indicatorClass、indicatorStyle |
| 范围 | 指示条内,按数值放置 | rangeClass、rangeStyle |
| 滑块(范围时两个) | 指示条内,以它的值为中心 | thumbClass、thumbStyle |
| 标签 | 滑块控件内,轨道之后 | 它自己的元素(children) |
轨道接收按压和拖动。指示条的长度就是数值范围,min 在它的起始边(垂直时在底部);滑块把范围和滑块放在指示条上,无论大小,滑块都以它的值为中心。有两种布局:
- 轨道就是滑轨(上面的例子):
h-2 px-2 rounded-full bg-muted,指示条h-full。内边距让滑块在两端都留在滑轨内,范围的-ml-2让填充从滑轨自己的边缘开始。 - 轨道是包住滑轨的一条带,更高,在触摸屏上手指才容易按到:
h-8 justify-center,指示条h-2 rounded-full(垂直滑块用w-8 items-center和w-2 h-full)。
部件显示的状态:
- 滑块:指针悬停时
hover:,拖动时active:,获得键盘焦点时focus:,以及disabled:。 - 轨道:按压轨道并拖动时
active:;它是一个group,所以指示条、范围和滑块可以用group-hover:和group-active:。 - 每个部件:滑块禁用时
disabled:。
滑块随数值移动,拖动时立即跟随。滑块各状态的动效由应用决定(例如 transition 让滑块在 hover: 和 active: 时变亮),
motion-reduce: 为要求减少动效的用户保持静止。键盘焦点在滑块上,滑块带有 focus: 类时会报告它:这时轨道上的 focus-within: 给整条滑轨加上焦点环,滑块的 focus:outline-none 取代平台自带的焦点轮廓。
#键盘
在 macOS 和 Web 上,每个滑块都是键盘焦点(Tab)的一站,获得焦点的滑块响应 WAI-ARIA 的滑块按键:
| 按键 | 作用 |
|---|---|
| 右、上 | 增加一步(垂直滑块也是)。 |
| 左、下 | 减少一步。 |
| Page Up、Page Down | 增加或减少范围的十分之一(沿刻度),至少一步。 |
| Home、End | 滑块能到达的最低或最高处(范围的起点最高到终点)。 |
每次改变数值的按键都先报告 onChange,再报告 onChangeEnd。按住方向键和翻页键会重复。只有在没有文本输入框正在编辑时,按键才会到达滑块。
#无障碍
- 每个滑块都是可调节元素(
"adjustable",在浏览器中是 slider),带有标签、数值和可取值的范围:范围的起点从min到终点,终点从起点到max,所以屏幕阅读器可以读出和设置两个滑块。数值按步长的小数位读出(2.5)。 - 屏幕阅读器的增加和减少操作(VoiceOver 的上下轻扫)像方向键一样让滑块走一步。
- 滑块是这个控件面向屏幕阅读器的元素,由
accessibilityLabel命名(范围的起点由startAccessibilityLabel命名);子元素中可见的标签在它所在的位置读出。 disabled时,屏幕阅读器报告滑块已变暗,滑块不接受操作。
标签、角色和操作的一般说明见无障碍。
#平台说明
- 每个平台都绘制应用设置的样式。按压和拖动由核心跟踪,鼠标、触控笔和手指都可以,从滑块开始的拖动不会被外层的滚动视图接管。
- 在 macOS 和 Web 上,Tab 在滑块之间移动键盘焦点,获得焦点的滑块响应滑块按键。
- 屏幕阅读器:VoiceOver(iOS、macOS)和 TalkBack 可以调节每个滑块;UI Automation(Windows)、GTK 的无障碍(Linux)和浏览器(
role="slider"、aria-valuenow、aria-valuetext)获得它的范围和数值。