Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

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, крейта 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(..)number0Наименьшее значение.
max.max(..)number100Наибольшее значение; значение меньше min приравнивается к min.
step.step(..)number1Шаг, к которому привязываются значения, считая от min; 0 принимает любое значение.
scale.scale(..)"linear" | "logarithmic""linear"Как значения распределяются по дорожке: равномерно или по порядкам величины (от 1 до 1000 треть пути соответствует 10). Логарифмической шкале нужен min больше 0; при меньшем min шкала линейная.
axis.axis(..)"horizontal" | "vertical""horizontal"Направление дорожки: min в начале (слева, а в раскладке справа налево — справа) или внизу. Считывается при создании слайдера.
disabled.disabled(..)booleanfalseНикаких изменений указателем, клавиатурой или скринридером; классы disabled: применяются к слайдеру и его частям.
accessibilityLabel.accessibility_label(..)stringнетЧто скринридеры произносят для бегунка (в диапазоне — для конечного).
startAccessibilityLabel.start_accessibility_label(..)stringaccessibilityLabelЧто скринридеры произносят для начального бегунка диапазона.
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) получают его диапазон и значение.