要素
Progress
プログレスバーは、範囲が分かっているタスク(ダウンロード、アップロード、セットアップの1ステップなど)がどこまで進んだかを示します。indeterminate の間は、範囲が不明なタスクが進行中であることを示します。スクリーンリーダーに伝わる内容(0〜100の範囲と値を持つ progress bar ロール)と、インジケーターの幅(トラックの value パーセント)は、プログレスバーが管理します。見た目はアプリ側のものです。Progress 自体には、色、サイズ、角丸、モーション、ラベルがありません。トラックとその内側のインジケーター、続いて子要素(ラベル、テキストとしての値)で構成され、各パーツのスタイルはアプリが指定します。
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 は、Rust の nsh-base クレートである nsh/base が提供します。値はアプリ側のものです。プログレスバーは、アプリが渡した value を 0〜100 の範囲に収めて表示します。Rust では、progress() が、プロパティごとのセッター(.value(..)、.indicator_style(..))、プログレスバー自体用の型付きスタイルメソッド、ラベル用の .child(..) を備えたプログレスバーを作成します。
#プロパティ
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
value | .value(..) | number | 0 | タスクがどこまで進んだかを 0〜100 のパーセントで表します(範囲外の場合は最も近い端の値になります)。インジケーターの幅はトラックの幅のその割合になり、スクリーンリーダーもこの値を読み上げます。 |
indeterminate | .indeterminate(..) | boolean | false | タスクの範囲が不明です。スクリーンリーダーには値のない progress bar として伝わり、インジケーターの幅はアプリ側で決めます(indicatorClass)。 |
accessibilityLabel | .accessibility_label(..) | string | なし | スクリーンリーダーが読み上げる内容です。子要素に進行中の内容を表すテキストがない場合に、何が進行中なのかを伝えます。 |
trackClass、trackStyle | .track_style(|s| ..) | string、StyleProp<ViewStyle> | なし | トラックです。インジケーターが満たしていく部分にあたります。 |
indicatorClass、indicatorStyle | .indicator_style(|s| ..) | string、StyleProp<ViewStyle> | なし | トラックの内側にあるインジケーターです。高さ、色、モーションを指定します。 |
children | .child(..) | JSX.Element | なし | トラックの後に続くコンテンツです。ラベルやテキストとしての値などで、スクリーンリーダーが読み上げます。 |
class、style | スタイルメソッド | string、StyleProp<ViewStyle> | なし | プログレスバー自体です。トラックとラベルを囲む列(または行)にあたります。 |
#パーツ
| パーツ | 内側 | スタイルの指定方法 |
|---|---|---|
| プログレスバー | class、style | |
| トラック | プログレスバーの先頭 | trackClass、trackStyle |
| インジケーター | トラック | indicatorClass、indicatorStyle。幅はトラックの value パーセント |
| ラベル | プログレスバー内のトラックの後 | 専用の要素(children) |
インジケーターはトラックの先頭側の端から始まります。overflow-hidden とトラックの角丸を指定すると、インジケーターの端がトラックの端と一緒に丸くなります。w-* クラスや indicatorStyle 内の width を指定すると、値に基づく幅の代わりにそちらが使われます。不確定状態のバーが独自の幅を持てるのは、この仕組みによります。
#Indeterminate
範囲が不明な間は indeterminate を設定し、アプリ側で決めた幅とモーションをインジケーターに与えます。モーションをどうするかはアプリが選びます。motion-reduce: を使うと、動きを減らすよう求めているユーザーに対してモーションを止めておけます。
<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"
}
/>
#アクセシビリティ
- プログレスバーはスクリーンリーダーにとって1つの要素であり、ロールは
"progressbar"、範囲は 0〜100 で、値を持ちます(VoiceOver と TalkBack はこれをパーセントとして読み上げます)。トラックとインジケーターはその一部です。ラベルは子要素のテキストです。子要素にテキストがない場合はaccessibilityLabelを指定してください。 indeterminateの間は、ロールと範囲は保たれ、値はありません(ブラウザーでは、ARIA の仕様どおり、不確定な progress bar としてaria-valuenowが省かれます)。- 押下操作もキーボードフォーカスも受け付けません。状況を伝えるだけで、タスクを開始・停止するコントロールはアプリ側で用意します。
ラベル、ロール、アナウンス全般については、アクセシビリティを参照してください。
#プラットフォームに関する注記
- どのプラットフォームでも、アプリが指定したスタイルどおりに描画されます。インジケーターの幅は
valueにすぐ追従し、アプリがアニメーションを指定していれば、変化はアニメーションします。 - スクリーンリーダー:VoiceOver(iOS、macOS)は、値をパーセントとして、バーを頻繁に更新されるものとして読み上げます。TalkBack、UI Automation(Windows)、GTK のアクセシビリティ(Linux)、ブラウザー(
aria-valuenow)には、範囲と値が渡されます。