要素
Slider
Slider は、トラック上の値(音量、明るさなど)、または各端にサムを持つ2つの値の範囲(価格帯など)を選択するコンポーネントです。動作は Slider 自身が管理します。トラックを押すと近いほうのサムがその位置へ移動し、サムはマウスや指でのドラッグに追従します。値は step にスナップします。各サムはキーボードフォーカスの停止位置となり、矢印キーで動かせ、スクリーンリーダーには固有の値を持つ調整可能な要素として公開されます。見た目はアプリ側が決めます。Slider 自体は色、サイズ、角丸、モーションを持ちません。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 は Rust の nsh-base クレートである nsh/base に由来します。Rust では、slider() がプロパティごとのセッター(.value(..)、.thumb_style(..))、イベントごとのリスナーメソッド(.on_change(..))、Slider 自身用の型付きスタイルメソッド、ラベル用の .child(..) を備えた Slider を作成します。値は SliderValue で、Single(f64) または Range(f64, f64) のいずれかです。
#値
値は数値、または2つのサムを持つ範囲の場合は [start, end] です。値の形によってサムの数が決まります。アプリは value で値を管理し、nsh/base のすべてのコントロールと同様に、onChange が通知する値を設定します。あるいは defaultValue で値を Slider に任せ、変更の通知だけを受け取ることもできます。ドラッグ中は、サムとレンジがネイティブに同じフレームでポインターに追従します。ポインターを離すと、ドラッグの最後の変更に対してアプリが設定した 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は、変更が終了した時点の値を1回だけ通知します。値を設定した押下やドラッグの後にポインターを離したとき、および各キー入力やスクリーンリーダーの各ステップの後(それぞれが1つの完結した変更)に通知されます。値の保存や送信はここで行ってください。- 値は
minを起点としたstepにスナップし(min={1} step={2}では 1、3、5、…)、minとmaxの間に収まります。maxには常に到達できるため、範囲がステップの整数倍でない場合は最後のステップが短くなります。step={0}は任意の値を取ります。 - 範囲のサムは交差しません。ドラッグ中のサムはもう一方のサムの位置で止まります。2つのサムが同じ値にある状態でドラッグを開始した場合は、ポインターの向きに動けるほうのサムが動きます。
- 表示される値はアプリの値で、
minとmaxの範囲に制限されます(範囲の両端は順序どおりに保たれます)。ステップのグリッドから外れた値はその位置にそのまま表示され、ユーザーの次の変更でスナップします。
#プロパティ
| プロパティ | Rust | 型 | 既定値 | 説明 |
|---|---|---|---|---|
value | .value(..) | number | [number, number] | なし | 値(制御モード)。数値、または2つのサムを持つ範囲。アプリは onChange が通知する値を設定します。 |
defaultValue | .default_value(..) | number | [number, number] | 0 | value が未設定の間の初期値。この場合、Slider が値を自身で保持します。 |
min | .min(..) | number | 0 | 最小値。 |
max | .max(..) | number | 100 | 最大値。min より小さい値は min になります。 |
step | .step(..) | number | 1 | 値がスナップするステップ。min を起点として数えます。0 は任意の値を取ります。 |
scale | .scale(..) | "linear" | "logarithmic" | "linear" | 値がトラック上にどう分布するか。均等に分布するか、桁数(オーダー)ごとに分布します(1 から 1000 の場合、3分の1の位置が 10)。対数スケールには 0 より大きい min が必要で、min がそれ以下の場合は線形になります。 |
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | トラックの方向。min は開始側(左、右から左へのレイアウトでは右)、または下端に置きます。Slider の作成時に読み取られます。 |
disabled | .disabled(..) | boolean | false | ポインター、キーボード、スクリーンリーダーによる変更を受け付けません。disabled: クラスが Slider とそのパーツに適用されます。 |
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 からサムまで(範囲の場合は2つのサムの間)で、Slider が配置します。 |
thumbClass, thumbStyle | .thumb_style(|s| ..) | string, StyleProp<ViewStyle> | なし | 各サム。それぞれの値の位置を中心に配置されます。 |
children | .child(..) | JSX.Element | なし | トラックの後ろに置くコンテンツ(ラベル、テキストとしての値)。 |
class, style | スタイルメソッド | string, StyleProp<ViewStyle> | なし | Slider 自身。トラックとラベルを囲む列(または行)。 |
#イベント
| イベント | Rust | ペイロード | タイミング |
|---|---|---|---|
onChange | .on_change(|value: &SliderValue| ..) | { value: number | [number, number] } | ユーザーが新しい値を求めたとき。トラックの押下、ドラッグのステップ、キー入力、スクリーンリーダーの増加または減少操作。 |
onChangeEnd | .on_change_end(|value: &SliderValue| ..) | { value: number | [number, number] } | 変更が終了したとき。ポインターが離れた、またはキーやスクリーンリーダーのステップが完了した場合。変更ごとに1回。 |
#パーツ
| パーツ | 配置先 | スタイルの指定方法 |
|---|---|---|
| Slider | class, style | |
| トラック | Slider の最初の要素 | trackClass, trackStyle |
| インジケーター | トラックの内側 | indicatorClass, indicatorStyle |
| レンジ | インジケーターの内側。値に応じて配置 | rangeClass, rangeStyle |
| サム(範囲では2つ) | インジケーターの内側。それぞれの値の位置を中心に配置 | thumbClass, thumbStyle |
| ラベル | Slider の内側、トラックの後ろ | 独自の要素(children) |
トラックは押下とドラッグを受け付けます。インジケーターの長さが値の範囲を表し、min はその先頭端(縦向きの場合は下端)にあります。Slider はレンジとサムをインジケーター上に配置し、サムはそのサイズに関係なく値の位置を中心に置かれます。レイアウトには2つの方法があります。
- トラックをレールにする方法(上の例)。
h-fullのインジケーターを持つh-2 px-2 rounded-full bg-mutedを使います。パディングにより両端でサムがレール内に収まり、レンジの-ml-2により塗りつぶしがレール自身の端から始まります。 - トラックをレールの周囲の帯にする方法。タッチスクリーンで指が見つけやすいよう、より高くします。
h-2 rounded-fullのインジケーターを持つh-8 justify-centerを使います(縦向きの Slider ではw-8 items-centerとw-2 h-full)。
パーツが表す状態:
- サム: ポインターの下では
hover:、ドラッグ中はactive:、キーボードフォーカス時はfocus:、そしてdisabled:。 - トラック: トラックへの押下でドラッグしている間は
active:。これはgroupなので、インジケーター、レンジ、サムはそこからgroup-hover:とgroup-active:を引き継ぎます。 - すべてのパーツ: Slider が無効の間は
disabled:。
サムは値の変化に合わせて動き、ドラッグには即座に追従します。サムの状態に伴うモーションはアプリが選びます(hover: と active: で明るくなるサムには transition)。motion-reduce: を使うと、モーションの軽減を求めているユーザーに対してそれを静止状態に保ちます。キーボードフォーカスはサムが持ちます。サムがフォーカスを持つ間はトラックの focus-within: がレール全体を囲み、サムの focus:outline-none がプラットフォーム標準のフォーカスアウトラインの代わりになります。
#キーボード
macOS と Web では、各サムがキーボードフォーカスの停止位置(Tab)となり、フォーカスされたサムは WAI-ARIA の Slider キーを受け付けます。
| キー | 動作 |
|---|---|
| Right、Up | 1ステップ増やす(縦向きの Slider でも同様)。 |
| Left、Down | 1ステップ減らす。 |
| Page Up、Page Down | 範囲の10分の1だけ増減する(スケールに沿って)。最低でも1ステップ。 |
| Home、End | サムが移動できる最小または最大まで動かす(範囲の開始側は終端まで)。 |
右から左へのレイアウトでは、横向きの Slider は右から始まり、その min は右端にあります。Left が1ステップ増加、Right が1ステップ減少となり、左へのドラッグで値が増えます。縦向きの Slider では、Right と Up が引き続き1ステップ増加です。
値を変更する各キー入力は、onChange の後に onChangeEnd を通知します。矢印キーとページキーは押し続けると繰り返されます。キーが Slider に届くのは、テキスト入力が編集中でない間だけです。
#アクセシビリティ
- 各サムは、ラベル、値、取り得る値を持つ調整可能な要素(
"adjustable"、ブラウザーでは slider)です。範囲の開始側はminから終端まで、終端側は開始側からmaxまでの値を取るため、スクリーンリーダーは両方を読み取って設定できます。値は、ステップの小数桁数を持つ数値として読み上げられます(2.5)。 - スクリーンリーダーの増加および減少アクション(VoiceOver の上下スワイプ)は、矢印キーと同様にサムをステップ移動します。
- サムはスクリーンリーダーに対する Slider の要素であり、
accessibilityLabelで名前が付けられます(範囲の開始側はstartAccessibilityLabel)。子要素にある可視ラベルは、その場所で読み上げられます。 disabledの間、スクリーンリーダーはサムが淡色表示であると伝え、サムはアクションを受け付けません。
ラベル、ロール、アクション全般については アクセシビリティ を参照してください。
#プラットフォームに関する注意
- すべてのプラットフォームで、アプリがスタイルを指定したものが描画されます。押下とドラッグはコアで追跡され、マウス、ペン、指のいずれにも対応します。Slider を囲むスクロールビューは、Slider 上で始まったドラッグを受け取りません。
- macOS と Web では、Tab でサム間のキーボードフォーカスを移動し、フォーカスされたサムが Slider キーを受け付けます。
- スクリーンリーダー: VoiceOver(iOS、macOS)と TalkBack は各サムを調整します。UI Automation(Windows)、GTK のアクセシビリティ(Linux)、ブラウザー(
role="slider"、aria-valuenow、aria-valuetext)は、その範囲と値を取得します。