✨ (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.
This commit is contained in:
Manuel Cillero 2026-09-21 16:27:44 +02:00
parent f8f023b513
commit c47724bb68
14 changed files with 1298 additions and 209 deletions

View file

@ -8,12 +8,12 @@
//! 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 component;
pub use component::NavbarBootsier;

View file

@ -7,7 +7,6 @@ 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`].
///
@ -151,9 +150,6 @@ pub trait NavbarBootsier {
/// 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;
}
#[builder_impl]
@ -184,23 +180,33 @@ impl NavbarBootsier for Navbar {
));
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 `Offcanvas`)
// 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"));
}
}
}
@ -268,10 +274,13 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
}
},
// 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.
@ -298,32 +307,41 @@ 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();
@let offcanvas = setup_offcanvas(&offcanvas, cx);
@let id_content = offcanvas
.as_ref()
.and_then(|oc| oc.id()).unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
@if let Some(oc) = &offcanvas {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
},
// Barra con marca a la izquierda y contenido en offcanvas.
bs::navbar::Layout::OffcanvasBrandLeft(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
@let offcanvas = setup_offcanvas(&offcanvas, cx);
@let id_content = offcanvas
.as_ref()
.and_then(|oc| oc.id()).unwrap_or_default();
(brand.render(cx).await)
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
@if let Some(oc) = &offcanvas {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
},
// Barra con contenido en offcanvas y marca a la derecha.
bs::navbar::Layout::OffcanvasBrandRight(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
@let offcanvas = setup_offcanvas(&offcanvas, cx);
@let id_content = offcanvas
.as_ref()
.and_then(|oc| oc.id()).unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
(brand.render(cx).await)
@if let Some(oc) = offcanvas.get() {
@if let Some(oc) = &offcanvas {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
},
@ -333,6 +351,16 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
})
}
// El panel se renderiza con `render_offcanvas()` para inyectarle los contenidos de la barra, así
// que no pasa por el ciclo de vida normal. Se clona y se le aplica aquí su `setup()`; sin él no
// tendría `id` (el botón lo necesita para referenciarlo) ni las clases `offcanvas*` que lo ocultan
// y lo colocan.
fn setup_offcanvas(offcanvas: &Embed<bs::Offcanvas>, cx: &mut Context) -> Option<bs::Offcanvas> {
let mut oc = offcanvas.get()?.clone();
oc.setup(cx);
Some(oc)
}
// Traduce el `navbar::Layout` semántico de base (sin `Offcanvas`, sin `Position`/`expand`) a la
// variante equivalente de `bs::navbar::Layout`, para las barras construidas con los constructores
// heredados de `Navbar` (`simple()`, `simple_toggle()`, `simple_brand_left()`, `brand_left()`,

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)]
@ -40,62 +38,3 @@ pub enum Layout {
/// 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
}
}

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,62 @@
// Verifies that a `Navbar` with its content in an `Offcanvas` renders a working panel: the toggle
// button must point to the `id` of the panel, and the panel must carry its own placement class.
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
fn cx() -> Context {
Context::default().with_theme(&pagetop_bootsier::Bootsier)
}
fn panel(placement: bs::offcanvas::Placement) -> bs::Offcanvas {
bs::Offcanvas::new().with_placement(placement)
}
#[pagetop::test]
async fn offcanvas_button_points_to_the_panel_id() {
let brand = || Brand::new().with_title(Lc::n("PageTop"));
let navbars = [
bs::Navbar::offcanvas(panel(bs::offcanvas::Placement::Start)),
bs::Navbar::offcanvas_brand_left(brand(), panel(bs::offcanvas::Placement::Start)),
bs::Navbar::offcanvas_brand_right(brand(), panel(bs::offcanvas::Placement::Start)),
];
for mut navbar in navbars {
navbar = navbar.with_item(bs::navbar::Item::nav(
bs::Nav::new().with_item(bs::nav::Item::link(Lc::n("Home"), "/")),
));
let html = navbar.render(&mut cx()).await.into_string();
// The panel is rendered by the navbar, so the navbar must set its `id` up.
let target = html
.split(r##"data-bs-target="#"##)
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap_or_default();
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"),
] {
let mut navbar = bs::Navbar::offcanvas(panel(placement)).with_item(bs::navbar::Item::nav(
bs::Nav::new().with_item(bs::nav::Item::link(Lc::n("Home"), "/")),
));
let html = navbar.render(&mut cx()).await.into_string();
// 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}"
);
}
}