Saltar al contenido

Visualización

Esta página está traducida del inglés. Leer la página en inglés

Componentes que muestran estado: mensajes, insignias, progreso, pares clave valor, historial y marcadores de posición. Se renderizan en el servidor y no necesitan peticiones propias.

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))

Información: Imports run in the background.

Advertencia: Stock

Three products are low on stock.

Paid
Overdue
Draft
Import64%
  1. Created

  2. Approved

    by the manager

Cargando…

Alert

Alert::info, success, warning y danger, o Alert::new(tone, text). .title(text) añade un encabezado y .dismissible() un botón de cierre. El tono también se pronuncia antes del mensaje, así que nunca se da solo por el color.

Badge

Badge::new(text).tone(Tone::...). Tonos: Neutral, Info, Success, Warning, Danger y Brand. Las tablas muestran valores como insignias con .badge(|v| tone).

Progress

Progress::new(label, value, max) con un .tone(...) opcional. Es un progressbar real con su etiqueta y su valor.

DescriptionList

Pares clave valor, a partir de los campos de un registro o a mano:

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

Los valores se formatean como las celdas de una tabla: dinero, fechas y choices en el idioma del usuario.

Timeline

Timeline::new().event(moment, title, detail, tone) lista eventos. Los instantes se muestran en la zona horaria y el idioma del usuario.

EmptyState y Skeleton

EmptyState::new(title).text(text).opens(label, screen, params) llena un área vacía con un siguiente paso. Skeleton::lines(n) es un marcador de posición mientras algo se carga.

Breadcrumb::new().link(label, screen, params).current(label): los enlaces son referencias tipadas a pantallas, así que una pantalla renombrada no deja un enlace roto.

ActionButton

ActionButton::new(label, action, input) ejecuta una acción. .confirm(text) pregunta primero, .tone(Tone::Danger) marca una destructiva y .on_success(|result, ui| ...) ejecuta effects. La entrada se fija en el servidor y nunca viene del cliente; el permiso de la acción se comprueba en el servidor como en cualquier formulario.

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) muestra texto con formato, como un comentario, la descripción de un producto o una ayuda. El texto es una cadena o una clave de locale. Necesita la feature markdown (zeyn = { features = ["markdown"] }), que no añade dependencias.

Markdown::new(product.description.clone())
Markdown::new(t!("help.import")).trusted()
  • Encabezados, párrafos, listas, tablas, bloques de código, citas, reglas, código en línea, enlaces, imágenes, negrita y cursiva.
  • El HTML sin procesar nunca se deja pasar: <script> aparece como texto, y los bloques de código también se escapan.
  • Los enlaces solo pueden apuntar a http, https, mailto, un ancla o una ruta relativa. Los demás, como javascript: o data:, conservan solo su etiqueta. Los enlaces de usuario reciben rel="noopener noreferrer nofollow ugc".
  • Las imágenes se cargan solo desde la propia aplicación (una ruta relativa); las demás imágenes muestran su texto alternativo.
  • .trusted() es para texto que escribió la aplicación: los encabezados reciben ids para enlazar a ellos y los enlaces pierden nofollow ugc. El escape y las reglas de enlaces siguen igual.
  • El texto de más de 64 KB (MAX_MARKDOWN) se recorta y el anidamiento está limitado, de modo que una entrada hostil sigue siendo barata de renderizar.

Embed

Embed muestra una página de otro sitio, como un reproductor de vídeo o un mapa, en un marco bloqueado. Se requiere un título (los lectores de pantalla anuncian los marcos por él); sin .title(...) el embed no se puede colocar y el compilador lo dice.

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")
  • El host debe estar en el frame-src de la aplicación: App::csp(|c| c.frame_src(["https://www.youtube-nocookie.com"])) (consulta Seguridad). Cuando la dirección no es https o su host no está en la lista, no se renderiza ningún marco; una advertencia dice que el contenido no se puede mostrar.
  • Cada marco recibe sandbox="allow-scripts allow-same-origin allow-presentation", referrerpolicy="no-referrer" y loading="lazy". Las ventanas emergentes, la navegación del nivel superior, los formularios y las descargas nunca se pueden permitir. .allow(...) añade funciones del navegador: Fullscreen, PictureInPicture, Autoplay y EncryptedMedia.
  • .click_to_load(poster) muestra primero un botón, con la imagen de póster (una ruta de la aplicación o un host de img-src; "" para ninguna) y el nombre del otro sitio. No se contacta con el otro sitio hasta que se pulsa el botón; entonces el marco ocupa su lugar y recibe el foco. El script forma parte de media.js, así que no cuesta ninguna petición extra.
  • .notice(t!(...)) pone un texto de la aplicación en ese botón, como una advertencia de contenido. Se escapa, se traduce como cualquier texto y forma parte del nombre accesible del botón, de modo que los lectores de pantalla lo leen antes de que el usuario decida. Sin .click_to_load activa el botón sin póster.
  • .remember("external-video", Duration::from_secs(24 * 60 * 60)) pregunta una vez en ese tiempo en lugar de en cada página. El navegador guarda solo la hora de fin (milisegundos Unix) en localStorage bajo zeyn.consent.{scope}; no se establece ninguna cookie y el servidor nunca la ve, así que las páginas públicas en caché siguen siendo iguales para todos. Las páginas posteriores, las navegaciones dentro de la aplicación y otras pestañas cargan por sí solas los marcos de ese scope, sin mover el foco (WCAG 2.2 3.2.1 On Focus, 2.4.3 Focus Order); el foco pasa al marco solo tras una pulsación real. El botón vuelve cuando ha pasado el tiempo, cuando el valor guardado no es un número entero o queda más lejos en el futuro que el tiempo del embed (cambiado a mano), cuando el almacenamiento está bloqueado y sin JavaScript. El scope son de 1 a 40 letras ASCII, dígitos, - o _; el tiempo va de 1 segundo a 30 días (MAX_EMBED_CONSENT). Cualquier otra cosa provoca un panic con un mensaje que lo dice. Los embeds que comparten un aviso comparten un scope.
  • Un campo que contiene una dirección para incrustar lleva #[rule(embed)]. Los formularios aceptan una URL o código de embed pegado, conservan solo el src, exigen https y un host de la lista, descartan los parámetros de seguimiento (utm_*, fbclid, gclid y otros) y guardan la dirección canónica, nunca el HTML. Las entradas de la API deben enviar la dirección canónica.
#[entity(table = "videos")]
pub struct Video {
    #[key]
    pub id: VideoId,
    #[rule(required, embed)]
    pub url: String,
}

Image

Image necesita su texto alternativo y su tamaño; sin .alt(...) y .size(width, height) no se puede colocar y el compilador lo dice. El tamaño mantiene el espacio mientras se carga el archivo, así que la página no se mueve.

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 es una ruta de la aplicación (/static/logo.png) o una dirección https en un host del img-src de la aplicación (App::csp(|c| c.img_src([...]))). Cualquier otra imagen no se renderiza y en su lugar se muestra su texto alternativo, de modo que el navegador nunca registra una petición bloqueada.
  • Cada imagen recibe decoding="async"; .lazy() añade loading="lazy" para las imágenes que quedan fuera de la primera vista. srcset admite descriptores de densidad (2x) o de ancho (800w); las entradas en otros hosts o con otros descriptores se omiten. Los descriptores de ancho añaden sizes a partir del ancho de la imagen.
  • Una imagen puramente decorativa recibe un texto alternativo vacío: .alt(Text::from("")).

ExternalLink::new(url, text) enlaza con otro sitio con rel="noopener noreferrer", de modo que el otro sitio no recibe ni la dirección de la página ni un control sobre la ventana. Los lectores de pantalla oyen "external site" tras el texto; una flecha lo marca visualmente.

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

.user_content() añade nofollow ugc para las direcciones que escribieron los usuarios. .new_tab() abre una pestaña nueva y se lo dice a los lectores de pantalla; los enlaces se quedan en la misma pestaña salvo que se indique. Solo las direcciones https y http se convierten en enlaces; cualquier otra cosa, como javascript:, se muestra como texto plano.

Tooltip, popover y superposición de ocupado

Un tooltip es una descripción corta de un control: ActionButton::tip(text), ToolButton::tip(text) en una barra de herramientas de pantalla y Form::tip(field, text), que añade un botón de información tras la etiqueta del campo. El texto es el aria-describedby del control, así que los lectores de pantalla lo oyen con el control. Se muestra tras un breve instante con el cursor encima y de inmediato con el foco, permanece mientras el puntero está sobre él (WCAG 1.4.13), se oculta con Escape sin mover el foco y se muestra en pantallas táctiles tras una pulsación larga (un toque en el botón de información). Un tooltip nunca lleva información que el usuario deba tener: ponla en la etiqueta, en las reglas del campo o en la página.

Popover::new(label).title(title).add(node) es un botón que abre un diálogo no modal junto a él, para pequeños detalles. El foco pasa a su interior; Escape, el botón de cierre o un clic fuera lo cierran y el foco vuelve al botón. En pantallas estrechas se abre como una hoja inferior. Sus hijos son nodos normales, así que un formulario dentro de él se envía a su propia ruta de nodo con todas las comprobaciones del servidor.

Busy::new().add(node) cubre su contenido mientras se ejecuta una petición dentro de él: un formulario guardando, un botón de acción, una tabla o un árbol cargando. El contenedor recibe aria-busy de inmediato; el spinner y el texto "Working" (un mensaje de estado) se muestran solo tras 400 ms, o .delay_ms(n), de modo que las respuestas rápidas no parpadean, y permanecen al menos 300 ms una vez mostrados. La superposición bloquea el puntero, no el teclado, y el foco se queda donde estaba.

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, ())))
TeclaDóndeQué hace
Tabun control con tooltipEl foco muestra el tooltip; al salir se oculta
Escapeen cualquier sitio mientras se muestra un tooltipOculta el tooltip; el foco se queda
Enter, Espaciobotón del popoverAbre el popover; el foco pasa a su primer control
Escapedentro del popoverLo cierra; el foco vuelve al botón
Tabdentro del popoverRecorre sus controles; al salir se cierra

Los tres necesitan unos 2,2 KB de script con gzip y ninguna petición propia; un tooltip es 1 nodo, un popover 6 más su contenido y la superposición de ocupado 3.

MenuButton::new(label) abre un menú de comandos; MenuButton::split(label, action, input) ejecuta una acción desde su parte principal y abre el menú desde su flecha. Los comandos son .action(label, action, input) (o .action_then(...) con effects tras el éxito), .item(label, effect) para effects como abrir una ventana y .separator(). .confirm(text) pregunta antes de que se ejecute el último comando de acción.

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"))

Los comandos de acción son nodos con su propia ruta y se envían al endpoint de formularios como un ActionButton: el acceso de la pantalla, los guards de la feature, el permiso de la acción, los interceptores, CSRF y el límite de frecuencia de escritura se comprueban en cada llamada, y la entrada se queda en el servidor. Los comandos que el usuario no puede ejecutar se omiten del menú (en un botón dividido, en cambio, se deshabilita la acción principal), y el servidor los rechaza igualmente si se llaman.

TeclaDóndeQué hace
Enter, Espacio, Flecha abajoel botónAbre el menú en el primer comando
Flecha arribael botónAbre el menú en el último comando
Flecha abajo, Flecha arribael menúComando siguiente y anterior, de forma circular
Inicio, Finel menúPrimer y último comando
Letrasel menúEl siguiente comando que empieza por ellas
Enter, Espacioun comandoLo ejecuta y cierra el menú
Escapeel menúLo cierra; el foco vuelve al botón
Tabel menúLo cierra y avanza

El menú necesita unos 0,8 KB de script con gzip y ninguna petición hasta que se ejecuta un comando; son 4 nodos más 2 por comando.