diff --git a/assets/css/basic.css b/assets/css/basic.css index 325a34d5..1f92da1f 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -353,11 +353,13 @@ body { display: block; } +/* Options and headers stay on one line, so the menu grows to fit the longest one. */ .dropdown-item { display: block; width: 100%; padding: 0.375rem 1rem; text-align: left; + white-space: nowrap; color: var(--val-color--text); text-decoration: none; background-color: transparent; @@ -387,6 +389,7 @@ body { display: block; padding: 0.5rem 1rem 0.25rem; margin: 0; + white-space: nowrap; font-size: 0.8125rem; font-weight: 600; color: var(--val-color--text--muted); @@ -672,28 +675,182 @@ input:disabled + label { color: var(--val-color--text--muted); } -@media (max-width: 767.98px) { - .navbar { +/* + * 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 + * width) never collapses and needs no rules, since the base rules above are the expanded layout. + * The widths below match the default `Theme::breakpoint_entry()` table, so a child theme that + * renames or resizes the breakpoints must also redefine these classes. + * + * 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. + */ +@media (max-width: 575.98px) { + .navbar-expand-sm { flex-wrap: wrap; } - .navbar:has(.navbar-toggle) .navbar-toggle { + .navbar-expand-sm:has(.navbar-toggle) .navbar-toggle { display: inline-block; order: -1; } - .navbar:has(.navbar-toggle) .navbar-content { + .navbar-expand-sm:has(.navbar-toggle) .navbar-content { display: none; flex-direction: column; align-items: stretch; width: 100%; } - .navbar:has(.navbar-toggle) .navbar-content.show { + .navbar-expand-sm:has(.navbar-toggle) .navbar-content.show { display: flex; } - .navbar .nav { + .navbar-expand-sm .nav { flex-direction: column; align-items: stretch; width: 100%; } + .navbar-expand-sm .nav .dropdown-menu { + position: static; + box-shadow: none; + } +} + +@media (max-width: 767.98px) { + .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 { + display: none; + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-md:has(.navbar-toggle) .navbar-content.show { + display: flex; + } + .navbar-expand-md .nav { + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-md .nav .dropdown-menu { + position: static; + box-shadow: none; + } +} + +@media (max-width: 991.98px) { + .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 { + display: none; + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-lg:has(.navbar-toggle) .navbar-content.show { + display: flex; + } + .navbar-expand-lg .nav { + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-lg .nav .dropdown-menu { + position: static; + box-shadow: none; + } +} + +@media (max-width: 1199.98px) { + .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 { + display: none; + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-xl:has(.navbar-toggle) .navbar-content.show { + display: flex; + } + .navbar-expand-xl .nav { + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-xl .nav .dropdown-menu { + position: static; + box-shadow: none; + } +} + +@media (max-width: 1399.98px) { + .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 { + display: none; + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-xxl:has(.navbar-toggle) .navbar-content.show { + display: flex; + } + .navbar-expand-xxl .nav { + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-xxl .nav .dropdown-menu { + position: static; + box-shadow: none; + } +} + +@media (max-width: 1919.98px) { + .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 { + display: none; + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-xxxl:has(.navbar-toggle) .navbar-content.show { + display: flex; + } + .navbar-expand-xxxl .nav { + flex-direction: column; + align-items: stretch; + width: 100%; + } + .navbar-expand-xxxl .nav .dropdown-menu { + position: static; + box-shadow: none; + } } /* diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index 0b948e20..54528768 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -156,7 +156,7 @@ impl Theme for Bootsier { 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, cx), + Navbar => |c| theme::bs::navbar::setup(c), 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), diff --git a/extensions/pagetop-bootsier/src/theme/bs/container.rs b/extensions/pagetop-bootsier/src/theme/bs/container.rs index f846f47f..2bb08e79 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/container.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/container.rs @@ -36,9 +36,9 @@ const EXTRA_WIDTH: &str = "bootsier.container.width"; pub trait ContainerBootsier { /// Establece el comportamiento del ancho para el contenedor. /// - /// Determina si el contenedor aplica los anchos máximos predefinidos para cada punto de - /// ruptura, o si ocupa siempre el 100% del ancho disponible, o lo hace hasta un ancho máximo - /// explícito. Ver [`Width`] para las variantes disponibles. + /// Determina si el contenedor aplica los anchos máximos predefinidos para cada punto de corte, + /// o si ocupa siempre el 100% del ancho disponible, o lo hace hasta un ancho máximo explícito. + /// Ver [`Width`] para las variantes disponibles. fn with_width(self, width: Width) -> Self; } @@ -55,12 +55,12 @@ impl ContainerBootsier for Container { /// Define cómo se comporta el ancho de un contenedor ([`Container`]). #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub enum Width { - /// Comportamiento por defecto, aplica los anchos máximos predefinidos para cada punto de - /// ruptura. Por debajo del menor punto de ruptura ocupa el 100% del ancho disponible. + /// Comportamiento por defecto, aplica los anchos máximos predefinidos para cada punto de corte. + /// Por debajo del menor punto de corte ocupa el 100% del ancho disponible. #[default] 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. + /// Aplica los anchos máximos predefinidos a partir del punto de corte indicado. Por debajo de + /// ese punto de corte ocupa el 100% del ancho disponible. From(Breakpoint), /// Ocupa el 100% del ancho disponible siempre. Fluid, @@ -73,7 +73,7 @@ impl Width { /// 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`. + /// El nombre del punto de corte se resuelve en el tema activo de `cx`. #[inline] pub fn push_to(self, cx: &Context, classes: &mut String) { match self { diff --git a/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs b/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs index b1352bba..c07fcceb 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/dropdown/props.rs @@ -112,28 +112,28 @@ 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 corte ([`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. + /// Alineación al inicio a partir del punto de corte indicado. StartAt(Breakpoint), - /// Alineación al inicio por defecto, y al final a partir de un punto de ruptura válido. + /// Alineación al inicio por defecto, y al final a partir de un punto de corte válido. StartAndEnd(Breakpoint), /// Alineación al final. End, - /// Alineación al final a partir del punto de ruptura indicado. + /// Alineación al final a partir del punto de corte indicado. EndAt(Breakpoint), - /// Alineación al final por defecto, y al inicio a partir de un punto de ruptura válido. + /// Alineación al final por defecto, y al inicio a partir de un punto de corte válido. 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`. + /// El nombre del punto de corte se resuelve en el tema activo de `cx`. #[inline] pub fn push_to(self, cx: &Context, classes: &mut String) { const MENU: &str = "dropdown-menu"; diff --git a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs index 7700641a..48059ec7 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/navbar/component.rs @@ -8,16 +8,18 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas"; const EXTRA_LAYOUT: &str = "bootsier.navbar.layout"; const EXTRA_POSITION: &str = "bootsier.navbar.position"; -const EXTRA_EXPAND: &str = "bootsier.navbar.expand"; -/// Extensión de Bootsier para [`Navbar`](crate::theme::bs::Navbar). +/// 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`](crate::theme::bs::Offcanvas)), controladas por -/// [`navbar::Layout`](crate::theme::bs::navbar::Layout). También puede fijarse en la parte superior -/// o inferior del documento mediante [`navbar::Position`](crate::theme::bs::navbar::Position), y -/// definir a partir de qué punto de ruptura deja de colapsar con -/// [`with_expand()`](Self::with_expand). +/// 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 +/// 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::Position`]: crate::theme::bs::navbar::Position /// /// # Ejemplos /// @@ -42,7 +44,6 @@ 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_item(bs::navbar::Item::nav( /// bs::Nav::new() /// .with_item(bs::nav::Item::link(Lc::n("Home"), "/")) @@ -151,9 +152,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 a partir de qué punto de ruptura la barra de navegación deja de colapsar. - 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,11 +185,6 @@ impl NavbarBootsier for Navbar { navbar } - fn with_expand(mut self, bp: Breakpoint) -> Self { - self.alter_prop(PropsOp::set_extra(EXTRA_EXPAND, bp)); - self - } - fn with_position(mut self, position: bs::navbar::Position) -> Self { self.alter_prop(PropsOp::set_extra(EXTRA_POSITION, position)); self @@ -200,15 +193,11 @@ impl NavbarBootsier for Navbar { // **< Navbar SETUP >******************************************************************************* -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); +pub(crate) fn setup(navbar: &mut Navbar) { let position = navbar .props() .extra_or(EXTRA_POSITION, bs::navbar::Position::default()); let mut classes = String::new(); - 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)); diff --git a/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs b/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs index 8ee43ea8..68709e80 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/offcanvas/component.rs @@ -14,7 +14,7 @@ use crate::theme::*; /// - Se puede configurar el borde de la ventana desde el que se desliza el panel /// ([`with_placement()`](Self::with_placement)). /// - Encabezado con título ([`with_title()`](Self::with_title)) y **botón de cierre** integrado. -/// - Puede cambiar su comportamiento a partir de un punto de ruptura +/// - Puede cambiar su comportamiento a partir de un punto de corte /// ([`with_breakpoint()`](Self::with_breakpoint)). /// - Asocia título y controles de accesibilidad a un identificador único y expone atributos /// adecuados para lectores de pantalla y navegación por teclado. @@ -47,7 +47,7 @@ pub struct Offcanvas { props: Props, /// Devuelve el título del panel. title: Lc, - /// Devuelve el punto de ruptura configurado para cambiar el comportamiento del panel. + /// Devuelve el punto de corte configurado para cambiar el comportamiento del panel. breakpoint: Breakpoint, /// Devuelve el comportamiento configurado para la capa de fondo. backdrop: bs::offcanvas::Backdrop, @@ -112,7 +112,7 @@ impl Offcanvas { self } - /// Establece el punto de ruptura a partir del cual cambia el comportamiento del panel. + /// Establece el punto de corte a partir del cual cambia el comportamiento del panel. /// /// - **Por debajo** de ese tamaño de pantalla, el componente actúa como panel deslizante /// ([`Offcanvas`]). diff --git a/extensions/pagetop-bootsier/src/theme/class.rs b/extensions/pagetop-bootsier/src/theme/class.rs index ee51497b..8ce0a0c8 100644 --- a/extensions/pagetop-bootsier/src/theme/class.rs +++ b/extensions/pagetop-bootsier/src/theme/class.rs @@ -1,7 +1,7 @@ //! Define clases para aplicar en componentes del tema. //! -//! Incluyen puntos de ruptura, colores y niveles de opacidad, escalas de tamaño y lados, necesarios -//! para crear determinadas clases del tema: +//! Incluyen colores y niveles de opacidad, escalas de tamaño y lados, necesarios para crear +//! determinadas clases del tema: //! //! ```rust,no_run //! use pagetop_bootsier::theme::*; diff --git a/extensions/pagetop-bootsier/tests/breakpoint_class.rs b/extensions/pagetop-bootsier/tests/breakpoint_class.rs index fe6514d0..659bc926 100644 --- a/extensions/pagetop-bootsier/tests/breakpoint_class.rs +++ b/extensions/pagetop-bootsier/tests/breakpoint_class.rs @@ -50,12 +50,22 @@ async fn navbar_expands_at_md_by_default_and_at_the_chosen_breakpoint() { let mut navbar = bs::Navbar::simple().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() .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() + .with_expand(Breakpoint::Xs) + .with_item(item()); + let html = navbar.render(&mut cx()).await.into_string(); + assert!(html.contains("navbar-expand")); + assert!(!html.contains("navbar-expand-")); } #[pagetop::test] diff --git a/src/base/component/navbar/component.rs b/src/base/component/navbar/component.rs index c10a5cb5..7731f8bb 100644 --- a/src/base/component/navbar/component.rs +++ b/src/base/component/navbar/component.rs @@ -3,7 +3,8 @@ use crate::prelude::*; /// Componente para crear una **barra de navegación**. /// /// Permite mostrar enlaces, menús desplegables ([`nav::Item::dropdown()`]) y una marca de -/// identidad, en distintas disposiciones controladas por [`navbar::Layout`]. +/// identidad, en distintas disposiciones controladas por [`navbar::Layout`]. El punto de corte a +/// partir del cual deja de colapsar se define con [`with_expand()`](Self::with_expand). /// /// Si no contiene elementos, el componente **no se renderiza**. /// @@ -23,11 +24,13 @@ use crate::prelude::*; /// )); /// ``` /// -/// Barra **colapsable**, con botón de despliegue: +/// Barra **colapsable**, con botón de despliegue, que muestra su contenido en línea a partir de un +/// punto de corte (por defecto, [`Breakpoint::Md`]): /// /// ```rust,no_run /// # use pagetop::prelude::*; /// let navbar = Navbar::simple_toggle() +/// .with_expand(Breakpoint::Lg) /// .with_item(navbar::Item::nav( /// Nav::new() /// .with_item(nav::Item::link(Lc::n("Home"), "/")) @@ -81,6 +84,10 @@ pub struct Navbar { props: Props, /// Devuelve la disposición configurada para la barra de navegación. layout: navbar::Layout, + /// Devuelve el punto de corte a partir del cual la barra deja de colapsar. + #[default(Breakpoint::Md)] + #[getters(copy)] + expand: Breakpoint, /// Devuelve la lista de contenidos. items: Children, } @@ -101,6 +108,12 @@ impl Component for Navbar { // de despliegue lo necesita para referenciar ese contenido colapsable con `aria-controls` // si el *layout* lo incluye. self.alter_prop(PropsOp::ensure_id(cx.build_id::(1))); + + let class: CowStr = match self.expand().resolved(cx) { + Some(entry) => util::join!("navbar-expand-", entry.name).into(), + None => "navbar-expand".into(), + }; + self.alter_prop(PropsOp::prepend_classes(class)); self.alter_prop(PropsOp::prepend_classes("navbar")); } @@ -218,6 +231,17 @@ impl Navbar { self } + /// Define a partir de qué punto de corte ([`Breakpoint`]) la barra de navegación deja de + /// colapsar y muestra su contenido en línea. + /// + /// Por debajo de ese punto de corte, en las disposiciones con botón de despliegue el contenido + /// queda oculto tras el botón. Con [`Breakpoint::Xs`] la barra nunca colapsa. Por defecto es + /// [`Breakpoint::Md`]. + pub fn with_expand(mut self, bp: Breakpoint) -> Self { + self.expand = bp; + self + } + /// Añade un nuevo contenido a la barra de navegación o modifica la lista de contenidos de la /// barra con una operación [`TypedOp`]. /// diff --git a/tests/component_navbar.rs b/tests/component_navbar.rs index 7c9b864c..e3267952 100644 --- a/tests/component_navbar.rs +++ b/tests/component_navbar.rs @@ -26,3 +26,45 @@ async fn push_end_adds_an_automatic_start_margin() { assert!(html.contains("_flex-item-offset_auto_")); assert!(assets.contains("_flex-item-offset_auto_{margin-inline-start:auto}")); } + +// **< Navbar::with_expand >************************************************************************ + +#[pagetop::test] +async fn expand_is_md_by_default_and_can_be_set() { + assert_eq!(Navbar::simple().expand(), Breakpoint::Md); + assert_eq!( + Navbar::simple().with_expand(Breakpoint::Lg).expand(), + Breakpoint::Lg + ); +} + +#[pagetop::test] +async fn expand_class_is_md_by_default() { + let mut navbar = Navbar::simple_toggle().with_item(navbar::Item::nav(one_link_nav())); + let html = navbar.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("navbar-expand-md")); +} + +#[pagetop::test] +async fn expand_class_uses_the_breakpoint_name() { + let mut navbar = Navbar::simple_toggle() + .with_expand(Breakpoint::Xl) + .with_item(navbar::Item::nav(one_link_nav())); + let html = navbar.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("navbar-expand-xl")); + assert!(!html.contains("navbar-expand-md")); +} + +#[pagetop::test] +async fn expand_class_has_no_name_without_a_breakpoint_width() { + // `Xs` has no minimum width, so the class carries no breakpoint name. + let mut navbar = Navbar::simple_toggle() + .with_expand(Breakpoint::Xs) + .with_item(navbar::Item::nav(one_link_nav())); + let html = navbar.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("navbar-expand")); + assert!(!html.contains("navbar-expand-")); +}