元素
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:時亦同。
滑塊會隨值的變化而移動,並即時跟隨拖曳。滑塊各狀態的動態效果由應用程式決定(例如 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)可取得其範圍和值。