Перейти к содержимому

Текст и содержимое

Эта страница переведена с английского. Читать страницу на английском

Страницы для чтения, такие как статьи, профили и списки, собираются из нескольких небольших компонентов. Это обычный 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) вся строка становится одной ссылкой: это одна остановка в порядке табуляции с видимой рамкой фокуса, и она открывается без перезагрузки страницы. Длинный текст переносится; на телефонах дополнительный текст переходит под заголовок, а на страницах с письмом справа налево строка зеркально отражается.

List::new() хранит переданные ему элементы. List::of(action) загружает строки через action того же вида, что используется в DataGrid, и разбивает на страницы так же, как сетка карточек: 20 строк и кнопка «Show more», если paging не задаёт иное, варианты сортировки, поиск на стороне сервера через .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) содержит обычные ссылки на экраны, например теги статьи, внутри nav, названного по label. Chips::filter(label, screen, params) делает переключатели поверх параметра экрана: каждый chip является ссылкой на тот же экран, значение которой добавляется в параметр или убирается из него, поэтому сервер отрисовывает aria-pressed по полученным параметрам, а в браузере ничего не хранится. Нажатие на chip открывает новый адрес через nav.js без перезагрузки страницы, фокус и позиция прокрутки остаются на chip. Нажатым может быть одно значение, если не вызвать .multiple(); значения в параметрах, не являющиеся вариантами, игнорируются. Клавиатура: Tab перемещает между chip, Enter или Space нажимает. Нажатый chip также показывает галочку, поэтому состояние не зависит только от цвета. Скрипт (chips.js, менее 1 КБ) загружается только на страницах с фильтрующими 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 carousel: кнопки выбора слайда, каждый слайд является группой с именем «2 из 3», выбранный слайд объявляется вежливо. Слайды меняются только по выбору пользователя, пока приложение не вызовет .rotate(seconds) (от 4 до 30). Тогда первой идёт кнопка паузы и воспроизведения (WCAG 2.2.2), смена останавливается, пока указатель над hero или фокус внутри него и пока вкладка скрыта, и никогда не начинается, если пользователь предпочитает уменьшенное движение; во время смены слайды не объявляются. Без скрипта виден первый слайд.

Изображение подчиняется правилам Image (путь приложения или адрес https на хосте из img-src приложения). Оно служит декором за текстом, поэтому его альтернативный текст пуст: важное помещайте в заголовок и текст, которые лежат на тёмном градиенте для контраста. Изображение первого слайда загружается с высоким приоритетом для быстрой первой отрисовки, остальные лениво. Скрипт (hero.js, 1,6 КБ 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

Ряд кнопок реакций, выбранных приложением, у каждой есть ключ, название, символ (например, emoji) и счётчик. Reactions::of(action, target) задаёт элемент, к которому относятся реакции; target определяет экран, и он никогда не читается из браузера. Нажатие кнопки отправляет реакцию в POST /_zeyn/data, который проверяет CSRF, учитывается в лимите частоты записи, открывает экран, проверяет разрешение действия и отклоняет реакцию вне набора до запуска действия. Действие получает ReactionPress (target, reaction, pressed), сохраняет его и возвращает новый ReactionState (счётчики и собственные реакции пользователя), а счётчики и состояния aria-pressed обновляются на месте. Каждая кнопка является toggle button с именем из названия реакции и её счётчика; символ скрыт от программ чтения с экрана. .state(...) задаёт счётчики для первой отрисовки, так что они видны без скрипта. Скрипт (reactions.js, 1,3 КБ 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 не меняется от минуты к минуте, и кешированная страница остаётся верной. Среда выполнения переписывает текст через Intl.RelativeTimeFormat при загрузке страницы, когда nav.js подставляет экран, и раз в минуту; точное время остаётся во всплывающей подсказке. Без скрипта текстом служит точное время.

RelativeTime::new(post.published)

Share

Кнопка, которая делится ссылкой: системное меню «Поделиться» там, где в браузере есть Web Share API (на большинстве телефонов), а иначе копирование в буфер обмена с toast об этом. Она делится показанной страницей или экраном через Share::screen(screen, params), параметры которого проверяет компилятор. .title задаёт заголовок, который показывает меню, а подпись по умолчанию «Поделиться» на языке пользователя, если .label не задаёт другую. Сервер отрисовывает кнопку скрытой, и её скрипт показывает её, так что на странице без скрипта нет кнопки, которая ничего не делает. Скрипт (share.js, менее 1 КБ) загружается только на страницах с кнопкой «Поделиться».

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

AlphaIndex

Полоса букв, каждая из которых является ссылкой на 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. Неделя начинается с первого дня недели пользователя (понедельник в турецком, воскресенье в американском английском), сегодняшний день отмечается, дни без строк сообщают об этом, а на узких экранах дни располагаются друг под другом. Сервер загружает до limit строк (100, если не задано, не более 500) в порядке action; передайте 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