Elementos
Progress
A progress bar muestra hasta dónde ha avanzado una tarea de extensión conocida (una descarga, una subida, un paso de una configuración) o, mientras
indeterminate, que una tarea de extensión desconocida está en curso. Se encarga de lo que reciben los lectores de pantalla, el rol de barra de progreso
con el rango de 0 a 100 y el valor, y del ancho del indicador: value por ciento del ancho de la pista. El aspecto es
el de la app: un Progress no tiene colores, tamaños, radios, movimiento ni etiqueta propios. Se compone de una pista con un indicador
dentro, y después sus hijos (una etiqueta, el valor como texto), y la app da estilo a cada parte.
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 proviene de nsh/base, el crate nsh-base de Rust. El valor es de la app: la
progress bar muestra el value que la app le pasa, limitado a un rango de 0 a 100. En Rust, progress() crea una con un setter por prop
(.value(..), .indicator_style(..)), los métodos de estilo tipados para la propia progress bar y .child(..) para su
etiqueta.
#Props
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
value |
.value(..) |
number |
0 |
Hasta dónde ha avanzado la tarea, como porcentaje de 0 a 100 (fuera del rango, se usa el extremo más cercano). El indicador ocupa esa parte del ancho de la pista, y los lectores de pantalla la anuncian. |
indeterminate |
.indeterminate(..) |
boolean |
false |
La extensión de la tarea es desconocida: los lectores de pantalla oyen una barra de progreso sin valor, y el ancho del indicador lo define la app (indicatorClass). |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla: qué está en curso, cuando sus hijos no tienen texto que lo diga. |
trackClass, trackStyle |
.track_style(|s| ..) |
string, StyleProp<ViewStyle> |
ninguno | La pista, la parte que llena el indicador. |
indicatorClass, indicatorStyle |
.indicator_style(|s| ..) |
string, StyleProp<ViewStyle> |
ninguno | El indicador, dentro de la pista: su altura, color y movimiento. |
children |
.child(..) |
JSX.Element |
ninguno | El contenido después de la pista: una etiqueta o el valor como texto, que los lectores de pantalla anuncian. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | La propia progress bar: la columna (o fila) que rodea la pista y la etiqueta. |
#Partes
| Parte | Dentro de | Estilo con |
|---|---|---|
| La progress bar | class, style |
|
| La pista | la progress bar, primero | trackClass, trackStyle |
| El indicador | la pista | indicatorClass, indicatorStyle; su ancho es value por ciento del de la pista |
| La etiqueta | la progress bar, después de la pista | sus propios elementos (children) |
El indicador comienza en el borde inicial de la pista; overflow-hidden y un radio en la pista redondean su extremo junto con el
de la pista. Una clase w-* o un width en indicatorStyle sustituye el ancho del valor, que es como una
barra indeterminada obtiene el suyo.
#Indeterminada
Mientras la extensión sea desconocida, establece indeterminate y dale al indicador un ancho y un movimiento propios de la app. El movimiento es
decisión de la app, y motion-reduce: lo mantiene quieto para las personas que piden menos movimiento:
<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"
}
/>
#Accesibilidad
- Una progress bar es un solo elemento para los lectores de pantalla, con el rol
"progressbar", el rango de 0 a 100 y su valor (VoiceOver y TalkBack lo dicen como porcentaje); la pista y el indicador son partes de ella. Su etiqueta es el texto de sus hijos; indicaaccessibilityLabelcuando no tiene ninguno. - Mientras
indeterminate, conserva el rol y el rango y no tiene valor (en el navegador:aria-valuenowse omite, tal como ARIA define una barra de progreso indeterminada). - No admite pulsaciones ni foco de teclado: informa, y los controles que inician o detienen la tarea son de la app.
Consulta Accesibilidad para ver etiquetas, roles y anuncios en general.
#Notas de plataforma
- Cada plataforma dibuja lo que la app define con estilos; el ancho del indicador sigue a
valueal instante, y un cambio se anima cuando la app lo anima. - Lectores de pantalla: VoiceOver (iOS, macOS) oye el valor como porcentaje y la barra como de actualización frecuente;
TalkBack, UI Automation (Windows), la accesibilidad de GTK (Linux) y el navegador (
aria-valuenow) reciben el rango y el valor.