元素
Avatar
头像显示一个人的图片:团队成员、评论作者、标题栏里的账号。图片加载完成前、加载失败时以及没有图片时,它显示后备内容:这个人 name 的首字母,或者应用自己的内容(一个图标)。它负责图片的加载状态(由图片自己的加载和错误事件在原生侧驱动,不经过 JavaScript)以及屏幕阅读器读到的内容:一张以这个人名字为标签的图片。外观由应用决定:Avatar 没有自己的尺寸、形状、颜色或字体。AvatarGroup 显示一排头像中的前几个,以及代表其余人的溢出部分(“+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 和 AvatarGroup 来自 nsh/base,在 Rust 中是 nsh-base crate。在 Rust 中,avatar() 和 avatar_group() 创建它们,每个 prop 有一个 setter(.src(..)、.fallback_delay(..)),头像本身用带类型的样式方法设置,显示在图片之上的内容用 .child(..) 添加。
#Props
| Prop | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
src | .src(..) | string | ImageAsset | 无 | 图片,取值与 <Image> 的 src 相同:URL、asset:/ 或 data: URI、blob: URL(用户选的照片:URL.createObjectURL(file)),或导入的图片。加载完成后显示;空字符串表示没有图片。 |
name | .name(..) | string | 无 | 这个人的名字:屏幕阅读器读出的内容,以及应用没有传 fallback 时后备内容显示的首字母。 |
accessibilityLabel | .accessibility_label(..) | string | name | 屏幕阅读器读出的内容,优先于 name。"" 使头像成为装饰性的,对屏幕阅读器隐藏。 |
fallbackDelay | .fallback_delay(..) | number(毫秒) | 0 | 图片加载时后备内容等待多久才显示,让很快加载完的图片直接出现,不会先闪一下后备内容。 |
imageClass、imageStyle | .image_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 图片:它的填充方式、圆角和动效。 |
fallbackClass、fallbackStyle | .fallback_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 后备内容:它的背景、文字颜色和大小,以及内容的对齐方式。 |
fallback | .fallback(..) | JSX.Element | 首字母 | 代替首字母的后备内容:一个图标、没有名字的人的占位内容。 |
children | .child(..) | JSX.Element | 无 | 显示在图片之上的内容:在线状态圆点、徽标。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 头像本身:它的尺寸和形状。 |
| 事件 | Rust | 参数 | 说明 |
|---|---|---|---|
onStatusChange | .on_status_change(..) | { status: AvatarStatus } | 图片的状态变化了:"idle"(没有 src)、"loading"、"loaded" 或 "error"。 |
#部件
| 部件 | 位于 | 样式 |
|---|---|---|
| 头像 | class、style | |
| 后备内容 | 头像中,第一个,与头像一样大;图片加载中(fallbackDelay 之后)、失败或没有图片时出现 | fallbackClass、fallbackStyle;内容是首字母或 fallback |
| 图片 | 头像中,覆盖在后备内容之上并填满头像 | imageClass、imageStyle |
| 应用的内容 | 头像中,最后一个 | 它自己的元素(children) |
给头像设置尺寸(class="size-10"):后备内容和图片都填满它,所以后备内容离开时不会触发重新布局。图片从一开始就参与布局,保持透明,加载完成后变为不透明,然后后备内容离开。在 imageClass 中加上 transition-opacity 让它淡入(减少动态效果时直接显示)。给图片和后备内容分别加圆角(各自 rounded-full),而不是裁剪头像本身,这样放在边缘的在线状态圆点(absolute bottom-0 right-0)能完整显示。
首字母取名字前两个单词的首字母并转为大写(“Ada Lovelace” 是 “AL”);只有一个单词时取它的前两个字母(“huacnlee” 是 “HU”),没有大小写之分的文字取第一个字符(“王小明” 是 “王”)。
#头像组
import { Text } from "nsh";
import { Avatar, AvatarGroup } from "nsh/base";
<AvatarGroup
limit={3}
total={members.length}
accessibilityLabel={`${members.length} 位成员`}
class="flex-row"
overflowClass="-ml-2 size-8 items-center justify-center rounded-full bg-muted ring-2 ring-background"
overflowAccessibilityLabel={`还有 ${members.length - 3} 人`}
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 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
limit | .limit(..) | number | 3 | 最多显示这么多个头像,即前面几个;其余的不创建视图。 |
total | .total(..) | number | 子元素的数量 | 这一组代表多少人,用于应用只传入前几个人的情况。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 屏幕阅读器对这一组读出的内容。 |
overflowClass、overflowStyle | .overflow_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 溢出部件,位于显示的头像之后。 |
overflow | .overflow(..) | JSX.Element | 无 | 代表未显示的人的内容(“+5”、“⋯”),组里的人比显示的头像多时出现。 |
overflowAccessibilityLabel | .overflow_accessibility_label(..) | string | 无 | 屏幕阅读器读出的溢出部件内容,代替它显示的内容:例如“+5”读作“还有 5 人”。 |
children | .child(..) | JSX.Element | 无 | 头像,按顺序排列。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 这一排:它的方向(flex-row)和间距。 |
重叠、描边和溢出部件的形状都由应用决定:在第一个之后的每个头像上加负外边距(-ml-2)让它们叠在一起,再用与背景同色的描边把它们分开。对于很长的列表,只传入前 limit 个人并设置 total:只会创建显示出来的头像。
#无障碍
- 对屏幕阅读器来说,头像是一张图片,标签是
accessibilityLabel或name:名字只读一次。后备内容和图片对屏幕阅读器隐藏,所以首字母不会单独读出。两者都没有,或者accessibilityLabel=""时,头像是装饰性的,对屏幕阅读器隐藏,适合旁边已经显示了名字的头像。 - 头像组是一个组,标签是它的
accessibilityLabel(“Ada、Grace 和另外 5 人”);显示的头像是其中的图片。溢出部件读出它的内容(“+5”),应用提供overflowAccessibilityLabel(“还有 5 人”)时读出这个标签:NSH 不写任何自己的文字。 - 两者都不接受按压或键盘焦点:需要按压时把头像放进
Button。
关于标签、角色和播报的一般说明,见无障碍。
#平台说明
- 每个平台都用自己的图片加载器和缓存加载图片(见 Image);图片的加载和错误事件在 Rust 中改变头像的状态,所以图片加载中或失败时只有图片和后备内容会变化,其他内容不受影响。
- 图片有了 frame 才开始加载,所以它在透明时也保持参与布局。