Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Elemente

Slider

Ein Slider wählt einen Wert entlang eines Tracks (eine Lautstärke, eine Helligkeit) oder einen Bereich aus zwei Werten mit je einem Thumb (eine Preisspanne). Er bringt sein Verhalten selbst mit: Ein Druck auf den Track bewegt den näheren Thumb dorthin, ein Thumb folgt einem Ziehen, mit Maus oder Finger; Werte rasten auf step ein; jeder Thumb ist ein Haltepunkt des Tastaturfokus, bewegt sich mit den Pfeiltasten und ist für Screenreader ein einstellbares Element mit eigenem Wert. Das Aussehen bestimmt die App: Ein Slider hat keine eigenen Farben, Größen, Radien oder Animationen. Er besteht aus einem Track mit einem Indikator darin, dem Bereich und den Thumbs auf dem Indikator, dann seinen Kindern (ein Label, der Wert als Text), und die App gestaltet jeden Teil.

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 kommt aus nsh/base, dem nsh-base-Crate in Rust. In Rust erzeugt slider() einen Slider mit einem Setter pro Prop (.value(..), .thumb_style(..)), einer Listener-Methode pro Event (.on_change(..)), den typisierten Style-Methoden für den Slider selbst und .child(..) für sein Label. Sein Wert ist ein SliderValue: Single(f64) oder Range(f64, f64).

#Wert

Der Wert ist eine Zahl oder [start, end] für einen Bereich mit zwei Thumbs; die Form des Werts entscheidet, wie viele Thumbs es gibt. Die App besitzt ihn mit value und setzt den Wert, den onChange meldet, wie bei jedem Steuerelement von nsh/base; oder sie überlässt den Wert dem Slider mit defaultValue und bekommt die Änderungen trotzdem mit. Während eines Ziehens folgen Thumbs und Bereich dem Zeiger im selben Frame, nativ; wenn der Zeiger losgelassen wird, zeigt der Slider wieder den App-Wert value, so wie die App ihn für die letzte Änderung des Ziehens gesetzt hat; Screenreader bekommen die Werte eines Ziehens laufend zu hören, solange es läuft, sonst nur den Endwert.

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 meldet jeden neuen Wert, den der Nutzer anfordert: bei einem Druck auf den Track, bei jedem Schritt eines Ziehens, bei jeder Taste. Jede Meldung ist ein neuer Wert.
  • onChangeEnd meldet den Wert, bei dem die Änderung endete, einmal: wenn der Zeiger nach einem Druck oder Ziehen, das einen Wert gesetzt hat, losgelassen wird, sowie nach jedem Tasten- oder Screenreader-Schritt (jeder ist eine ganze Änderung). Speichere oder sende den Wert dort.
  • Werte rasten auf step ein, gezählt ab min (min={1} step={2} ergibt 1, 3, 5, …), und bleiben zwischen min und max; max ist immer erreichbar, daher ist der letzte Schritt kürzer, wenn der Bereich keine ganze Zahl von Schritten umfasst. step={0} akzeptiert jeden Wert.
  • Die Thumbs eines Bereichs kreuzen sich nie: Ein gezogener Thumb stoppt am anderen. Ein Ziehen, das bei zwei Thumbs mit demselben Wert beginnt, bewegt den, der in Richtung des Zeigers gehen kann.
  • Der angezeigte Wert ist der der App, begrenzt auf min und max (bei einem Bereich die Enden in Reihenfolge); ein Wert abseits des Schrittrasters wird dort angezeigt, wo er liegt, und die nächste Änderung des Nutzers rastet ein.

#Props

Prop Rust Typ Standard Beschreibung
value .value(..) number | [number, number] keiner Der Wert (kontrolliert): eine Zahl oder ein Bereich mit zwei Thumbs. Die App setzt den Wert, den onChange meldet.
defaultValue .default_value(..) number | [number, number] 0 Der Anfangswert, solange value nicht gesetzt ist; der Slider behält dann seinen eigenen.
min .min(..) number 0 Der kleinste Wert.
max .max(..) number 100 Der größte Wert; ein Wert unterhalb von min wird zu min.
step .step(..) number 1 Die Schrittweite, auf die Werte einrasten, gezählt ab min; 0 akzeptiert jeden Wert.
scale .scale(..) "linear" | "logarithmic" "linear" Wie sich die Werte entlang des Tracks verteilen: gleichmäßig oder nach Größenordnungen (von 1 bis 1000 liegt bei einem Drittel der Strecke die 10). Logarithmisch braucht min über 0; bei einem niedrigeren min ist sie linear.
axis .axis(..) "horizontal" | "vertical" "horizontal" Die Richtung des Tracks: min am Anfang (links, in einem Rechts-nach-links-Layout rechts) oder unten. Wird gelesen, wenn der Slider erstellt wird.
disabled .disabled(..) boolean false Keine Änderungen durch Zeiger, Tastatur oder Screenreader; disabled:-Klassen gelten für den Slider und seine Teile.
accessibilityLabel .accessibility_label(..) string keiner Was Screenreader für den Thumb sagen (bei einem Bereich für den Thumb am Ende).
startAccessibilityLabel .start_accessibility_label(..) string accessibilityLabel Was Screenreader für den Start-Thumb eines Bereichs sagen.
trackClass, trackStyle .track_style(|s| ..) string, StyleProp<ViewStyle> keiner Der Track: die Fläche, die Drücken und Ziehen annimmt.
indicatorClass, indicatorStyle .indicator_style(|s| ..) string, StyleProp<ViewStyle> keiner Der Indikator, die Schiene, auf die die Werte abgebildet werden.
rangeClass, rangeStyle .range_style(|s| ..) string, StyleProp<ViewStyle> keiner Der Bereich, von min bis zum Thumb (bei einem Bereich zwischen dessen Thumbs), vom Slider platziert.
thumbClass, thumbStyle .thumb_style(|s| ..) string, StyleProp<ViewStyle> keiner Jeder Thumb, auf seinem Wert zentriert.
children .child(..) JSX.Element keiner Der Inhalt nach dem Track: ein Label, der Wert als Text.
class, style Style-Methoden string, StyleProp<ViewStyle> keiner Der Slider selbst: die Spalte (oder Zeile) um Track und Label.

#Events

Event Rust Payload Wann
onChange .on_change(|value: &SliderValue| ..) { value: number | [number, number] } Der Nutzer hat einen neuen Wert angefordert: ein Druck auf den Track, ein Schritt eines Ziehens, eine Taste, ein Erhöhen oder Verringern per Screenreader.
onChangeEnd .on_change_end(|value: &SliderValue| ..) { value: number | [number, number] } Die Änderung ist beendet: Der Zeiger wurde losgelassen, eine Taste oder ein Screenreader-Schritt wurde ausgeführt. Einmal pro Änderung.

#Teile

Teil Darin Gestaltet mit
Der Slider class, style
Der Track der Slider, als Erstes trackClass, trackStyle
Der Indikator der Track indicatorClass, indicatorStyle
Der Bereich der Indikator, von den Werten platziert rangeClass, rangeStyle
Ein Thumb (zwei bei einem Bereich) der Indikator, auf seinem Wert zentriert thumbClass, thumbStyle
Das Label der Slider, nach dem Track eigene Elemente (children)

Der Track nimmt Drücken und Ziehen an. Die Länge des Indikators ist der Wertebereich, mit min an seiner vorderen Kante (bei vertikaler Ausrichtung an seiner Unterseite); der Slider platziert Bereich und Thumbs darauf, einen Thumb auf seinem Wert zentriert, gleich wie groß er ist. Zwei Möglichkeiten für das Layout:

  • Der Track ist die Schiene (das Beispiel oben): h-2 px-2 rounded-full bg-muted mit einem h-full-Indikator. Das Padding hält die Thumbs an beiden Enden innerhalb der Schiene, und -ml-2 am Bereich lässt die Füllung am eigenen Rand der Schiene beginnen.
  • Der Track ist ein Band um die Schiene, höher, damit ein Finger ihn auf einem Touchscreen findet: h-8 justify-center mit einem h-2 rounded-full-Indikator (w-8 items-center und w-2 h-full bei einem vertikalen Slider).

Zustände, die die Teile zeigen:

  • Ein Thumb: hover: unter dem Zeiger, active: während er gezogen wird, focus: mit dem Tastaturfokus, disabled:.
  • Der Track: active:, solange ein Druck auf ihn zieht; er fungiert als group, daher übernehmen Indikator, Bereich und Thumbs group-hover: und group-active: von ihm.
  • Jeder Teil: disabled:, solange dies auch für den Slider gilt.

Die Thumbs bewegen sich, wenn sich der Wert ändert, und folgen einem Ziehen sofort. Animationen der Thumb-Zustände sind Sache der App (transition für einen Thumb, der bei hover: und active: heller wird), und motion-reduce: hält sie still für Menschen, die weniger Bewegung wünschen. Der Tastaturfokus gehört einem Thumb: focus-within: am Track umrandet die ganze Schiene, während ein Thumb ihn hat, und das focus:outline-none des Thumbs ersetzt den eigenen Fokusrahmen der Plattform.

#Tastatur

Unter macOS und im Web ist jeder Thumb ein Haltepunkt des Tastaturfokus (Tab), und der fokussierte Thumb nimmt die Slider-Tasten von WAI-ARIA an:

Taste Aktion
Pfeil rechts, Pfeil hoch Ein Schritt aufwärts (auch bei einem vertikalen Slider).
Pfeil links, Pfeil runter Ein Schritt abwärts.
Bild auf, Bild ab Ein Zehntel des Bereichs aufwärts oder abwärts (entlang der Skala), mindestens ein Schritt.
Pos1, Ende So weit nach unten oder oben, wie der Thumb kommt (der Start eines Bereichs bis zu dessen Ende).

In einem Rechts-nach-links-Layout läuft ein horizontaler Slider von rechts, sein min am rechten Ende: Pfeil links ist ein Schritt aufwärts und Pfeil rechts ein Schritt abwärts, und ein Ziehen nach links erhöht den Wert. Ein vertikaler Slider behält Pfeil rechts und Pfeil hoch als Schritt aufwärts.

Jede Taste, die den Wert ändert, meldet onChange und dann onChangeEnd. Pfeile und Bild-Tasten wiederholen sich, solange sie gehalten werden. Die Tasten erreichen den Slider nur, solange keine Texteingabe bearbeitet wird.

#Barrierefreiheit

  • Jeder Thumb ist ein einstellbares Element ("adjustable", im Browser ein slider) mit seinem Label, seinem Wert und den Werten, die er annehmen kann: der Start eines Bereichs von min bis zum Ende, das Ende vom Start bis max, sodass Screenreader beide lesen und setzen können. Der Wert wird als Zahl mit den Dezimalstellen des Schritts gesprochen (2.5).
  • Die Aktionen Erhöhen und Verringern eines Screenreaders (bei VoiceOver das Wischen nach oben und unten) schalten den Thumb schrittweise weiter, wie die Pfeiltasten.
  • Die Thumbs sind die Elemente des Sliders für Screenreader, benannt durch accessibilityLabel (und der Start eines Bereichs durch startAccessibilityLabel); ein sichtbares Label unter seinen Kindern wird dort gelesen, wo es steht.
  • Bei disabled sagen Screenreader, dass die Thumbs abgeblendet sind, und sie nehmen keine Aktionen an.

Siehe Barrierefreiheit für Labels, Rollen und Aktionen im Allgemeinen.

#Hinweise zu Plattformen

  • Jede Plattform zeichnet, was die App gestaltet. Drücken und Ziehen werden im Core verfolgt, für Maus, Stift oder Finger, und eine Scroll-View um den Slider nimmt ein Ziehen, das auf ihm begann, nicht an.
  • Tab bewegt den Tastaturfokus zwischen den Thumbs unter macOS und im Web, wo der fokussierte Thumb die Slider-Tasten annimmt.
  • Screenreader: VoiceOver (iOS, macOS) und TalkBack stellen jeden Thumb ein; UI Automation (Windows), die Barrierefreiheit von GTK (Linux) und der Browser (role="slider", aria-valuenow, aria-valuetext) erhalten Bereich und Wert.