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”).
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 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
accessibilityLabeloname: 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 conaccessibilityLabel="", 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 comooverflowAccessibilityLabel(“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
Buttonpara 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.