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

العناصر

Avatar

تعرض الصورة الرمزية (Avatar) صورة شخص: عضو في فريق، أو كاتب تعليق، أو الحساب في الترويسة. إلى أن تُحمَّل الصورة، وعند فشل تحميلها، وعند عدم وجود صورة، تعرض بديلاً (fallback): الأحرف الأولى من name الشخص، أو محتوى التطبيق نفسه (أيقونة). وهي تتولى حالة تحميل الصورة، وتُدار أصلياً (natively) انطلاقاً من حدثي التحميل والخطأ الخاصين بالصورة دون أي JavaScript، كما تتولى ما تقرؤه قارئات الشاشة: صورة موسومة باسم الشخص. أما المظهر فهو من شأن التطبيق: فـ Avatar ليس له حجم أو شكل أو ألوان أو خط خاص به. ويعرض AvatarGroup الصور الرمزية الأولى من صف، مع عنصر فائض يمثل البقية (“+5”).

el-avatar.tsx
el-avatar.tsxLive
import { type JSX, mount, reduceMotion, signal, Text, View } from "nsh";
import { Avatar, AvatarGroup, Button } from "nsh/base";
import { AVATAR_BROKEN, avatarIdentity, avatarPortrait, button, buttonText, desktop, focusRing, hoverFill, Log, Panel, Segmented, Split, ui } from "./palette";

// The look is the demo's: initials on a tint of the person's hue, the
// picture round over them (the parts are rounded, not the avatar, so a
// presence dot over it is not clipped), a ring of the card around stacked
// avatars, a dot whose color fades as it changes, a picture that fades in.

type Presence = "online" | "away" | "busy";
const PRESENCE: Presence[] = ["online", "away", "busy"];
const dotColor = (p: Presence) => (p === "online" ? ui().up : p === "away" ? ui().warn : ui().danger);
const surface = () => ui().card.backgroundColor;

function Person(props: {
  name: string;
  side: number;
  src?: string;
  label?: string;
  ring?: boolean;
  onStatus?: (status: string) => void;
  children?: JSX.Element;
}) {
  const id = () => avatarIdentity(props.name);
  return (
    <Avatar
      src={props.src}
      name={props.name}
      accessibilityLabel={props.label}
      fallbackDelay={300}
      onStatusChange={({ status }) => props.onStatus?.(status)}
      style={{
        width: props.side,
        height: props.side,
        ...(props.ring ? { borderRadius: props.side / 2, borderWidth: 2, borderColor: surface(), backgroundColor: surface() } : {}),
      }}
      imageClass={`rounded-full overflow-hidden ${reduceMotion() ? "" : "transition-opacity duration-300"}`}
      fallbackClass={`rounded-full overflow-hidden items-center justify-center font-semibold text-[${Math.round(props.side / 2.8)}px] text-[${id().foreground}]`}
      fallbackStyle={{ backgroundColor: id().background, borderWidth: 1, borderColor: id().border }}
    >
      {props.children}
    </Avatar>
  );
}

const TEAM = ["Ada Lovelace", "Grace Hopper", "Alan Turing"];
const MEMBERS = ["Ada Lovelace", "Grace Hopper", "Alan Turing", "Katherine Johnson", "Edsger Dijkstra", "Barbara Liskov", "Donald Knuth", "Frances Allen", "Radia Perlman", "Ken Thompson"];

function App() {
  const presence = signal<Presence[]>(["online", "busy", "away"]);
  const members = signal(8);
  const limit = signal(3);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const side = desktop ? 36 : 44;
  const more = () => Math.max(0, members() - limit());
  const label = { fontSize: desktop ? 13 : 15, color: ui().text } as const;
  const detail = { fontSize: 12, color: ui().muted } as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>avatar</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Click a member to change their presence" : "Tap a member to change their presence"}</Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="AvatarGroup · limit">
              <Segmented options={[2, 3, 4]} value={limit()} onChange={(v) => (limit.set(v), log(`limit ${v}`))} />
              <View style={{ flexDirection: "row", gap: 8, marginTop: 6 }}>
                <Button onPress={() => (members.set(Math.min(MEMBERS.length, members() + 1)), log(`members ${members()}`))} disabled={members() >= MEMBERS.length} {...button("secondary")}>
                  <Text style={buttonText()}>Add</Text>
                </Button>
                <Button onPress={() => (members.set(Math.max(1, members() - 1)), log(`members ${members()}`))} disabled={members() <= 1} {...button("ghost")}>
                  <Text style={buttonText("ghost")}>Remove</Text>
                </Button>
              </View>
            </Panel>
            <Log title="onStatusChange" lines={events()} minHeight={110} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 12 : 16, gap: 14 }}>
          <View>
            {TEAM.map((name, i) => (
              <Button
                onPress={() => {
                  const next = PRESENCE[(PRESENCE.indexOf(presence()[i]) + 1) % PRESENCE.length];
                  presence.set(presence().map((p, j) => (j === i ? next : p)));
                  log(`${name}: ${next}`);
                }}
                class="-mx-2 flex-row items-center gap-3 rounded-lg px-2 py-2 active:opacity-70"
                hoverStyle={hoverFill()}
                focusStyle={focusRing()}
              >
                <Person name={name} side={side} src={i === 2 ? undefined : avatarPortrait(name)} label={`${name}, ${presence()[i]}`} onStatus={(s) => log(`${name}: ${s}`)}>
                  <View
                    class={reduceMotion() ? "" : "transition-colors duration-200"}
                    style={{ position: "absolute", right: -1, bottom: -1, width: 11, height: 11, borderRadius: 6, borderWidth: 2, borderColor: surface(), backgroundColor: dotColor(presence()[i]) }}
                  />
                </Person>
                <View style={{ flex: 1, gap: 2 }}>
                  <Text style={{ ...label, fontWeight: "500" }}>{name}</Text>
                  <Text style={detail}>{presence()[i]}</Text>
                </View>
              </Button>
            ))}
          </View>
          <View style={{ height: 1, backgroundColor: ui().line }} />
          <View class="flex-row items-center gap-3">
            <AvatarGroup
              limit={limit()}
              accessibilityLabel={`${members()} members`}
              class="flex-row items-center"
              overflowClass="items-center justify-center rounded-full"
              overflowStyle={{ ...ui().raised, width: side, height: side, marginLeft: -side / 4, borderWidth: 2, borderColor: surface() }}
              overflowAccessibilityLabel={`${more()} more people`}
              overflow={<Text style={{ fontSize: side / 3.2, fontWeight: "600", color: ui().muted, fontVariant: ["tabular-nums"] }}>{`+${more()}`}</Text>}
            >
              <Each of={MEMBERS.slice(0, members())}>
                {(name, i) => (
                  <View style={{ marginLeft: i() === 0 ? 0 : -side / 4 }}>
                    <Person name={name()} side={side} ring src={i() % 3 === 2 ? undefined : avatarPortrait(name())} />
                  </View>
                )}
              </Each>
            </AvatarGroup>
            <Text style={detail}>{`${members()} members`}</Text>
          </View>
          <View style={{ height: 1, backgroundColor: ui().line }} />
          <View class="flex-row items-start gap-3">
            <Person name="Barbara Liskov" side={desktop ? 28 : 32} src={AVATAR_BROKEN} label="" onStatus={(s) => log(`Barbara Liskov: ${s}`)} />
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={detail}>
                <Text style={{ fontWeight: "600", color: ui().text }}>Barbara Liskov</Text>
                {"  40m · her picture failed to load"}
              </Text>
              <Text style={label}>Same as before within a millisecond.</Text>
            </View>
          </View>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{avatar, theme};
use nsh_core::prelude::*;

fn author(name: &'static str, picture: &'static str) -> impl IntoElement {
    avatar()
        .src(picture)
        .name(name)
        .size(px(40.0))
        .image_style(|s| s.rounded_full())
        .fallback_style(|s| {
            s.rounded_full()
                .items_center()
                .justify_center()
                .bg(move || theme().muted)
                .text_color(move || theme().muted_foreground)
                .text_sm()
        })
}

يأتي Avatar وAvatarGroup من nsh/base، وهي الحزمة (crate) nsh-base في Rust. في Rust، يُنشئهما avatar() وavatar_group() بدالة ضبط (setter) لكل خاصية (.src(..)، .fallback_delay(..))، مع دوال الأنماط المكتوبة للصورة الرمزية نفسها و.child(..) لما يوضع فوق الصورة.

#الخصائص (Props)

الخاصيةRustالنوعالقيمة الافتراضيةالوصف
src.src(..)string | ImageAssetلا شيءالصورة، بالصيغة التي يقبلها src في <Image>: عنوان URL، أو URI من نوع asset:/ أو data:، أو عنوان URL من نوع blob: (صورة اختارها المستخدم: URL.createObjectURL(file))، أو صورة مستوردة. تظهر بعد تحميلها؛ والسلسلة الفارغة تعني عدم وجود صورة.
name.name(..)stringلا شيءاسم الشخص: ما تنطق به قارئات الشاشة، والأحرف الأولى التي يعرضها البديل عندما لا يمرر التطبيق fallback.
accessibilityLabel.accessibility_label(..)stringnameما تنطق به قارئات الشاشة، ويتقدم على name. يجعل "" الصورة الرمزية زخرفية، مخفية عن قارئات الشاشة.
fallbackDelay.fallback_delay(..)number (ms)0المدة التي ينتظرها البديل أثناء تحميل الصورة، حتى تظهر الصورة سريعة التحميل دون أن يومض البديل أولاً. Infinity: لا بديل أثناء التحميل، بل فقط عند الفشل أو عدم وجود الصورة.
imageClass، imageStyle.image_style(|s| ..)string، StyleProp<ViewStyle>لا شيءالصورة: ملاءمتها (fit) ونصف قطرها (radius) وحركتها.
fallbackClass، fallbackStyle.fallback_style(|s| ..)string، StyleProp<ViewStyle>لا شيءالبديل: خلفيته ولون نصه وحجمه، وكيفية محاذاة محتواه.
fallback.fallback(..)JSX.Elementالأحرف الأولىمحتوى البديل بدلاً من الأحرف الأولى: أيقونة، أو عنصر نائب لشخص بلا اسم.
children.child(..)JSX.Elementلا شيءما يظهر فوق الصورة: نقطة الحضور، أو شارة. تسمع قارئات الشاشة وسم الصورة الرمزية لا هذه العناصر (انظر إمكانية الوصول).
class، styleدوال الأنماطstring، StyleProp<ViewStyle>لا شيءالصورة الرمزية نفسها: حجمها وشكلها.
الحدثRustالحمولة (Payload)الوصف
onStatusChange.on_status_change(..){ status: AvatarStatus }تغيرت حالة الصورة: "idle" (بلا src)، أو "loading"، أو "loaded"، أو "error".

#الأجزاء

الجزءموضعهيُنسَّق باستخدام
الصورة الرمزيةclass، style
البديلداخل الصورة الرمزية، أولاً، وبحجمها كاملاً؛ يوجد أثناء تحميل الصورة (بعد fallbackDelay)، أو عند فشلها، أو عند عدم وجودهاfallbackClass، fallbackStyle؛ ومحتواه الأحرف الأولى أو fallback
الصورةداخل الصورة الرمزية، فوق البديل، وتملؤهimageClass، imageStyle
محتوى التطبيقداخل الصورة الرمزية، أخيراًعناصره الخاصة (children)

حدد للصورة الرمزية حجمها (class="size-10"): فيملؤه البديل والصورة، فلا يُعاد أي تخطيط عند زوال البديل. تُخطَّط الصورة منذ البداية وهي شفافة، ثم تصبح معتمة بمجرد تحميلها؛ ثم يزول البديل. ويؤدي transition-opacity في imageClass إلى ظهورها تدريجياً (وعند تفعيل تقليل الحركة تظهر فوراً). دوِّر زوايا الصورة والبديل (rounded-full على كل منهما) بدلاً من قص الصورة الرمزية، حتى تبقى نقطة الحضور الموضوعة عند حافتها (absolute bottom-0 right-0) كاملة.

الأحرف الأولى هي أول حرف من أول كلمتين في الاسم، بحروف كبيرة (“Ada Lovelace” تصبح “AL”)؛ والكلمة الواحدة تعطي أول حرفين منها (“huacnlee” تصبح “HU”)، أما الاسم المكتوب بنظام كتابة لا يميز بين الأحرف الكبيرة والصغيرة فيعطي أول حرف منه (“王小明” تصبح “王”).

#المجموعات

import { Text } from "nsh";
import { Avatar, AvatarGroup } from "nsh/base";

<AvatarGroup
  limit={3}
  total={members.length}
  accessibilityLabel={`${members.length} members`}
  class="flex-row"
  overflowClass="-ml-2 size-8 items-center justify-center rounded-full bg-muted ring-2 ring-background"
  overflowAccessibilityLabel={`${members.length - 3} more people`}
  overflow={<Text class="text-xs">{`+${members.length - 3}`}</Text>}
>
  <Each of={members.slice(0, 3)} key={(m) => m.id}>
    {(m, i) => <Avatar src={m().picture} name={m().name} class={i() > 0 ? "-ml-2 size-8" : "size-8"} />}
  </Each>
</AvatarGroup>
الخاصيةRustالنوعالقيمة الافتراضيةالوصف
limit.limit(..)number3يظهر منها هذا العدد على الأكثر، وهي الصور الرمزية الأولى؛ أما البقية فلا تُنشأ لها عناصر عرض.
total.total(..)numberعدد العناصر الفرعيةعدد الأشخاص الذين تمثلهم المجموعة، عندما يمرر التطبيق الأوائل منهم فقط.
accessibilityLabel.accessibility_label(..)stringلا شيءما تنطق به قارئات الشاشة عن المجموعة.
overflowClass، overflowStyle.overflow_style(|s| ..)string، StyleProp<ViewStyle>لا شيءجزء الفائض، بعد الصور الرمزية المعروضة.
overflow.overflow(..)JSX.Elementلا شيءما يمثل الأشخاص غير المعروضين (“+5”، ”⋯”)، ويوجد ما دام في المجموعة أكثر من الصور الرمزية المعروضة.
overflowAccessibilityLabel.overflow_accessibility_label(..)stringلا شيءما تنطق به قارئات الشاشة عن الفائض بدلاً من محتواه: “5 أشخاص آخرين” بدلاً من “+5”.
children.child(..)JSX.Elementلا شيءالصور الرمزية، بالترتيب.
class، styleدوال الأنماطstring، StyleProp<ViewStyle>لا شيءالصف: اتجاهه (flex-row) وتباعده.

التداخل والحلقات وشكل الفائض من شأن التطبيق: فهامش سالب على كل صورة رمزية بعد الأولى (-ml-2) يرصّها فوق بعضها، وحلقة بلون الخلفية تفصل بينها. وللقوائم الطويلة، مرر أول limit من الأشخاص مع total: فلا تُنشأ إلا الصور الرمزية المعروضة. والعدد الذي يمثله الفائض هو total ناقص الصور الرمزية المعروضة، وهي الأقل بين عدد العناصر الفرعية وlimit: فـ total={8} مع عنصرين فرعيين يعرض صورتين رمزيتين و”+6”.

#إمكانية الوصول

  • الصورة الرمزية صورة واحدة بالنسبة إلى قارئات الشاشة، موسومة بـ accessibilityLabel أو name: فيُنطق الاسم مرة واحدة. أما البديل والصورة فمخفيان عنها، فلا تُقرأ الأحرف الأولى منفصلة. والصورة الرمزية التي ليس لها أي منهما، أو التي لها accessibilityLabel=""، زخرفية: مخفية عن قارئات الشاشة، كما ينبغي أن تكون الصورة الرمزية الموضوعة بجوار الاسم الذي تعرضه.
  • المجموعة مجموعة (group)، موسومة بـ accessibilityLabel (“Ada وGrace و5 آخرون”)؛ والصور الرمزية المعروضة صور داخلها. ويُقرأ الفائض بمحتواه (“+5”)، أو بـ overflowAccessibilityLabel (“5 أشخاص آخرين”) عندما يحدده التطبيق: فلا يكتب NSH أي كلمات من عنده.
  • الصورة الرمزية عنصر واحد بالنسبة إلى قارئات الشاشة، فما تعرضه عناصرها الفرعية لا يُقرأ منفصلاً، وعلى iOS لا يمكن الوصول إطلاقاً إلى زر أو رابط بينها. اذكر معنى نقطة الحضور أو الشارة في accessibilityLabel (“Ada Lovelace، متصل”)، وضع ما يمكن الضغط عليه بجوار الصورة الرمزية لا داخلها.
  • لا تستقبل أي منهما الضغطات ولا تركيز لوحة المفاتيح: ضع الصورة الرمزية داخل Button لتصبح عنصراً قابلاً للضغط.

انظر إمكانية الوصول للاطلاع على الوسوم والأدوار والإعلانات بوجه عام.

#ملاحظات حول المنصات

  • تحمّل كل منصة الصورة بمحمّل الصور (image loader) والذاكرة المؤقتة الخاصين بها (انظر Image)؛ وتغيّر أحداث التحميل والخطأ فيها حالة الصورة الرمزية في Rust، فلا يغيّر تحميل الصورة أو فشلها سوى الصورة والبديل.
  • يبدأ تحميل الصورة بمجرد أن يكون لها إطار (frame)، فتبقى مُخطَّطة وهي شفافة.