Compare commits

...

3 commits

Author SHA1 Message Date
6df71885cc ♻️ (bootsier): Cambia Offcanvas por Panel en Navbar 2026-09-22 18:58:43 +02:00
c47724bb68 ✨ (base): Implementa Navbar con funcionalidad base
Añade `Navbar::with_position()` y `navbar::Position` al núcleo, con las
clases de Basic para barras fijas y pegajosas, y `navbar::Item::form()`
para mostrar un formulario dentro de la barra.

Las barras sin botón de despliegue (`Simple` y `SimpleBrandLeft`) ya no
colapsan: el punto de corte no les afecta y su menú se mantiene en
línea, con salto de línea si no cabe. En Bootsier usan `navbar-expand` y
`SimpleBrandLeft` agrupa los contenidos junto a la marca.
2026-09-21 16:27:44 +02:00
f8f023b513 ✨ (pagetop): Añade PageTopSvg::Tile y ajusta Image 2026-09-21 13:51:18 +02:00
22 changed files with 1891 additions and 337 deletions

View file

@ -268,20 +268,20 @@ body {
margin-inline: auto;
padding-inline: 1rem;
}
@media (min-width: 576px) {
@media (width >= 576px) {
.container,
.container-sm {
max-width: 540px;
}
}
@media (min-width: 768px) {
@media (width >= 768px) {
.container,
.container-sm,
.container-md {
max-width: 720px;
}
}
@media (min-width: 992px) {
@media (width >= 992px) {
.container,
.container-sm,
.container-md,
@ -289,7 +289,7 @@ body {
max-width: 960px;
}
}
@media (min-width: 1200px) {
@media (width >= 1200px) {
.container,
.container-sm,
.container-md,
@ -298,7 +298,7 @@ body {
max-width: 1140px;
}
}
@media (min-width: 1400px) {
@media (width >= 1400px) {
.container,
.container-sm,
.container-md,
@ -308,7 +308,7 @@ body {
max-width: 1320px;
}
}
@media (min-width: 1920px) {
@media (width >= 1920px) {
.container,
.container-sm,
.container-md,
@ -687,6 +687,13 @@ input:disabled + label {
color: var(--val-color--text--muted);
pointer-events: none;
}
/* The toggle of a dropdown inside a `Nav` looks like the other links: it drops the border that a
* standalone `Dropdown` toggle has (see "Dropdown component"). */
.nav-link.dropdown-toggle {
width: 100%;
text-align: start;
border: 0;
}
.nav-start {
justify-content: flex-start;
@ -737,17 +744,64 @@ input:disabled + label {
content: "\2630";
}
/* Pushes the toggle (`BrandLeft`) or the brand (`BrandRight`) to the far end of the bar. */
.brand ~ .navbar-toggle,
.navbar-toggle ~ .brand {
margin-inline-start: auto;
}
.navbar-content {
display: flex;
align-items: center;
gap: 1rem;
flex: 1 1 auto;
}
/* A bar without a toggle button never stacks its `.nav` items in a column, so they wrap onto
* another line when they don't fit instead of overflowing the bar. */
.navbar-content .nav {
flex-wrap: wrap;
}
.navbar-text {
color: var(--val-color--text--muted);
}
/*
* Navbar position, from the `navbar::Position` classes. `.navbar` has no background of its own, so
* a bar taken out of the flow (fixed) or pinned while scrolling (sticky) gets one, or the content
* passing underneath would show through.
*/
.navbar.fixed-top,
.navbar.fixed-bottom,
.navbar.sticky-top,
.navbar.sticky-bottom {
background-color: var(--val-color--bg);
}
.navbar.fixed-top,
.navbar.fixed-bottom {
position: fixed;
right: 0;
left: 0;
z-index: 1030;
}
.navbar.fixed-top {
top: 0;
}
.navbar.fixed-bottom {
bottom: 0;
}
.navbar.sticky-top,
.navbar.sticky-bottom {
position: sticky;
z-index: 1020;
}
.navbar.sticky-top {
top: 0;
}
.navbar.sticky-bottom {
bottom: 0;
}
/*
* A navbar with a toggle button collapses below the breakpoint set by its `navbar-expand-{name}`
* class, added by the `Navbar` component. `navbar-expand` alone (a breakpoint with no minimum
@ -757,16 +811,17 @@ input:disabled + label {
*
* While collapsed, the `.nav` items are stacked in a column, so a dropdown menu inside it is laid
* out in the flow (`position: static`) to push down the following items instead of covering them.
* A navbar without a toggle button has nothing to collapse: its `.nav` items stay in a row.
*/
@media (max-width: 575.98px) {
@media (width < 576px) {
.navbar-expand-sm {
flex-wrap: wrap;
}
.navbar-expand-sm:has(.navbar-toggle) .navbar-toggle {
display: inline-block;
order: -1;
}
.navbar-expand-sm:has(.navbar-toggle) .navbar-content {
order: 1;
display: none;
flex-direction: column;
align-items: stretch;
@ -775,26 +830,26 @@ input:disabled + label {
.navbar-expand-sm:has(.navbar-toggle) .navbar-content.show {
display: flex;
}
.navbar-expand-sm .nav {
.navbar-expand-sm:has(.navbar-toggle) .nav {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-expand-sm .nav .dropdown-menu {
.navbar-expand-sm:has(.navbar-toggle) .nav .dropdown-menu {
position: static;
box-shadow: none;
}
}
@media (max-width: 767.98px) {
@media (width < 768px) {
.navbar-expand-md {
flex-wrap: wrap;
}
.navbar-expand-md:has(.navbar-toggle) .navbar-toggle {
display: inline-block;
order: -1;
}
.navbar-expand-md:has(.navbar-toggle) .navbar-content {
order: 1;
display: none;
flex-direction: column;
align-items: stretch;
@ -803,26 +858,26 @@ input:disabled + label {
.navbar-expand-md:has(.navbar-toggle) .navbar-content.show {
display: flex;
}
.navbar-expand-md .nav {
.navbar-expand-md:has(.navbar-toggle) .nav {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-expand-md .nav .dropdown-menu {
.navbar-expand-md:has(.navbar-toggle) .nav .dropdown-menu {
position: static;
box-shadow: none;
}
}
@media (max-width: 991.98px) {
@media (width < 992px) {
.navbar-expand-lg {
flex-wrap: wrap;
}
.navbar-expand-lg:has(.navbar-toggle) .navbar-toggle {
display: inline-block;
order: -1;
}
.navbar-expand-lg:has(.navbar-toggle) .navbar-content {
order: 1;
display: none;
flex-direction: column;
align-items: stretch;
@ -831,26 +886,26 @@ input:disabled + label {
.navbar-expand-lg:has(.navbar-toggle) .navbar-content.show {
display: flex;
}
.navbar-expand-lg .nav {
.navbar-expand-lg:has(.navbar-toggle) .nav {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-expand-lg .nav .dropdown-menu {
.navbar-expand-lg:has(.navbar-toggle) .nav .dropdown-menu {
position: static;
box-shadow: none;
}
}
@media (max-width: 1199.98px) {
@media (width < 1200px) {
.navbar-expand-xl {
flex-wrap: wrap;
}
.navbar-expand-xl:has(.navbar-toggle) .navbar-toggle {
display: inline-block;
order: -1;
}
.navbar-expand-xl:has(.navbar-toggle) .navbar-content {
order: 1;
display: none;
flex-direction: column;
align-items: stretch;
@ -859,26 +914,26 @@ input:disabled + label {
.navbar-expand-xl:has(.navbar-toggle) .navbar-content.show {
display: flex;
}
.navbar-expand-xl .nav {
.navbar-expand-xl:has(.navbar-toggle) .nav {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-expand-xl .nav .dropdown-menu {
.navbar-expand-xl:has(.navbar-toggle) .nav .dropdown-menu {
position: static;
box-shadow: none;
}
}
@media (max-width: 1399.98px) {
@media (width < 1400px) {
.navbar-expand-xxl {
flex-wrap: wrap;
}
.navbar-expand-xxl:has(.navbar-toggle) .navbar-toggle {
display: inline-block;
order: -1;
}
.navbar-expand-xxl:has(.navbar-toggle) .navbar-content {
order: 1;
display: none;
flex-direction: column;
align-items: stretch;
@ -887,26 +942,26 @@ input:disabled + label {
.navbar-expand-xxl:has(.navbar-toggle) .navbar-content.show {
display: flex;
}
.navbar-expand-xxl .nav {
.navbar-expand-xxl:has(.navbar-toggle) .nav {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-expand-xxl .nav .dropdown-menu {
.navbar-expand-xxl:has(.navbar-toggle) .nav .dropdown-menu {
position: static;
box-shadow: none;
}
}
@media (max-width: 1919.98px) {
@media (width < 1920px) {
.navbar-expand-xxxl {
flex-wrap: wrap;
}
.navbar-expand-xxxl:has(.navbar-toggle) .navbar-toggle {
display: inline-block;
order: -1;
}
.navbar-expand-xxxl:has(.navbar-toggle) .navbar-content {
order: 1;
display: none;
flex-direction: column;
align-items: stretch;
@ -915,12 +970,12 @@ input:disabled + label {
.navbar-expand-xxxl:has(.navbar-toggle) .navbar-content.show {
display: flex;
}
.navbar-expand-xxxl .nav {
.navbar-expand-xxxl:has(.navbar-toggle) .nav {
flex-direction: column;
align-items: stretch;
width: 100%;
}
.navbar-expand-xxxl .nav .dropdown-menu {
.navbar-expand-xxxl:has(.navbar-toggle) .nav .dropdown-menu {
position: static;
box-shadow: none;
}

View file

@ -1,7 +1,30 @@
menus_slogan = Navbars and menus
menus_note = Narrow the browser window to see how the navbars with a toggle button collapse.
## Blocks
menus_block_title_menus = Menus and dropdowns
menus_block_title_brand = Brand
menus_block_title_content = Other content
menus_block_title_colors = Color schemes
menus_block_title_container = Containers
menus_block_title_position = Placement
menus_block_title_layout = Layout and toggle button
menus_block_title_expand = Expand breakpoint
menus_block_title_offcanvas = Content in a side panel (Offcanvas)
## Menus and dropdowns
menus_title_full = Menu with dropdown, headers, dividers and a second menu at the end
menus_item_home = Home
menus_item_label = Label
menus_item_link = Link
menus_item_blank = External link
menus_item_disabled = Disabled link
menus_item_action = Action
menus_item_another = Another action
menus_item_something = Something else
menus_test_title = Dropdown
@ -21,3 +44,66 @@ menus_plugin_cache = Rust Plugin Cache
menus_item_sign_up = Sign up
menus_item_login = Login
## Brand
menus_title_brand_link = Text only, as a link
menus_title_brand_span = Text only, without a link
menus_title_brand_image = Image only
menus_title_brand_both = Image and text
## Other content
menus_title_form_search = Search form next to the brand
menus_title_form_buttons = Buttons
menus_title_form_group = Input group
menus_title_text = Text
menus_title_brand_and_text = Brand and text
menus_title_nav_text = Menu and text
menus_search_label = Search
menus_search_button = Search
menus_button_main = Main button
menus_button_small = Smaller button
menus_group_placeholder = Username
menus_text_inline = Navbar text
## Color schemes
menus_title_color_dark = Dark background
menus_title_color_primary = Primary color with light text
menus_title_color_light = Light theme background
menus_title_color_custom = Custom color
## Containers
menus_title_container_full = Takes all the available width
menus_title_container_max = Inside a container with a maximum width
## Placement
menus_position_note = Navbars are anchored to the frame of each example, not to the window, so they don't cover the rest of the page. Scroll the frames with sticky navbars.
menus_title_position_fixed_top = Fixed top
menus_title_position_fixed_bottom = Fixed bottom
menus_title_position_sticky_top = Sticky top
menus_title_position_sticky_bottom = Sticky bottom
menus_filler = Page content. Fixed elements are taken out of the document flow, while sticky ones stay at the edge of the scrolling container.
## Layout
menus_title_layout_toggle = Toggle button only, no brand
menus_title_layout_brand_left = Brand on the left and toggle button on the right
menus_title_layout_brand_right = Toggle button on the left and brand on the right
menus_title_layout_brand_always = Always visible brand, no toggle button
## Expand breakpoint
menus_title_expand = Expands from { $name } ({ $width })
## Offcanvas
menus_offcanvas_title = Main menu
menus_title_offcanvas_start = Panel from the start, no brand
menus_title_offcanvas_brand_left = Brand on the left and panel from the end
menus_title_offcanvas_brand_right = Brand on the right and panel without backdrop
menus_title_offcanvas_dark = Dark navbar and panel, with a static backdrop

View file

@ -1,7 +1,30 @@
menus_slogan = Barras de navegación y menús
menus_note = Reduce el ancho de la ventana para ver cómo colapsan las barras que tienen botón de despliegue.
## Bloques
menus_block_title_menus = Menús y desplegables
menus_block_title_brand = Marca de identidad
menus_block_title_content = Otros contenidos
menus_block_title_colors = Esquemas de color
menus_block_title_container = Contenedores
menus_block_title_position = Posición
menus_block_title_layout = Disposición y botón de despliegue
menus_block_title_expand = Punto de corte de expansión
menus_block_title_offcanvas = Contenido en un panel lateral (Offcanvas)
## Menús y desplegables
menus_title_full = Menú con desplegable, cabeceras, separadores y un segundo menú al final
menus_item_home = Inicio
menus_item_label = Etiqueta
menus_item_link = Enlace
menus_item_blank = Externo
menus_item_disabled = Deshabilitado
menus_item_action = Acción
menus_item_another = Otra acción
menus_item_something = Algo más
menus_test_title = Desplegable
@ -21,3 +44,66 @@ menus_plugin_cache = Plugin Rust de caché
menus_item_sign_up = Registrarse
menus_item_login = Iniciar sesión
## Marca de identidad
menus_title_brand_link = Sólo texto, como enlace
menus_title_brand_span = Sólo texto, sin enlace
menus_title_brand_image = Sólo imagen
menus_title_brand_both = Imagen y texto
## Otros contenidos
menus_title_form_search = Formulario de búsqueda junto a la marca
menus_title_form_buttons = Botones
menus_title_form_group = Grupo de campos
menus_title_text = Texto
menus_title_brand_and_text = Marca y texto
menus_title_nav_text = Menú y texto
menus_search_label = Buscar
menus_search_button = Buscar
menus_button_main = Botón principal
menus_button_small = Botón pequeño
menus_group_placeholder = Usuario
menus_text_inline = Texto de la barra de navegación
## Esquemas de color
menus_title_color_dark = Fondo oscuro
menus_title_color_primary = Color primario con texto claro
menus_title_color_light = Fondo claro del tema
menus_title_color_custom = Color personalizado
## Contenedores
menus_title_container_full = Ocupa todo el ancho disponible
menus_title_container_max = Dentro de un contenedor de ancho máximo
## Posición
menus_position_note = Las barras se anclan al marco de cada ejemplo, no a la ventana, para no tapar el resto de la página. Desplaza el contenido de los marcos con barras pegajosas.
menus_title_position_fixed_top = Fija arriba
menus_title_position_fixed_bottom = Fija abajo
menus_title_position_sticky_top = Pegajosa arriba
menus_title_position_sticky_bottom = Pegajosa abajo
menus_filler = Contenido de la página. Los elementos con posición fija salen del flujo del documento, y los pegajosos se quedan en el borde del contenedor que se desplaza.
## Disposición
menus_title_layout_toggle = Sólo botón de despliegue, sin marca
menus_title_layout_brand_left = Marca a la izquierda y botón de despliegue a la derecha
menus_title_layout_brand_right = Botón de despliegue a la izquierda y marca a la derecha
menus_title_layout_brand_always = Marca siempre visible, sin botón de despliegue
## Punto de corte
menus_title_expand = Se expande a partir de { $name } ({ $width })
## Offcanvas
menus_offcanvas_title = Menú principal
menus_title_offcanvas_start = Panel desde el inicio, sin marca
menus_title_offcanvas_brand_left = Marca a la izquierda y panel desde el final
menus_title_offcanvas_brand_right = Marca a la derecha y panel sin capa de fondo
menus_title_offcanvas_dark = Barra y panel oscuros, con capa de fondo estática

View file

@ -1,104 +1,783 @@
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
include_locales!(LOC from "examples/locale");
struct SuperMenu;
struct NavbarMenus;
#[async_trait]
impl Extension for SuperMenu {
impl Extension for NavbarMenus {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
&pagetop_aliner::Aliner,
&pagetop_bootsier::Bootsier,
&pagetop::base::extension::Welcome,
]
vec![&pagetop_bootsier::Bootsier]
}
async fn initialize(&self) {
let navbar_menu = Navbar::brand_left(Brand::new())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(
Nav::new()
.with_item(nav::Item::link(Lc::t("menus_item_link", &LOC), "/"))
.with_item(nav::Item::link_blank(
Lc::t("menus_item_blank", &LOC),
"https://docs.rs/pagetop",
))
.with_item(nav::Item::dropdown(
Dropdown::new()
.with_title(Lc::t("menus_test_title", &LOC))
.with_item(dropdown::Item::header(Lc::t("menus_dev_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_dev_getting_started", &LOC),
"/dev/getting-started",
))
.with_item(dropdown::Item::link(
Lc::t("menus_dev_guides", &LOC),
"/dev/guides",
))
.with_item(dropdown::Item::link_blank(
Lc::t("menus_dev_forum", &LOC),
"https://forum.example.dev",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(Lc::t("menus_sdk_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_rust", &LOC),
"/dev/sdks/rust",
))
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_js", &LOC),
"/dev/sdks/js",
))
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_python", &LOC),
"/dev/sdks/python",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(Lc::t("menus_plugin_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_plugin_auth", &LOC),
"/dev/sdks/rust/plugins/auth",
))
.with_item(dropdown::Item::link(
Lc::t("menus_plugin_cache", &LOC),
"/dev/sdks/rust/plugins/cache",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::label(Lc::t("menus_item_label", &LOC)))
.with_item(dropdown::Item::link_disabled(
Lc::t("menus_item_disabled", &LOC),
"#",
)),
))
.with_item(nav::Item::link_disabled(
Lc::t("menus_item_disabled", &LOC),
"#",
)),
))
.with_item(navbar::Item::nav(
Nav::new()
// Empuja este menú (y lo que le siga) al extremo final de la barra.
.with_prop(FlexItem::push_end())
.with_item(nav::Item::link(
Lc::t("menus_item_sign_up", &LOC),
"/auth/sign-up",
))
.with_item(nav::Item::link(
Lc::t("menus_item_login", &LOC),
"/auth/login",
)),
));
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(navbar_menus))
}
}
InRegion::Global(&CoreRegions::Header).add(
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
.with_child(navbar_menu),
async fn navbar_menus(request: HttpRequest) -> Result<Markup, ErrorPage> {
let page = Page::new(request)
.with_assets(demo_title_styles())
.with_assets(demo_code_styles())
.with_assets(demo_frame_styles())
.with_assets(demo_scroll_styles());
let mut intro = Intro::custom()
.with_width(intro::Width::Full)
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("menus_slogan", &LOC))
.with_child(Html::with(|cx| {
html! {
p class="intro-text-lead" { (Lc::t("menus_note", &LOC).using(cx)) }
}
}))
.with_child(menus_block())
.with_child(brand_block())
.with_child(container_block())
.with_child(layout_block())
.with_child(expand_block())
.with_child(content_block())
.with_child(position_block());
// Demostraciones asociadas a Bootsier: usan sus tipos, o clases y variables CSS de Bootstrap
// que otros temas no definen. Sólo se muestran con el tema Bootsier activo.
if is_bootsier() {
intro = intro
.with_child(colors_block())
.with_child(offcanvas_block());
}
page.with_child(intro).render().await
}
// **< Menús >**************************************************************************************
fn menus_block() -> Block {
let full_menu = Navbar::brand_left(demo_brand())
.with_prop(light_bg())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(
Nav::new()
.with_item(nav::Item::link(Lc::t("menus_item_home", &LOC), "/").with_active(true))
.with_item(nav::Item::link_blank(
Lc::t("menus_item_blank", &LOC),
"https://docs.rs/pagetop",
))
.with_item(nav::Item::dropdown(
Dropdown::new()
.with_title(Lc::t("menus_test_title", &LOC))
.with_item(dropdown::Item::header(Lc::t("menus_dev_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_dev_getting_started", &LOC),
"/dev/getting-started",
))
.with_item(dropdown::Item::link(
Lc::t("menus_dev_guides", &LOC),
"/dev/guides",
))
.with_item(dropdown::Item::link_blank(
Lc::t("menus_dev_forum", &LOC),
"https://forum.example.dev",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(Lc::t("menus_sdk_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_rust", &LOC),
"/dev/sdks/rust",
))
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_js", &LOC),
"/dev/sdks/js",
))
.with_item(dropdown::Item::link(
Lc::t("menus_sdk_python", &LOC),
"/dev/sdks/python",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(Lc::t("menus_plugin_header", &LOC)))
.with_item(dropdown::Item::link(
Lc::t("menus_plugin_auth", &LOC),
"/dev/sdks/rust/plugins/auth",
))
.with_item(dropdown::Item::link(
Lc::t("menus_plugin_cache", &LOC),
"/dev/sdks/rust/plugins/cache",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::label(Lc::t("menus_item_label", &LOC)))
.with_item(dropdown::Item::link_disabled(
Lc::t("menus_item_disabled", &LOC),
"#",
)),
))
.with_item(nav::Item::link_disabled(
Lc::t("menus_item_disabled", &LOC),
"#",
)),
))
.with_item(navbar::Item::nav(
Nav::new()
// Empuja este menú (y lo que le siga) al extremo final de la barra.
.with_prop(FlexItem::push_end())
.with_item(nav::Item::link(
Lc::t("menus_item_sign_up", &LOC),
"/auth/sign-up",
))
.with_item(nav::Item::link(
Lc::t("menus_item_login", &LOC),
"/auth/login",
)),
));
add_demo(
Block::new().with_title(Lc::t("menus_block_title_menus", &LOC)),
Lc::t("menus_title_full", &LOC),
concat!(
"Navbar::brand_left(brand)",
".with_item(navbar::Item::nav(Nav::new()...with_item(nav::Item::dropdown(...))))",
".with_item(navbar::Item::nav(Nav::new().with_prop(FlexItem::push_end())...))",
),
full_menu,
)
}
// **< Marca de identidad >*************************************************************************
fn brand_block() -> Block {
// Una barra sin contenidos no se renderiza, así que la marca se añade como un elemento más.
let brand_variants: [(&str, Brand, &str); 4] = [
(
"menus_title_brand_link",
demo_brand(),
"navbar::Item::brand(Brand::new().with_title(...).with_route(Route::from(\"/\")))",
),
(
"menus_title_brand_span",
demo_brand().with_route(None::<Route>),
"navbar::Item::brand(Brand::new().with_title(...).with_route(None))",
),
(
"menus_title_brand_image",
Brand::new()
.with_title(Lc::none())
.with_image(demo_logo())
.with_route(Route::from("/")),
"navbar::Item::brand(Brand::new().with_title(Lc::none()).with_image(image))",
),
(
"menus_title_brand_both",
demo_brand().with_image(demo_logo()),
"navbar::Item::brand(Brand::new().with_title(...).with_image(image))",
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_brand", &LOC));
for (title_key, brand, code) in brand_variants {
block = add_demo(
block,
Lc::t(title_key, &LOC),
code,
Navbar::simple()
.with_prop(light_bg())
.with_item(navbar::Item::brand(brand)),
);
}
block
}
// **< Otros contenidos >***************************************************************************
fn content_block() -> Block {
let gap = align::Gap::Both(UnitValue::RelRem(0.5));
let search_form = Form::new()
.with_action("/")
.with_method(form::Method::Get)
.with_child(
Flex::new()
.with_align(align::Items::Center)
.with_gap(gap)
.with_child(
form::input::Field::search()
.with_name("q")
.with_placeholder(Lc::t("menus_search_label", &LOC))
// Sin el margen inferior con que algunos temas separan los campos.
.with_prop(Margin::new().with_bottom(UnitValue::Zero)),
)
.with_child(
Button::submit(Lc::t("menus_search_button", &LOC))
.with_style(button::Style::Outline(Intent::Success)),
),
);
let buttons = Form::new().with_child(
Flex::new()
.with_align(align::Items::Center)
.with_gap(gap)
.with_child(
Button::plain(Lc::t("menus_button_main", &LOC))
.with_style(button::Style::Outline(Intent::Success)),
)
.with_child(
Button::plain(Lc::t("menus_button_small", &LOC))
.with_size(button::Size::Small)
.with_style(button::Style::Outline(Intent::Neutral)),
),
);
let mut content_variants: Vec<(&str, Navbar, &str)> = vec![
(
"menus_title_form_search",
Navbar::simple_brand_left(demo_brand()).with_item(navbar::Item::form(search_form)),
"Navbar::simple_brand_left(brand).with_item(navbar::Item::form(Form::new()...))",
),
(
"menus_title_form_buttons",
Navbar::simple().with_item(navbar::Item::form(buttons)),
"Navbar::simple().with_item(navbar::Item::form(Form::new()...))",
),
(
"menus_title_text",
Navbar::simple().with_item(navbar::Item::text(Lc::t("menus_text_inline", &LOC))),
"Navbar::simple().with_item(navbar::Item::text(Lc::t(...)))",
),
(
"menus_title_brand_and_text",
Navbar::simple()
.with_item(navbar::Item::brand(demo_brand()))
.with_item(navbar::Item::text(Lc::t("menus_text_inline", &LOC))),
concat!(
"Navbar::simple().with_item(navbar::Item::brand(brand))",
".with_item(navbar::Item::text(...))",
),
),
(
"menus_title_nav_text",
Navbar::brand_left(demo_brand())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(
demo_nav().with_prop(Margin::new().with_end(UnitValue::Auto)),
))
.with_item(navbar::Item::text(Lc::t("menus_text_inline", &LOC))),
concat!(
"Navbar::brand_left(brand).with_item(navbar::Item::nav(",
"Nav::new().with_prop(Margin::new().with_end(UnitValue::Auto))...))",
".with_item(navbar::Item::text(...))",
),
),
];
// El núcleo no tiene un componente de grupo de entrada, así que esta demostración usa clases de
// Bootstrap y sólo se muestra con el tema Bootsier.
if is_bootsier() {
let input_group = Nav::new().with_item(nav::Item::html(Html::with(|cx| {
html! {
form {
div class="input-group" {
span class="input-group-text" { "@" }
input
class="form-control"
type="text"
placeholder=[Lc::t("menus_group_placeholder", &LOC).lookup(cx)]
aria-label=[Lc::t("menus_group_placeholder", &LOC).lookup(cx)];
}
}
}
})));
content_variants.insert(
2,
(
"menus_title_form_group",
Navbar::simple().with_item(navbar::Item::nav(input_group)),
"Navbar::simple().with_item(navbar::Item::nav(Nav::new()...nav::Item::html(...)))",
),
);
}
let mut block = Block::new().with_title(Lc::t("menus_block_title_content", &LOC));
for (title_key, navbar, code) in content_variants {
block = add_demo(
block,
Lc::t(title_key, &LOC),
code,
navbar.with_prop(light_bg()),
);
}
block
}
// **< Esquemas de color >**************************************************************************
fn colors_block() -> Block {
let color_variants: [(&str, Vec<PropsOp>, &str); 4] = [
(
"menus_title_color_dark",
vec![
PropsOp::add_classes(class::Bg::with(BootsierColors::Dark)),
PropsOp::set("data-bs-theme", "dark"),
],
concat!(
"Navbar::brand_left(brand)",
".with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Dark)))",
".with_prop(PropsOp::set(\"data-bs-theme\", \"dark\"))",
),
),
(
"menus_title_color_primary",
vec![
PropsOp::add_classes(class::Bg::with(BootsierColors::Primary)),
PropsOp::set("data-bs-theme", "dark"),
],
concat!(
"Navbar::brand_left(brand)",
".with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Primary)))",
".with_prop(PropsOp::set(\"data-bs-theme\", \"dark\"))",
),
),
(
"menus_title_color_light",
vec![light_bg()],
concat!(
"Navbar::brand_left(brand)",
".with_prop(PropsOp::add_classes(class::Bg::with(class::BgColor::BodyTertiary)))",
),
),
(
"menus_title_color_custom",
vec![PropsOp::add_style("background-color", "#e3f2fd")],
concat!(
"Navbar::brand_left(brand)",
".with_prop(PropsOp::add_style(\"background-color\", \"#e3f2fd\"))",
),
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_colors", &LOC));
for (title_key, props, code) in color_variants {
let mut navbar = Navbar::brand_left(demo_brand())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(demo_nav()));
for op in props {
navbar = navbar.with_prop(op);
}
block = add_demo(block, Lc::t(title_key, &LOC), code, navbar);
}
block
}
// **< Contenedores >*******************************************************************************
fn container_block() -> Block {
let navbar = || {
Navbar::brand_left(demo_brand())
.with_prop(light_bg())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(demo_nav()))
};
let block = Block::new().with_title(Lc::t("menus_block_title_container", &LOC));
let block = add_demo(
block,
Lc::t("menus_title_container_full", &LOC),
"Navbar::brand_left(brand)...",
navbar(),
);
add_demo(
block,
Lc::t("menus_title_container_max", &LOC),
concat!(
"Container::new()",
".with_width(container::Width::FluidMax(UnitValue::RelRem(40.0)))",
".with_child(Navbar::brand_left(brand)...)",
),
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(40.0)))
.with_child(navbar()),
)
}
// **< Posición >***********************************************************************************
fn position_block() -> Block {
let navbar = |position| {
Navbar::simple()
.with_position(position)
.with_prop(light_bg())
.with_item(navbar::Item::nav(demo_nav()))
};
// Las barras fijas se anclan al marco y no a la ventana, para no tapar el resto de la página.
let fixed_variants: [(&str, navbar::Position, &str); 2] = [
(
"menus_title_position_fixed_top",
navbar::Position::FixedTop,
"Navbar::simple().with_position(navbar::Position::FixedTop)",
),
(
"menus_title_position_fixed_bottom",
navbar::Position::FixedBottom,
"Navbar::simple().with_position(navbar::Position::FixedBottom)",
),
];
let mut block = Block::new()
.with_title(Lc::t("menus_block_title_position", &LOC))
.with_child(Html::with(|cx| {
html! {
p { (Lc::t("menus_position_note", &LOC).using(cx)) }
}
}));
for (title_key, position, code) in fixed_variants {
block = add_demo(
block,
Lc::t(title_key, &LOC),
code,
Container::new()
.with_width(container::Width::Fluid)
.with_prop(PropsOp::add_classes("menus-demo-frame"))
.with_child(navbar(position))
.with_child(filler()),
);
}
// Con `sticky` la barra ocupa su lugar en el flujo y se queda pegada al borde del contenedor
// que se desplaza (aquí un marco con scroll).
block = add_demo(
block,
Lc::t("menus_title_position_sticky_top", &LOC),
"Navbar::simple().with_position(navbar::Position::StickyTop)",
Container::new()
.with_width(container::Width::Fluid)
.with_prop(PropsOp::add_classes("menus-demo-scroll"))
.with_child(navbar(navbar::Position::StickyTop))
.with_child(filler())
.with_child(filler())
.with_child(filler())
.with_child(filler()),
);
add_demo(
block,
Lc::t("menus_title_position_sticky_bottom", &LOC),
"Navbar::simple().with_position(navbar::Position::StickyBottom)",
Container::new()
.with_width(container::Width::Fluid)
.with_prop(PropsOp::add_classes("menus-demo-scroll"))
.with_child(filler())
.with_child(filler())
.with_child(filler())
.with_child(filler())
.with_child(navbar(navbar::Position::StickyBottom)),
)
}
// **< Disposición >********************************************************************************
fn layout_block() -> Block {
let layout_variants: [(&str, Navbar, &str); 4] = [
(
"menus_title_layout_toggle",
Navbar::simple_toggle(),
"Navbar::simple_toggle()",
),
(
"menus_title_layout_brand_left",
Navbar::brand_left(demo_brand()),
"Navbar::brand_left(brand)",
),
(
"menus_title_layout_brand_right",
Navbar::brand_right(demo_brand()),
"Navbar::brand_right(brand)",
),
(
"menus_title_layout_brand_always",
Navbar::simple_brand_left(demo_brand()),
"Navbar::simple_brand_left(brand)",
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_layout", &LOC));
for (title_key, navbar, code) in layout_variants {
block = add_demo(
block,
Lc::t(title_key, &LOC),
code,
navbar
.with_prop(light_bg())
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(demo_nav())),
);
}
block
}
// **< Punto de corte >*****************************************************************************
fn expand_block() -> Block {
let expand_variants: [(Lc, Breakpoint, &str); 5] = [
(
expand_title("sm", "576px"),
Breakpoint::Sm,
"Navbar::brand_left(brand).with_expand(Breakpoint::Sm)",
),
(
expand_title("md", "768px"),
Breakpoint::Md,
"Navbar::brand_left(brand).with_expand(Breakpoint::Md)",
),
(
expand_title("lg", "992px"),
Breakpoint::Lg,
"Navbar::brand_left(brand).with_expand(Breakpoint::Lg)",
),
(
expand_title("xl", "1200px"),
Breakpoint::Xl,
"Navbar::brand_left(brand).with_expand(Breakpoint::Xl)",
),
(
expand_title("xxl", "1400px"),
Breakpoint::Xxl,
"Navbar::brand_left(brand).with_expand(Breakpoint::Xxl)",
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_expand", &LOC));
for (title, breakpoint, code) in expand_variants {
block = add_demo(
block,
title,
code,
Navbar::brand_left(demo_brand())
.with_prop(light_bg())
.with_expand(breakpoint)
.with_item(navbar::Item::nav(demo_nav())),
);
}
block
}
fn expand_title(name: &'static str, width: &'static str) -> Lc {
Lc::t("menus_title_expand", &LOC)
.with_arg("name", name)
.with_arg("width", width)
}
// **< Offcanvas >**********************************************************************************
fn offcanvas_block() -> Block {
let panel = |placement, backdrop| {
bs::navbar::Panel::new()
.with_title(Lc::t("menus_offcanvas_title", &LOC))
.with_placement(placement)
.with_backdrop(backdrop)
};
let offcanvas_variants: [(&str, Navbar, &str); 4] = [
(
"menus_title_offcanvas_start",
Navbar::offcanvas(panel(
bs::offcanvas::Placement::Start,
bs::offcanvas::Backdrop::Enabled,
)),
concat!(
"Navbar::offcanvas(bs::navbar::Panel::new()",
".with_placement(bs::offcanvas::Placement::Start))",
),
),
(
"menus_title_offcanvas_brand_left",
Navbar::offcanvas_brand_left(
demo_brand(),
panel(
bs::offcanvas::Placement::End,
bs::offcanvas::Backdrop::Enabled,
),
),
concat!(
"Navbar::offcanvas_brand_left(brand, bs::navbar::Panel::new()",
".with_placement(bs::offcanvas::Placement::End))",
),
),
(
"menus_title_offcanvas_brand_right",
Navbar::offcanvas_brand_right(
demo_brand(),
panel(
bs::offcanvas::Placement::Start,
bs::offcanvas::Backdrop::Disabled,
),
),
concat!(
"Navbar::offcanvas_brand_right(brand, bs::navbar::Panel::new()",
".with_backdrop(bs::offcanvas::Backdrop::Disabled))",
),
),
(
"menus_title_offcanvas_dark",
Navbar::offcanvas_brand_left(
demo_brand(),
panel(
bs::offcanvas::Placement::End,
bs::offcanvas::Backdrop::Static,
),
)
.with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Dark)))
.with_prop(PropsOp::set("data-bs-theme", "dark")),
concat!(
"Navbar::offcanvas_brand_left(brand, bs::navbar::Panel::new()",
".with_backdrop(bs::offcanvas::Backdrop::Static))",
".with_prop(PropsOp::set(\"data-bs-theme\", \"dark\"))",
),
),
];
let mut block = Block::new().with_title(Lc::t("menus_block_title_offcanvas", &LOC));
for (title_key, navbar, code) in offcanvas_variants {
let navbar = navbar
.with_expand(Breakpoint::Lg)
.with_item(navbar::Item::nav(demo_nav()));
// La barra oscura ya define su propio fondo.
let navbar = if title_key == "menus_title_offcanvas_dark" {
navbar
} else {
navbar.with_prop(light_bg())
};
block = add_demo(block, Lc::t(title_key, &LOC), code, navbar);
}
block
}
// **< HELPERS >************************************************************************************
// Marca de identidad de las demostraciones.
fn demo_brand() -> Brand {
Brand::new()
.with_title(Lc::n("PageTop"))
.with_route(Route::from("/"))
}
// Logotipo de las demostraciones, con el tamaño habitual de una marca en una barra.
fn demo_logo() -> Image {
Image::with(image::Source::logo(PageTopSvg::Tile))
.with_size(image::Size::Both(UnitValue::Px(36)))
}
// Menú común a la mayoría de las demostraciones: activo, enlace, desplegable y deshabilitado.
fn demo_nav() -> Nav {
Nav::new()
.with_item(nav::Item::link(Lc::t("menus_item_home", &LOC), "/").with_active(true))
.with_item(nav::Item::link(Lc::t("menus_item_link", &LOC), "/link"))
.with_item(nav::Item::dropdown(
Dropdown::new()
.with_title(Lc::t("menus_test_title", &LOC))
.with_item(dropdown::Item::link(
Lc::t("menus_item_action", &LOC),
"/action",
))
.with_item(dropdown::Item::link(
Lc::t("menus_item_another", &LOC),
"/another",
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::link(
Lc::t("menus_item_something", &LOC),
"/something",
)),
))
.with_item(nav::Item::link_disabled(
Lc::t("menus_item_disabled", &LOC),
"#",
))
}
fn is_bootsier() -> bool {
global::SETTINGS.app.theme.eq_ignore_ascii_case("bootsier")
}
// Fondo claro para que las barras destaquen sobre el fondo de la página. Basic no da fondo a
// `.navbar`, así que con otros temas se usa el color de fondo de Basic (o un gris claro si el tema
// activo tampoco lo define).
fn light_bg() -> PropsOp {
if is_bootsier() {
PropsOp::add_classes(class::Bg::with(class::BgColor::BodyTertiary))
} else {
PropsOp::add_style("background-color", "var(--val-color--bg, #f8f9fa)")
}
}
// Título y fragmento de código que introducen cada demostración, seguidos de ésta.
fn add_demo(block: Block, title: Lc, code: &'static str, demo: impl Into<ChildOp>) -> Block {
block
.with_child(caption(title, Lc::n(code)))
.with_child(demo)
}
// Párrafo de relleno para que los marcos de demostración tengan contenido que desplazar.
fn filler() -> Html {
Html::with(|cx| {
html! {
p { (Lc::t("menus_filler", &LOC).using(cx)) }
}
})
}
// Título y fragmento de código que introducen cada demostración.
fn caption(title: Lc, code: Lc) -> Html {
Html::with(move |cx| {
html! {
h3 class="menus-demo-title" { (title.using(cx)) }
p { code class="menus-demo-code" { (code.using(cx)) } }
}
})
}
// Separa el título de cada demostración de la barra (o marco) que le precede.
fn demo_title_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(None, "menus-demo-title", [("margin-top", "1.5rem")])
}
// Evita que los fragmentos de código largos desborden su contenedor.
fn demo_code_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(None, "menus-demo-code", [("overflow-wrap", "anywhere")])
}
// Marco de tamaño fijo para las barras con posición `fixed`.
fn demo_frame_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"menus-demo-frame",
[
("position", "relative"),
// Un `transform` convierte al marco en el bloque contenedor de sus descendientes con
// `position: fixed`, así la barra se ancla al marco en vez de a la ventana.
("transform", "translateZ(0)"),
("height", "12rem"),
("padding", "4rem 1rem"),
("overflow", "hidden"),
("border", "1px solid #dee2e6"),
("margin", "0 0 1.5rem"),
],
)
}
// Marco de tamaño fijo con desplazamiento, para las barras con posición `sticky`.
fn demo_scroll_styles() -> AssetsOp {
AssetsOp::add_responsive_styles(
None,
"menus-demo-scroll",
[
("height", "12rem"),
("overflow-y", "auto"),
("padding", "0 1rem"),
("border", "1px solid #dee2e6"),
("margin", "0 0 1.5rem"),
],
)
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&SuperMenu).await.run().await
Application::prepare(&NavbarMenus).await.run().await
}

View file

@ -8,12 +8,15 @@
//! que identifique la compañía, producto o nombre del proyecto asociado a la solución web.
mod props;
pub use props::{Layout, Position};
pub use props::Layout;
pub use super::Brand;
pub use pagetop::base::component::Navbar;
pub use pagetop::base::component::navbar::Item;
pub use pagetop::base::component::navbar::{Item, Position};
mod panel;
pub use panel::Panel;
mod component;
pub use component::NavbarBootsier;

View file

@ -7,18 +7,17 @@ const TOGGLE_COLLAPSE: &str = "collapse";
const TOGGLE_OFFCANVAS: &str = "offcanvas";
const EXTRA_LAYOUT: &str = "bootsier.navbar.layout";
const EXTRA_POSITION: &str = "bootsier.navbar.position";
/// Extensión de Bootsier para [`Navbar`].
///
/// Permite mostrar enlaces, menús y una marca de identidad en distintas disposiciones (simples, con
/// botón de despliegue o dentro de un [`Offcanvas`]), controladas por [`navbar::Layout`]. También
/// puede fijarse en la parte superior o inferior del documento mediante [`navbar::Position`]. El
/// botón de despliegue o dentro de un [`navbar::Panel`]), controladas por [`navbar::Layout`].
/// También puede fijarse en la parte superior o inferior del documento con [`navbar::Position`]. El
/// punto de corte a partir del cual deja de colapsar se define con [`Navbar::with_expand()`].
///
/// [`Navbar`]: crate::theme::bs::Navbar
/// [`Offcanvas`]: crate::theme::bs::Offcanvas
/// [`navbar::Layout`]: crate::theme::bs::navbar::Layout
/// [`navbar::Panel`]: crate::theme::bs::navbar::Panel
/// [`navbar::Position`]: crate::theme::bs::navbar::Position
///
/// # Ejemplos
@ -103,13 +102,12 @@ const EXTRA_POSITION: &str = "bootsier.navbar.position";
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let oc = bs::Offcanvas::new()
/// .with_id("main_offcanvas")
/// let panel = bs::navbar::Panel::new()
/// .with_title(Lc::n("Main menu"))
/// .with_placement(bs::offcanvas::Placement::Start)
/// .with_backdrop(bs::offcanvas::Backdrop::Enabled);
///
/// let navbar = bs::Navbar::offcanvas(oc)
/// let navbar = bs::Navbar::offcanvas(panel)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new()
/// .with_item(bs::nav::Item::link(Lc::n("Home"), "/"))
@ -143,64 +141,73 @@ const EXTRA_POSITION: &str = "bootsier.navbar.position";
/// ```
#[builder_impl]
pub trait NavbarBootsier {
/// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**.
fn offcanvas(oc: bs::Offcanvas) -> Self;
/// Crea una barra de navegación cuyo contenido se muestra en un panel lateral.
fn offcanvas(panel: bs::navbar::Panel) -> Self;
/// Crea una barra de navegación con **marca de identidad** y contenido en **offcanvas**.
fn offcanvas_brand_left(brand: Brand, oc: bs::Offcanvas) -> Self;
/// Crea una barra de navegación con **marca de identidad a la izquierda** y contenido en un
/// panel lateral.
fn offcanvas_brand_left(brand: Brand, panel: bs::navbar::Panel) -> Self;
/// Crea una barra de navegación con **marca de identidad** y contenido en **offcanvas**.
fn offcanvas_brand_right(brand: Brand, oc: bs::Offcanvas) -> Self;
/// Define dónde se mostrará la barra de navegación dentro del documento.
fn with_position(self, position: bs::navbar::Position) -> Self;
/// Crea una barra de navegación con **marca de identidad a la derecha** y contenido en un panel
/// lateral.
fn offcanvas_brand_right(brand: Brand, panel: bs::navbar::Panel) -> Self;
}
#[builder_impl]
impl NavbarBootsier for Navbar {
fn offcanvas(oc: bs::Offcanvas) -> Self {
fn offcanvas(panel: bs::navbar::Panel) -> Self {
let mut navbar = Self::new();
navbar.alter_prop(PropsOp::set_extra(
EXTRA_LAYOUT,
bs::navbar::Layout::Offcanvas(Embed::with(oc)),
bs::navbar::Layout::Offcanvas(panel),
));
navbar
}
fn offcanvas_brand_left(brand: Brand, oc: bs::Offcanvas) -> Self {
fn offcanvas_brand_left(brand: Brand, panel: bs::navbar::Panel) -> Self {
let mut navbar = Self::new();
navbar.alter_prop(PropsOp::set_extra(
EXTRA_LAYOUT,
bs::navbar::Layout::OffcanvasBrandLeft(Embed::with(brand), Embed::with(oc)),
bs::navbar::Layout::OffcanvasBrandLeft(Embed::with(brand), panel),
));
navbar
}
fn offcanvas_brand_right(brand: Brand, oc: bs::Offcanvas) -> Self {
fn offcanvas_brand_right(brand: Brand, panel: bs::navbar::Panel) -> Self {
let mut navbar = Self::new();
navbar.alter_prop(PropsOp::set_extra(
EXTRA_LAYOUT,
bs::navbar::Layout::OffcanvasBrandRight(Embed::with(brand), Embed::with(oc)),
bs::navbar::Layout::OffcanvasBrandRight(Embed::with(brand), panel),
));
navbar
}
fn with_position(mut self, position: bs::navbar::Position) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_POSITION, position));
self
}
}
// **< Navbar SETUP >*******************************************************************************
pub(crate) fn setup(navbar: &mut Navbar) {
let position = navbar
// Sin botón de despliegue no hay nada que colapsar, así que el punto de corte no debe afectar a
// la barra: Bootstrap apilaría igualmente el menú por debajo de él. `navbar-expand` a secas es
// su «expandida siempre». Las disposiciones de Bootsier (`EXTRA_LAYOUT`, con panel lateral)
// siempre llevan botón; de las de base sólo `Simple` y `SimpleBrandLeft` no lo llevan.
let has_toggle = navbar
.props()
.extra_or(EXTRA_POSITION, bs::navbar::Position::default());
let mut classes = String::new();
position.push_to(&mut classes);
if !classes.is_empty() {
navbar.alter_prop(PropsOp::add_classes(classes));
.extra::<bs::navbar::Layout>(EXTRA_LAYOUT)
.is_ok()
|| !matches!(
navbar.layout(),
navbar::Layout::Simple | navbar::Layout::SimpleBrandLeft(_)
);
if !has_toggle {
let expand = navbar.props().get_classes().and_then(|classes| {
classes
.split_whitespace()
.find(|class| class.starts_with("navbar-expand-"))
.map(String::from)
});
if let Some(class) = expand {
navbar.alter_prop(PropsOp::replace_classes(class, "navbar-expand"));
}
}
}
@ -236,13 +243,16 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
return Ok(html! {});
}
// `Navbar::setup()` (base) garantiza que habrá un `id` antes de renderizar.
// `Navbar::setup()` (base) garantiza que habrá un `id` antes de renderizar. El área de
// contenido (colapsable o panel lateral) deriva su identificador del de la barra.
let id = navbar.id().unwrap();
let id_content = util::join!(&id, "-content");
// `with_layout()` (extra propio de Bootsier) tiene prioridad; si no se ha usado, se traduce el
// `navbar::Layout` de base que hayan podido fijar los constructores heredados de `Navbar`
// (`simple()`, `brand_left()`...), que Bootsier no puede sobrescribir por nombre -las funciones
// inherentes de base siempre ganan sobre las de un trait con el mismo nombre-.
// El extra `EXTRA_LAYOUT` (fijado por los constructores propios de Bootsier `offcanvas()`,
// `offcanvas_brand_left()`, `offcanvas_brand_right()`) tiene prioridad; si no se ha fijado, se
// traduce el `navbar::Layout` de base que hayan podido fijar los constructores heredados de
// `Navbar` (`simple()`, `brand_left()`...), que Bootsier no puede sobrescribir por nombre -las
// funciones inherentes de base siempre ganan sobre las de un trait con el mismo nombre-.
let layout = navbar
.props()
.extra::<bs::navbar::Layout>(EXTRA_LAYOUT)
@ -260,24 +270,23 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
// Barra sencilla que se puede contraer/expandir.
bs::navbar::Layout::SimpleToggle => {
@let id_content = util::join!(&id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
// Barra con marca a la izquierda, siempre visible.
// Barra con marca a la izquierda, siempre visible. El contenedor deja los
// contenidos junto a la marca: sin él, el `space-between` de `container-fluid`
// los llevaría al extremo opuesto. Sin `collapse`, `setup()` garantiza
// `navbar-expand` y Bootstrap lo muestra siempre.
bs::navbar::Layout::SimpleBrandLeft(brand) => {
(brand.render(cx).await)
(items)
div class="navbar-collapse" { (items) }
},
// Barra con marca a la izquierda y botón a la derecha.
bs::navbar::Layout::BrandLeft(brand) => {
@let id_content = util::join!(&id, "-content");
(brand.render(cx).await)
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(&id_content) class="collapse navbar-collapse" {
@ -287,8 +296,6 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
// Barra con botón a la izquierda y marca a la derecha.
bs::navbar::Layout::BrandRight(brand) => {
@let id_content = util::join!(&id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
(brand.render(cx).await)
div id=(&id_content) class="collapse navbar-collapse" {
@ -296,36 +303,24 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
}
},
// Barra cuyo contenido se muestra en un offcanvas, sin marca.
bs::navbar::Layout::Offcanvas(offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
// Barra cuyo contenido se muestra en un panel lateral, sin marca.
bs::navbar::Layout::Offcanvas(panel) => {
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
(panel.render_panel(cx, &id_content, &items))
},
// Barra con marca a la izquierda y contenido en offcanvas.
bs::navbar::Layout::OffcanvasBrandLeft(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
// Barra con marca a la izquierda y contenido en un panel lateral.
bs::navbar::Layout::OffcanvasBrandLeft(brand, panel) => {
(brand.render(cx).await)
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
(panel.render_panel(cx, &id_content, &items))
},
// Barra con contenido en offcanvas y marca a la derecha.
bs::navbar::Layout::OffcanvasBrandRight(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
// Barra con contenido en un panel lateral y marca a la derecha.
bs::navbar::Layout::OffcanvasBrandRight(brand, panel) => {
(button(cx, TOGGLE_OFFCANVAS, &id_content))
(brand.render(cx).await)
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
(panel.render_panel(cx, &id_content, &items))
},
}
}

View file

@ -0,0 +1,118 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
/// Panel lateral deslizante (*offcanvas*) para una barra de navegación [`Navbar`].
///
/// Es el panel en el que se muestran los elementos de la barra cuando está colapsada. Se abre con
/// su botón de despliegue y sólo recoge lo que tiene sentido en una barra: el título, el borde
/// desde el que se despliega, la capa de fondo y el desplazamiento de la página. El resto lo decide
/// la propia barra ya que el contenido son sus elementos, el punto de corte a partir del cual se
/// muestra (definido en [`Navbar::with_expand()`]) y el identificador que se deriva del suyo.
///
/// Para un panel lateral independiente, con contenido propio, se usa [`Offcanvas`].
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let panel = bs::navbar::Panel::new()
/// .with_title(Lc::n("Main menu"))
/// .with_placement(bs::offcanvas::Placement::End)
/// .with_backdrop(bs::offcanvas::Backdrop::Static);
///
/// let navbar = bs::Navbar::offcanvas(panel)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new().with_item(bs::nav::Item::link(Lc::n("Home"), "/"))
/// ));
/// ```
///
/// [`Navbar`]: crate::theme::bs::Navbar
/// [`Navbar::with_expand()`]: crate::theme::bs::Navbar::with_expand
/// [`Offcanvas`]: crate::theme::bs::Offcanvas
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Panel {
/// Devuelve el título del panel.
title: Lc,
/// Devuelve el borde desde el que se despliega el panel.
#[getters(copy)]
placement: bs::offcanvas::Placement,
/// Devuelve el comportamiento configurado para la capa de fondo.
#[getters(copy)]
backdrop: bs::offcanvas::Backdrop,
/// Indica si la página principal puede desplazarse mientras el panel está abierto.
#[getters(copy)]
body_scroll: bs::offcanvas::BodyScroll,
}
#[builder_impl]
impl Panel {
/// Crea un panel sin título, que se despliega desde el borde inicial, con una capa de fondo y
/// sin permitir el desplazamiento de la página mientras está abierto.
pub fn new() -> Self {
Self::default()
}
/// Establece el título del encabezado.
pub fn with_title(mut self, title: Lc) -> Self {
self.title = title;
self
}
/// Indica desde qué borde de la ventana se despliega y se ancla el panel.
pub fn with_placement(mut self, placement: bs::offcanvas::Placement) -> Self {
self.placement = placement;
self
}
/// Ajusta la capa de fondo del panel para definir su comportamiento al hacer clic fuera del
/// panel.
pub fn with_backdrop(mut self, backdrop: bs::offcanvas::Backdrop) -> Self {
self.backdrop = backdrop;
self
}
/// Permite o bloquea el desplazamiento de la página principal mientras el panel está abierto.
pub fn with_body_scroll(mut self, scrolling: bs::offcanvas::BodyScroll) -> Self {
self.body_scroll = scrolling;
self
}
// Renderiza el panel con `id` como identificador y `body` (los elementos de la barra ya
// renderizados) como contenido. Con `navbar-expand-{bp}` Bootstrap lo muestra en línea a partir
// del punto de corte de la barra, así que no lleva clase de punto de corte propia.
pub(super) fn render_panel(&self, cx: &Context, id: &str, body: &Markup) -> Markup {
let title = self.title().using(cx);
let id_label = util::join!(id, "-label");
let id_target = util::join!("#", id);
let classes = util::join!("offcanvas ", self.placement().as_str());
html! {
div
id=(id)
class=(classes)
tabindex="-1"
data-bs-scroll=[self.body_scroll().opt_str()]
data-bs-backdrop=[self.backdrop().opt_str()]
aria-labelledby=[(!title.is_empty()).then_some(&id_label)]
{
div class="offcanvas-header" {
@if !title.is_empty() {
h5 id=(&id_label) class="offcanvas-title" { (title) }
}
button
type="button"
class="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target=(id_target)
aria-label=[Lc::t("offcanvas_close", &LOCALES_BOOTSIER).lookup(cx)]
{}
}
div class="offcanvas-body" { (body) }
}
}
}
}

View file

@ -2,8 +2,6 @@ use pagetop::prelude::*;
use crate::theme::*;
// **< Layout >*************************************************************************************
/// Representa los diferentes tipos de presentación de una barra de navegación
/// [`Navbar`](crate::theme::bs::Navbar).
#[derive(AutoDefault, Clone, Debug)]
@ -28,74 +26,15 @@ pub enum Layout {
/// Barra con botón de despliegue a la izquierda y marca de identidad a la derecha.
BrandRight(Embed<Brand>),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
/// izquierda y sin marca de identidad.
Offcanvas(Embed<bs::Offcanvas>),
/// Contenido en un [`Panel`](crate::theme::bs::navbar::Panel) lateral, con botón de despliegue
/// a la izquierda y sin marca de identidad.
Offcanvas(bs::navbar::Panel),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con marca de identidad a la
/// izquierda y botón de despliegue a la derecha.
OffcanvasBrandLeft(Embed<Brand>, Embed<bs::Offcanvas>),
/// Contenido en un [`Panel`](crate::theme::bs::navbar::Panel) lateral, con marca de identidad
/// a la izquierda y botón de despliegue a la derecha.
OffcanvasBrandLeft(Embed<Brand>, bs::navbar::Panel),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
/// izquierda y marca de identidad a la derecha.
OffcanvasBrandRight(Embed<Brand>, Embed<bs::Offcanvas>),
}
// **< Position >***********************************************************************************
/// Posición global de una barra de navegación [`Navbar`](crate::theme::bs::Navbar) en el documento.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Position {
/// Barra normal, fluye con el documento.
#[default]
Static,
/// Barra fijada en la parte superior, siempre visible.
///
/// Puede ser necesario reservar espacio en la parte superior del contenido que fluye debajo
/// para evitar que quede oculto por la barra.
FixedTop,
/// Barra fijada en la parte inferior, siempre visible.
///
/// Puede ser necesario reservar espacio en la parte inferior del contenido que fluye debajo
/// para evitar que quede oculto por la barra.
FixedBottom,
/// La barra de navegación se fija en la parte superior al hacer *scroll*.
StickyTop,
/// La barra de navegación se fija en la parte inferior al hacer *scroll*.
StickyBottom,
}
impl Position {
/// Devuelve la clase base asociada a la posición de la barra de navegación.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::Static => "",
Self::FixedTop => "fixed-top",
Self::FixedBottom => "fixed-bottom",
Self::StickyTop => "sticky-top",
Self::StickyBottom => "sticky-bottom",
}
}
/// Añade la clase asociada a la posición de la barra de navegación a la cadena de clases.
#[inline]
pub fn push_to(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Devuelve la clase asociada a la posición de la barra de navegación.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
/// Contenido en un [`Panel`](crate::theme::bs::navbar::Panel) lateral, con botón de despliegue
/// a la izquierda y marca de identidad a la derecha.
OffcanvasBrandRight(Embed<Brand>, bs::navbar::Panel),
}

View file

@ -48,14 +48,19 @@ pub struct Offcanvas {
/// Devuelve el título del panel.
title: Lc,
/// Devuelve el punto de corte configurado para cambiar el comportamiento del panel.
#[getters(copy)]
breakpoint: Breakpoint,
/// Devuelve el comportamiento configurado para la capa de fondo.
#[getters(copy)]
backdrop: bs::offcanvas::Backdrop,
/// Indica si la página principal puede desplazarse mientras el panel está abierto.
#[getters(copy)]
body_scroll: bs::offcanvas::BodyScroll,
/// Devuelve la posición de inicio del panel.
#[getters(copy)]
placement: bs::offcanvas::Placement,
/// Devuelve el estado inicial del panel.
#[getters(copy)]
visibility: bs::offcanvas::Visibility,
/// Devuelve la lista de componentes (`children`) del panel.
children: Children,
@ -78,7 +83,7 @@ impl Component for Offcanvas {
// Clases CSS por defecto para el panel.
self.alter_prop(PropsOp::prepend_classes({
let mut classes = String::new();
push_breakpoint_class(cx, *self.breakpoint(), &mut classes, "offcanvas", "");
push_breakpoint_class(cx, self.breakpoint(), &mut classes, "offcanvas", "");
self.placement().push_to(&mut classes);
self.visibility().push_to(&mut classes);
classes
@ -86,7 +91,41 @@ impl Component for Offcanvas {
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(self.render_offcanvas(cx, None).await)
let body = self.children().render(cx).await;
if body.is_empty() {
return Ok(html! {});
}
// `setup()` garantiza que habrá un `id` antes de renderizar.
let id = self.id().unwrap();
let id_label = util::join!(&id, "-label");
let title = self.title().using(cx);
Ok(html! {
div
(self.props().unpack(cx))
tabindex="-1"
data-bs-scroll=[self.body_scroll().opt_str()]
data-bs-backdrop=[self.backdrop().opt_str()]
aria-labelledby=[(!title.is_empty()).then_some(&id_label)]
{
div class="offcanvas-header" {
@if !title.is_empty() {
h5 id=(&id_label) class="offcanvas-title" { (title) }
}
button
type="button"
class="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target=(util::join!("#", &id))
aria-label=[Lc::t("offcanvas_close", &LOCALES_BOOTSIER).lookup(cx)]
{}
}
div class="offcanvas-body" {
(body)
}
}
})
}
}
@ -157,59 +196,4 @@ impl Offcanvas {
self.children.alter_child(op.into());
self
}
// **< Offcanvas HELPERS >**********************************************************************
pub(crate) async fn render_offcanvas(
&self,
cx: &mut Context,
extra: Option<&Children>,
) -> Markup {
let body = self.children().render(cx).await;
let body_extra = if let Some(c) = extra {
c.render(cx).await
} else {
html! {}
};
if body.is_empty() && body_extra.is_empty() {
return html! {};
}
// `setup()` garantiza que habrá un `id` antes de renderizar.
let id = self.id().unwrap();
let id_label = util::join!(id, "-label");
let id_target = util::join!("#", id);
let body_scroll = self.body_scroll().opt_str();
let backdrop = self.backdrop().opt_str();
let title = self.title().using(cx);
html! {
div
(self.props().unpack(cx))
tabindex="-1"
data-bs-scroll=[body_scroll]
data-bs-backdrop=[backdrop]
aria-labelledby=(id_label)
{
div class="offcanvas-header" {
@if !title.is_empty() {
h5 id=(&id_label) class="offcanvas-title" { (title) }
}
button
type="button"
class="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target=(id_target)
aria-label=[Lc::t("offcanvas_close", &LOCALES_BOOTSIER).lookup(cx)]
{}
}
div class="offcanvas-body" {
(body)
(body_extra)
}
}
}
}
}

View file

@ -68,20 +68,20 @@ async fn navbar_expands_at_md_by_default_and_at_the_chosen_breakpoint() {
let item =
|| bs::navbar::Item::nav(bs::Nav::new().with_item(bs::nav::Item::link(Lc::n("Home"), "/")));
let mut navbar = bs::Navbar::simple().with_item(item());
let mut navbar = bs::Navbar::simple_toggle().with_item(item());
let html = navbar.render(&mut cx()).await.into_string();
assert!(html.contains("navbar-expand-md"));
// The expansion class comes from the `Navbar` component only, so it is not duplicated.
assert_eq!(html.matches("navbar-expand").count(), 1);
let mut navbar = bs::Navbar::simple()
let mut navbar = bs::Navbar::simple_toggle()
.with_expand(Breakpoint::Xxxl)
.with_item(item());
let html = navbar.render(&mut cx()).await.into_string();
assert!(html.contains("navbar-expand-xxl"));
// Bootstrap needs a plain `navbar-expand` to keep the bar always expanded.
let mut navbar = bs::Navbar::simple()
let mut navbar = bs::Navbar::simple_toggle()
.with_expand(Breakpoint::Xs)
.with_item(item());
let html = navbar.render(&mut cx()).await.into_string();
@ -89,6 +89,37 @@ async fn navbar_expands_at_md_by_default_and_at_the_chosen_breakpoint() {
assert!(!html.contains("navbar-expand-"));
}
#[pagetop::test]
async fn navbar_without_toggle_ignores_the_breakpoint() {
let item =
|| bs::navbar::Item::nav(bs::Nav::new().with_item(bs::nav::Item::link(Lc::n("Home"), "/")));
let brand = || Brand::new().with_title(Lc::n("PageTop"));
// With nothing to collapse, Bootstrap would still stack the menu below the breakpoint.
let mut navbar = bs::Navbar::simple()
.with_expand(Breakpoint::Lg)
.with_item(item());
let html = navbar.render(&mut cx()).await.into_string();
assert!(html.contains("navbar-expand"));
assert!(!html.contains("navbar-expand-"));
let mut navbar = bs::Navbar::simple_brand_left(brand())
.with_expand(Breakpoint::Lg)
.with_item(item());
let html = navbar.render(&mut cx()).await.into_string();
assert!(html.contains("navbar-expand"));
assert!(!html.contains("navbar-expand-"));
// The menu stays next to the brand instead of being pushed to the opposite end.
assert!(html.contains(r#"<div class="navbar-collapse">"#));
// The layouts with a toggle keep their breakpoint.
let mut navbar = bs::Navbar::brand_left(brand())
.with_expand(Breakpoint::Lg)
.with_item(item());
let html = navbar.render(&mut cx()).await.into_string();
assert!(html.contains("navbar-expand-lg"));
}
#[pagetop::test]
async fn offcanvas_uses_either_the_plain_or_the_breakpoint_class() {
// Bootstrap's `.offcanvas` is always fixed, so it must not accompany `.offcanvas-{bp}`.

View file

@ -0,0 +1,142 @@
// Verifies that a `Navbar` with its content in a `navbar::Panel` renders a working side panel: the
// toggle button points to the panel, the panel carries its own classes and attributes, and the
// items of the navbar are its only content.
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
fn cx() -> Context {
Context::default().with_theme(&pagetop_bootsier::Bootsier)
}
fn panel(placement: bs::offcanvas::Placement) -> bs::navbar::Panel {
bs::navbar::Panel::new().with_placement(placement)
}
fn home() -> bs::navbar::Item {
bs::navbar::Item::nav(bs::Nav::new().with_item(bs::nav::Item::link(Lc::n("Home"), "/")))
}
async fn render(navbar: Navbar) -> String {
let mut navbar = navbar.with_item(home());
navbar.render(&mut cx()).await.into_string()
}
// The `id` the toggle button points to.
fn target(html: &str) -> &str {
html.split(r##"data-bs-target="#"##)
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap_or_default()
}
#[pagetop::test]
async fn offcanvas_button_points_to_the_panel_id() {
let brand = || Brand::new().with_title(Lc::n("PageTop"));
let start = bs::offcanvas::Placement::Start;
let navbars = [
bs::Navbar::offcanvas(panel(start)),
bs::Navbar::offcanvas_brand_left(brand(), panel(start)),
bs::Navbar::offcanvas_brand_right(brand(), panel(start)),
];
for navbar in navbars {
let html = render(navbar).await;
// The panel is rendered by the navbar, which derives its `id` from its own.
let target = target(&html);
assert!(!target.is_empty(), "the button has no target in {html}");
assert!(html.contains(&format!(r#"aria-controls="{target}""#)));
assert!(
html.contains(&format!(r#"id="{target}""#)),
"no panel with id `{target}` in {html}"
);
}
}
#[pagetop::test]
async fn offcanvas_panel_gets_its_own_classes() {
for (placement, class) in [
(bs::offcanvas::Placement::Start, "offcanvas-start"),
(bs::offcanvas::Placement::End, "offcanvas-end"),
(bs::offcanvas::Placement::Top, "offcanvas-top"),
(bs::offcanvas::Placement::Bottom, "offcanvas-bottom"),
] {
let html = render(bs::Navbar::offcanvas(panel(placement))).await;
// Without them Bootstrap would show the panel inline instead of hiding and placing it.
assert!(
html.contains(&format!(r#"class="offcanvas {class}""#)),
"missing `offcanvas {class}` in {html}"
);
}
}
#[pagetop::test]
async fn offcanvas_panel_follows_the_expand_of_the_navbar() {
let navbar = bs::Navbar::offcanvas(panel(bs::offcanvas::Placement::Start));
let html = render(navbar.with_expand(Breakpoint::Lg)).await;
// Bootstrap shows the panel inline from `navbar-expand-lg`; the panel has no breakpoint of its
// own that could contradict it, and it is not open on load.
assert!(html.contains("navbar-expand-lg"));
assert!(html.contains(r#"class="offcanvas offcanvas-start""#));
assert!(!html.contains("offcanvas-lg") && !html.contains("offcanvas-md"));
}
#[pagetop::test]
async fn offcanvas_panel_title_labels_the_panel() {
let start = bs::offcanvas::Placement::Start;
let html = render(bs::Navbar::offcanvas(
bs::navbar::Panel::new()
.with_title(Lc::n("Main menu"))
.with_placement(start),
))
.await;
let target = target(&html);
assert!(html.contains(&format!(
r#"<h5 id="{target}-label" class="offcanvas-title">Main menu</h5>"#
)));
assert!(html.contains(&format!(r#"aria-labelledby="{target}-label""#)));
// Without a title there is nothing to point `aria-labelledby` to.
let html = render(bs::Navbar::offcanvas(panel(start))).await;
assert!(!html.contains("<h5"));
assert!(!html.contains("aria-labelledby"));
}
#[pagetop::test]
async fn offcanvas_panel_backdrop_and_body_scroll_are_attributes() {
let panel = || bs::navbar::Panel::new();
let html = render(bs::Navbar::offcanvas(panel())).await;
assert!(!html.contains("data-bs-backdrop") && !html.contains("data-bs-scroll"));
for (backdrop, value) in [
(bs::offcanvas::Backdrop::Disabled, "false"),
(bs::offcanvas::Backdrop::Static, "static"),
] {
let html = render(bs::Navbar::offcanvas(panel().with_backdrop(backdrop))).await;
assert!(html.contains(&format!(r#"data-bs-backdrop="{value}""#)));
}
let scrolling = panel().with_body_scroll(bs::offcanvas::BodyScroll::Enabled);
let html = render(bs::Navbar::offcanvas(scrolling)).await;
assert!(html.contains(r#"data-bs-scroll="true""#));
}
#[pagetop::test]
async fn offcanvas_panel_contains_the_items_once_and_can_be_closed() {
let html = render(bs::Navbar::offcanvas(panel(bs::offcanvas::Placement::End))).await;
let target = target(&html);
// The items are the body of the panel and are not rendered anywhere else.
assert_eq!(html.matches(">Home<").count(), 1);
assert!(html.contains(r#"<div class="offcanvas-body"><ul"#));
// The close button of the header points to the same panel.
assert!(html.contains(&format!(
r##"data-bs-dismiss="offcanvas" data-bs-target="#{target}""##
)));
}

View file

@ -0,0 +1,63 @@
// Verifies the rendering of the standalone `Offcanvas`: its accessible name comes from the title,
// and it is not rendered without content.
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
fn cx() -> Context {
Context::default().with_theme(&pagetop_bootsier::Bootsier)
}
fn content() -> Html {
Html::with(|_| html! { p { "Content" } })
}
async fn render(mut offcanvas: bs::Offcanvas) -> String {
offcanvas.render(&mut cx()).await.into_string()
}
#[pagetop::test]
async fn title_labels_the_panel() {
let html = render(
bs::Offcanvas::new()
.with_id("panel")
.with_title(Lc::n("Menu"))
.with_child(content()),
)
.await;
assert!(html.contains(r#"<h5 id="panel-label" class="offcanvas-title">Menu</h5>"#));
assert!(html.contains(r#"aria-labelledby="panel-label""#));
}
#[pagetop::test]
async fn without_a_title_there_is_nothing_to_point_aria_labelledby_to() {
let html = render(bs::Offcanvas::new().with_id("panel").with_child(content())).await;
assert!(!html.contains("<h5"));
assert!(!html.contains("aria-labelledby"));
// The close button and the body are still there.
assert!(html.contains(r##"data-bs-target="#panel""##));
assert!(html.contains(r#"<div class="offcanvas-body"><p>Content</p></div>"#));
}
#[pagetop::test]
async fn is_not_rendered_without_content() {
let html = render(bs::Offcanvas::new().with_title(Lc::n("Menu"))).await;
assert!(html.is_empty());
}
#[pagetop::test]
async fn getters_of_the_options_return_their_values() {
let offcanvas = bs::Offcanvas::new()
.with_placement(bs::offcanvas::Placement::End)
.with_backdrop(bs::offcanvas::Backdrop::Static)
.with_body_scroll(bs::offcanvas::BodyScroll::Enabled)
.with_visibility(bs::offcanvas::Visibility::Show);
assert_eq!(offcanvas.placement(), bs::offcanvas::Placement::End);
assert_eq!(offcanvas.backdrop(), bs::offcanvas::Backdrop::Static);
assert_eq!(offcanvas.body_scroll(), bs::offcanvas::BodyScroll::Enabled);
assert_eq!(offcanvas.visibility(), bs::offcanvas::Visibility::Show);
}

View file

@ -44,18 +44,11 @@ impl Component for Image {
fn setup(&mut self, _cx: &mut Context) {
self.alter_prop(PropsOp::prepend_classes(match self.source() {
image::Source::Logo(_) => "image image-fluid",
image::Source::Responsive(_) => "image image-fluid",
image::Source::Logo(_) | image::Source::Responsive(_) => "image image-fluid",
image::Source::Thumbnail(_) => "image image-thumbnail",
image::Source::Plain(_) => "image",
}));
// Se asigna un tamaño predefinido para el logotipo que `with_size()` puede sobrescribir.
if matches!(self.source(), image::Source::Logo(_)) {
self.alter_prop(PropsOp::add_style("width", "1.25em"));
self.alter_prop(PropsOp::add_style("height", "1.25em"));
}
// El tamaño se aplica como declaraciones `style` individuales sobre `Props`.
match *self.size() {
image::Size::Auto => {}
@ -74,6 +67,15 @@ impl Component for Image {
self.alter_prop(PropsOp::add_style("height", v.to_string()));
}
}
// El logotipo es cuadrado. Sin `width`/`height` toma un tamaño predefinido.
if matches!(self.source(), image::Source::Logo(_))
&& self.props().get_style("width").is_none()
&& self.props().get_style("height").is_none()
{
self.alter_prop(PropsOp::add_style("width", "1.25rem"));
self.alter_prop(PropsOp::add_style("height", "1.25rem"));
}
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
@ -81,9 +83,9 @@ impl Component for Image {
image::Source::Logo(svg) => {
return Ok(svg.markup_with(cx, self.props(), self.alternative().clone()));
}
image::Source::Responsive(source) => Some(source),
image::Source::Thumbnail(source) => Some(source),
image::Source::Plain(source) => Some(source),
image::Source::Responsive(source)
| image::Source::Thumbnail(source)
| image::Source::Plain(source) => Some(source),
};
Ok(html! {
img
@ -121,7 +123,7 @@ impl Image {
self
}
/// Establece el origen de la imagen, influyendo en su disposición en el contenido.
/// Establece el origen de la imagen.
pub fn with_source(mut self, source: image::Source) -> Self {
self.source = source;
self
@ -146,8 +148,8 @@ impl From<image::Source> for Image {
impl From<image::Source> for Option<Image> {
/// Permite pasar un [`image::Source`] directamente donde se espera `impl Into<Option<Image>>`
/// (p. ej. [`Brand::with_image()`](super::super::Brand::with_image)), sin construir la
/// [`Image`] a mano.
/// (p. ej. [`Brand::with_image()`](crate::base::component::Brand::with_image)), sin construir
/// la [`Image`] a mano.
fn from(source: image::Source) -> Self {
Some(Image::with(source))
}

View file

@ -7,7 +7,9 @@ use crate::prelude::*;
pub enum Size {
/// Ajuste automático por defecto.
///
/// La imagen usa su tamaño natural o se ajusta al contenedor donde se publica.
/// La imagen usa su tamaño natural o se ajusta al contenedor donde se publica. El logotipo
/// ([`Source::Logo`]) toma un tamaño predefinido de `1.25rem` de ancho y alto, salvo que se
/// haya fijado alguna de sus dimensiones.
#[default]
Auto,
/// Establece explícitamente el **ancho y alto** de la imagen.
@ -40,6 +42,10 @@ pub enum Size {
#[derive(AutoDefault, Clone, Debug, PartialEq)]
pub enum Source {
/// Imagen con el logotipo de PageTop.
///
/// Se renderiza como un `<svg>` en línea, siempre cuadrado. Sin dimensiones explícitas
/// ([`Size::Auto`]) mide `1.25rem` de ancho y alto; si se fija sólo una, la otra se ajusta
/// proporcionalmente.
#[default]
Logo(PageTopSvg),
/// Imagen que se adapta automáticamente a su contenedor.

View file

@ -1,7 +1,7 @@
//! Definiciones para crear barras de navegación ([`Navbar`]) y sus elementos ([`Item`]).
mod props;
pub use props::Layout;
pub use props::{Layout, Position};
mod component;
pub use component::Navbar;

View file

@ -88,6 +88,9 @@ pub struct Navbar {
#[default(Breakpoint::Md)]
#[getters(copy)]
expand: Breakpoint,
/// Devuelve la posición de la barra en el documento.
#[getters(copy)]
position: navbar::Position,
/// Devuelve la lista de contenidos.
items: Children,
}
@ -115,6 +118,11 @@ impl Component for Navbar {
};
self.alter_prop(PropsOp::prepend_classes(class));
self.alter_prop(PropsOp::prepend_classes("navbar"));
let position = self.position().as_str();
if !position.is_empty() {
self.alter_prop(PropsOp::add_classes(position));
}
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
@ -237,11 +245,23 @@ impl Navbar {
/// Por debajo de ese punto de corte, en las disposiciones con botón de despliegue el contenido
/// queda oculto tras el botón. Con [`Breakpoint::Xs`] la barra nunca colapsa. Por defecto es
/// [`Breakpoint::Md`].
///
/// Sólo tiene efecto en las disposiciones con botón de despliegue: en
/// [`navbar::Layout::Simple`] y [`navbar::Layout::SimpleBrandLeft`] no hay nada que colapsar y
/// la barra siempre se muestra en línea.
pub fn with_expand(mut self, bp: Breakpoint) -> Self {
self.expand = bp;
self
}
/// Define dónde se mostrará la barra de navegación dentro del documento.
///
/// Por defecto es [`navbar::Position::Static`], la barra fluye con el documento.
pub fn with_position(mut self, position: navbar::Position) -> Self {
self.position = position;
self
}
/// Añade un nuevo contenido a la barra de navegación o modifica la lista de contenidos de la
/// barra con una operación [`TypedOp`].
///

View file

@ -18,6 +18,8 @@ pub enum Item {
Brand(Embed<Brand>),
/// Representa un menú de navegación [`Nav`].
Nav(Embed<Nav>),
/// Representa un formulario [`Form`] (búsqueda, botones...), renderizado tal cual.
Form(Embed<Form>),
/// Representa un *texto localizado* libre.
Text(Lc),
}
@ -33,6 +35,7 @@ impl Component for Item {
Self::Void => None,
Self::Brand(brand) => brand.id(),
Self::Nav(nav) => nav.id(),
Self::Form(form) => form.id(),
Self::Text(_) => None,
}
}
@ -53,6 +56,7 @@ impl Component for Item {
Self::Void => html! {},
Self::Brand(brand) => html! { (brand.render(cx).await) },
Self::Nav(nav) => html! { (nav.render(cx).await) },
Self::Form(form) => html! { (form.render(cx).await) },
Self::Text(text) => html! {
span class="navbar-text" {
(text.using(cx))
@ -77,6 +81,27 @@ impl Item {
Self::Nav(Embed::with(item))
}
/// Crea un elemento de tipo [`Form`] para añadir al contenido de [`Navbar`](super::Navbar).
///
/// # Ejemplo
///
/// Formulario de búsqueda dentro de la barra:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// let search = Form::new().with_action("/search").with_child(
/// Flex::new()
/// .with_gap(align::Gap::Both(UnitValue::RelRem(0.5)))
/// .with_child(form::input::Field::search().with_name("q"))
/// .with_child(Button::submit(Lc::n("Search"))),
/// );
///
/// let navbar = Navbar::simple().with_item(navbar::Item::form(search));
/// ```
pub fn form(form: Form) -> Self {
Self::Form(Embed::with(form))
}
/// Crea un elemento con un *texto localizado*, mostrado sin interacción.
pub fn text(item: Lc) -> Self {
Self::Text(item)

View file

@ -6,8 +6,8 @@ use crate::prelude::*;
/// [`Navbar`](super::Navbar).
///
/// Sólo recoge las combinaciones de marca y botón de despliegue independientes de cualquier
/// framework CSS. Un tema puede definir su propia variante de disposición (posiciones fijas,
/// contenido en un panel lateral...) con su propio tipo, sin depender de éste.
/// framework CSS. Un tema puede definir su propia variante de disposición (contenido en un panel
/// lateral...) con su propio tipo, sin depender de éste.
#[derive(AutoDefault, Clone, Debug)]
pub enum Layout {
/// Barra simple, sin marca de identidad y sin botón de despliegue.
@ -30,3 +30,39 @@ pub enum Layout {
/// Barra con botón de despliegue y marca de identidad, en ese orden.
BrandRight(Embed<Brand>),
}
// **< Position >***********************************************************************************
/// Posición de una barra de navegación [`Navbar`](super::Navbar) en el documento.
///
/// Las variantes `Fixed*` sacan la barra del flujo del documento, por lo que pueden tapar el
/// contenido que queda debajo: hay que reservarle espacio.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Position {
/// Barra normal, fluye con el documento.
#[default]
Static,
/// Barra fijada en la parte superior, siempre visible.
FixedTop,
/// Barra fijada en la parte inferior, siempre visible.
FixedBottom,
/// Barra que se fija en la parte superior al hacer *scroll*.
StickyTop,
/// Barra que se fija en la parte inferior al hacer *scroll*.
StickyBottom,
}
impl Position {
/// Devuelve la clase CSS asociada a la posición, o una cadena vacía si es `Static`.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::Static => "",
Self::FixedTop => "fixed-top",
Self::FixedBottom => "fixed-bottom",
Self::StickyTop => "sticky-top",
Self::StickyBottom => "sticky-bottom",
}
}
}

View file

@ -3,6 +3,11 @@ use crate::core::component::Context;
use crate::html::{Markup, Props, html};
use crate::locale::Lc;
// Color oscuro del logotipo para `LineDark` y el fondo predeterminado de `Tile`.
const DARK: (u8, u8, u8) = (10, 11, 9);
// Color claro del logotipo para `LineLight` y línea blanca de `Tile`.
const LIGHT: (u8, u8, u8) = (255, 255, 255);
/// Representación SVG del **logotipo de PageTop** para incrustar en HTML.
///
/// # Ejemplo
@ -23,10 +28,15 @@ use crate::locale::Lc;
/// div class="line_red" {
/// (PageTopSvg::LineRGB(255, 0, 0).markup())
/// }
/// div class="tile" {
/// (PageTopSvg::Tile.markup())
/// }
/// div class="tile_blue" {
/// (PageTopSvg::TileRGB(0, 82, 204).markup())
/// }
/// }
/// };
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum PageTopSvg {
/// Versión por defecto con el logotipo a color.
@ -38,6 +48,12 @@ pub enum PageTopSvg {
LineLight,
/// Versión monocroma configurable por RGB.
LineRGB(u8, u8, u8),
/// Versión icono de aplicación. Muestra un recuadro de fondo oscuro y esquinas redondeadas
/// (salvo la inferior derecha), con el logotipo ampliado y recortado en línea clara.
Tile,
/// Igual que [`Tile`](Self::Tile), aunque el color del recuadro de fondo se configura por RGB.
/// El logotipo se muestra con línea clara u oscura según su contraste con el fondo.
TileRGB(u8, u8, u8),
}
impl PageTopSvg {
@ -94,9 +110,11 @@ impl PageTopSvg {
fn path_fills(&self) -> Markup {
match self {
Self::Color => self.logo_color(),
Self::LineDark => self.logo_line(10, 11, 9),
Self::LineLight => self.logo_line(255, 255, 255),
Self::LineDark => self.logo_line(DARK.0, DARK.1, DARK.2),
Self::LineLight => self.logo_line(LIGHT.0, LIGHT.1, LIGHT.2),
Self::LineRGB(r, g, b) => self.logo_line(*r, *g, *b),
Self::Tile => self.logo_tile(DARK.0, DARK.1, DARK.2),
Self::TileRGB(r, g, b) => self.logo_tile(*r, *g, *b),
}
}
@ -125,4 +143,37 @@ impl PageTopSvg {
path fill=(logo_rgb) d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {}
}
}
fn logo_tile(&self, r: u8, g: u8, b: u8) -> Markup {
// Luminancia relativa (WCAG 2.x), a partir de los canales sRGB.
let luminance = |(r, g, b): (u8, u8, u8)| {
let linear = |c: u8| {
let c = f32::from(c) / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
};
// Elige la línea, oscura o clara, con más contraste sobre el fondo. El contraste entre dos
// luminancias es `(L_mayor + 0.05) / (L_menor + 0.05)`.
let bg = luminance((r, g, b)) + 0.05;
let (lr, lg, lb) = if bg / (luminance(DARK) + 0.05) > (luminance(LIGHT) + 0.05) / bg {
DARK
} else {
LIGHT
};
// El logotipo se amplía y desplaza para recortar todo salvo la parte superior izquierda.
let fill = format!("rgb({r},{g},{b})");
html! {
rect width="1614" height="1614" rx="200" fill=(fill) {}
rect x="1414" y="1414" width="200" height="200" fill=(fill) {}
g transform="translate(161.4 161.4) scale(1.375)" {
(self.logo_line(lr, lg, lb))
}
}
}
}

133
tests/component_image.rs Normal file
View file

@ -0,0 +1,133 @@
use pagetop::prelude::*;
async fn render(mut image: Image) -> String {
image.render(&mut Context::default()).await.into_string()
}
fn logo() -> Image {
Image::with(image::Source::logo(PageTopSvg::Color))
}
// **< Logo size >**********************************************************************************
#[pagetop::test]
async fn logo_without_size_gets_the_default_dimensions() {
let html = render(logo()).await;
assert!(html.contains(r#"style="width: 1.25rem; height: 1.25rem""#));
}
#[pagetop::test]
async fn logo_with_only_width_does_not_get_a_default_height() {
let html = render(logo().with_size(image::Size::Width(UnitValue::Px(320)))).await;
assert!(html.contains(r#"style="width: 320px""#));
assert!(!html.contains("height"));
}
#[pagetop::test]
async fn logo_with_only_height_does_not_get_a_default_width() {
let html = render(logo().with_size(image::Size::Height(UnitValue::Px(40)))).await;
assert!(html.contains(r#"style="height: 40px""#));
assert!(!html.contains("width:"));
}
#[pagetop::test]
async fn logo_with_a_percentage_keeps_a_single_dimension() {
// A percentage height would refer to the container height, not to the width.
let html = render(logo().with_size(image::Size::Width(UnitValue::RelPct(33.0)))).await;
assert!(html.contains(r#"style="width: 33%""#));
assert!(!html.contains("height"));
}
#[pagetop::test]
async fn logo_with_both_dimensions_uses_them() {
let both = render(logo().with_size(image::Size::Both(UnitValue::Px(36)))).await;
let different = render(logo().with_size(image::Size::Dimensions(
UnitValue::Px(10),
UnitValue::Px(20),
)))
.await;
assert!(both.contains(r#"style="width: 36px; height: 36px""#));
// Both dimensions are set explicitly, so the default is not applied.
assert!(different.contains(r#"style="width: 10px; height: 20px""#));
}
#[pagetop::test]
async fn logo_keeps_a_custom_style_instead_of_the_default_dimensions() {
let width = render(logo().with_prop(PropsOp::add_style("width", "3em"))).await;
let both = render(
logo()
.with_prop(PropsOp::add_style("width", "3em"))
.with_prop(PropsOp::add_style("height", "4em")),
)
.await;
assert!(width.contains(r#"style="width: 3em""#));
assert!(!width.contains("height"));
assert!(both.contains(r#"style="width: 3em; height: 4em""#));
assert!(!both.contains("1.25rem"));
}
#[pagetop::test]
async fn logo_with_a_size_without_value_gets_the_default_dimensions() {
// `UnitValue::None` writes no declaration, so no dimension is actually set.
for size in [
image::Size::Width(UnitValue::None),
image::Size::Height(UnitValue::None),
image::Size::Both(UnitValue::None),
image::Size::Dimensions(UnitValue::None, UnitValue::None),
] {
let html = render(logo().with_size(size)).await;
assert!(html.contains(r#"style="width: 1.25rem; height: 1.25rem""#));
}
}
// **< Other sources >******************************************************************************
#[pagetop::test]
async fn responsive_image_without_size_has_no_style() {
let html = render(Image::with(image::Source::responsive("/photo.jpg"))).await;
assert!(html.starts_with(r#"<img src="/photo.jpg""#));
assert!(!html.contains("style="));
}
#[pagetop::test]
async fn responsive_image_with_only_width_does_not_get_a_height() {
let html = render(
Image::with(image::Source::responsive("/photo.jpg"))
.with_size(image::Size::Width(UnitValue::Px(320))),
)
.await;
assert!(html.contains(r#"style="width: 320px""#));
assert!(!html.contains("height"));
}
#[pagetop::test]
async fn responsive_image_with_a_size_without_value_has_no_style() {
let html = render(
Image::with(image::Source::responsive("/photo.jpg"))
.with_size(image::Size::Width(UnitValue::None)),
)
.await;
assert!(!html.contains("style="));
}
// **< Alternative text >***************************************************************************
#[pagetop::test]
async fn logo_alternative_is_an_accessible_label_or_hides_the_svg() {
let labeled = render(logo().with_alternative(Lc::n("PageTop"))).await;
let decorative = render(logo()).await;
assert!(labeled.contains(r#"role="img" aria-label="PageTop""#));
assert!(decorative.contains(r#"aria-hidden="true""#));
assert!(!decorative.contains("aria-label"));
}

View file

@ -14,6 +14,23 @@ async fn is_not_rendered_when_empty() {
assert!(html.is_empty());
}
// **< navbar::Item::form >*************************************************************************
#[pagetop::test]
async fn item_form_renders_the_form() {
let mut navbar = Navbar::simple().with_item(navbar::Item::form(
Form::new()
.with_id("search")
.with_action("/search")
.with_child(Button::submit(Lc::n("Go"))),
));
let html = navbar.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<form"#));
assert!(html.contains(r#"id="search""#));
assert!(html.contains("Go"));
}
// **< Navbar + FlexItem::push_end >****************************************************************
#[pagetop::test]
@ -27,6 +44,36 @@ async fn push_end_adds_an_automatic_start_margin() {
assert!(assets.contains("_flex-item-offset_auto_{margin-inline-start:auto}"));
}
// **< Navbar::with_position >**********************************************************************
#[pagetop::test]
async fn position_is_static_by_default_and_adds_no_class() {
assert_eq!(Navbar::simple().position(), navbar::Position::Static);
let mut navbar = Navbar::simple().with_item(navbar::Item::nav(one_link_nav()));
let html = navbar.render(&mut Context::default()).await.into_string();
assert!(!html.contains("fixed-"));
assert!(!html.contains("sticky-"));
}
#[pagetop::test]
async fn position_adds_its_class() {
for (position, class) in [
(navbar::Position::FixedTop, "fixed-top"),
(navbar::Position::FixedBottom, "fixed-bottom"),
(navbar::Position::StickyTop, "sticky-top"),
(navbar::Position::StickyBottom, "sticky-bottom"),
] {
let mut navbar = Navbar::simple()
.with_position(position)
.with_item(navbar::Item::nav(one_link_nav()));
let html = navbar.render(&mut Context::default()).await.into_string();
assert!(html.contains(class), "missing `{class}` in {html}");
}
}
// **< Navbar::with_expand >************************************************************************
#[pagetop::test]

53
tests/html_logo.rs Normal file
View file

@ -0,0 +1,53 @@
use pagetop::prelude::*;
// The background is made of `rect`s; only the logo paths use the line color.
fn line_color(bg: (u8, u8, u8)) -> &'static str {
let svg = PageTopSvg::TileRGB(bg.0, bg.1, bg.2).markup().into_string();
let dark = svg.contains("<path fill=\"rgb(10,11,9)\"");
let white = svg.contains("<path fill=\"rgb(255,255,255)\"");
assert_ne!(dark, white, "the line must use exactly one color");
if dark { "dark" } else { "white" }
}
// **< PageTopSvg::TileRGB >************************************************************************
#[pagetop::test]
async fn tile_rgb_uses_a_white_line_on_dark_backgrounds() {
assert_eq!(line_color((0, 82, 204)), "white");
assert_eq!(line_color((0, 0, 0)), "white");
assert_eq!(line_color((220, 20, 60)), "white");
}
#[pagetop::test]
async fn tile_rgb_uses_a_dark_line_on_light_backgrounds() {
assert_eq!(line_color((255, 184, 75)), "dark");
assert_eq!(line_color((0, 255, 0)), "dark");
assert_eq!(line_color((255, 255, 255)), "dark");
}
#[pagetop::test]
async fn tile_rgb_switches_line_color_around_the_contrast_threshold() {
// Relative luminance ~0.181 and ~0.188 respectively; with the default dark and light lines the
// contrast with both is equal at ~0.186.
assert_eq!(line_color((118, 118, 118)), "white");
assert_eq!(line_color((120, 120, 120)), "dark");
}
#[pagetop::test]
async fn tile_keeps_a_white_line_on_its_default_dark_background() {
let svg = PageTopSvg::Tile.markup().into_string();
assert!(svg.contains("<path fill=\"rgb(255,255,255)\""));
}
#[pagetop::test]
async fn tile_keeps_a_square_bottom_right_corner() {
for svg in [
PageTopSvg::Tile.markup().into_string(),
PageTopSvg::TileRGB(0, 82, 204).markup().into_string(),
] {
// Rounded background plus a square of the same color covering the bottom-right corner.
assert!(svg.contains("rx=\"200\""));
assert!(svg.contains("<rect x=\"1414\" y=\"1414\" width=\"200\" height=\"200\""));
}
}