元素

Slider

滑块沿轨道选取一个值(音量、亮度),或两个值组成的范围,每个值一个滑块(价格区间)。它负责行为:按压轨道时,较近的滑块跳到按下的位置;拖动滑块时它跟随指针或手指;数值按 step 对齐;每个滑块都是键盘焦点的一站,可以用方向键移动,并且对屏幕阅读器是带自己数值的可调节元素。外观由应用决定:Slider 没有自己的颜色、尺寸、圆角或动效。它由轨道、轨道里的指示条、指示条上的范围和滑块,以及之后的子元素(标签、文字形式的数值)组成,每个部件都由应用设置样式。

el-slider.tsx
el-slider.tsx运行中
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),
        )
}

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

PropRust类型默认值说明
value.value(..)number | [number, number]无值(受控):一个数字,或带两个滑块的范围。应用设置 onChange 报告的值。
defaultValue.default_value(..)number | [number, number]0未设置 value 时的初始值;之后由滑块自己保存。
min.min(..)number0最小值。
max.max(..)number100最大值;小于 min 时取 min。
step.step(..)number1数值对齐的步长,从 min 起算;0 可取任意值。
scale.scale(..)"linear" | "logarithmic""linear"数值沿轨道的分布:均匀,或按数量级(从 1 到 1000,三分之一处是 10)。对数刻度需要 min 大于 0;min 更小时为线性。
axis.axis(..)"horizontal" | "vertical""horizontal"轨道的方向:min 在左边,或在底部。创建滑块时读取。
disabled.disabled(..)booleanfalse指针、键盘和屏幕阅读器都不能修改;disabled: 类作用于滑块及其部件。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器为滑块(范围时为终点的滑块)读出的内容。
startAccessibilityLabel.start_accessibility_label(..)stringaccessibilityLabel屏幕阅读器为范围起点的滑块读出的内容。
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)获得它的范围和数值。