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

元素

Avatar

頭像(avatar)顯示一個人的圖片:團隊成員、留言作者、標題列中的帳戶。在圖片載入完成之前、載入失敗時以及沒有圖片時,它會顯示後備內容:以此人的 name 取得的姓名縮寫,或應用程式自己的內容(圖示)。它負責管理圖片的載入狀態,由圖片自身的 load 和 error 事件以原生方式驅動,不需要 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,即 Rust 中的 nsh-base crate。在 Rust 中,avatar() 和 avatar_group() 以每個 prop 一個 setter(.src(..)、.fallback_delay(..))來建立它們;頭像本身使用具型別的樣式方法,而位於圖片上方的內容則使用 .child(..)。

#Props

Prop Rust 類型 預設值 說明
src .src(..) string | ImageAsset 無 圖片,格式與 <Image> 的 src 相同:URL、asset:/ 或 data: URI、blob: URL(使用者選取的相片:URL.createObjectURL(file)),或匯入的圖片。圖片載入完成後才會顯示;空字串代表沒有圖片。
name .name(..) string 無 此人的姓名:螢幕閱讀器會讀出它;若應用程式沒有傳入 fallback,後備內容也會以它產生縮寫。
accessibilityLabel .accessibility_label(..) string name 螢幕閱讀器讀出的內容,優先於 name。"" 會令頭像成為裝飾性元素,對螢幕閱讀器隱藏。
fallbackDelay .fallback_delay(..) number(毫秒) 0 圖片載入期間,後備內容等待多久才顯示,讓載入很快的圖片不會先閃現後備內容。Infinity:載入期間不顯示後備內容,只在載入失敗或沒有圖片時顯示。
imageClass、imageStyle .image_style(|s| ..) string、StyleProp<ViewStyle> 無 圖片:其適配方式、圓角半徑和動態效果。
fallbackClass、fallbackStyle .fallback_style(|s| ..) string、StyleProp<ViewStyle> 無 後備內容:其背景、文字顏色和大小,以及內容的對齊方式。
fallback .fallback(..) JSX.Element 姓名縮寫 取代姓名縮寫的後備內容:圖示,或給沒有姓名的人使用的佔位內容。
children .child(..) JSX.Element 無 顯示在圖片上方的內容:在線狀態圓點、徽章。螢幕閱讀器讀出的是頭像的標籤,而不是這些內容(參閱無障礙功能)。
class、style 樣式方法 string、StyleProp<ViewStyle> 無 頭像本身:其大小和形狀。
事件 Rust 負載 說明
onStatusChange .on_status_change(..) { status: AvatarStatus } 圖片的狀態改變:"idle"(沒有 src)、"loading"、"loaded" 或 "error"。

#組成部分

部分 位置 樣式設定
頭像 class、style
後備內容 在頭像內,排在最前,大小與頭像相同;圖片載入期間(fallbackDelay 之後)、載入失敗或沒有圖片時才會出現 fallbackClass、fallbackStyle;其內容為姓名縮寫或 fallback
圖片 在頭像內,位於後備內容之上,填滿頭像 imageClass、imageStyle
應用程式的內容 在頭像內,排在最後 自身的元素(children)

請為頭像指定大小(class="size-10"):後備內容和圖片會填滿頭像,因此後備內容離開時不會令版面重新排列。圖片從一開始就已排進版面,初始為透明,載入完成後變為不透明,然後後備內容離開。在 imageClass 中設定 transition-opacity 可令圖片淡入(啟用減少動態效果時會立即顯示)。請為圖片和後備內容分別設定圓角(各自使用 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>
Prop Rust 類型 預設值 說明
limit .limit(..) number 3 最多顯示這麼多個頭像,取最前面的;其餘的不會建立視圖。
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="" 的頭像屬於裝飾性元素:對螢幕閱讀器隱藏,這正適合與其所代表的姓名並排顯示的頭像。
  • 群組是一個群組,標籤為其 accessibilityLabel(「Ada、Grace 和另外 5 人」);顯示的頭像是其中的圖片。溢出部分會讀出其內容(「+5」),若應用程式提供了 overflowAccessibilityLabel,則讀出該內容(「另外 5 人」):NSH 本身不會寫入任何文字。
  • 對螢幕閱讀器而言,頭像是單一元素,因此其子元素顯示的內容不會被單獨讀出,而在 iOS 上,其中的按鈕或連結更完全無法聚焦。請在 accessibilityLabel 中說明在線狀態圓點或徽章的意思(「Ada Lovelace,在線」),並把可按下的元素放在頭像旁邊,而不是放在頭像裏面。
  • 兩者都不接受按下操作或鍵盤焦點:要令頭像可被按下,請把它放進 Button。

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

#平台說明

  • 各平台都使用自己的圖片載入器和快取來載入圖片(參閱 Image);圖片的 load 和 error 事件會在 Rust 中改變頭像的狀態,因此圖片載入中或載入失敗時,只會改變圖片和後備內容,不會影響其他部分。
  • 圖片在取得框架(frame)後才開始載入,因此它在透明期間仍保持在版面中。