Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

Progress

Une barre de progression indique où en est une tâche dont l’étendue est connue (un téléchargement, un envoi, une étape d’installation) ou, tant que indeterminate, qu’une tâche d’étendue inconnue est en cours. Elle gère ce que reçoivent les lecteurs d’écran, à savoir le rôle de barre de progression avec la plage de 0 à 100 et la valeur, ainsi que la largeur de l’indicateur : value pour cent de celle de la piste. L’apparence revient à l’app : un Progress n’a ni couleurs, ni tailles, ni rayons, ni animation, ni libellé propres. Il se compose d’une piste contenant un indicateur, puis de ses enfants (un libellé, la valeur sous forme de texte), et l’app stylise chaque partie.

el-progress.tsx
el-progress.tsxLive
import { cleanup, mount, signal, Text, View } from "nsh";
import { reduceMotion } from "nsh";
import { Button, Progress, Switch } from "nsh/base";
import { button, buttonText, desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";

// The parts: geometry and motion in classes, colors from the palette. The
// label row comes first (`flex-col-reverse`: the children follow the track),
// its value tabular and at the trailing edge. An indeterminate indicator is a
// third of the track that sweeps from end to end (the app flips its
// translation, a transition moves it), and rests in the middle with reduced
// motion. A determinate one is the whole track, slid back by what is left to
// do, so a new value glides there (the track clips it). The pointer's hover
// and the keyboard's focus ring come from the palette. Buttons are as wide as
// their labels with a pointer.
const pad = desktop ? "px-3 py-3" : "px-4 py-4";
const track = "h-1.5 overflow-hidden rounded-full";
const SWEEP = 800;
const indicator = (indeterminate: boolean, end: boolean) =>
  !indeterminate
    ? "h-full w-full rounded-full transition-transform duration-180 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none"
    : reduceMotion()
      ? "h-full w-1/3 rounded-full translate-x-full"
      : `h-full w-1/3 rounded-full transition-transform duration-700 ease-in-out ${end ? "translate-x-[200%]" : "translate-x-0"}`;

function Transfer(props: { label: string; detail: string; value: number; indeterminate?: boolean; end: boolean }) {
  return (
    <Progress
      value={props.value}
      indeterminate={props.indeterminate}
      class={`flex-col-reverse gap-2 ${pad}`}
      trackClass={track}
      trackStyle={ui().inset}
      indicatorClass={indicator(!!props.indeterminate, props.end)}
      indicatorStyle={{ backgroundColor: ui().accent, translate: props.indeterminate ? undefined : `${props.value - 100}% 0` }}
    >
      <View class="flex-row items-baseline gap-3">
        <View style={{ flex: 1, gap: 2 }}>
          <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
          <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
        </View>
        <Text style={{ fontSize: size.label, color: ui().muted, fontVariant: ["tabular-nums"], textAlign: "right" }}>
          {props.indeterminate ? "—" : `${Math.round(props.value)}%`}
        </Text>
      </View>
    </Progress>
  );
}

function Action(props: { label: string; disabled?: boolean; onPress: () => void }) {
  return (
    <Button
      onPress={props.onPress}
      disabled={props.disabled}
      {...button("secondary", { style: desktop ? {} : { flex: 1 } })}
    >
      <Text style={buttonText()}>{props.label}</Text>
    </Button>
  );
}

function App() {
  const sent = signal(35);
  const running = signal(false);
  const unknown = signal(false);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  // The sweep of the indeterminate bars: still with reduced motion.
  const end = signal(false);
  const sweep = setInterval(() => {
    if (!reduceMotion()) end.set(!end());
  }, SWEEP);
  cleanup(() => clearInterval(sweep));
  let timer: ReturnType<typeof setInterval> | undefined;
  const stop = () => {
    if (timer !== undefined) clearInterval(timer);
    timer = undefined;
    running.set(false);
  };
  const start = () => {
    if (sent() >= 100) sent.set(0);
    running.set(true);
    log(`Start at ${sent()}%`);
    timer = setInterval(() => {
      sent.set(Math.min(100, sent() + 5));
      if (sent() >= 100) {
        stop();
        log("Done: value 100");
      }
    }, 200);
  };
  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 }}>progress</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Click Start, or Tab to it and press Space" : "Tap Start to send the photos"}</Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value · indeterminate">
              <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
                {unknown() ? "indeterminate (no value for screen readers)" : `value ${sent()} of 100`}
              </Text>
              <View class={desktop ? "flex-row self-start gap-2" : "flex-row gap-2"}>
                <Action label={running() ? "Pause" : sent() >= 100 ? "Again" : "Start"} disabled={unknown()} onPress={() => (running() ? (stop(), log(`Pause at ${sent()}%`)) : start())} />
                <Action
                  label="Reset"
                  disabled={sent() === 0}
                  onPress={() => {
                    stop();
                    sent.set(0);
                    log("Reset: value 0");
                  }}
                />
              </View>
              <Switch
                checked={unknown()}
                onChange={({ checked }) => {
                  unknown.set(checked);
                  if (checked) stop();
                  log(checked ? "Size unknown: indeterminate" : "Size known: value " + sent());
                }}
                class={`-mx-2 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`}
                hoverStyle={hoverFill()}
                focusStyle={focusRing()}
                trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
                trackStyle={unknown() ? ui().accentFill : ui().inset}
                thumbClass={desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5"}
                thumbStyle={unknown() ? { backgroundColor: ui().onAccent } : ui().raised}
              >
                <Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Size unknown</Text>
              </Switch>
            </Panel>
            <Log title="events" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
          <Transfer
            label="Sending 24 photos"
            detail={unknown() ? "Counting what to send" : sent() >= 100 ? "Sent" : `${Math.round((sent() / 100) * 24)} of 24`}
            value={sent()}
            indeterminate={unknown()}
            end={end()}
          />
          {line()}
          <Transfer label="Preparing the backup" detail="Its size is not known yet" value={0} indeterminate end={end()} />
          {line()}
          <Transfer label="Library" detail="212 GB of 256 GB" value={83} end={end()} />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{progress, theme};
use nsh_core::prelude::*;

fn upload(sent: Signal<f32>) -> impl IntoElement {
    progress()
        .value(sent)
        .accessibility_label("Uploading photos")
        .gap_2()
        .track_style(|s| s.h(px(8.0)).rounded_full().overflow_hidden().bg(move || theme().muted))
        .indicator_style(|s| s.h_full().rounded_full().bg(move || theme().primary))
        .child(
            text(move || format!("{:.0}%", sent.get()))
                .text_sm()
                .text_color(move || theme().muted_foreground),
        )
}

Progress provient de nsh/base, le crate nsh-base en Rust. La valeur appartient à l’app : la barre de progression affiche le value que l’app lui transmet, borné entre 0 et 100. En Rust, progress() en crée une avec un setter par prop (.value(..), .indicator_style(..)), les méthodes de style typées pour la barre de progression elle-même et .child(..) pour son libellé.

#Props

PropRustTypeValeur par défautDescription
value.value(..)number0Où en est la tâche, en pourcentage de 0 à 100 (hors de la plage, on retient l’extrémité la plus proche). L’indicateur occupe cette part de la largeur de la piste, et les lecteurs d’écran l’annoncent.
indeterminate.indeterminate(..)booleanfalseL’étendue de la tâche est inconnue : les lecteurs d’écran entendent une barre de progression sans valeur, et la largeur de l’indicateur revient à l’app (indicatorClass).
accessibilityLabel.accessibility_label(..)stringaucuneCe que disent les lecteurs d’écran : ce qui est en cours, lorsque les enfants n’ont pas de texte qui le dise.
trackClass, trackStyle.track_style(|s| ..)string, StyleProp<ViewStyle>aucuneLa piste, la partie que remplit l’indicateur.
indicatorClass, indicatorStyle.indicator_style(|s| ..)string, StyleProp<ViewStyle>aucuneL’indicateur, à l’intérieur de la piste : sa hauteur, sa couleur et son animation.
children.child(..)JSX.ElementaucuneLe contenu après la piste : un libellé ou la valeur sous forme de texte, que les lecteurs d’écran annoncent.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucuneLa barre de progression elle-même : la colonne (ou la ligne) autour de la piste et du libellé.

#Parties

PartieÀ l’intérieur deStylisée avec
La barre de progressionclass, style
La pistela barre de progression, en premiertrackClass, trackStyle
L’indicateurla pisteindicatorClass, indicatorStyle ; sa largeur est de value pour cent de celle de la piste
Le libelléla barre de progression, après la pisteses propres éléments (children)

L’indicateur part du bord d’attaque de la piste ; overflow-hidden et un rayon sur la piste arrondissent son extrémité avec celle de la piste. Une classe w-* ou un width dans indicatorStyle remplace la largeur issue de la valeur, ce qui permet à une barre indéterminée d’avoir la sienne.

#Indéterminé

Tant que l’étendue est inconnue, définissez indeterminate et donnez à l’indicateur une largeur et une animation propres à l’app. L’animation est un choix de l’app, et motion-reduce: la fige pour les personnes qui demandent moins d’animation :

<Progress
  value={0}
  indeterminate={connecting()}
  trackClass="h-2 overflow-hidden rounded-full bg-muted"
  indicatorClass={
    connecting()
      ? "h-full w-1/3 rounded-full bg-primary animate-pulse motion-reduce:animate-none"
      : "h-full rounded-full bg-primary"
  }
/>

#Accessibilité

  • Une barre de progression est un seul élément pour les lecteurs d’écran, avec le rôle "progressbar", la plage de 0 à 100 et sa valeur (VoiceOver et TalkBack l’annoncent sous forme de pourcentage) ; la piste et l’indicateur en font partie. Son libellé est le texte de ses enfants ; renseignez accessibilityLabel lorsqu’elle n’en a pas.
  • Tant que indeterminate, elle conserve le rôle et la plage, mais n’a pas de valeur (dans le navigateur : aria-valuenow est omis, comme le prévoit ARIA pour une barre de progression indéterminée).
  • Elle ne reçoit ni appuis ni focus clavier : elle informe, et les contrôles qui démarrent ou arrêtent la tâche relèvent de l’app.

Voir Accessibilité pour les libellés, les rôles et les annonces en général.

#Notes par plateforme

  • Chaque plateforme dessine ce que l’app stylise ; la largeur de l’indicateur suit value immédiatement, et un changement s’anime lorsque l’app l’anime.
  • Lecteurs d’écran : VoiceOver (iOS, macOS) entend la valeur sous forme de pourcentage et la barre comme se mettant à jour fréquemment ; TalkBack, UI Automation (Windows), l’accessibilité de GTK (Linux) et le navigateur (aria-valuenow) reçoivent la plage et la valeur.