diff --git a/examples/navbar-menus.rs b/examples/navbar-menus.rs index d56affd7..97c2009d 100644 --- a/examples/navbar-menus.rs +++ b/examples/navbar-menus.rs @@ -568,7 +568,7 @@ fn expand_title(name: &'static str, width: &'static str) -> Lc { fn offcanvas_block() -> Block { let panel = |placement, backdrop| { - bs::Offcanvas::new() + bs::navbar::Panel::new() .with_title(Lc::t("menus_offcanvas_title", &LOC)) .with_placement(placement) .with_backdrop(backdrop) @@ -581,7 +581,10 @@ fn offcanvas_block() -> Block { bs::offcanvas::Placement::Start, bs::offcanvas::Backdrop::Enabled, )), - "Navbar::offcanvas(Offcanvas::new().with_placement(offcanvas::Placement::Start))", + concat!( + "Navbar::offcanvas(bs::navbar::Panel::new()", + ".with_placement(bs::offcanvas::Placement::Start))", + ), ), ( "menus_title_offcanvas_brand_left", @@ -592,7 +595,10 @@ fn offcanvas_block() -> Block { bs::offcanvas::Backdrop::Enabled, ), ), - "Navbar::offcanvas_brand_left(brand, Offcanvas::new().with_placement(Placement::End))", + concat!( + "Navbar::offcanvas_brand_left(brand, bs::navbar::Panel::new()", + ".with_placement(bs::offcanvas::Placement::End))", + ), ), ( "menus_title_offcanvas_brand_right", @@ -604,8 +610,8 @@ fn offcanvas_block() -> Block { ), ), concat!( - "Navbar::offcanvas_brand_right(brand, ", - "Offcanvas::new().with_backdrop(Backdrop::Disabled))", + "Navbar::offcanvas_brand_right(brand, bs::navbar::Panel::new()", + ".with_backdrop(bs::offcanvas::Backdrop::Disabled))", ), ), ( @@ -615,15 +621,14 @@ fn offcanvas_block() -> Block { panel( bs::offcanvas::Placement::End, bs::offcanvas::Backdrop::Static, - ) - .with_prop(PropsOp::set("data-bs-theme", "dark")), + ), ) .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Dark))) .with_prop(PropsOp::set("data-bs-theme", "dark")), concat!( - "Navbar::offcanvas_brand_left(brand, Offcanvas::new()", - ".with_backdrop(Backdrop::Static)", - ".with_prop(PropsOp::set(\"data-bs-theme\", \"dark\")))", + "Navbar::offcanvas_brand_left(brand, bs::navbar::Panel::new()", + ".with_backdrop(bs::offcanvas::Backdrop::Static))", + ".with_prop(PropsOp::set(\"data-bs-theme\", \"dark\"))", ), ), ]; diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar.rs index df8517b7..aae33cd5 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar.rs @@ -15,6 +15,9 @@ pub use super::Brand; pub use pagetop::base::component::Navbar; pub use pagetop::base::component::navbar::{Item, Position}; +mod panel; +pub use panel::Panel; + mod component; pub use component::NavbarBootsier; pub(crate) use component::{render, setup}; diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs index c92a4c54..07d53923 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs @@ -11,13 +11,13 @@ const EXTRA_LAYOUT: &str = "bootsier.navbar.layout"; /// 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 @@ -102,13 +102,12 @@ const EXTRA_LAYOUT: &str = "bootsier.navbar.layout"; /// ```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"), "/")) @@ -142,41 +141,43 @@ const EXTRA_LAYOUT: &str = "bootsier.navbar.layout"; /// ``` #[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; + /// 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 } @@ -187,7 +188,7 @@ impl NavbarBootsier for Navbar { pub(crate) fn setup(navbar: &mut 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`) + // 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() @@ -242,13 +243,16 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result(EXTRA_LAYOUT) @@ -266,8 +270,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)) div id=(&id_content) class="collapse navbar-collapse" { (items) @@ -285,8 +287,6 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result { - @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" { @@ -296,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" { @@ -305,45 +303,24 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result { - @let offcanvas = setup_offcanvas(&offcanvas, cx); - @let id_content = offcanvas - .as_ref() - .and_then(|oc| oc.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 { - (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 offcanvas = setup_offcanvas(&offcanvas, cx); - @let id_content = offcanvas - .as_ref() - .and_then(|oc| oc.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 { - (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 offcanvas = setup_offcanvas(&offcanvas, cx); - @let id_content = offcanvas - .as_ref() - .and_then(|oc| oc.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 { - (oc.render_offcanvas(cx, Some(navbar.items())).await) - } + (panel.render_panel(cx, &id_content, &items)) }, } } @@ -351,16 +328,6 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result, cx: &mut Context) -> Option { - 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()`, 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 b710ecd0..d20f700d 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar/props.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/props.rs @@ -26,15 +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), + /// 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/navbar_offcanvas.rs b/extensions/pagetop-bootsier/tests/navbar_offcanvas.rs index 848f7ba3..cf3bf624 100644 --- a/extensions/pagetop-bootsier/tests/navbar_offcanvas.rs +++ b/extensions/pagetop-bootsier/tests/navbar_offcanvas.rs @@ -1,5 +1,6 @@ -// 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. +// 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::*; @@ -8,31 +9,42 @@ fn cx() -> Context { Context::default().with_theme(&pagetop_bootsier::Bootsier) } -fn panel(placement: bs::offcanvas::Placement) -> bs::Offcanvas { - bs::Offcanvas::new().with_placement(placement) +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(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)), + bs::Navbar::offcanvas(panel(start)), + bs::Navbar::offcanvas_brand_left(brand(), panel(start)), + bs::Navbar::offcanvas_brand_right(brand(), panel(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(); + for navbar in navbars { + let html = render(navbar).await; - // 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(); + // 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!( @@ -47,11 +59,10 @@ 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 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(); + let html = render(bs::Navbar::offcanvas(panel(placement))).await; // Without them Bootstrap would show the panel inline instead of hiding and placing it. assert!( @@ -60,3 +71,72 @@ async fn offcanvas_panel_gets_its_own_classes() { ); } } + +#[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#"
Main menu
"# + ))); + 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("Home<").count(), 1); + assert!(html.contains(r#"
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#"
Menu
"#)); + 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("

Content

"#)); +} + +#[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); +}