Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Elemente

Avatar

Ein Avatar zeigt das Bild einer Person: ein Teammitglied, den Verfasser eines Kommentars, das Konto in einer Kopfzeile. Solange das Bild lädt, wenn es fehlschlägt und wenn es keines gibt, zeigt er einen Fallback: die Initialen aus dem name der Person oder den eigenen Inhalt der App (ein Icon). Er verwaltet den Ladezustand des Bildes, nativ aus den load- und error-Events des Bildes und ohne JavaScript, und liefert den Screenreadern ein Bild, das mit dem Namen der Person beschriftet ist. Das Aussehen bestimmt die App: Ein Avatar hat keine eigene Größe, Form, Farben oder Schrift. Eine AvatarGroup zeigt die ersten Avatare einer Reihe und einen Überlauf, der für die übrigen steht (“+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 und AvatarGroup stammen aus nsh/base, dem nsh-base-Crate in Rust. In Rust erzeugen avatar() und avatar_group() sie mit je einem Setter pro Prop (.src(..), .fallback_delay(..)), den typisierten Style-Methoden für den Avatar selbst und .child(..) für das, was über dem Bild liegt.

#Props

Prop Rust Typ Standard Beschreibung
src .src(..) string | ImageAsset keiner Das Bild, so wie src von <Image> es entgegennimmt: eine URL, eine asset:/- oder data:-URI, eine blob:-URL (ein vom Nutzer gewähltes Foto: URL.createObjectURL(file)) oder ein importiertes Bild. Es wird angezeigt, sobald es geladen ist; ein leerer String bedeutet kein Bild.
name .name(..) string keiner Der Name der Person: was Screenreader vorlesen, und die Initialen, die der Fallback zeigt, wenn die App kein fallback übergibt.
accessibilityLabel .accessibility_label(..) string name Was Screenreader vorlesen, vorrangig vor name. "" macht den Avatar dekorativ, sodass er vor Screenreadern verborgen ist.
fallbackDelay .fallback_delay(..) number (ms) 0 Wie lange der Fallback wartet, während das Bild lädt, damit ein schnell geladenes Bild erscheint, ohne dass der Fallback zuvor kurz aufblitzt. Infinity: kein Fallback während des Ladens, nur wenn das Bild fehlschlägt oder fehlt.
imageClass, imageStyle .image_style(|s| ..) string, StyleProp<ViewStyle> keiner Das Bild: Einpassung, Radius und Bewegung.
fallbackClass, fallbackStyle .fallback_style(|s| ..) string, StyleProp<ViewStyle> keiner Der Fallback: Hintergrund, Textfarbe und Größe sowie die Ausrichtung seines Inhalts.
fallback .fallback(..) JSX.Element die Initialen Der Inhalt des Fallbacks anstelle der Initialen: ein Icon, ein Platzhalter für jemanden ohne Namen.
children .child(..) JSX.Element keiner Was über dem Bild erscheint: ein Präsenzpunkt, ein Badge. Screenreader hören das Label des Avatars, nicht diese Elemente (siehe Accessibility).
class, style Style-Methoden string, StyleProp<ViewStyle> keiner Der Avatar selbst: Größe und Form.
Event Rust Payload Beschreibung
onStatusChange .on_status_change(..) { status: AvatarStatus } Der Status des Bildes hat sich geändert: "idle" (kein src), "loading", "loaded" oder "error".

#Parts

Part Enthalten in Gestaltet mit
Der Avatar class, style
Der Fallback dem Avatar, als Erstes, so groß wie dieser; vorhanden, solange das Bild lädt (nach fallbackDelay), fehlschlägt oder fehlt fallbackClass, fallbackStyle; sein Inhalt sind die Initialen oder fallback
Das Bild dem Avatar, über dem Fallback, ihn ausfüllend imageClass, imageStyle
Der Inhalt der App dem Avatar, als Letztes seine eigenen Elemente (children)

Gib dem Avatar seine Größe (class="size-10"): Fallback und Bild füllen ihn aus, sodass das Verschwinden des Fallbacks kein neues Layout auslöst. Das Bild wird von Anfang an layoutet, zunächst transparent, und wird deckend, sobald es geladen ist; der Fallback verschwindet dann. Ein transition-opacity in imageClass blendet es ein (bei reduzierter Bewegung erscheint es sofort). Runde das Bild und den Fallback ab (jeweils mit rounded-full), statt den Avatar zu beschneiden, damit ein Präsenzpunkt an seinem Rand (absolute bottom-0 right-0) vollständig sichtbar bleibt.

Die Initialen sind die ersten Buchstaben der ersten zwei Wörter des Namens in Großbuchstaben (“Ada Lovelace” ergibt “AL”); bei einem einzelnen Wort sind es dessen ersten zwei Buchstaben (“huacnlee” ergibt “HU”), und bei einem Namen in einer Schrift ohne Groß- und Kleinschreibung das erste Zeichen (“王小明” ergibt “王”).

#Groups

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 Typ Standard Beschreibung
limit .limit(..) number 3 Höchstens so viele ihrer Avatare werden angezeigt, und zwar die ersten; für die übrigen werden keine Views erzeugt.
total .total(..) number die Anzahl der Kinder Für wie viele Personen die Gruppe steht, wenn die App nur die ersten von ihnen übergibt.
accessibilityLabel .accessibility_label(..) string keiner Was Screenreader über die Gruppe vorlesen.
overflowClass, overflowStyle .overflow_style(|s| ..) string, StyleProp<ViewStyle> keiner Der Überlauf-Part, nach den angezeigten Avataren.
overflow .overflow(..) JSX.Element keiner Was für die nicht angezeigten Personen steht (“+5”, ”⋯”); vorhanden, solange die Gruppe mehr Personen umfasst als Avatare angezeigt werden.
overflowAccessibilityLabel .overflow_accessibility_label(..) string keiner Was Screenreader anstelle des Inhalts des Überlaufs vorlesen: “5 weitere Personen” für ein “+5”.
children .child(..) JSX.Element keiner Die Avatare, in der angegebenen Reihenfolge.
class, style Style-Methoden string, StyleProp<ViewStyle> keiner Die Reihe: Richtung (flex-row) und Abstand.

Die Überlappung, die Ringe und die Form des Überlaufs bestimmt die App: Ein negativer Margin an jedem Avatar nach dem ersten (-ml-2) staffelt sie übereinander, und ein Ring in der Farbe des Hintergrunds trennt sie voneinander. Übergib bei einer langen Liste nur die ersten limit Personen und total: Es werden nur die angezeigten Avatare erzeugt. Die Anzahl, für die der Überlauf steht, ist total abzüglich der angezeigten Avatare; deren Anzahl ist der kleinere Wert aus der Zahl der Kinder und limit: total={8} mit zwei Kindern zeigt zwei Avatare und “+6”.

#Accessibility

  • Ein Avatar ist für Screenreader ein einzelnes Bild, beschriftet mit accessibilityLabel oder name: Der Name wird einmal vorgelesen. Der Fallback und das Bild sind vor ihnen verborgen, sodass die Initialen nicht gesondert vorgelesen werden. Ein Avatar ohne beides oder mit accessibilityLabel="" ist dekorativ und vor Screenreadern verborgen, wie es sich für einen Avatar neben dem Namen, den er zeigt, gehört.
  • Eine Gruppe ist eine Gruppe, beschriftet mit ihrem accessibilityLabel (“Ada, Grace und 5 weitere”); die angezeigten Avatare sind Bilder darin. Der Überlauf wird als sein Inhalt vorgelesen (“+5”) oder als overflowAccessibilityLabel (“5 weitere Personen”), wenn die App eines angibt: NSH schreibt keine eigenen Texte.
  • Ein Avatar ist für Screenreader ein einzelnes Element, daher wird nicht gesondert vorgelesen, was seine Kinder zeigen, und ein Button oder Link darin ist unter iOS überhaupt nicht erreichbar. Sage in accessibilityLabel, was ein Präsenzpunkt oder ein Badge bedeutet (“Ada Lovelace, online”), und platziere alles, was gedrückt werden kann, neben dem Avatar, nicht darin.
  • Keiner von beiden nimmt Eingaben per Tippen oder Tastaturfokus an: Setze einen Avatar in einen Button, damit er bedienbar wird.

Siehe Accessibility für Labels, Rollen und Ansagen im Allgemeinen.

#Platform notes

  • Jede Plattform lädt das Bild mit ihrem eigenen Image-Loader und eigenen Caches (siehe Image); dessen load- und error-Events ändern den Zustand des Avatars in Rust, sodass ein ladendes oder fehlgeschlagenes Bild das Bild und den Fallback verändert und sonst nichts.
  • Das Bild beginnt zu laden, sobald es einen Frame hat, und bleibt daher auch im transparenten Zustand im Layout.