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

Отображение

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

Компоненты, которые показывают состояние: сообщения, бейджи, прогресс, пары ключ и значение, историю и заглушки. Они отрисовываются на сервере и не требуют собственных запросов.

Stack::new()
    .add(Alert::info("Imports run in the background.").dismissible())
    .add(Alert::warning("Three products are low on stock.").title("Stock"))
    .add(Row::new().compact().col(Badge::new("Paid").tone(Tone::Success)).col(Badge::new("Overdue").tone(Tone::Danger)).col(Badge::new("Draft")))
    .add(Progress::new("Import", 64, 100))
    .add(Timeline::new()
        .event(ctx.now().add_ms(-26 * 3_600_000), "Created", None, Tone::Info)
        .event(ctx.now().add_ms(-3_600_000), "Approved", Some(Text::from("by the manager")), Tone::Success))
    .add(Skeleton::lines(2))

Информация: Imports run in the background.

Предупреждение: Stock

Three products are low on stock.

Paid
Overdue
Draft
Import64%
  1. Created

  2. Approved

    by the manager

Загрузка…

Alert

Alert::info, success, warning и danger, либо Alert::new(tone, text). .title(text) добавляет заголовок, а .dismissible() кнопку закрытия. Тон также озвучивается перед сообщением, поэтому он никогда не передаётся одним лишь цветом.

Badge

Badge::new(text).tone(Tone::...). Тона: Neutral, Info, Success, Warning, Danger и Brand. Таблицы показывают значения как бейджи через .badge(|v| tone).

Progress

Progress::new(label, value, max) с необязательным .tone(...). Это настоящий progressbar со своей подписью и значением.

DescriptionList

Пары ключ и значение, из полей записи или вручную:

DescriptionList::of(&invoice)
    .field(Invoice::customer)
    .field(Invoice::issued)
    .item(t!("ui.status"), "INV-2026-0042")

Значения форматируются как ячейки таблицы: деньги, даты и варианты выбора на языке пользователя.

Timeline

Timeline::new().event(moment, title, detail, tone) перечисляет события. Моменты показываются в часовом поясе и на языке пользователя.

EmptyState и Skeleton

EmptyState::new(title).text(text).opens(label, screen, params) заполняет пустую область следующим шагом. Skeleton::lines(n) это заглушка, пока что-то загружается.

Breadcrumb::new().link(label, screen, params).current(label): ссылки это типизированные ссылки на экраны, поэтому переименованный экран не оставляет битой ссылки.

ActionButton

ActionButton::new(label, action, input) запускает действие (action). .confirm(text) сначала спрашивает подтверждение, .tone(Tone::Danger) отмечает разрушительное действие, а .on_success(|result, ui| ...) выполняет эффекты. Входные данные фиксируются на сервере и никогда не приходят от клиента; разрешение действия проверяется на сервере, как у любой формы.

ActionButton::new(t!("docs.live.count"), count_samples, ())
    .on_success(|n, ui| {
        if let Ok(n) = n {
            ui.toast(t!("docs.live.counted", n = n));
        }
    })

Markdown

Markdown::new(text) показывает форматированный текст, например комментарий, описание товара или справку. Текст это строка или ключ локали. Нужна возможность (feature) markdown (zeyn = { features = ["markdown"] }), которая не добавляет зависимостей.

Markdown::new(product.description.clone())
Markdown::new(t!("help.import")).trusted()
  • Заголовки, абзацы, списки, таблицы, блоки кода, цитаты, линии, встроенный код, ссылки, изображения, жирный шрифт и курсив.
  • Сырой HTML никогда не пропускается: <script> показывается как текст, а блоки кода тоже экранируются.
  • Ссылки могут вести только на http, https, mailto, якорь или относительный путь. У остальных, например javascript: или data:, остаётся только подпись. Ссылки пользователей получают rel="noopener noreferrer nofollow ugc".
  • Изображения загружаются только из самого приложения (относительный путь); остальные изображения показывают свой альтернативный текст.
  • .trusted() предназначен для текста, который написало приложение: заголовки получают id для ссылок на них, а ссылки теряют nofollow ugc. Экранирование и правила для ссылок остаются теми же.
  • Текст длиннее 64 КБ (MAX_MARKDOWN) обрезается, а вложенность ограничена, поэтому враждебный ввод остаётся дешёвым для отрисовки.

Embed

Embed показывает страницу с другого сайта, например видеоплеер или карту, в запертом фрейме. Заголовок обязателен (программы чтения с экрана объявляют фреймы по нему); без .title(...) embed нельзя разместить, и компилятор говорит об этом.

Embed::url("https://www.youtube-nocookie.com/embed/abc")
    .title(t!("tour.title"))
    .allow([EmbedPermission::Fullscreen, EmbedPermission::PictureInPicture])
    .aspect(16, 9)
    .click_to_load("/static/tour.jpg")
  • Хост должен входить в frame-src приложения: App::csp(|c| c.frame_src(["https://www.youtube-nocookie.com"])) (см. Безопасность). Если адрес не https или его хоста нет в списке, фрейм не отрисовывается вовсе; предупреждение сообщает, что содержимое показать нельзя.
  • Каждый фрейм получает sandbox="allow-scripts allow-same-origin allow-presentation", referrerpolicy="no-referrer" и loading="lazy". Всплывающие окна, переход верхнего уровня, формы и загрузки разрешить нельзя никогда. .allow(...) добавляет возможности браузера: Fullscreen, PictureInPicture, Autoplay и EncryptedMedia.
  • .click_to_load(poster) сначала показывает кнопку с изображением-постером (путь в приложении или хост из img-src; "" если постера нет) и названием другого сайта. К другому сайту нет обращений, пока кнопку не нажмут; затем фрейм занимает её место и получает фокус. Скрипт входит в media.js, поэтому лишнего запроса нет.
  • .notice(t!(...)) помещает на эту кнопку текст приложения, например предупреждение о содержимом. Он экранируется, переводится как любой текст и входит в доступное имя кнопки, поэтому программы чтения с экрана произносят его до того, как пользователь решит. Без .click_to_load он включает кнопку без постера.
  • .remember("external-video", Duration::from_secs(24 * 60 * 60)) спрашивает один раз за это время, а не на каждой странице. Браузер хранит только время окончания (миллисекунды Unix) в localStorage под ключом zeyn.consent.{scope}; cookie не ставится, а сервер его не видит, поэтому кэшированные публичные страницы остаются одинаковыми для всех. Последующие страницы, переходы внутри приложения и другие вкладки загружают фреймы этого scope сами, не перемещая фокус (WCAG 2.2 3.2.1 On Focus, 2.4.3 Focus Order); фокус переходит на фрейм только после настоящего нажатия. Кнопка возвращается, когда время вышло, когда сохранённое значение не целое число или лежит дальше по времени, чем срок embed (изменено вручную), когда хранилище заблокировано и без JavaScript. Scope это от 1 до 40 ASCII-букв, цифр, - или _; время от 1 секунды до 30 дней (MAX_EMBED_CONSENT). Всё остальное вызывает panic с сообщением об этом. Embed с общим уведомлением делят общий scope.
  • Поле с адресом для встраивания получает #[rule(embed)]. Формы принимают URL или вставленный код встраивания, оставляют только src, требуют https и хост из списка, убирают параметры отслеживания (utm_*, fbclid, gclid и другие) и сохраняют канонический адрес, а не HTML. Входные данные API должны присылать канонический адрес.
#[entity(table = "videos")]
pub struct Video {
    #[key]
    pub id: VideoId,
    #[rule(required, embed)]
    pub url: String,
}

Image

Image требует альтернативный текст и размер; без .alt(...) и .size(width, height) его нельзя разместить, и компилятор говорит об этом. Размер сохраняет место, пока файл загружается, поэтому страница не сдвигается.

Image::new("https://images.example.com/logo.png")
    .alt(t!("brand.logo"))
    .size(240, 80)
    .lazy()
    .srcset([("https://images.example.com/logo@2x.png", "2x")])
  • src это путь в приложении (/static/logo.png) или адрес https на хосте из img-src приложения (App::csp(|c| c.img_src([...]))). Любое другое изображение не отрисовывается, вместо него показывается его альтернативный текст, поэтому браузер никогда не записывает заблокированный запрос.
  • Каждое изображение получает decoding="async"; .lazy() добавляет loading="lazy" для изображений ниже первого экрана. srcset принимает дескрипторы плотности (2x) или ширины (800w); записи на других хостах или с другими дескрипторами отбрасываются. Дескрипторы ширины добавляют sizes по ширине изображения.
  • Чисто декоративное изображение получает пустой альтернативный текст: .alt(Text::from("")).

ExternalLink::new(url, text) ведёт на другой сайт с rel="noopener noreferrer", поэтому другой сайт не получает ни адреса страницы, ни доступа к окну. Программы чтения с экрана произносят "external site" после текста; стрелка отмечает это визуально.

ExternalLink::new("https://example.com/help", t!("help.center")).new_tab()
ExternalLink::new(customer.website.clone(), t!("customer.website")).user_content()

.user_content() добавляет nofollow ugc для адресов, которые ввели пользователи. .new_tab() открывает новую вкладку и сообщает об этом программам чтения с экрана; без него ссылки остаются в той же вкладке. Ссылками становятся только адреса https и http; всё остальное, например javascript:, показывается простым текстом.

Tooltip, popover и индикатор занятости

Tooltip это короткое описание одного элемента управления: ActionButton::tip(text), ToolButton::tip(text) на панели инструментов экрана и Form::tip(field, text), который добавляет кнопку информации после подписи поля. Текст становится aria-describedby элемента, поэтому программы чтения с экрана произносят его вместе с элементом. Он появляется после короткого наведения и сразу при фокусе, остаётся, пока указатель над ним (WCAG 1.4.13), скрывается клавишей Escape без перемещения фокуса и на сенсорных экранах появляется после долгого нажатия (касание кнопки информации). Tooltip никогда не несёт сведений, которые пользователю необходимо знать: их нужно поместить в подпись, правила поля или на страницу.

Popover::new(label).title(title).add(node) это кнопка, которая открывает рядом с собой немодальный диалог для небольших подробностей. Фокус переходит внутрь; Escape, кнопка закрытия или щелчок снаружи закрывают его, и фокус возвращается на кнопку. На узких экранах он открывается как нижняя панель (bottom sheet). Его дочерние элементы это обычные узлы, поэтому форма внутри него отправляется на собственный путь узла со всеми проверками на сервере.

Busy::new().add(node) закрывает своё содержимое, пока выполняется запрос внутри него: сохранение формы, кнопка действия, загрузка таблицы или дерева. Контейнер сразу получает aria-busy; индикатор и текст "Working" (сообщение о состоянии) показываются только через 400 мс, либо .delay_ms(n), поэтому быстрые ответы не мигают, а показанный индикатор остаётся не менее 300 мс. Накладка блокирует указатель, но не клавиатуру, и фокус остаётся там, где был.

Stack::new()
    .add(ActionButton::new(t!("docs.live.count"), count_samples, ()).tip(t!("docs.live.count_tip")))
    .add(Popover::new(t!("docs.live.rules")).title(t!("docs.live.rules_title")).add(rules_list))
    .add(Busy::new().add(ActionButton::new(t!("docs.live.slow"), slow_count, ())))
КлавишаГдеДействие
Tabэлемент управления с tooltipФокус показывает tooltip; уход фокуса скрывает его
Escapeгде угодно, пока виден tooltipСкрывает tooltip; фокус остаётся
Enter, пробелкнопка popoverОткрывает popover; фокус переходит на его первый элемент
Escapeвнутри popoverЗакрывает его; фокус возвращается на кнопку
Tabвнутри popoverПеремещает по его элементам; выход из него закрывает его

Всем трём нужно около 2,2 КБ gzip скрипта и ни одного собственного запроса; tooltip это 1 узел, popover 6 плюс его содержимое, индикатор занятости 3.

MenuButton::new(label) открывает меню команд; MenuButton::split(label, action, input) запускает одно действие основной частью и открывает меню стрелкой. Команды это .action(label, action, input) (или .action_then(...) с эффектами после успеха), .item(label, effect) для эффектов, например открытия окна, и .separator(). .confirm(text) спрашивает подтверждение перед запуском последней команды-действия.

MenuButton::split(t!("orders.save"), save_draft, id)
    .action(t!("orders.save_and_send"), save_and_send, id)
    .separator()
    .action(t!("orders.delete"), delete_order, id)
    .confirm(t!("orders.delete_confirm"))

Команды-действия это узлы со своим путём, которые отправляют данные на endpoint формы, как ActionButton: доступ к экрану, защитные проверки (guards) feature, разрешение действия, перехватчики (interceptors), CSRF и ограничение частоты записи проверяются при каждом вызове, а входные данные остаются на сервере. Команды, которые пользователю нельзя запускать, не попадают в меню (основное действие кнопки с разделением вместо этого отключается), и сервер всё равно откажет, если их вызвать.

КлавишаГдеДействие
Enter, пробел, стрелка внизкнопкаОткрывает меню на первой команде
Стрелка вверхкнопкаОткрывает меню на последней команде
Стрелка вниз, стрелка вверхменюСледующая и предыдущая команда по кругу
Home, EndменюПервая и последняя команда
БуквыменюСледующая команда, которая с них начинается
Enter, пробелкомандаЗапускает её и закрывает меню
EscapeменюЗакрывает его; фокус возвращается на кнопку
TabменюЗакрывает его и переводит фокус дальше

Меню требует около 0,8 КБ gzip скрипта и ни одного запроса, пока команда не запущена; это 4 узла плюс 2 на каждую команду.