Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

Avatar

Un avatar muestra la imagen de una persona: un miembro de un equipo, el autor de un comentario, la cuenta en una cabecera. Mientras la imagen se carga, cuando falla y cuando no hay ninguna, muestra un contenido de respaldo: las iniciales del name de la persona, o el propio contenido de la app (un icono). Se encarga del estado de carga de la imagen, ejecutado de forma nativa a partir de los eventos load y error de la propia imagen, sin JavaScript, y de lo que reciben los lectores de pantalla: una imagen etiquetada con el nombre de la persona. El aspecto es el de la app: un Avatar no tiene tamaño, forma, colores ni fuente propios. Un AvatarGroup muestra los primeros avatares de una fila y un indicador de desbordamiento que representa a los demás (“+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 y AvatarGroup provienen de nsh/base, el crate nsh-base de Rust. En Rust, avatar() y avatar_group() los crean con un setter por cada prop (.src(..), .fallback_delay(..)), los métodos de estilo tipados para el propio avatar y .child(..) para lo que se coloca sobre la imagen.

#Props

Prop Rust Tipo Predeterminado Descripción
src .src(..) string | ImageAsset ninguno La imagen, tal como la recibe src de <Image>: una URL, un URI asset:/ o data:, una URL blob: (una foto elegida por el usuario: URL.createObjectURL(file)) o una imagen importada. Se muestra una vez cargada; una cadena vacía equivale a ninguna imagen.
name .name(..) string ninguno El nombre de la persona: lo que dicen los lectores de pantalla y las iniciales que muestra el contenido de respaldo cuando la app no pasa ningún fallback.
accessibilityLabel .accessibility_label(..) string name Lo que dicen los lectores de pantalla, con prioridad sobre name. "" hace que el avatar sea decorativo y quede oculto para los lectores de pantalla.
fallbackDelay .fallback_delay(..) number (ms) 0 Cuánto espera el contenido de respaldo mientras se carga la imagen, de modo que una imagen que carga rápido se muestre sin que el contenido de respaldo parpadee antes. Infinity: sin contenido de respaldo mientras carga, solo cuando falla o no hay imagen.
imageClass, imageStyle .image_style(|s| ..) string, StyleProp<ViewStyle> ninguno La imagen: su ajuste, su radio y su movimiento.
fallbackClass, fallbackStyle .fallback_style(|s| ..) string, StyleProp<ViewStyle> ninguno El contenido de respaldo: su fondo, el color y el tamaño del texto, y cómo alinea su contenido.
fallback .fallback(..) JSX.Element las iniciales El contenido del respaldo en lugar de las iniciales: un icono, un marcador de posición para alguien sin nombre.
children .child(..) JSX.Element ninguno Lo que se muestra sobre la imagen: un punto de presencia, una insignia. Los lectores de pantalla oyen la etiqueta del avatar, no estos elementos (consulta Accesibilidad).
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El propio avatar: su tamaño y su forma.
Evento Rust Payload Descripción
onStatusChange .on_status_change(..) { status: AvatarStatus } El estado de la imagen cambió: "idle" (sin src), "loading", "loaded" o "error".

#Partes

Parte Dentro de Se estiliza con
El avatar class, style
El contenido de respaldo el avatar, primero, tan grande como este; está presente mientras la imagen se carga (después de fallbackDelay), falla o no existe fallbackClass, fallbackStyle; su contenido son las iniciales o fallback
La imagen el avatar, sobre el contenido de respaldo, llenándolo imageClass, imageStyle
El contenido de la app el avatar, último sus propios elementos (children)

Dale al avatar su tamaño (class="size-10"): el contenido de respaldo y la imagen lo llenan, así que cuando el respaldo desaparece no se recalcula ningún diseño. La imagen se coloca en el diseño desde el principio, transparente, y pasa a ser opaca una vez cargada; entonces el contenido de respaldo desaparece. Un transition-opacity en imageClass la muestra con un fundido (con movimiento reducido se muestra de inmediato). Redondea la imagen y el contenido de respaldo (rounded-full en cada uno) en lugar de recortar el avatar, para que un punto de presencia situado en su borde (absolute bottom-0 right-0) se vea completo.

Las iniciales son las primeras letras de las dos primeras palabras del nombre, en mayúsculas (“Ada Lovelace” es “AL”); una sola palabra da sus dos primeras letras (“huacnlee” es “HU”), y un nombre en una escritura sin distinción de mayúsculas y minúsculas da su primer carácter (“王小明” es “王”).

#Grupos

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 Tipo Predeterminado Descripción
limit .limit(..) number 3 Se muestran como máximo este número de avatares, los primeros; los demás no crean vistas.
total .total(..) number el número de hijos A cuántas personas representa el grupo, cuando la app pasa solo las primeras.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla sobre el grupo.
overflowClass, overflowStyle .overflow_style(|s| ..) string, StyleProp<ViewStyle> ninguno La parte de desbordamiento, después de los avatares mostrados.
overflow .overflow(..) JSX.Element ninguno Lo que representa a las personas no mostradas (“+5”, ”⋯”); está presente mientras el grupo tenga más personas que avatares mostrados.
overflowAccessibilityLabel .overflow_accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla sobre el desbordamiento en lugar de su contenido: “5 personas más” para un “+5”.
children .child(..) JSX.Element ninguno Los avatares, en orden.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno La fila: su dirección (flex-row) y su espaciado.

El solapamiento, los anillos y la forma del desbordamiento son cosa de la app: un margen negativo en cada avatar después del primero (-ml-2) los apila, y un anillo del color del fondo los separa. Para una lista larga, pasa las primeras limit personas y total: solo se crean los avatares mostrados. El número que representa el desbordamiento es total menos los avatares mostrados, que son el menor entre los hijos y limit: total={8} con dos hijos muestra dos avatares y “+6”.

#Accesibilidad

  • Un avatar es una sola imagen para los lectores de pantalla, etiquetada con accessibilityLabel o name: el nombre se dice una sola vez. El contenido de respaldo y la imagen quedan ocultos para ellos, de modo que las iniciales no se leen por separado. Un avatar sin ninguno de los dos, o con accessibilityLabel="", es decorativo: queda oculto para los lectores de pantalla, como debe ser un avatar situado junto al nombre que representa.
  • Un grupo es un grupo, etiquetado con su accessibilityLabel (“Ada, Grace y otras 5 personas”); los avatares mostrados son imágenes dentro de él. El desbordamiento se lee como su contenido (“+5”), o como overflowAccessibilityLabel (“5 personas más”) cuando la app proporciona uno: NSH no escribe ningún texto por su cuenta.
  • Un avatar es un solo elemento para los lectores de pantalla, así que lo que muestran sus hijos no se lee por separado, y en iOS un botón o un enlace entre ellos no se puede alcanzar en absoluto. Indica lo que significa un punto de presencia o una insignia en accessibilityLabel (“Ada Lovelace, en línea”), y coloca lo que se pueda pulsar junto al avatar, no dentro de él.
  • Ninguno de los dos recibe pulsaciones ni foco de teclado: coloca un avatar dentro de un Button para que lo haga.

Consulta Accesibilidad para ver etiquetas, roles y anuncios en general.

#Notas de plataforma

  • Cada plataforma carga la imagen con su propio cargador de imágenes y sus propias cachés (consulta Image); sus eventos load y error cambian el estado del avatar en Rust, de modo que una imagen que se está cargando o que ha fallado cambia la imagen y el contenido de respaldo y nada más.
  • La imagen empieza a cargarse en cuanto tiene un marco, por lo que permanece en el diseño mientras es transparente.