Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

Avatar

Un avatar affiche l’image d’une personne : un membre d’une équipe, l’auteur d’un commentaire, le compte dans un en-tête. Tant que l’image n’est pas chargée, quand elle échoue et quand il n’y en a pas, il affiche un contenu de repli : les initiales du name de la personne, ou le contenu propre à l’app (une icône). Il gère l’état de chargement de l’image, exécuté nativement à partir des événements de chargement et d’erreur de l’image elle-même, sans JavaScript, ainsi que ce que reçoivent les lecteurs d’écran : une image étiquetée avec le nom de la personne. L’apparence est celle de l’app : un Avatar n’a ni taille, ni forme, ni couleurs, ni police propres. Un AvatarGroup affiche les premiers avatars d’une rangée et un débordement qui représente les autres (“+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 et AvatarGroup proviennent de nsh/base, le crate nsh-base en Rust. En Rust, avatar() et avatar_group() les construisent avec un setter par prop (.src(..), .fallback_delay(..)), les méthodes de style typées pour l’avatar lui-même et .child(..) pour ce qui se superpose à l’image.

#Props

PropRustTypeDéfautDescription
src.src(..)string | ImageAssetaucunL’image, telle que la prend le src de <Image> : une URL, une URI asset:/ ou data:, une URL blob: (une photo choisie par l’utilisateur : URL.createObjectURL(file)), ou une image importée. Elle s’affiche une fois chargée ; une chaîne vide signifie aucune image.
name.name(..)stringaucunLe nom de la personne : ce que disent les lecteurs d’écran, et les initiales que le repli affiche lorsque l’app ne passe pas de fallback.
accessibilityLabel.accessibility_label(..)stringnameCe que disent les lecteurs d’écran, prioritaire sur name. "" rend l’avatar décoratif, masqué aux lecteurs d’écran.
fallbackDelay.fallback_delay(..)number (ms)0Combien de temps le repli attend pendant le chargement de l’image, afin qu’une image qui se charge rapidement s’affiche sans que le repli clignote d’abord. Infinity : pas de repli pendant le chargement, seulement en cas d’échec ou d’absence d’image.
imageClass, imageStyle.image_style(|s| ..)string, StyleProp<ViewStyle>aucunL’image : son ajustement, son rayon et son animation.
fallbackClass, fallbackStyle.fallback_style(|s| ..)string, StyleProp<ViewStyle>aucunLe repli : son arrière-plan, la couleur et la taille de son texte, et l’alignement de son contenu.
fallback.fallback(..)JSX.Elementles initialesLe contenu du repli à la place des initiales : une icône, un substitut pour une personne sans nom.
children.child(..)JSX.ElementaucunCe qui s’affiche par-dessus l’image : un point de présence, un badge. Les lecteurs d’écran entendent l’étiquette de l’avatar, pas ces éléments (voir Accessibilité).
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunL’avatar lui-même : sa taille et sa forme.
ÉvénementRustDonnéesDescription
onStatusChange.on_status_change(..){ status: AvatarStatus }L’état de l’image a changé : "idle" (sans src), "loading", "loaded" ou "error".

#Parties

PartieContenue dansStylée avec
L’avatarclass, style
Le replil’avatar, en premier, aussi grand que lui ; présent pendant le chargement de l’image (après fallbackDelay), en cas d’échec ou d’absencefallbackClass, fallbackStyle ; son contenu : les initiales ou fallback
L’imagel’avatar, au-dessus du repli, qu’elle remplitimageClass, imageStyle
Le contenu de l’appl’avatar, en dernierses propres éléments (children)

Donnez sa taille à l’avatar (class="size-10") : le repli et l’image le remplissent, de sorte que la disparition du repli ne redéclenche aucune mise en page. L’image est mise en page dès le départ, transparente, et devient opaque une fois chargée ; le repli disparaît alors. Un transition-opacity dans imageClass la fait apparaître en fondu (avec le mouvement réduit, elle s’affiche immédiatement). Arrondissez l’image et le repli (rounded-full sur chacun) plutôt que de rogner l’avatar, afin qu’un point de présence placé à son bord (absolute bottom-0 right-0) reste entier.

Les initiales sont les premières lettres des deux premiers mots du nom, en majuscules (“Ada Lovelace” donne “AL”) ; un mot unique donne ses deux premières lettres (“huacnlee” donne “HU”), et un nom dans une écriture sans casse donne son premier caractère (“王小明” donne “王”).

#Groupes

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>
PropRustTypeDéfautDescription
limit.limit(..)number3Au plus ce nombre de ses avatars s’affichent, les premiers ; les autres ne créent aucune vue.
total.total(..)numberle nombre d’enfantsLe nombre de personnes que représente le groupe, lorsque l’app ne passe que les premières d’entre elles.
accessibilityLabel.accessibility_label(..)stringaucunCe que disent les lecteurs d’écran du groupe.
overflowClass, overflowStyle.overflow_style(|s| ..)string, StyleProp<ViewStyle>aucunLa partie de débordement, après les avatars affichés.
overflow.overflow(..)JSX.ElementaucunCe qui représente les personnes non affichées (“+5”, ”⋯”), présent tant que le groupe compte plus de personnes que d’avatars affichés.
overflowAccessibilityLabel.overflow_accessibility_label(..)stringaucunCe que disent les lecteurs d’écran du débordement à la place de son contenu : “5 personnes de plus” pour un “+5”.
children.child(..)JSX.ElementaucunLes avatars, dans l’ordre.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLa rangée : sa direction (flex-row) et son espacement.

Le chevauchement, les anneaux et la forme du débordement relèvent de l’app : une marge négative sur chaque avatar après le premier (-ml-2) les empile, et un anneau de la couleur de l’arrière-plan les sépare. Pour une longue liste, passez les limit premières personnes et total : seuls les avatars affichés sont créés. Le nombre que représente le débordement est total moins les avatars affichés, c’est-à-dire le plus petit nombre entre les enfants et limit : total={8} avec deux enfants affiche deux avatars et “+6”.

#Accessibilité

  • Un avatar est une seule image pour les lecteurs d’écran, étiquetée avec accessibilityLabel ou name : le nom n’est dit qu’une fois. Le repli et l’image leur sont masqués, si bien que les initiales ne sont pas lues séparément. Un avatar sans aucun des deux, ou avec accessibilityLabel="", est décoratif : masqué aux lecteurs d’écran, comme doit l’être un avatar placé à côté du nom qu’il représente.
  • Un groupe est un groupe, étiqueté avec son accessibilityLabel (“Ada, Grace et 5 autres”) ; les avatars affichés sont des images à l’intérieur. Le débordement est lu comme son contenu (“+5”), ou comme overflowAccessibilityLabel (“5 personnes de plus”) lorsque l’app en fournit un : NSH n’écrit aucun mot de son cru.
  • Un avatar est un seul élément pour les lecteurs d’écran, donc ce qu’affichent ses enfants n’est pas lu séparément, et sur iOS un bouton ou un lien parmi eux est tout simplement inaccessible. Indiquez ce que signifie un point de présence ou un badge dans accessibilityLabel (“Ada Lovelace, en ligne”), et placez ce sur quoi l’on peut appuyer à côté de l’avatar, pas à l’intérieur.
  • Ni l’un ni l’autre ne reçoit d’appuis ni le focus clavier : placez un avatar dans un Button pour en faire un élément interactif.

Voir Accessibilité pour les étiquettes, les rôles et les annonces en général.

#Notes sur les plateformes

  • Chaque plateforme charge l’image avec son propre chargeur d’images et ses propres caches (voir Image) ; ses événements de chargement et d’erreur modifient l’état de l’avatar en Rust, de sorte qu’une image en cours de chargement ou en échec modifie l’image et le repli, et rien d’autre.
  • L’image commence à se charger dès qu’elle a un cadre, elle reste donc mise en page tout en étant transparente.