تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

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، وهي الحزمة nsh-base في Rust. والقيمة من شأن التطبيق: إذ يعرض شريط التقدم value الذي يمرره التطبيق، مقيّدًا بين 0 و100. وفي Rust يُنشئ progress() شريطًا بدالة ضبط لكل خاصية (.value(..)، .indicator_style(..))، ودوال التنسيق المطبّعة لشريط التقدم نفسه، و.child(..) لتسميته.

#الخصائص

الخاصيةRustالنوعالقيمة الافتراضيةالوصف
value.value(..)number0مدى تقدم المهمة كنسبة مئوية من 0 إلى 100 (وخارج هذا المدى تُؤخذ أقرب نهاية). يكون المؤشر بهذه النسبة من عرض المسار، وتنطقها قارئات الشاشة.
indeterminate.indeterminate(..)booleanfalseمدى المهمة غير معروف: تسمع قارئات الشاشة شريط تقدم بلا قيمة، ويكون عرض المؤشر من شأن التطبيق (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-* أو width في indicatorStyle محل عرض القيمة، وبهذا يحصل الشريط غير المحدد على عرضه الخاص.

#غير المحدد

أثناء كون المدى غير معروف، اضبط 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) فتحصل على المدى و القيمة.