Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

Slider

Uno slider sceglie un valore lungo una traccia (un volume, una luminosità), oppure un intervallo di due valori, con un thumb ciascuno (una fascia di prezzo). Gestisce da sé il proprio comportamento: una pressione sulla traccia sposta lì il thumb più vicino, un thumb segue il trascinamento, con un mouse o con un dito; i valori si agganciano a step; ogni thumb è una tappa del focus da tastiera e si muove con i tasti freccia, ed è un elemento regolabile per gli screen reader, con un proprio valore. L’aspetto è dell’app: un Slider non ha colori, dimensioni, raggi o animazioni propri. È composto da una traccia con un indicatore al suo interno, l’intervallo e i thumb sull’indicatore, poi i suoi figli (un’etichetta, il valore come testo), e l’app stilizza ogni parte.

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 proviene da nsh/base, il crate nsh-base in Rust. In Rust, slider() ne crea uno con un setter per ogni prop (.value(..), .thumb_style(..)), un metodo listener per ogni evento (.on_change(..)), i metodi di stile tipizzati per lo slider stesso e .child(..) per la sua etichetta. Il suo valore è un SliderValue: Single(f64) oppure Range(f64, f64).

#Valore

Il valore è un numero, oppure [start, end] per un intervallo con due thumb; la forma del valore decide quanti thumb ci sono. L’app lo possiede con value e imposta quello che onChange riporta, come per ogni controllo di nsh/base; oppure lascia il valore allo slider con defaultValue, e ascolta comunque le modifiche. Durante un trascinamento i thumb e l’intervallo seguono il puntatore nello stesso frame, in modo nativo; quando il puntatore si solleva, torna a mostrarsi il value dell’app, così come l’app lo ha impostato per l’ultima modifica del trascinamento; gli screen reader sentono i valori del trascinamento man mano che procede, mentre uno è in corso, e il suo valore finale in caso contrario.

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 riporta ogni nuovo valore richiesto dall’utente: a una pressione sulla traccia, a ogni passo di un trascinamento, per ogni tasto. Ogni segnalazione è un nuovo valore.
  • onChangeEnd riporta il valore a cui la modifica è terminata, una sola volta: quando il puntatore si solleva dopo una pressione o un trascinamento che ha impostato un valore, e dopo ogni passo da tastiera o da screen reader (ognuno è una modifica completa). Salva o invia il valore qui.
  • I valori si agganciano a step, contati a partire da min (min={1} step={2} dà 1, 3, 5, …), e restano tra min e max; max è sempre raggiungibile, quindi l’ultimo passo è più corto quando l’intervallo non è un numero intero di passi. step={0} accetta qualsiasi valore.
  • I thumb di un intervallo non si incrociano mai: un thumb trascinato si ferma contro l’altro. Un trascinamento che inizia su due thumb con lo stesso valore sposta quello che può andare nella direzione del puntatore.
  • Il valore mostrato è quello dell’app, limitato da min e max (gli estremi di un intervallo in ordine), e un valore fuori dalla griglia del passo viene mostrato dov’è; la modifica successiva dell’utente lo aggancia.

#Props

PropRustTipoPredefinitoDescrizione
value.value(..)number | [number, number]nessunoIl valore (controllato): un numero, oppure un intervallo con due thumb. L’app imposta quello che onChange riporta.
defaultValue.default_value(..)number | [number, number]0Il valore iniziale finché value non è impostato; lo slider mantiene poi il proprio.
min.min(..)number0Il valore minimo.
max.max(..)number100Il valore massimo; uno inferiore a min diventa min.
step.step(..)number1Il passo a cui i valori si agganciano, contato a partire da min; 0 accetta qualsiasi valore.
scale.scale(..)"linear" | "logarithmic""linear"Come i valori si distribuiscono lungo la traccia: in modo uniforme, oppure per ordini di grandezza (da 1 a 1000, un terzo del percorso corrisponde a 10). La scala logaritmica richiede min maggiore di 0; con un min inferiore è lineare.
axis.axis(..)"horizontal" | "vertical""horizontal"La direzione della traccia: min all’inizio (a sinistra, a destra in un layout da destra a sinistra), oppure in basso. Letta quando lo slider viene creato.
disabled.disabled(..)booleanfalseNessuna modifica da puntatore, tastiera o screen reader; le classi disabled: si applicano allo slider e alle sue parti.
accessibilityLabel.accessibility_label(..)stringnessunoCiò che gli screen reader dicono per il thumb (in un intervallo, quello finale).
startAccessibilityLabel.start_accessibility_label(..)stringaccessibilityLabelCiò che gli screen reader dicono per il thumb iniziale di un intervallo.
trackClass, trackStyle.track_style(|s| ..)string, StyleProp<ViewStyle>nessunoLa traccia: l’area che riceve pressioni e trascinamenti.
indicatorClass, indicatorStyle.indicator_style(|s| ..)string, StyleProp<ViewStyle>nessunoL’indicatore, la guida su cui si mappano i valori.
rangeClass, rangeStyle.range_style(|s| ..)string, StyleProp<ViewStyle>nessunoL’intervallo, da min fino al thumb (tra i thumb di un intervallo), posizionato dallo slider.
thumbClass, thumbStyle.thumb_style(|s| ..)string, StyleProp<ViewStyle>nessunoOgni thumb, centrato sul proprio valore.
children.child(..)JSX.ElementnessunoIl contenuto dopo la traccia: un’etichetta, il valore come testo.
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoLo slider stesso: la colonna (o riga) attorno alla traccia e all’etichetta.

#Eventi

EventoRustPayloadQuando
onChange.on_change(|value: &SliderValue| ..){ value: number | [number, number] }L’utente ha richiesto un nuovo valore: una pressione sulla traccia, un passo di un trascinamento, un tasto, un incremento o decremento da screen reader.
onChangeEnd.on_change_end(|value: &SliderValue| ..){ value: number | [number, number] }La modifica è terminata: il puntatore si è sollevato, un tasto o un passo da screen reader è stato completato. Una volta per modifica.

#Parti

ParteDentroStilizzata con
Lo sliderclass, style
La traccialo slider, per primatrackClass, trackStyle
L’indicatorela tracciaindicatorClass, indicatorStyle
L’intervallol’indicatore, posizionato dai valorirangeClass, rangeStyle
Un thumb (due in un intervallo)l’indicatore, centrato sul proprio valorethumbClass, thumbStyle
L’etichettalo slider, dopo la tracciai propri elementi (children)

La traccia riceve pressioni e trascinamenti. La lunghezza dell’indicatore è l’intervallo di valori, da min al suo bordo iniziale (in basso quando è verticale); lo slider vi posiziona l’intervallo e i thumb, con ogni thumb centrato sul proprio valore qualunque sia la sua dimensione. Due modi per disporli:

  • La traccia è la guida (l’esempio sopra): h-2 px-2 rounded-full bg-muted con un indicatore h-full. Il padding mantiene i thumb dentro la guida a entrambe le estremità, e il -ml-2 dell’intervallo fa partire il riempimento dal bordo della guida stessa.
  • La traccia è una fascia attorno alla guida, più alta in modo che un dito la trovi su un touch screen: h-8 justify-center con un indicatore h-2 rounded-full (w-8 items-center e w-2 h-full su uno slider verticale).

Gli stati che le parti mostrano:

  • Un thumb: hover: sotto il puntatore, active: mentre viene trascinato, focus: con il focus da tastiera, disabled:.
  • La traccia: active: mentre una pressione su di essa trascina; è un group, quindi l’indicatore, l’intervallo e i thumb ereditano group-hover: e group-active: da essa.
  • Ogni parte: disabled: finché lo è lo slider.

I thumb si muovono al variare del valore e seguono subito un trascinamento. L’animazione degli stati dei thumb è una scelta dell’app (transition per un thumb che si illumina su hover: e active:), e motion-reduce: la ferma per le persone che chiedono meno movimento. Il focus da tastiera appartiene a un thumb: focus-within: sulla traccia cerchia l’intera guida mentre un thumb ha il focus, e il focus:outline-none del thumb prende il posto del contorno di focus della piattaforma.

#Tastiera

Su macOS e sul web ogni thumb è una tappa del focus da tastiera (Tab), e il thumb con il focus risponde ai tasti slider di WAI-ARIA:

TastoAzione
Destra, SuUn passo in su (anche su uno slider verticale).
Sinistra, GiùUn passo in giù.
Page Up, Page DownUn decimo dell’intervallo in su o in giù (lungo la scala), di almeno un passo.
Home, EndFino al punto più basso o più alto che il thumb può raggiungere (l’inizio di un intervallo fino alla sua fine).

In un layout da destra a sinistra uno slider orizzontale parte da destra, con il suo min all’estremità destra: Sinistra è un passo in su e Destra un passo in giù, e un trascinamento verso sinistra aumenta il valore. Uno slider verticale mantiene Destra e Su come passo in su.

Ogni tasto che cambia il valore riporta onChange e poi onChangeEnd. Le frecce e le pagine si ripetono finché sono tenute premute. I tasti raggiungono lo slider solo mentre nessun campo di testo è in modifica.

#Accessibilità

  • Ogni thumb è un elemento regolabile ("adjustable", uno slider nel browser) con la propria etichetta, il proprio valore e i valori che può assumere: l’inizio di un intervallo da min fino alla fine, la fine dall’inizio fino a max, così gli screen reader leggono e impostano entrambi. Il valore viene pronunciato come il numero con i decimali del passo (2.5).
  • Le azioni di incremento e decremento di uno screen reader (lo scorrimento in su e in giù di VoiceOver) spostano il thumb di un passo, come fanno i tasti freccia.
  • I thumb sono gli elementi dello slider per gli screen reader, denominati da accessibilityLabel (e l’inizio di un intervallo da startAccessibilityLabel); un’etichetta visibile tra i suoi figli viene letta dove si trova.
  • Mentre disabled, gli screen reader dicono che i thumb sono attenuati, e questi non accettano azioni.

Vedi Accessibilità per etichette, ruoli e azioni in generale.

#Note sulle piattaforme

  • Ogni piattaforma disegna ciò che l’app stilizza. Pressioni e trascinamenti vengono seguiti nel core, per un mouse, una penna o un dito, e una scroll view attorno allo slider non cattura un trascinamento iniziato su di esso.
  • Tab sposta il focus da tastiera tra i thumb su macOS e sul web, dove il thumb con il focus risponde ai tasti dello slider.
  • Screen reader: VoiceOver (iOS, macOS) e TalkBack regolano ogni thumb; UI Automation (Windows), l’accessibilità di GTK (Linux) e il browser (role="slider", aria-valuenow, aria-valuetext) ne ricevono intervallo e valore.