Elemente
Progress
Ein Fortschrittsbalken zeigt, wie weit eine Aufgabe mit bekanntem Umfang ist (ein Download, ein Upload, ein Schritt einer Einrichtung) oder, solange indeterminate, dass eine Aufgabe mit unbekanntem Umfang läuft. Er bestimmt, was Screenreader erhalten, nämlich die Rolle Progress Bar mit dem Bereich 0 bis 100 und dem Wert, sowie die Breite des Indikators: value Prozent der Breite des Tracks. Das Aussehen liegt bei der App: Progress hat keine eigenen Farben, Größen, Radien, Animationen oder Beschriftungen. Er besteht aus einem Track mit einem Indikator darin und danach seinen Children (eine Beschriftung, der Wert als Text), und die App gestaltet jeden Teil.
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),
)
}import { mount, signal, Text } from "nsh";
import { Progress } from "nsh/base";
function App() {
const sent = signal(40);
return (
<Progress
value={sent()}
accessibilityLabel="Uploading photos"
class="gap-2"
trackClass="h-2 overflow-hidden rounded-full bg-muted"
indicatorClass="h-full rounded-full bg-primary"
>
<Text class="text-sm text-muted-foreground">{`${sent()}%`}</Text>
</Progress>
);
}
mount(App);Progress stammt aus nsh/base, dem nsh-base-Crate in Rust. Der Wert gehört der App: Der Fortschrittsbalken zeigt den value, den die App übergibt, begrenzt auf 0 bis 100. In Rust erzeugt progress() einen Fortschrittsbalken mit einem Setter pro Prop (.value(..), .indicator_style(..)), den typisierten Style-Methoden für den Fortschrittsbalken selbst und .child(..) für seine Beschriftung.
#Props
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
value |
.value(..) |
number |
0 |
Wie weit die Aufgabe ist, als Prozentwert von 0 bis 100 (außerhalb des Bereichs gilt das nächstliegende Ende). Der Indikator nimmt diesen Anteil der Breite des Tracks ein, und Screenreader sagen ihn an. |
indeterminate |
.indeterminate(..) |
boolean |
false |
Der Umfang der Aufgabe ist unbekannt: Screenreader hören einen Fortschrittsbalken ohne Wert, und die Breite des Indikators bestimmt die App (indicatorClass). |
accessibilityLabel |
.accessibility_label(..) |
string |
keiner | Was Screenreader sagen: was in Arbeit ist, wenn die Children keinen Text enthalten, der es sagt. |
trackClass, trackStyle |
.track_style(|s| ..) |
string, StyleProp<ViewStyle> |
keiner | Der Track, der Teil, den der Indikator füllt. |
indicatorClass, indicatorStyle |
.indicator_style(|s| ..) |
string, StyleProp<ViewStyle> |
keiner | Der Indikator im Track: seine Höhe, Farbe und Animation. |
children |
.child(..) |
JSX.Element |
keiner | Der Inhalt nach dem Track: eine Beschriftung oder der Wert als Text, den Screenreader ansagen. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keiner | Der Fortschrittsbalken selbst: die Spalte (oder Zeile) um den Track und die Beschriftung. |
#Teile
| Teil | Enthalten in | Gestaltet mit |
|---|---|---|
| Der Fortschrittsbalken | class, style |
|
| Der Track | dem Fortschrittsbalken, als Erstes | trackClass, trackStyle |
| Der Indikator | dem Track | indicatorClass, indicatorStyle; seine Breite beträgt value Prozent der des Tracks |
| Die Beschriftung | dem Fortschrittsbalken, nach dem Track | eigenen Elementen (children) |
Der Indikator beginnt an der vorderen Kante des Tracks; overflow-hidden und ein Radius am Track runden sein Ende zusammen mit dem des Tracks ab. Eine w-*-Klasse oder ein width in indicatorStyle ersetzt die Breite des Werts, und so erhält ein unbestimmter Balken seine eigene.
#Unbestimmt
Solange der Umfang unbekannt ist, setze indeterminate und gib dem Indikator eine Breite und eine Animation der App. Die Animation ist Sache der App, und motion-reduce: hält sie für Personen still, die weniger Bewegung wünschen:
<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"
}
/>
#Barrierefreiheit
- Ein Fortschrittsbalken ist für Screenreader ein einzelnes Element mit der Rolle
"progressbar", dem Bereich 0 bis 100 und seinem Wert (VoiceOver und TalkBack sagen ihn als Prozentwert an); der Track und der Indikator sind Teile davon. Seine Beschriftung ist der Text seiner Children; gibaccessibilityLabelan, wenn er keinen hat. - Solange
indeterminate, behält er die Rolle und den Bereich und hat keinen Wert (im Browser entfälltaria-valuenow, so wie ARIA es für einen unbestimmten Fortschrittsbalken vorsieht). - Er nimmt keine Eingaben per Tippen oder Klicken und keinen Tastaturfokus an: Er meldet nur, und die Steuerelemente, die die Aufgabe starten oder stoppen, sind Sache der App.
Siehe Barrierefreiheit für Beschriftungen, Rollen und Ansagen im Allgemeinen.
#Hinweise zu den Plattformen
- Jede Plattform zeichnet, was die App gestaltet; die Breite des Indikators folgt
valuesofort, und eine Änderung wird animiert, wenn die App sie animiert. - Screenreader: VoiceOver (iOS, macOS) hört den Wert als Prozentwert und den Balken als sich häufig aktualisierend; TalkBack, UI Automation (Windows), die Barrierefreiheit von GTK (Linux) und der Browser (
aria-valuenow) erhalten den Bereich und den Wert.