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

元素

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(..) 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| ..) { 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: 時亦同。

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

#鍵盤

在 macOS 和網頁上,每個滑塊都是鍵盤焦點(Tab)的停靠點,獲得焦點的滑塊支援 WAI-ARIA 的滑桿按鍵:

按鍵 作用
右方向鍵、上方向鍵 增加一步(垂直滑桿亦然)。
左方向鍵、下方向鍵 減少一步。
Page Up、Page Down 向上或向下移動值域的十分之一(沿刻度計算),至少一步。
Home、End 移到滑塊能到達的最低或最高處(區間的起始滑塊最高到結束滑塊處)。

在由右至左的版面中,水平滑桿由右側開始,其 min 位於右端:左方向鍵為增加一步,右方向鍵為減少一步,向左拖曳會提高值。垂直滑桿仍以右方向鍵和上方向鍵增加一步。

每個更改值的按鍵都會先報告 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)可取得其範圍和值。