♻️ (bootsier): Sustituye BreakPoint por Breakpoint

Elimina el enum BreakPoint propio de Bootsier. Los componentes pasan a
usar el Breakpoint del núcleo, cuyo nombre y ancho resuelve el tema
activo.
This commit is contained in:
Manuel Cillero 2026-09-18 18:40:59 +02:00
parent 307ca6f62c
commit 5bf28ef4f0
10 changed files with 185 additions and 113 deletions

View file

@ -125,21 +125,38 @@ impl Extension for Bootsier {
#[async_trait]
impl Theme for Bootsier {
#[rustfmt::skip]
fn breakpoint_entry(&self, bp: Breakpoint) -> BreakpointEntry {
use Breakpoint::*;
// Anchos mínimos de la rejilla de Bootstrap (`$grid-breakpoints`). Bootstrap no tiene un
// punto de corte por encima de `xxl`, así que `Xxxl` se resuelve como `xxl`.
match bp {
Xs => BreakpointEntry { breakpoint: Xs, name: "xs", min_width: "" },
Sm => BreakpointEntry { breakpoint: Sm, name: "sm", min_width: "576px" },
Md => BreakpointEntry { breakpoint: Md, name: "md", min_width: "768px" },
Lg => BreakpointEntry { breakpoint: Lg, name: "lg", min_width: "992px" },
Xl => BreakpointEntry { breakpoint: Xl, name: "xl", min_width: "1200px" },
Xxl => BreakpointEntry { breakpoint: Xxl, name: "xxl", min_width: "1400px" },
Xxxl => BreakpointEntry { breakpoint: Xxxl, name: "xxl", min_width: "1400px" },
}
}
fn intent_color(&self, intent: Intent) -> &'static str {
theme::BootsierColors::from(intent).as_str()
}
fn setup_component(&self, component: &mut dyn Component, _cx: &mut Context) {
fn setup_component(&self, component: &mut dyn Component, cx: &mut Context) {
setup_component!(component, {
Badge => |c| theme::bs::badge::setup(c),
Brand => |c| theme::bs::brand::setup(c),
Button => |c| theme::bs::button::setup(c),
Container => |c| theme::bs::container::setup(c),
Container => |c| theme::bs::container::setup(c, cx),
Dialog => |c| theme::bs::dialog::setup(c),
Dropdown => |c| theme::bs::dropdown::setup(c),
Image => |c| theme::bs::image::setup(c),
Nav => |c| theme::bs::nav::setup(c),
Navbar => |c| theme::bs::navbar::setup(c),
Navbar => |c| theme::bs::navbar::setup(c, cx),
form::input::Field => |c| theme::bs::form::input::setup(c),
form::select::Field => |c| theme::bs::form::select::setup(c),
form::Textarea => |c| theme::bs::form::textarea::setup(c),

View file

@ -11,13 +11,10 @@
//!
//! # Plantillas
//!
//! Bootsier maqueta las dos plantillas de PageTop
//! ([`CoreTemplates`](pagetop::prelude::CoreTemplates)): `Standard`, con
//! cabecera, contenido y pie, que es la plantilla por defecto de cualquier página; y `Admin`, con
//! la shell completa de AdminLTE 4 (barra superior + barra lateral + área de contenido), que se
//! activa creando la página con [`Page::admin()`](pagetop::response::Page::admin) en lugar de
//! [`Page::new()`](pagetop::response::Page::new). No define sus propias variantes de plantilla:
//! intercepta el componente `Template` en `render_component()` (ver `bs::layout`).
//! Bootsier maqueta las dos plantillas de PageTop ([`CoreTemplates`]): `Standard`, con cabecera,
//! contenido y pie, que es la plantilla por defecto de cualquier página; y `Admin`, con la shell
//! completa de administración (barra superior + barra lateral + área de contenido), que se activa
//! creando la página con [`Page::admin()`] en lugar de [`Page::new()`].
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
@ -32,6 +29,10 @@
//! }
//! ```
//!
//! [`CoreTemplates`]: pagetop::prelude::CoreTemplates
//! [`Page::admin()`]: pagetop::response::Page::admin
//! [`Page::new()`]: pagetop::response::Page::new
//!
//! # Barra lateral
//!
//! Registra elementos en [`BootsierRegions::Sidebar`](bs::BootsierRegions::Sidebar) para poblar la
@ -88,6 +89,8 @@
//! }));
//! ```
use pagetop::prelude::*;
pub mod bs;
pub mod class;
@ -113,3 +116,35 @@ pub use bs::form::textarea::TextareaBootsier;
pub use bs::nav::NavBootsier;
#[doc(hidden)]
pub use bs::navbar::NavbarBootsier;
// Añade la clase de un punto de corte con un prefijo y un sufijo (opcional) a la cadena de clases,
// separada con un espacio de las que ya hubiera: `prefix-name-suffix`, sin `-suffix` si no hay
// sufijo, y sin `-name` si el punto de corte aplica siempre.
//
// `name` es el nombre del punto de corte según `Theme::breakpoint_entry()` en el tema activo de
// `cx`. Si el tema no le asocia un ancho mínimo (`Breakpoint::resolved()` devuelve `None`), el
// punto de corte aplica siempre y se omite `name`, igual que Bootstrap con `container` frente a
// `container-lg`, o `container-fluid` frente a `container-lg-fluid`.
pub(crate) fn push_breakpoint_class(
cx: &Context,
bp: Breakpoint,
classes: &mut String,
prefix: &str,
suffix: &str,
) {
if prefix.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(prefix);
if let Some(entry) = bp.resolved(cx) {
classes.push('-');
classes.push_str(entry.name);
}
if !suffix.is_empty() {
classes.push('-');
classes.push_str(suffix);
}
}

View file

@ -26,7 +26,7 @@ const EXTRA_WIDTH: &str = "bootsier.container.width";
///
/// let main = bs::Container::main()
/// .with_id("main-page")
/// .with_width(bs::container::Width::From(BreakPoint::LG))
/// .with_width(bs::container::Width::From(Breakpoint::Lg))
/// .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light)))
/// .with_prop(PropsOp::add_classes(class::Text::with(BootsierColors::Dark)))
/// .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One)))
@ -61,7 +61,7 @@ pub enum Width {
Default,
/// Aplica los anchos máximos predefinidos a partir del punto de ruptura indicado. Por debajo de
/// ese punto de ruptura ocupa el 100% del ancho disponible.
From(BreakPoint),
From(Breakpoint),
/// Ocupa el 100% del ancho disponible siempre.
Fluid,
/// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito.
@ -72,30 +72,34 @@ impl Width {
const CONTAINER: &str = "container";
/// Añade la clase asociada al ancho del contenedor a la cadena de clases.
///
/// El nombre del punto de ruptura se resuelve en el tema activo de `cx`.
#[inline]
pub fn push_to(self, classes: &mut String) {
pub fn push_to(self, cx: &Context, classes: &mut String) {
match self {
Self::Default => BreakPoint::None.push_to(classes, Self::CONTAINER, ""),
Self::From(bp) => bp.push_to(classes, Self::CONTAINER, ""),
Self::Default => {
push_breakpoint_class(cx, Breakpoint::Xs, classes, Self::CONTAINER, "")
}
Self::From(bp) => push_breakpoint_class(cx, bp, classes, Self::CONTAINER, ""),
Self::Fluid | Self::FluidMax(_) => {
BreakPoint::None.push_to(classes, Self::CONTAINER, "fluid")
push_breakpoint_class(cx, Breakpoint::Xs, classes, Self::CONTAINER, "fluid")
}
}
}
/// Devuelve la clase asociada al ancho del contenedor.
pub fn to_class(self) -> String {
pub fn to_class(self, cx: &Context) -> String {
let mut class = String::new();
self.push_to(&mut class);
self.push_to(cx, &mut class);
class
}
}
// **< Container SETUP >****************************************************************************
pub(crate) fn setup(container: &mut Container) {
pub(crate) fn setup(container: &mut Container, cx: &Context) {
let width = container.props().extra_or(EXTRA_WIDTH, Width::default());
container.alter_prop(PropsOp::prepend_classes(width.to_class()));
container.alter_prop(PropsOp::prepend_classes(width.to_class(cx)));
if let Width::FluidMax(w) = width
&& w.is_measurable()
{

View file

@ -168,7 +168,7 @@ pub(crate) async fn render(
let auto_close = auto_close.opt_str();
let menu_classes = {
let mut classes = "dropdown-menu".to_string();
menu_align.push_to(&mut classes);
menu_align.push_to(cx, &mut classes);
classes
};

View file

@ -112,65 +112,69 @@ impl Direction {
/// Alineación horizontal del menú desplegable [`Dropdown`](crate::theme::bs::Dropdown).
///
/// Permite alinear el menú al inicio o al final del botón (respetando LTR/RTL) y añadirle una
/// alineación diferente a partir de un punto de ruptura ([`BreakPoint`]).
/// alineación diferente a partir de un punto de ruptura ([`Breakpoint`]).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum MenuAlign {
/// Alineación al inicio (comportamiento por defecto).
#[default]
Start,
/// Alineación al inicio a partir del punto de ruptura indicado.
StartAt(BreakPoint),
StartAt(Breakpoint),
/// Alineación al inicio por defecto, y al final a partir de un punto de ruptura válido.
StartAndEnd(BreakPoint),
StartAndEnd(Breakpoint),
/// Alineación al final.
End,
/// Alineación al final a partir del punto de ruptura indicado.
EndAt(BreakPoint),
EndAt(Breakpoint),
/// Alineación al final por defecto, y al inicio a partir de un punto de ruptura válido.
EndAndStart(BreakPoint),
EndAndStart(Breakpoint),
}
impl MenuAlign {
/// Añade las clases de alineación a la cadena de clases (sin incluir la base `dropdown-menu`).
///
/// El nombre del punto de ruptura se resuelve en el tema activo de `cx`.
#[inline]
pub fn push_to(self, classes: &mut String) {
pub fn push_to(self, cx: &Context, classes: &mut String) {
const MENU: &str = "dropdown-menu";
match self {
// Alineación por defecto (start), no añade clases extra.
Self::Start => {}
// `dropdown-menu-{bp}-start`
Self::StartAt(bp) => {
bp.push_to(classes, "dropdown-menu", "start");
push_breakpoint_class(cx, bp, classes, MENU, "start");
}
// `dropdown-menu-start` + `dropdown-menu-{bp}-end`
Self::StartAndEnd(bp) => {
BreakPoint::None.push_to(classes, "dropdown-menu", "start");
bp.push_to(classes, "dropdown-menu", "end");
push_breakpoint_class(cx, Breakpoint::Xs, classes, MENU, "start");
push_breakpoint_class(cx, bp, classes, MENU, "end");
}
// `dropdown-menu-end`
Self::End => {
BreakPoint::None.push_to(classes, "dropdown-menu", "end");
push_breakpoint_class(cx, Breakpoint::Xs, classes, MENU, "end");
}
// `dropdown-menu-{bp}-end`
Self::EndAt(bp) => {
bp.push_to(classes, "dropdown-menu", "end");
push_breakpoint_class(cx, bp, classes, MENU, "end");
}
// `dropdown-menu-end` + `dropdown-menu-{bp}-start`
Self::EndAndStart(bp) => {
BreakPoint::None.push_to(classes, "dropdown-menu", "end");
bp.push_to(classes, "dropdown-menu", "start");
push_breakpoint_class(cx, Breakpoint::Xs, classes, MENU, "end");
push_breakpoint_class(cx, bp, classes, MENU, "start");
}
}
}
/// Devuelve las clases de alineación del menú (sin incluir la base `dropdown-menu`).
pub fn to_class(self) -> String {
pub fn to_class(self, cx: &Context) -> String {
let mut classes = String::new();
self.push_to(&mut classes);
self.push_to(cx, &mut classes);
classes
}
}

View file

@ -42,7 +42,7 @@ const EXTRA_EXPAND: &str = "bootsier.navbar.expand";
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let navbar = bs::Navbar::simple_toggle()
/// .with_expand(BreakPoint::MD)
/// .with_expand(Breakpoint::Md)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new()
/// .with_item(bs::nav::Item::link(Lc::n("Home"), "/"))
@ -88,7 +88,7 @@ const EXTRA_EXPAND: &str = "bootsier.navbar.expand";
/// .with_route(Route::from("/"));
///
/// let navbar = bs::Navbar::brand_right(brand)
/// .with_expand(BreakPoint::LG)
/// .with_expand(Breakpoint::Lg)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new()
/// .with_kind(bs::nav::Kind::Pills)
@ -152,7 +152,7 @@ pub trait NavbarBootsier {
fn offcanvas_brand_right(brand: Brand, oc: bs::Offcanvas) -> Self;
/// Define a partir de qué punto de ruptura la barra de navegación deja de colapsar.
fn with_expand(self, bp: BreakPoint) -> Self;
fn with_expand(self, bp: Breakpoint) -> Self;
/// Define dónde se mostrará la barra de navegación dentro del documento.
fn with_position(self, position: bs::navbar::Position) -> Self;
@ -187,7 +187,7 @@ impl NavbarBootsier for Navbar {
navbar
}
fn with_expand(mut self, bp: BreakPoint) -> Self {
fn with_expand(mut self, bp: Breakpoint) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_EXPAND, bp));
self
}
@ -200,15 +200,15 @@ impl NavbarBootsier for Navbar {
// **< Navbar SETUP >*******************************************************************************
pub(crate) fn setup(navbar: &mut Navbar) {
pub(crate) fn setup(navbar: &mut Navbar, cx: &Context) {
// Sin `with_expand()`, colapsa por debajo de 768px, igual que el tema Basic (que no tiene
// punto de ruptura configurable y siempre usa ese umbral, ver `static/css/basic.css`).
let expand = navbar.props().extra_or(EXTRA_EXPAND, BreakPoint::MD);
let expand = navbar.props().extra_or(EXTRA_EXPAND, Breakpoint::Md);
let position = navbar
.props()
.extra_or(EXTRA_POSITION, bs::navbar::Position::default());
let mut classes = String::new();
expand.push_to(&mut classes, "navbar-expand", "");
push_breakpoint_class(cx, expand, &mut classes, "navbar-expand", "");
position.push_to(&mut classes);
if !classes.is_empty() {
navbar.alter_prop(PropsOp::add_classes(classes));

View file

@ -48,7 +48,7 @@ pub struct Offcanvas {
/// Devuelve el título del panel.
title: Lc,
/// Devuelve el punto de ruptura configurado para cambiar el comportamiento del panel.
breakpoint: BreakPoint,
breakpoint: Breakpoint,
/// Devuelve el comportamiento configurado para la capa de fondo.
backdrop: bs::offcanvas::Backdrop,
/// Indica si la página principal puede desplazarse mientras el panel está abierto.
@ -77,8 +77,8 @@ impl Component for Offcanvas {
// Clases CSS por defecto para el panel.
self.alter_prop(PropsOp::prepend_classes({
let mut classes = "offcanvas".to_string();
self.breakpoint().push_to(&mut classes, "offcanvas", "");
let mut classes = String::new();
push_breakpoint_class(cx, *self.breakpoint(), &mut classes, "offcanvas", "");
self.placement().push_to(&mut classes);
self.visibility().push_to(&mut classes);
classes
@ -118,10 +118,10 @@ impl Offcanvas {
/// ([`Offcanvas`]).
/// - **Por encima**, el contenido del panel se muestra tal cual, integrado en la página.
///
/// Por ejemplo, con `BreakPoint::Lg`, será *offcanvas* en móviles y tabletas, y visible
/// directamente en pantallas grandes. Por defecto usa `BreakPoint::None` para que sea
/// Por ejemplo, con `Breakpoint::Lg`, será *offcanvas* en móviles y tabletas, y visible
/// directamente en pantallas grandes. Por defecto usa `Breakpoint::Xs` para que sea
/// *offcanvas* siempre.
pub fn with_breakpoint(mut self, bp: BreakPoint) -> Self {
pub fn with_breakpoint(mut self, bp: Breakpoint) -> Self {
self.breakpoint = bp;
self
}

View file

@ -1,8 +1,5 @@
//! Tipos enumerados para construir clases del tema.
mod breakpoint;
pub use breakpoint::BreakPoint;
mod color;
pub use color::{BootsierColors, OpacityLevel};

View file

@ -1,61 +0,0 @@
use pagetop::prelude::*;
/// Puntos de ruptura (*breakpoints*) para aplicar diseño *responsive*.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum BreakPoint {
/// **Menos de 576px**. Dispositivos muy pequeños: teléfonos en modo vertical.
#[default]
None,
/// **576px o más**. Dispositivos pequeños: teléfonos en modo horizontal.
SM,
/// **768px o más**. Dispositivos medianos: tabletas.
MD,
/// **992px o más**. Dispositivos grandes: puestos de escritorio.
LG,
/// **1200px o más**. Dispositivos muy grandes: puestos de escritorio grandes.
XL,
/// **1400px o más**. Dispositivos extragrandes: puestos de escritorio más grandes.
XXL,
}
impl BreakPoint {
/// Devuelve la identificación del punto de ruptura (`"sm"`, `"md"`, etc.), o `""` para `None`.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "",
Self::SM => "sm",
Self::MD => "md",
Self::LG => "lg",
Self::XL => "xl",
Self::XXL => "xxl",
}
}
// Añade el punto de ruptura con un prefijo y un sufijo (opcional) a la cadena de clases.
//
// - Para `None`: `prefix` o `prefix-suffix` (si `suffix` no está vacío).
// - Para `SM..XXL`: `prefix-{breakpoint}` o `prefix-{breakpoint}-{suffix}`.
#[inline]
pub(crate) fn push_to(self, classes: &mut String, prefix: &str, suffix: &str) {
if prefix.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::None => classes.push_str(prefix),
_ => {
classes.push_str(prefix);
classes.push('-');
classes.push_str(self.as_str());
}
}
if !suffix.is_empty() {
classes.push('-');
classes.push_str(suffix);
}
}
}

View file

@ -0,0 +1,76 @@
// Verifies that Bootsier builds its breakpoint-based Bootstrap classes from the breakpoint names
// of `Bootsier::breakpoint_entry()`, and that `Xxxl` (absent in Bootstrap) resolves as `xxl`.
use pagetop::prelude::*;
use pagetop_bootsier::theme::*;
fn cx() -> Context {
Context::default().with_theme(&pagetop_bootsier::Bootsier)
}
#[pagetop::test]
async fn container_width_uses_breakpoint_name() {
let cx = cx();
assert_eq!(bs::container::Width::Default.to_class(&cx), "container");
assert_eq!(bs::container::Width::Fluid.to_class(&cx), "container-fluid");
assert_eq!(
bs::container::Width::From(Breakpoint::Lg).to_class(&cx),
"container-lg"
);
}
#[pagetop::test]
async fn container_width_xxxl_resolves_as_xxl() {
let cx = cx();
assert_eq!(
bs::container::Width::From(Breakpoint::Xxxl).to_class(&cx),
"container-xxl"
);
}
#[pagetop::test]
async fn dropdown_menu_align_combines_breakpoint_classes() {
let cx = cx();
assert_eq!(bs::dropdown::MenuAlign::Start.to_class(&cx), "");
assert_eq!(
bs::dropdown::MenuAlign::EndAt(Breakpoint::Md).to_class(&cx),
"dropdown-menu-md-end"
);
assert_eq!(
bs::dropdown::MenuAlign::StartAndEnd(Breakpoint::Xl).to_class(&cx),
"dropdown-menu-start dropdown-menu-xl-end"
);
}
#[pagetop::test]
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 html = navbar.render(&mut cx()).await.into_string();
assert!(html.contains("navbar-expand-md"));
let mut navbar = bs::Navbar::simple()
.with_expand(Breakpoint::Xxxl)
.with_item(item());
let html = navbar.render(&mut cx()).await.into_string();
assert!(html.contains("navbar-expand-xxl"));
}
#[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}`.
let panel =
|offcanvas: bs::Offcanvas| offcanvas.with_child(Html::with(|_| html! { p { "Content" } }));
let mut plain = panel(bs::Offcanvas::new());
let html = plain.render(&mut cx()).await.into_string();
assert!(html.contains(r#"class="offcanvas "#));
assert!(!html.contains("offcanvas-lg"));
let mut responsive = panel(bs::Offcanvas::new().with_breakpoint(Breakpoint::Lg));
let html = responsive.render(&mut cx()).await.into_string();
assert!(html.contains(r#"class="offcanvas-lg "#));
assert!(!html.contains(r#"class="offcanvas "#));
}