♻️ (bootsier): Cambia Offcanvas por Panel en Navbar

This commit is contained in:
Manuel Cillero 2026-09-22 18:58:43 +02:00
parent c47724bb68
commit 6df71885cc
8 changed files with 388 additions and 168 deletions

View file

@ -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\"))",
),
),
];

View file

@ -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};

View file

@ -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<Markup,
return Ok(html! {});
}
// `Navbar::setup()` (base) garantiza que habrá un `id` antes de renderizar.
// `Navbar::setup()` (base) garantiza que habrá un `id` antes de renderizar. El área de
// contenido (colapsable o panel lateral) deriva su identificador del de la barra.
let id = navbar.id().unwrap();
let id_content = util::join!(&id, "-content");
// `with_layout()` (extra propio de Bootsier) tiene prioridad; si no se ha usado, se traduce el
// `navbar::Layout` de base que hayan podido fijar los constructores heredados de `Navbar`
// (`simple()`, `brand_left()`...), que Bootsier no puede sobrescribir por nombre -las funciones
// inherentes de base siempre ganan sobre las de un trait con el mismo nombre-.
// El extra `EXTRA_LAYOUT` (fijado por los constructores propios de Bootsier `offcanvas()`,
// `offcanvas_brand_left()`, `offcanvas_brand_right()`) tiene prioridad; si no se ha fijado, se
// traduce el `navbar::Layout` de base que hayan podido fijar los constructores heredados de
// `Navbar` (`simple()`, `brand_left()`...), que Bootsier no puede sobrescribir por nombre -las
// funciones inherentes de base siempre ganan sobre las de un trait con el mismo nombre-.
let layout = navbar
.props()
.extra::<bs::navbar::Layout>(EXTRA_LAYOUT)
@ -266,8 +270,6 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
// Barra sencilla que se puede contraer/expandir.
bs::navbar::Layout::SimpleToggle => {
@let id_content = util::join!(&id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(&id_content) class="collapse navbar-collapse" {
(items)
@ -285,8 +287,6 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
// 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" {
@ -296,8 +296,6 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
// Barra con botón a la izquierda y marca a la derecha.
bs::navbar::Layout::BrandRight(brand) => {
@let id_content = util::join!(&id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
(brand.render(cx).await)
div id=(&id_content) class="collapse navbar-collapse" {
@ -305,45 +303,24 @@ pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup,
}
},
// Barra cuyo contenido se muestra en un offcanvas, sin marca.
bs::navbar::Layout::Offcanvas(offcanvas) => {
@let 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<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

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

View file

@ -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<Brand>),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
/// izquierda y sin marca de identidad.
Offcanvas(Embed<bs::Offcanvas>),
/// Contenido en un [`Panel`](crate::theme::bs::navbar::Panel) lateral, con botón de despliegue
/// a la izquierda y sin marca de identidad.
Offcanvas(bs::navbar::Panel),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con marca de identidad a la
/// izquierda y botón de despliegue a la derecha.
OffcanvasBrandLeft(Embed<Brand>, Embed<bs::Offcanvas>),
/// Contenido en un [`Panel`](crate::theme::bs::navbar::Panel) lateral, con marca de identidad
/// a la izquierda y botón de despliegue a la derecha.
OffcanvasBrandLeft(Embed<Brand>, bs::navbar::Panel),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
/// izquierda y marca de identidad a la derecha.
OffcanvasBrandRight(Embed<Brand>, Embed<bs::Offcanvas>),
/// Contenido en un [`Panel`](crate::theme::bs::navbar::Panel) lateral, con botón de despliegue
/// a la izquierda y marca de identidad a la derecha.
OffcanvasBrandRight(Embed<Brand>, bs::navbar::Panel),
}

View file

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

View file

@ -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#"<h5 id="{target}-label" class="offcanvas-title">Main menu</h5>"#
)));
assert!(html.contains(&format!(r#"aria-labelledby="{target}-label""#)));
// Without a title there is nothing to point `aria-labelledby` to.
let html = render(bs::Navbar::offcanvas(panel(start))).await;
assert!(!html.contains("<h5"));
assert!(!html.contains("aria-labelledby"));
}
#[pagetop::test]
async fn offcanvas_panel_backdrop_and_body_scroll_are_attributes() {
let panel = || bs::navbar::Panel::new();
let html = render(bs::Navbar::offcanvas(panel())).await;
assert!(!html.contains("data-bs-backdrop") && !html.contains("data-bs-scroll"));
for (backdrop, value) in [
(bs::offcanvas::Backdrop::Disabled, "false"),
(bs::offcanvas::Backdrop::Static, "static"),
] {
let html = render(bs::Navbar::offcanvas(panel().with_backdrop(backdrop))).await;
assert!(html.contains(&format!(r#"data-bs-backdrop="{value}""#)));
}
let scrolling = panel().with_body_scroll(bs::offcanvas::BodyScroll::Enabled);
let html = render(bs::Navbar::offcanvas(scrolling)).await;
assert!(html.contains(r#"data-bs-scroll="true""#));
}
#[pagetop::test]
async fn offcanvas_panel_contains_the_items_once_and_can_be_closed() {
let html = render(bs::Navbar::offcanvas(panel(bs::offcanvas::Placement::End))).await;
let target = target(&html);
// The items are the body of the panel and are not rendered anywhere else.
assert_eq!(html.matches(">Home<").count(), 1);
assert!(html.contains(r#"<div class="offcanvas-body"><ul"#));
// The close button of the header points to the same panel.
assert!(html.contains(&format!(
r##"data-bs-dismiss="offcanvas" data-bs-target="#{target}""##
)));
}

View file

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