本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

Progress

進度條顯示已知範圍任務的進度(例如下載、上傳或安裝設定的某個步驟);當 indeterminate 時,則表示範圍未知的任務正在進行。進度條負責提供螢幕閱讀器所讀取的資訊,即範圍為 0 至 100 的進度條角色及其數值,以及指示器的寬度:佔軌道寬度的 value %。外觀由應用程式決定:Progress 本身沒有顏色、尺寸、圓角、動態效果或標籤。它由一條軌道(內含指示器)加上其子元素(標籤、以文字顯示的數值)組成,各部分的樣式均由應用程式設定。

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 來自 nsh/base,即 Rust 的 nsh-base crate。數值由應用程式持有:進度條顯示應用程式傳入的 value,並限定在 0 至 100 之間。在 Rust 中,progress() 會建立進度條,每個屬性各有一個 setter(.value(..)、.indicator_style(..)),進度條本身有具型別的樣式方法,其標籤則使用 .child(..)。

#屬性

屬性 Rust 類型 預設值 說明
value .value(..) number 0 任務的進度,以 0 至 100 的百分比表示(超出範圍時取最接近的一端)。指示器的寬度即軌道寬度的該百分比,螢幕閱讀器也會讀出此數值。
indeterminate .indeterminate(..) boolean false 任務的範圍未知:螢幕閱讀器會讀出一個沒有數值的進度條,而指示器的寬度由應用程式決定(indicatorClass)。
accessibilityLabel .accessibility_label(..) string 無 螢幕閱讀器讀出的內容:當子元素沒有文字說明時,用來表示正在進行的是甚麼。
trackClass、trackStyle .track_style(|s| ..) string、StyleProp<ViewStyle> 無 軌道,即指示器所填滿的部分。
indicatorClass、indicatorStyle .indicator_style(|s| ..) string、StyleProp<ViewStyle> 無 軌道內的指示器:its 高度、顏色和動態效果。
children .child(..) JSX.Element 無 軌道之後的內容:標籤或以文字顯示的數值,螢幕閱讀器會讀出這些內容。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 進度條本身:環繞軌道和標籤的欄(或列)。

#部件

部件 位置 樣式設定方式
進度條 class、style
軌道 進度條內,最先 trackClass、trackStyle
指示器 軌道內 indicatorClass、indicatorStyle;其寬度為軌道寬度的 value %
標籤 進度條內,軌道之後 自身的元素(children)

指示器從軌道的起始邊緣開始;overflow-hidden 以及軌道上的圓角會讓指示器的末端與軌道一同變圓。w-* class 或 indicatorStyle 中的 width 會取代數值所決定的寬度,不確定進度條便是以此取得自己的寬度。

#不確定狀態

當範圍未知時,設定 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"
  }
/>

#無障礙

  • 對螢幕閱讀器而言,進度條是單一元素,角色為 "progressbar",範圍為 0 至 100,並帶有其數值(VoiceOver 和 TalkBack 會以百分比讀出);軌道和指示器是它的組成部分。其標籤是子元素的文字;如果沒有子元素文字,請提供 accessibilityLabel。
  • 當 indeterminate 時,它保留角色和範圍,但沒有數值(在瀏覽器中:不設 aria-valuenow,與 ARIA 對不確定進度條的規定一致)。
  • 它不接受按壓操作,也沒有鍵盤焦點:它只負責回報狀態,而啟動或停止任務的控制項由應用程式提供。

有關標籤、角色和播報的一般說明,請參閱無障礙。

#平台備註

  • 每個平台都會繪製應用程式所設定的樣式;指示器的寬度會即時跟隨 value,而當應用程式為變化加上動畫時,變化便會以動畫呈現。
  • 螢幕閱讀器:VoiceOver(iOS、macOS)會以百分比讀出數值,並將進度條視為頻繁更新;TalkBack、UI Automation(Windows)、GTK 的無障礙功能(Linux)以及瀏覽器(aria-valuenow)則取得範圍和數值。