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”).
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()
})
}import { mount } from "nsh";
import { Avatar } from "nsh/base";
function App() {
return (
<Avatar
src="https://example.com/ada.png"
name="Ada Lovelace"
class="size-10"
imageClass="rounded-full"
fallbackClass="items-center justify-center rounded-full bg-muted text-sm text-muted-foreground"
/>
);
}
mount(App);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
accessibilityLabelodername: 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 mitaccessibilityLabel=""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 alsoverflowAccessibilityLabel(“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.