diff --git a/assets/css/basic.css b/assets/css/basic.css index 2fe4bb7c..b323e0e0 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -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; } diff --git a/examples/locale/en-US/navbar-menus.ftl b/examples/locale/en-US/navbar-menus.ftl index 98de2401..7ed6654c 100644 --- a/examples/locale/en-US/navbar-menus.ftl +++ b/examples/locale/en-US/navbar-menus.ftl @@ -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 diff --git a/examples/locale/es-ES/navbar-menus.ftl b/examples/locale/es-ES/navbar-menus.ftl index a87e407d..33617636 100644 --- a/examples/locale/es-ES/navbar-menus.ftl +++ b/examples/locale/es-ES/navbar-menus.ftl @@ -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 diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index 54cb37b7..97c2009d 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -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 { - 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 { + 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::), + "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, &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) -> 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 } diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar.rs index e79213dd..aae33cd5 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar.rs @@ -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; diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs index 48059ec7..07d53923 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs @@ -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::(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(EXTRA_LAYOUT) @@ -260,24 +270,23 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result { - @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 { - @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 { - @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)) }, } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar/panel.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar/panel.rs new file mode 100644 index 00000000..1151dd25 --- /dev/null +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/panel.rs @@ -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) } + } + } + } +} diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar/props.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar/props.rs index d23c75c1..d20f700d 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar/props.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/props.rs @@ -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), - /// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la - /// izquierda y sin marca de identidad. - Offcanvas(Embed), + /// 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, Embed), + /// 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, 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, Embed), -} - -// **< 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, bs::navbar::Panel), } diff --git a/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs b/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs index 68709e80..729dc0cf 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs @@ -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 { - 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) - } - } - } - } } diff --git a/extensions/pagetop-bootsier/tests/breakpoint_class.rs b/extensions/pagetop-bootsier/tests/breakpoint_class.rs index 1261c8e5..cdadbec0 100644 --- a/extensions/pagetop-bootsier/tests/breakpoint_class.rs +++ b/extensions/pagetop-bootsier/tests/breakpoint_class.rs @@ -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#"