跳到正文

文本与内容

本页译自英文。 阅读英文页面

文章、个人资料和列表这类阅读页面由几个小组件构成。它们是在服务器上渲染的纯 HTML,不含脚本,所有文本都来自 locale 文件或经过转义。

Brewing green tea

12 May 20265 min readGuides

Water just off the boil and two minutes of steeping keep the taste soft.

Times are a starting point.

Heading

Heading::h2(text) 到 Heading::h6(text)。screen 标题是页面唯一的 h1,所以这里没有 h1:应按大纲的层级选择级别,而不是按想要的字号。.id("anchor") 让链接可以指向它。

Heading::h2(t!("shop.new_arrivals")).id("new")

Paragraph

一段经过转义的文本。.muted() 使用次要文字颜色,.small() 使用小字号。长单词会换行。

Paragraph::new(t!("shop.intro"))
Paragraph::new(t!("shop.tax_note")).muted().small()

MetaLine

在一行内显示简短的信息,例如日期、阅读时长和分类。分隔符(除非用 .separator("/") 指定,否则是间隔号)对屏幕阅读器隐藏,空的部分会被省略。

MetaLine::new().part(article.published.format(ctx.locale())).part(t!("blog.read_time")).part(article.category.clone())

PrevNext

指向上一项和下一项的链接,带有 rel="prev" 和 rel="next"。它们无需重新加载页面即可打开。“上一页”和“下一页”这两个词来自框架自带的文本;.prev_title 和 .next_title 会在它们下方添加该项的标题。在从右到左的语言中箭头会反向,在手机上两个链接会上下堆叠。

PrevNext::new()
    .prev(article, ArticleParams { id: prev.id })
    .prev_title(prev.title.clone())
    .next(article, ArticleParams { id: next.id })
    .next_title(next.title.clone())

AutoGrid

子项排列在列数随可用宽度变化的网格中:每列至少 min 像素(未设置时为 240),能放下几列就放几列,手机上为一列。浏览器用 repeat(auto-fill, minmax(min(240px, 100%), 1fr)) 完成布局,因此没有断点,也没有脚本。它可以容纳任何节点:卡片、面板、网格。这个名称是为了与显示数据行的 DataGrid 区分开。

AutoGrid::new()
    .min(220)
    .gap(4)
    .add(Card::new().title(t!("home.orders")))
    .add(Card::new().title(t!("home.stock")))
    .add(Card::new().title(t!("home.people")))

Orders

Stock

People

Invoices

Reports

List

一行接一行排列,位于一个 <ul> 中。每个 ListItem 有一个标题,还可以有一张开头的小图片(与卡片图片相同的安全地址:路径、https: 或 data:image/)、一个副标题和末尾的简短附注文本。使用 .link(screen, params) 时,整行就是一个链接,因此在 Tab 顺序中只占一个位置,有可见的焦点环,并且无需重新加载页面即可打开。长文本会换行;在手机上附注文本移到标题下方,从右到左的页面中该行会镜像。

List::new() 保存传给它的项。List::of(action) 通过与 DataGrid 相同类型的 action 加载行,并像卡片网格一样分页:除非 paging 另有设置,否则每次 20 行并带有“Show more”按钮,还有排序选项、用 .search(text) 实现的服务器端搜索,并且每一页都会检查该 action 的访问权限。

List::new()
    .title(t!("team.title"))
    .item(ListItem::new("Ada Lovelace").subtitle(t!("team.math")))
    .item(ListItem::new("Alan Turing").subtitle(t!("team.computing")).meta(t!("team.lead")))

List::of(search_people)
    .title(t!("people.title"))
    .item(|p: &Person| {
        ListItem::new(p.name.clone())
            .image(p.photo.clone())
            .subtitle(p.city.clone())
            .meta(p.joined.format(&locale))
            .link(person, PersonParams { id: p.id })
    })
    .sort_option(t!("people.by_name"), Person::name, false)
    .paging(Paging::pages(20))

Avatar

一个人或一个团队的圆形头像,尺寸为 32、40 或 64 像素(AvatarSize::Small、Medium、Large)。名称是必填的:它是替代文本,也是没有图片、或图片不在应用的 img-src 允许的主机上时显示的首字母的来源(规则与 Image 相同)。首字母遵循该语言的大写规则,因此土耳其语的 i 会变成 İ。当名称就写在头像旁边时,.decorative() 会对屏幕阅读器隐藏头像,避免名称被读两遍。

Avatar::new(p.name.clone()).image(p.photo.clone()).size(AvatarSize::Large)
Avatar::new(p.name.clone()).size(AvatarSize::Small).decorative()

Spoiler

在读者打开之前处于隐藏状态的内容,例如问题的答案。它是原生的展开控件(<details> 和 <summary>),因此无需任何脚本即可通过键盘和屏幕阅读器使用,并且内容在打开之前不在阅读顺序中。标签由应用提供。

Spoiler::new(t!("quiz.show_answer")).add(Paragraph::new(answer.clone()))
Show the answer

Water at about 80 degrees and two minutes of steeping.

Chips

一排可换行的圆角小链接。Chips::links(label) 在以 label 命名的 nav 中放置指向屏幕的普通链接,例如文章的标签。Chips::filter(label, screen, params) 基于屏幕参数生成切换按钮:每个 chip 都是指向同一屏幕的链接,其值会被加入或移出参数,因此服务器根据收到的参数渲染 aria-pressed,浏览器中不保存任何状态。按下 chip 会通过 nav.js 在不刷新页面的情况下打开新地址,焦点和滚动位置保持在该 chip 上。除非调用 .multiple(),否则同一时间只能按下一个值;参数中不属于选项的值会被忽略。键盘:Tab 在 chip 之间移动,Enter 或 Space 按下其中一个。被按下的 chip 还会显示对勾,因此状态不只依赖颜色。脚本(chips.js,小于 1 KB)只在带有筛选 chip 的页面上加载。

#[screen(route = "/articles", public)]
pub fn articles(p: ArticleParams) -> Screen {
    Screen::new(t!("blog.articles")).body(
        Chips::filter(t!("blog.tags"), articles, |tags| ArticleParams { tags: tags.join(",") })
            .current(p.tags.split(','))
            .option("guide", t!("blog.guides"))
            .option("news", t!("blog.news"))
            .multiple(),
    )
}

Hero

页面顶部的大型区块:背景图,加上标题、一行文字和按钮等操作。默认是静态的。.slide(title) 会添加另一张幻灯片,而 .text、.image 和 .action 始终作用于最后一张。有多张幻灯片时,它遵循 W3C APG 轮播模式:幻灯片选择按钮,每张幻灯片是名为“2 / 3”的分组,被选中的幻灯片会以礼貌方式播报。除非应用调用 .rotate(seconds)(4 到 30),幻灯片只在用户选择时切换。调用后,暂停与播放按钮排在最前(WCAG 2.2.2),指针位于 hero 上、焦点位于其中以及标签页隐藏时轮播会停止,用户偏好减少动态效果时则完全不会启动;幻灯片在切换过程中不会被播报。没有脚本时显示第一张幻灯片。

图片遵循 Image 的规则(应用的路径,或位于应用 img-src 允许的主机上的 https 地址)。它是文字后面的装饰,因此替代文本为空:重要内容请写入标题和文字,它们位于深色渐变之上以保证对比度。第一张幻灯片的图片以高优先级加载以加快首次绘制,其他的则延迟加载。脚本(hero.js,1.6 KB gzip)只在带有幻灯片的页面上加载。

Hero::new(t!("home.fresh"))
    .text(t!("home.fresh_text"))
    .image("/img/hero/kitchen.jpg")
    .action(ActionButton::new(t!("home.order"), start_order, ()))
    .slide(t!("home.new_teas"))
    .image("/img/hero/teas.jpg")
    .rotate(8)

Fresh every morning

Order before nine and it is at your door by noon.

Reactions

由应用选定的一排反应按钮,每个按钮有一个 key、一个名称和一个符号(如 emoji),以及计数。Reactions::of(action, target) 指明这些反应针对哪个条目;target 由屏幕设定,绝不从浏览器读取。按下按钮会把反应提交到 POST /_zeyn/data,该端点会检查 CSRF,计入写入速率限制,打开屏幕,检查操作的权限,并在操作运行之前拒绝集合之外的反应。操作会收到 ReactionPress(target、reaction、pressed),将其保存并返回新的 ReactionState(各计数和用户自己的反应),计数和 aria-pressed 状态就地更新。每个按钮都是以反应名称和计数命名的 toggle button;符号对屏幕阅读器隐藏。.state(...) 提供首次渲染时的计数,因此无需脚本也能显示。脚本(reactions.js,1.3 KB gzip)只在带有反应的页面上加载。

#[action(permission = "answers.react")]
pub async fn react_to_answer(press: ReactionPress, db: Db, ctx: Ctx) -> Result<ReactionState> {
    db.toggle_reaction(&ctx, &press.target, &press.reaction, press.pressed).await?;
    db.reaction_state(&ctx, &press.target).await
}

Reactions::of(react_to_answer, format!("answer-{}", answer.id))
    .reaction("helpful", t!("answers.helpful"), "\u{1f44d}")
    .reaction("thanks", t!("answers.thanks"), "\u{1f64f}")
    .state(answer.reactions.clone())

RelativeTime

以页面语言显示的相对时间,例如“3 分钟前”或“2 天后”。服务器把用户时区下的准确日期和时间写入 <time datetime="...">,因此 HTML 每分钟都保持不变,缓存的页面依然正确。运行时会在页面加载时、nav.js 换入屏幕时以及每分钟用 Intl.RelativeTimeFormat 重写文本;准确时间保留在提示中。没有脚本时,文本就是准确时间。

RelativeTime::new(post.published)

Share

分享链接的按钮:浏览器支持 Web Share API 时(大多数手机)使用系统分享面板,否则复制到剪贴板并用 toast 提示。它分享当前显示的页面,或通过 Share::screen(screen, params) 分享某个屏幕,其参数由编译器检查。.title 设置分享面板显示的标题;除非用 .label 指定,标签为用户语言的“分享”。服务器把按钮渲染为隐藏状态,由其脚本显示,因此没有脚本的页面不会出现什么都不做的按钮。脚本(share.js,小于 1 KB)只在包含分享按钮的页面上加载。

Share::page().title(post.title.clone())
Share::screen(article, ArticleParams { id: post.id }).label(t!("blog.share_article"))

AlphaIndex

一排字母,每个字母都是指向某个 screen 的链接,字母位于该 screen 的参数中,例如名字以该字母开头的人。字母来自用户的语言,并按该语言自己的顺序排列(zeyn::ui::alphabet):土耳其语中 Ç Ğ I İ Ö Ş Ü 各在其位,西班牙语有 Ñ,俄语和阿拉伯语有各自的字母表,其他语言一律为 A 到 Z。当前显示的 screen 的字母用 aria-current="page" 标记(土耳其语的 i 标记 İ),.other() 会在末尾添加一个 # 链接,用于以其他字符开头的名称。这些链接无需重新加载页面即可打开,在手机上会折成多行。

#[screen(route = "/people/{letter}", public)]
pub fn people_by_letter(p: LetterParams) -> Screen {
    Screen::new(t!("people.title"))
        .body(AlphaIndex::new(people_by_letter, |l| LetterParams { letter: l.to_string() }).current(&p.letter).other())
}

WeekSchedule

七个按天划分的列,其中的行来自某个 action,例如一周的课程、班次或活动。这个 action 与 DataGrid 使用的类型相同,检查方式也相同。.day 把一行映射到它所在的日子:可以是 Date、Option<Date>(没有日期的行会被忽略)或从 1(星期一)到 7(星期日)的星期几数字;其他类型无法通过编译。每一行都以 ListItem 显示,因此可以链接到某个 screen。一周从用户的第一个工作日开始(土耳其语为星期一,美式英语为星期日),今天会被标记,没有行的日子会注明,在窄屏幕上各天上下堆叠。服务器按 action 的顺序最多加载 limit 行(未设置时为 100,最多 500);请让 action 只返回这一周的行,例如对日期加过滤条件。

WeekSchedule::of(this_week_classes)
    .title(t!("classes.this_week"))
    .day(|c: &Class| c.date)
    .item(|c: &Class| {
        ListItem::new(c.name.clone())
            .meta(c.starts.to_string())
            .link(class, ClassParams { id: c.id })
    })

Deliveries

Monday

  • Cheese 135S-0007

Tuesday Today

  • Green tea 105S-0001
  • Bulgur 140S-0008

Wednesday

  • Coffee beans 110S-0002
  • Lentils 145S-0009

Thursday

  • Olive oil 115S-0003
  • Black tea 150S-0010

Friday

  • Honey 120S-0004
  • Green tea 155S-0011

Saturday

  • Hazelnuts 125S-0005
  • Coffee beans 160S-0012

Sunday

  • Dried apricots 130S-0006