Эта страница переведена с английского. Читать страницу на английском
Страницы для чтения, такие как статьи, профили и списки, собираются из нескольких небольших компонентов. Это обычный HTML, который отрисовывается на сервере, без скриптов, и каждый текст берётся из файлов locale или экранируется.
Brewing green tea
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))Products
Нечего показывать.
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)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 135
Tuesday Today
- Green tea 105
- Bulgur 140
Wednesday
- Coffee beans 110
- Lentils 145
Thursday
- Olive oil 115
- Black tea 150
Friday
- Honey 120
- Green tea 155
Saturday
- Hazelnuts 125
- Coffee beans 160
Sunday
- Dried apricots 130