Éléments
Slider
Un Slider choisit une valeur le long d’une piste (un volume, une luminosité), ou un intervalle de deux valeurs avec une poignée chacune (une fourchette de prix). Il possède son comportement : un appui sur la piste y déplace la poignée la plus proche, une poignée suit un glissement, à la souris ou au doigt ; les valeurs s’alignent sur step ; chaque poignée est un arrêt du focus clavier et se déplace avec les touches fléchées, et c’est un élément ajustable pour les lecteurs d’écran, avec sa propre valeur. L’apparence revient à l’app : un Slider n’a ni couleurs, ni tailles, ni rayons, ni animations propres. Il se compose d’une piste contenant un indicateur, de la plage et des poignées placées sur l’indicateur, puis de ses enfants (un libellé, la valeur sous forme de texte), et l’app style chaque partie.
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 provient de nsh/base, le crate nsh-base en Rust. En Rust, slider() en crée un avec un setter par prop (.value(..), .thumb_style(..)), une méthode d’écoute par événement (.on_change(..)), les méthodes de style typées pour le slider lui-même et .child(..) pour son libellé. Sa valeur est un SliderValue : Single(f64) ou Range(f64, f64).
#Valeur
La valeur est un nombre, ou [start, end] pour un intervalle à deux poignées ; la forme de la valeur détermine le nombre de poignées. L’app la possède avec value et applique celle que onChange signale, comme pour tous les contrôles de nsh/base ; ou bien elle laisse la valeur au slider avec defaultValue, et entend quand même les changements. Pendant un glissement, les poignées et la plage suivent le pointeur dans la même image, de façon native ; quand le pointeur se relève, le value de l’app s’affiche de nouveau, tel que l’app l’a défini pour le dernier changement du glissement ; les lecteurs d’écran entendent les valeurs du glissement au fur et à mesure tant qu’il dure, et sa valeur finale sinon.
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"
/>;
onChangesignale chaque nouvelle valeur demandée par l’utilisateur : lors d’un appui sur la piste, à chaque pas d’un glissement, pour chaque touche. Chaque signalement est une nouvelle valeur.onChangeEndsignale une seule fois la valeur à laquelle le changement s’est terminé : quand le pointeur se relève après un appui ou un glissement qui a défini une valeur, et après chaque touche ou chaque pas d’un lecteur d’écran (chacun est un changement complet). C’est là qu’il faut enregistrer ou envoyer la valeur.- Les valeurs s’alignent sur
step, comptés à partir demin(min={1} step={2}donne 1, 3, 5, …), et restent entreminetmax;maxest toujours atteignable, donc le dernier pas est plus court quand l’étendue n’est pas un nombre entier de pas.step={0}accepte n’importe quelle valeur. - Les poignées d’un intervalle ne se croisent jamais : une poignée glissée s’arrête à l’autre. Un glissement qui commence sur deux poignées à la même valeur déplace celle qui peut aller dans le sens du pointeur.
- La valeur affichée est celle de l’app, bornée par
minetmax(les extrémités d’un intervalle étant remises dans l’ordre), et une valeur hors de la grille des pas s’affiche là où elle se trouve ; le prochain changement de l’utilisateur s’aligne.
#Propriétés
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
value | .value(..) | number | [number, number] | aucun | La valeur (contrôlée) : un nombre, ou un intervalle à deux poignées. L’app applique celle que onChange signale. |
defaultValue | .default_value(..) | number | [number, number] | 0 | La valeur initiale tant que value n’est pas définie ; le slider garde ensuite la sienne. |
min | .min(..) | number | 0 | La plus petite valeur. |
max | .max(..) | number | 100 | La plus grande valeur ; une valeur inférieure à min devient min. |
step | .step(..) | number | 1 | Le pas sur lequel les valeurs s’alignent, compté à partir de min ; 0 accepte n’importe quelle valeur. |
scale | .scale(..) | "linear" | "logarithmic" | "linear" | La répartition des valeurs le long de la piste : uniforme, ou par ordres de grandeur (de 1 à 1000, au tiers du chemin on trouve 10). L’échelle logarithmique exige min supérieur à 0 ; avec un min plus bas, elle est linéaire. |
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | La direction de la piste : min au début (à gauche, à droite dans une disposition de droite à gauche), ou en bas. Lue à la création du slider. |
disabled | .disabled(..) | boolean | false | Aucun changement par pointeur, clavier ou lecteur d’écran ; les classes disabled: s’appliquent au slider et à ses parties. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Ce que les lecteurs d’écran annoncent pour la poignée (pour un intervalle, celle de la fin). |
startAccessibilityLabel | .start_accessibility_label(..) | string | accessibilityLabel | Ce que les lecteurs d’écran annoncent pour la poignée de début d’un intervalle. |
trackClass, trackStyle | .track_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | La piste : la zone qui reçoit les appuis et les glissements. |
indicatorClass, indicatorStyle | .indicator_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | L’indicateur, le rail sur lequel les valeurs sont projetées. |
rangeClass, rangeStyle | .range_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | La plage, de min à la poignée (entre les poignées d’un intervalle), placée par le slider. |
thumbClass, thumbStyle | .thumb_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | Chaque poignée, centrée sur sa valeur. |
children | .child(..) | JSX.Element | aucun | Le contenu après la piste : un libellé, la valeur sous forme de texte. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | Le slider lui-même : la colonne (ou la ligne) autour de la piste et du libellé. |
#Événements
| Événement | Rust | Charge utile | Quand |
|---|---|---|---|
onChange | .on_change(|value: &SliderValue| ..) | { value: number | [number, number] } | L’utilisateur a demandé une nouvelle valeur : un appui sur la piste, un pas de glissement, une touche, une incrémentation ou décrémentation d’un lecteur d’écran. |
onChangeEnd | .on_change_end(|value: &SliderValue| ..) | { value: number | [number, number] } | Le changement est terminé : le pointeur s’est relevé, une touche ou un pas d’un lecteur d’écran a été effectué. Une fois par changement. |
#Parties
| Partie | Contenue dans | Stylée avec |
|---|---|---|
| Le slider | class, style | |
| La piste | le slider, en premier | trackClass, trackStyle |
| L’indicateur | la piste | indicatorClass, indicatorStyle |
| La plage | l’indicateur, placée selon les valeurs | rangeClass, rangeStyle |
| Une poignée (deux pour un intervalle) | l’indicateur, centrée sur sa valeur | thumbClass, thumbStyle |
| Le libellé | le slider, après la piste | ses propres éléments (children) |
La piste reçoit les appuis et les glissements. La longueur de l’indicateur correspond à l’étendue des valeurs, à partir de min sur son bord d’attaque (en bas lorsqu’il est vertical) ; le slider y place la plage et les poignées, une poignée étant centrée sur sa valeur quelle que soit sa taille. Deux façons de les disposer :
- La piste est le rail (l’exemple ci-dessus) :
h-2 px-2 rounded-full bg-mutedavec un indicateurh-full. Le remplissage garde les poignées à l’intérieur du rail aux deux extrémités, et le-ml-2de la plage fait commencer le remplissage au bord même du rail. - La piste est une bande autour du rail, plus haute pour qu’un doigt la trouve sur un écran tactile :
h-8 justify-centeravec un indicateurh-2 rounded-full(w-8 items-centeretw-2 h-fullsur un slider vertical).
États affichés par les parties :
- Une poignée :
hover:sous le pointeur,active:pendant qu’elle est glissée,focus:avec le focus clavier,disabled:. - La piste :
active:pendant qu’un appui sur elle déclenche un glissement ; c’est ungroup, donc l’indicateur, la plage et les poignées en reçoiventgroup-hover:etgroup-active:. - Chaque partie :
disabled:quand le slider l’est.
Les poignées se déplacent au fil des changements de valeur et suivent immédiatement un glissement. L’animation des états des poignées est au choix de l’app (transition pour une poignée qui s’éclaircit avec hover: et active:), et motion-reduce: la supprime pour les personnes qui demandent moins d’animations. Le focus clavier appartient à une poignée : focus-within: sur la piste entoure tout le rail tant qu’une poignée l’a, et le focus:outline-none de la poignée remplace le contour de focus propre à la plateforme.
#Clavier
Sur macOS et le web, chaque poignée est un arrêt du focus clavier (Tab), et la poignée ayant le focus répond aux touches de slider de WAI-ARIA :
| Touche | Action |
|---|---|
| Droite, Haut | Un pas vers le haut (aussi sur un slider vertical). |
| Gauche, Bas | Un pas vers le bas. |
| Page précédente, Page suivante | Un dixième de l’étendue vers le haut ou le bas (le long de l’échelle), d’au moins un pas. |
| Début, Fin | Aussi bas ou aussi haut que la poignée peut aller (pour le début d’un intervalle, jusqu’à sa fin). |
Dans une disposition de droite à gauche, un slider horizontal part de la droite, son min à l’extrémité droite : Gauche est un pas vers le haut et Droite un pas vers le bas, et un glissement vers la gauche augmente la valeur. Un slider vertical conserve Droite et Haut comme pas vers le haut.
Chaque touche qui change la valeur signale onChange puis onChangeEnd. Les flèches et les pages se répètent tant qu’elles sont maintenues. Les touches n’atteignent le slider que lorsqu’aucun champ de texte n’est en cours d’édition.
#Accessibilité
- Chaque poignée est un élément ajustable (
"adjustable", un slider dans le navigateur) avec son libellé, sa valeur et les valeurs qu’elle peut prendre : le début d’un intervalle deminjusqu’à la fin, sa fin du début jusqu’àmax, de sorte que les lecteurs d’écran lisent et définissent les deux. La valeur est énoncée comme le nombre avec les décimales du pas (2.5). - Les actions d’incrémentation et de décrémentation d’un lecteur d’écran (le balayage vers le haut et vers le bas de VoiceOver) font avancer la poignée d’un pas, comme les touches fléchées.
- Les poignées sont les éléments du slider pour les lecteurs d’écran, nommées par
accessibilityLabel(et le début d’un intervalle parstartAccessibilityLabel) ; un libellé visible parmi ses enfants est lu là où il se trouve. - Tant que
disabled, les lecteurs d’écran indiquent que les poignées sont atténuées, et elles n’acceptent aucune action.
Voir Accessibilité pour les libellés, les rôles et les actions en général.
#Notes par plateforme
- Chaque plateforme dessine ce que l’app style. Les appuis et les glissements sont suivis dans le cœur, pour une souris, un stylet ou un doigt, et une vue défilante autour du slider ne prend pas un glissement qui a commencé sur lui.
- Tab déplace le focus clavier entre les poignées sur macOS et le web, où la poignée ayant le focus répond aux touches du slider.
- Lecteurs d’écran : VoiceOver (iOS, macOS) et TalkBack ajustent chaque poignée ; UI Automation (Windows), l’accessibilité de GTK (Linux) et le navigateur (
role="slider",aria-valuenow,aria-valuetext) reçoivent son intervalle et sa valeur.