Элементы
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, крейта nsh-base для Rust. В Rust slider() создаёт его с сеттером для каждого свойства (.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 и web каждый бегунок является остановкой клавиатурного фокуса (Tab), а бегунок в фокусе принимает клавиши слайдера из WAI-ARIA:
| Клавиша | Действие |
|---|---|
| Right, Up | Один шаг вверх (также у вертикального слайдера). |
| Left, Down | Один шаг вниз. |
| Page Up, Page Down | Десятая часть диапазона вверх или вниз (по шкале), но не меньше одного шага. |
| Home, End | Максимально вниз или вверх, насколько проходит бегунок (начало диапазона — вплоть до его конца). |
В раскладке справа налево горизонтальный слайдер идёт справа, его min находится на правом конце: Left — шаг вверх, а Right — шаг вниз, и перетаскивание влево увеличивает значение. У вертикального слайдера Right и Up по-прежнему означают шаг вверх.
Каждая клавиша, меняющая значение, вызывает onChange, а затем onChangeEnd. Стрелки и Page Up/Page Down повторяются, пока их удерживают. Клавиши доходят до слайдера, только пока не редактируется текстовое поле.
#Доступность
- Каждый бегунок — регулируемый элемент (
"adjustable", в браузере slider) со своей подписью, своим значением и значениями, которые он может принимать: начало диапазона отminдо конца, конец от начала доmax, так что скринридеры читают и задают оба. Значение произносится как число с десятичными знаками шага (2.5). - Действия скринридера «увеличить» и «уменьшить» (жесты VoiceOver — смахивание вверх и вниз) двигают бегунок на шаг так же, как стрелки.
- Бегунки — элементы слайдера для скринридеров, их имя задаёт
accessibilityLabel(а для начала диапазона —startAccessibilityLabel); видимая подпись среди дочерних элементов читается там, где она находится. - Пока
disabled, скринридеры сообщают, что бегунки неактивны, и действий они не принимают.
Общие сведения о подписях, ролях и действиях см. в разделе Доступность.
#Примечания по платформам
- Каждая платформа рисует то, что стилизует приложение. Нажатия и перетаскивания отслеживаются в ядре для мыши, пера или пальца, а прокручиваемая область вокруг слайдера не перехватывает перетаскивание, начатое на нём.
- Tab перемещает клавиатурный фокус между бегунками на macOS и web, где бегунок в фокусе принимает клавиши слайдера.
- Скринридеры: VoiceOver (iOS, macOS) и TalkBack регулируют каждый бегунок; UI Automation (Windows), GTK accessibility (Linux) и браузер (
role="slider",aria-valuenow,aria-valuetext) получают его диапазон и значение.