元素
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) => ({ 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),
)
}import { mount, signal, Text } from "nsh";
import { Slider } from "nsh/base";
function App() {
const strength = signal(64);
return (
<Slider
value={strength()}
onChange={({ value }) => strength.set(value as number)}
accessibilityLabel="Option strength"
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() 會建立一個滑桿,每個屬性有對應的 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:時,各部分也處於該狀態。
滑塊會隨值的變化而移動,並立即跟隨拖曳。滑塊各狀態的動態效果由應用程式決定(例如讓滑塊在 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)則可取得其範圍與值。