Эта страница переведена с английского. Читать страницу на английском
Компоненты, которые показывают состояние: сообщения, бейджи, прогресс, пары ключ и значение, историю и заглушки. Они отрисовываются на сервере и не требуют собственных запросов.
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.
Created
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
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
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, ())))Первый пример товара
- Code
- S-0001
- Unit
- Piece
- Price
- 83,69 ₺
- Stock
- 37
| Клавиша | Где | Действие |
|---|---|---|
| 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 на каждую команду.