本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

Slider

滑桿讓使用者沿著軌道選取一個值(例如音量、亮度),或是選取由兩個值構成的區間,每個值各有一個滑塊(例如價格區間)。滑桿自行負責其行為:在軌道上按下時,會將較近的滑塊移到該處;滑塊會跟隨拖曳,無論使用滑鼠或手指;值會對齊到 step;每個滑塊都是鍵盤焦點的一個停駐點,可用方向鍵移動,對螢幕閱讀器而言則是可調整的元素,並各自帶有自己的值。外觀由應用程式決定:Slider 本身沒有顏色、尺寸、圓角或動態效果。它由軌道組成,軌道內有指示條,範圍區段與滑塊位於指示條上,接著是其子元素(標籤、以文字呈現的值),由應用程式為各部分設定樣式。

el-slider.tsx
el-slider.tsxLive
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) => ({ value: next }: { value: 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={({ value }) => strength.set(value 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={({ value }) => position.set(value 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={({ value }) => price.set(value 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={({ value }) => bands[i].set(value 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("Option strength")
        .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() 會建立一個滑桿,每個屬性有對應的 setter(.value(..)、.thumb_style(..)),每個事件有對應的監聽方法(.on_change(..)),滑桿本身使用具型別的樣式方法,其標籤則使用 .child(..)。其值為 SliderValue:Single(f64) 或 Range(f64, f64)。

#值

值可以是一個數字,或是具有兩個滑塊的 [start, end] 區間;值的形態決定滑塊的數量。應用程式可以透過 value 擁有該值,並設定 onChange 所回報的值,與 nsh/base 的所有控制項相同;也可以透過 defaultValue 將值交給滑桿管理,同樣能收到變更通知。拖曳期間,滑塊與範圍區段會在同一個影格中以原生方式跟隨指標;指標抬起後,畫面會再次顯示應用程式的 value,也就是應用程式針對該次拖曳最後一次變更所設定的值;拖曳進行時,螢幕閱讀器會即時聽到拖曳過程中的值,否則聽到的是結束時的值。

const price = signal<[number, number]>([200, 800]);

<Slider
  value={price()}
  onChange={({ value }) => price.set(value as [number, number])}
  onChangeEnd={({ value }) => save(value)}
  min={0}
  max={1000}
  step={10}
  accessibilityLabel="Maximum price"
  startAccessibilityLabel="Minimum price"
/>;
  • onChange 會回報使用者要求的每一個新值:在軌道上按下時、拖曳的每一個步進、每次按鍵。每次回報都是一個新值。
  • onChangeEnd 會回報該次變更結束時的值,且只回報一次:在設定了值的按下或拖曳之後指標抬起時,以及每次按鍵或螢幕閱讀器步進之後(每一次都是一次完整的變更)。請在此儲存或傳送該值。
  • 值會對齊到 step,從 min 開始計算(min={1} step={2} 會得到 1、3、5……),並保持在 min 與 max 之間;max 一律可以到達,因此當範圍不是步進的整數倍時,最後一步會較短。step={0} 可接受任何值。
  • 區間的兩個滑塊不會交叉:被拖曳的滑塊會停在另一個滑塊處。若拖曳從兩個位於同一值的滑塊上開始,則移動能朝指標方向前進的那一個。
  • 顯示的值是應用程式提供的值,並受 min 與 max 限制(區間的兩端會依序排列);不在步進網格上的值會顯示在其實際位置,使用者的下一次變更才會對齊。

#屬性

屬性Rust型別預設值說明
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| ..){ value: number | [number, number] }使用者要求新值:在軌道上按下、拖曳的一個步進、一次按鍵、螢幕閱讀器的增加或減少。
onChangeEnd.on_change_end(|value: &SliderValue| ..){ value: 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: 時,各部分也處於該狀態。

滑塊會隨值的變化而移動,並立即跟隨拖曳。滑塊各狀態的動態效果由應用程式決定(例如讓滑塊在 hover: 與 active: 時變亮,可使用 transition),而 motion-reduce: 會讓要求減少動態效果的使用者看到靜止的畫面。鍵盤焦點屬於滑塊:當滑塊具有焦點時,軌道上的 focus-within: 會為整條軌條加上焦點環,而滑塊的 focus:outline-none 會取代平台本身的焦點外框。

#鍵盤

在 macOS 與網頁上,每個滑塊都是鍵盤焦點(Tab)的一個停駐點,取得焦點的滑塊可使用 WAI-ARIA 的滑桿按鍵:

按鍵作用
Right、Up增加一個步進(垂直滑桿上亦同)。
Left、Down減少一個步進。
Page Up、Page Down沿刻度增加或減少範圍的十分之一,至少為一個步進。
Home、End移到滑塊所能到達的最低或最高值(區間的起始滑塊最高到其結束端)。

在由右至左的版面中,水平滑桿從右側開始,其 min 位於右端:Left 為增加一個步進,Right 為減少一個步進,向左拖曳則會提高值。垂直滑桿仍以 Right 與 Up 作為增加一個步進。

每個會改變值的按鍵都會先回報 onChange,再回報 onChangeEnd。方向鍵與翻頁鍵在按住時會重複觸發。只有在沒有文字輸入正在編輯時,按鍵才會傳到滑桿。

#無障礙

  • 每個滑塊都是可調整的元素("adjustable",在瀏覽器中為 slider),帶有其標籤、其值,以及它可取的值:區間的起始滑塊從 min 到結束端,結束滑塊從起始端到 max,因此螢幕閱讀器可以讀取並設定兩者。朗讀的值為數字,並依步進的小數位數呈現(2.5)。
  • 螢幕閱讀器的增加與減少動作(VoiceOver 的上下滑動)會讓滑塊步進,效果與方向鍵相同。
  • 滑塊是螢幕閱讀器所見的滑桿元素,以 accessibilityLabel 命名(區間的起始滑塊則以 startAccessibilityLabel 命名);子元素中可見的標籤則會在其所在位置被朗讀。
  • 在 disabled 時,螢幕閱讀器會說明滑塊已變暗,且滑塊不接受任何動作。

關於標籤、角色與動作的一般說明,請參閱無障礙。

#平台說明

  • 每個平台都會繪製應用程式所設定的樣式。按下與拖曳是在核心中追蹤的,適用於滑鼠、觸控筆或手指,而滑桿周圍的捲動檢視不會接管從滑桿上開始的拖曳。
  • 在 macOS 與網頁上,Tab 會在滑塊之間移動鍵盤焦點,取得焦點的滑塊可使用滑桿按鍵。
  • 螢幕閱讀器:VoiceOver(iOS、macOS)與 TalkBack 可調整每個滑塊;UI Automation(Windows)、GTK 的無障礙功能(Linux)以及瀏覽器(role="slider"、aria-valuenow、aria-valuetext)則可取得其範圍與值。