Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

Avatar

Un avatar mostra l’immagine di una persona: un membro di un team, l’autore di un commento, l’account in un’intestazione. Finché l’immagine non è stata caricata, quando il caricamento fallisce e quando non c’è alcuna immagine, mostra un fallback: le iniziali del name della persona, oppure il contenuto dell’app stessa (un’icona). Gestisce lo stato di caricamento dell’immagine, eseguito in modo nativo a partire dagli eventi di caricamento e di errore dell’immagine stessa, senza JavaScript, e ciò che ricevono gli screen reader: un’immagine etichettata con il nome della persona. L’aspetto è quello dell’app: un Avatar non ha dimensioni, forma, colori o font propri. Un AvatarGroup mostra i primi avatar di una riga e un overflow che rappresenta gli altri (“+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 e AvatarGroup provengono da nsh/base, il crate nsh-base in Rust. In Rust, avatar() e avatar_group() li creano con un setter per ogni prop (.src(..), .fallback_delay(..)), i metodi di stile tipizzati per l’avatar stesso e .child(..) per ciò che sta sopra l’immagine.

#Props

PropRustTipoDefaultDescrizione
src.src(..)string | ImageAssetnessunoL’immagine, nel formato accettato da src di <Image>: un URL, un URI asset:/ o data:, un URL blob: (una foto scelta dall’utente: URL.createObjectURL(file)) oppure un’immagine importata. Viene mostrata una volta caricata; una stringa vuota equivale a nessuna immagine.
name.name(..)stringnessunoIl nome della persona: ciò che dicono gli screen reader e le iniziali che il fallback mostra quando l’app non passa alcun fallback.
accessibilityLabel.accessibility_label(..)stringnameCiò che dicono gli screen reader, con precedenza su name. "" rende l’avatar decorativo, nascosto agli screen reader.
fallbackDelay.fallback_delay(..)number (ms)0Per quanto tempo il fallback attende mentre l’immagine si carica, in modo che un’immagine che si carica rapidamente venga mostrata senza che il fallback lampeggi prima. Infinity: nessun fallback durante il caricamento, solo quando fallisce o manca.
imageClass, imageStyle.image_style(|s| ..)string, StyleProp<ViewStyle>nessunoL’immagine: adattamento, raggio e movimento.
fallbackClass, fallbackStyle.fallback_style(|s| ..)string, StyleProp<ViewStyle>nessunoIl fallback: sfondo, colore e dimensione del testo, e come allinea il proprio contenuto.
fallback.fallback(..)JSX.Elementle inizialiIl contenuto del fallback al posto delle iniziali: un’icona, un segnaposto per qualcuno senza nome.
children.child(..)JSX.ElementnessunoCiò che viene mostrato sopra l’immagine: un indicatore di presenza, un badge. Gli screen reader sentono l’etichetta dell’avatar, non questi elementi (vedi Accessibilità).
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoL’avatar stesso: dimensione e forma.
EventoRustPayloadDescrizione
onStatusChange.on_status_change(..){ status: AvatarStatus }Lo stato dell’immagine è cambiato: "idle" (senza src), "loading", "loaded" o "error".

#Parti

PartePosizioneStile
L’avatarclass, style
Il fallbackdentro l’avatar, per primo, grande quanto esso; presente mentre l’immagine si carica (dopo fallbackDelay), fallisce o mancafallbackClass, fallbackStyle; il suo contenuto sono le iniziali o fallback
L’immaginedentro l’avatar, sopra il fallback, lo riempieimageClass, imageStyle
Il contenuto dell’appdentro l’avatar, per ultimoi propri elementi (children)

Assegna all’avatar la sua dimensione (class="size-10"): il fallback e l’immagine la riempiono, quindi quando il fallback scompare non viene ricalcolato alcun layout. L’immagine viene disposta fin dall’inizio, trasparente, e diventa opaca una volta caricata; il fallback a quel punto scompare. Un transition-opacity in imageClass la mostra con una dissolvenza (con movimento ridotto viene mostrata subito). Arrotonda l’immagine e il fallback (rounded-full su ciascuno) invece di ritagliare l’avatar, così un indicatore di presenza posto al suo bordo (absolute bottom-0 right-0) resta intero.

Le iniziali sono le prime lettere delle prime due parole del nome, in maiuscolo (“Ada Lovelace” diventa “AL”); una sola parola dà le sue prime due lettere (“huacnlee” diventa “HU”), e un nome in una scrittura senza distinzione tra maiuscole e minuscole dà il suo primo carattere (“王小明” diventa “王”).

#Gruppi

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>
PropRustTipoDefaultDescrizione
limit.limit(..)number3Vengono mostrati al massimo questi avatar, i primi; gli altri non creano view.
total.total(..)numberil numero di figliQuante persone rappresenta il gruppo, quando l’app passa solo le prime.
accessibilityLabel.accessibility_label(..)stringnessunoCiò che dicono gli screen reader del gruppo.
overflowClass, overflowStyle.overflow_style(|s| ..)string, StyleProp<ViewStyle>nessunoLa parte di overflow, dopo gli avatar mostrati.
overflow.overflow(..)JSX.ElementnessunoCiò che rappresenta le persone non mostrate (“+5”, ”⋯”), presente quando nel gruppo ci sono più persone degli avatar mostrati.
overflowAccessibilityLabel.overflow_accessibility_label(..)stringnessunoCiò che dicono gli screen reader dell’overflow al posto del suo contenuto: “5 persone in più” per un “+5”.
children.child(..)JSX.ElementnessunoGli avatar, in ordine.
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoLa riga: direzione (flex-row) e spaziatura.

La sovrapposizione, gli anelli e la forma dell’overflow sono scelte dell’app: un margine negativo su ogni avatar dopo il primo (-ml-2) li sovrappone, e un anello del colore dello sfondo li separa. Per una lista lunga, passa le prime limit persone e total: vengono creati solo gli avatar mostrati. Il conteggio che l’overflow rappresenta è total meno gli avatar mostrati, che sono il minore tra il numero di figli e limit: total={8} con due figli mostra due avatar e “+6”.

#Accessibilità

  • Un avatar è un’unica immagine per gli screen reader, etichettata con accessibilityLabel o name: il nome viene letto una sola volta. Il fallback e l’immagine sono nascosti agli screen reader, quindi le iniziali non vengono lette a parte. Un avatar privo di entrambi, o con accessibilityLabel="", è decorativo: nascosto agli screen reader, come dovrebbe essere un avatar accanto al nome che mostra.
  • Un gruppo è un gruppo, etichettato con il suo accessibilityLabel (“Ada, Grace e altri 5”); gli avatar mostrati sono immagini al suo interno. L’overflow viene letto come il suo contenuto (“+5”), oppure come overflowAccessibilityLabel (“5 persone in più”) quando l’app ne fornisce uno: NSH non scrive parole proprie.
  • Un avatar è un unico elemento per gli screen reader, quindi ciò che mostrano i suoi figli non viene letto a parte, e su iOS un pulsante o un link al loro interno non è raggiungibile affatto. Indica il significato di un indicatore di presenza o di un badge in accessibilityLabel (“Ada Lovelace, online”), e metti ciò che può essere premuto accanto all’avatar, non al suo interno.
  • Nessuno dei due accetta pressioni o il focus da tastiera: inserisci un avatar in un Button per renderlo interattivo.

Vedi Accessibilità per etichette, ruoli e annunci in generale.

#Note sulle piattaforme

  • Ogni piattaforma carica l’immagine con il proprio image loader e le proprie cache (vedi Image); i suoi eventi di caricamento e di errore cambiano lo stato dell’avatar in Rust, quindi un’immagine in caricamento o fallita modifica l’immagine e il fallback e nient’altro.
  • L’immagine inizia a caricarsi quando ha un frame, quindi resta disposta nel layout mentre è trasparente.