♻️ (bootsier): Reorganiza en capas token/class/bs

Sustituye `attrs/` y `classes/` por tres capas diferenciadas:
- `token/`: primitivas CSS (BreakPoint, Color, ScaleSize, etc.).
- `class/`: tipos compuestos (Border, Background, Margin, etc.).
- `bs/*/props.rs`: props tipadas para cada componente de Bootstrap.

El patrón de construcción es `suffix()` -> `push_to()` -> `to_class()`.
`push_to()` es `pub` en `class/` y `bs/*/props.rs` para que los temas
derivados puedan acceder a los mismos mecanismos de construcción.
This commit is contained in:
Manuel Cillero 2026-06-29 20:32:20 +02:00
parent 64db114eb0
commit f37db56ec5
47 changed files with 1307 additions and 1201 deletions

View file

@ -0,0 +1 @@
pub use pagetop::base::component::{Button, ButtonAction};

View file

@ -0,0 +1,14 @@
//! Definiciones para crear contenedores de componentes ([`Container`]).
//!
//! Cada contenedor envuelve contenido usando la etiqueta semántica indicada por
//! [`container::Kind`](crate::theme::bs::container::Kind).
//!
//! Con [`container::Width`](crate::theme::bs::container::Width) se puede definir el ancho y el
//! comportamiento *responsive* del contenedor. También permite aplicar utilidades de estilo para el
//! fondo, texto, borde o esquinas redondeadas.
mod props;
pub use props::{Kind, Width};
mod component;
pub use component::Container;

View file

@ -0,0 +1,171 @@
use pagetop::prelude::*;
use crate::theme::*;
/// Componente para crear un **contenedor de componentes**
/// ([`container`](crate::theme::bs::container)).
///
/// Envuelve un conjunto de componentes en un contenedor establecido que se crea aplicando uno de
/// los tipos definidos en [`container::Kind`](crate::theme::bs::container::Kind).
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop_bootsier::theme::*;
///
/// let main = bs::Container::main()
/// .with_id("main-page")
/// .with_width(bs::container::Width::From(token::BreakPoint::LG));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Container {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el tipo semántico del contenedor.
container_kind: bs::container::Kind,
/// Devuelve el comportamiento para el ancho del contenedor.
container_width: bs::container::Width,
/// Devuelve la lista de componentes (`children`) del contenedor.
children: Children,
}
impl Component for Container {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
self.alter_prop(PropsOp::prepend_classes(self.container_width().to_class()));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let output = self.children().render(cx);
if output.is_empty() {
return Ok(html! {});
}
let style = match self.container_width() {
bs::container::Width::FluidMax(w) if w.is_measurable() => {
Some(util::join!("max-width: ", w.to_string(), ";"))
}
_ => None,
};
Ok(match self.container_kind() {
bs::container::Kind::Default => html! {
div (self.props()) style=[style] {
(output)
}
},
bs::container::Kind::Main => html! {
main (self.props()) style=[style] {
(output)
}
},
bs::container::Kind::Header => html! {
header (self.props()) style=[style] {
(output)
}
},
bs::container::Kind::Footer => html! {
footer (self.props()) style=[style] {
(output)
}
},
bs::container::Kind::Section => html! {
section (self.props()) style=[style] {
(output)
}
},
bs::container::Kind::Article => html! {
article (self.props()) style=[style] {
(output)
}
},
})
}
}
impl Container {
/// Crea un contenedor de tipo `Main` (`<main>`).
pub fn main() -> Self {
Self {
container_kind: bs::container::Kind::Main,
..Default::default()
}
}
/// Crea un contenedor de tipo `Header` (`<header>`).
pub fn header() -> Self {
Self {
container_kind: bs::container::Kind::Header,
..Default::default()
}
}
/// Crea un contenedor de tipo `Footer` (`<footer>`).
pub fn footer() -> Self {
Self {
container_kind: bs::container::Kind::Footer,
..Default::default()
}
}
/// Crea un contenedor de tipo `Section` (`<section>`).
pub fn section() -> Self {
Self {
container_kind: bs::container::Kind::Section,
..Default::default()
}
}
/// Crea un contenedor de tipo `Article` (`<article>`).
pub fn article() -> Self {
Self {
container_kind: bs::container::Kind::Article,
..Default::default()
}
}
// **< Container BUILDER >**********************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
///
/// También acepta clases predefinidas para:
///
/// - Modificar el color de fondo ([`Background`]).
/// - Definir la apariencia del texto ([`Text`]).
/// - Establecer bordes ([`Border`]).
/// - Redondear las esquinas ([`Rounded`]).
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Establece el comportamiento del ancho para el contenedor.
#[builder_fn]
pub fn with_width(mut self, width: bs::container::Width) -> Self {
self.container_width = width;
self
}
/// Añade un nuevo componente al contenedor o modifica la lista de componentes (`children`) con
/// una operación [`ChildOp`].
#[builder_fn]
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}

View file

@ -0,0 +1,67 @@
use pagetop::prelude::*;
use crate::theme::*;
// **< Kind >***************************************************************************************
/// Tipo de contenedor ([`Container`](crate::theme::bs::Container)).
///
/// Permite aplicar la etiqueta HTML apropiada (`<main>`, `<header>`, etc.) manteniendo una API
/// común a todos los contenedores.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// Contenedor genérico (`<div>`).
#[default]
Default,
/// Contenido principal de la página (`<main>`).
Main,
/// Encabezado de la página o de sección (`<header>`).
Header,
/// Pie de la página o de sección (`<footer>`).
Footer,
/// Sección de contenido (`<section>`).
Section,
/// Artículo de contenido (`<article>`).
Article,
}
// **< Width >**************************************************************************************
/// Define cómo se comporta el ancho de un contenedor ([`Container`](crate::theme::bs::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.
#[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.
From(token::BreakPoint),
/// Ocupa el 100% del ancho disponible siempre.
Fluid,
/// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito.
FluidMax(UnitValue),
}
impl Width {
const CONTAINER: &str = "container";
/// Añade la clase asociada al ancho del contenedor a la cadena de clases.
#[inline]
pub fn push_to(self, classes: &mut String) {
match self {
Self::Default => token::BreakPoint::None.push_to(classes, Self::CONTAINER, ""),
Self::From(bp) => bp.push_to(classes, Self::CONTAINER, ""),
Self::Fluid | Self::FluidMax(_) => {
token::BreakPoint::None.push_to(classes, Self::CONTAINER, "fluid")
}
}
}
/// Devuelve la clase asociada al ancho del contenedor.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}

View file

@ -0,0 +1,17 @@
//! Definiciones para crear menús desplegables ([`Dropdown`]).
//!
//! Cada [`dropdown::Item`](crate::theme::bs::dropdown::Item) representa un elemento individual del
//! desplegable [`Dropdown`], con distintos comportamientos según su finalidad, como enlaces de
//! navegación, botones de acción, encabezados o divisores visuales.
//!
//! Los ítems pueden estar activos, deshabilitados o abrirse en nueva ventana según su contexto y
//! configuración, y permiten incluir etiquetas localizables usando [`L10n`](pagetop::locale::L10n).
mod props;
pub use props::{AutoClose, Direction, MenuAlign, MenuPosition};
mod component;
pub use component::Dropdown;
mod item;
pub use item::{Item, ItemKind};

View file

@ -0,0 +1,275 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
/// Componente para crear un **menú desplegable** ([`dropdown`](crate::theme::bs::dropdown)).
///
/// Renderiza un botón (único o desdoblado, ver [`with_button_split()`](Self::with_button_split))
/// con un menú desplegable de elementos [`dropdown::Item`](crate::theme::bs::dropdown::Item), que
/// se muestra u oculta según la interacción del usuario. Admite variaciones para el tamaño y el
/// color del botón, también para la dirección de apertura, alineación o política de cierre.
///
/// Si no tiene título (ver [`with_title()`](Self::with_title)) se muestra únicamente la lista de
/// elementos sin ningún botón para interactuar.
///
/// Si este componente se usa en un menú [`Nav`](crate::theme::bs::Nav) (ver
/// [`nav::Item::dropdown()`](crate::theme::bs::nav::Item::dropdown)) sólo se tendrán en cuenta **el
/// título** (si no existe le asigna uno por defecto) y **la lista de elementos**; el resto de
/// propiedades no afectarán a su representación en [`Nav`](crate::theme::bs::Nav).
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let dd = bs::Dropdown::new()
/// .with_title(L10n::n("Menu"))
/// .with_button_color(class::ButtonColor::solid(token::Color::Secondary))
/// .with_auto_close(bs::dropdown::AutoClose::ClickableInside)
/// .with_direction(bs::dropdown::Direction::Dropend)
/// .with_item(bs::dropdown::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(bs::dropdown::Item::link_blank(L10n::n("Doc"), |_| "https://docs.rs".into()))
/// .with_item(bs::dropdown::Item::divider())
/// .with_item(bs::dropdown::Item::header(L10n::n("User session")))
/// .with_item(bs::dropdown::Item::button(L10n::n("Sign out")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Dropdown {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el título del menú desplegable.
title: L10n,
/// Devuelve el tamaño configurado del botón.
button_size: class::ButtonSize,
/// Devuelve el color/estilo configurado del botón.
button_color: class::ButtonColor,
/// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*).
button_split: bool,
/// Devuelve si el botón del menú está integrado en un grupo de botones.
button_grouped: bool,
/// Devuelve la política de cierre automático del menú desplegado.
auto_close: bs::dropdown::AutoClose,
/// Devuelve la dirección de despliegue configurada.
direction: bs::dropdown::Direction,
/// Devuelve la configuración de alineación horizontal del menú desplegable.
menu_align: bs::dropdown::MenuAlign,
/// Devuelve la posición configurada para el menú desplegable.
menu_position: bs::dropdown::MenuPosition,
/// Devuelve la lista de elementos del menú.
items: Children,
}
impl Component for Dropdown {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
self.alter_prop(PropsOp::prepend_classes(
self.direction().to_class(*self.button_grouped()),
));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Si no hay elementos en el menú, no se prepara.
let items = self.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
// Título opcional para el menú desplegable.
let title = self.title().using(cx);
Ok(html! {
div (self.props()) {
@if !title.is_empty() {
@let btn_base = {
let mut classes = String::from("btn");
self.button_size().push_to(&mut classes);
self.button_color().push_to(&mut classes);
classes
};
@let pos = self.menu_position();
@let offset = pos.data_offset();
@let reference = pos.data_reference();
@let auto_close = self.auto_close().opt_str();
@let menu_classes = {
let mut classes = "dropdown-menu".to_string();
self.menu_align().push_to(&mut classes);
classes
};
// Renderizado en modo split (dos botones) o simple (un botón).
@if *self.button_split() {
// Botón principal (acción/etiqueta).
@let btn = html! {
button
type="button"
class=(&btn_base)
{
(title)
}
};
// Botón *toggle* que abre/cierra el menú asociado.
@let btn_toggle_classes =
util::join!(&btn_base, " dropdown-toggle dropdown-toggle-split");
@let btn_toggle = html! {
button
type="button"
class=(&btn_toggle_classes)
data-bs-toggle="dropdown"
data-bs-offset=[offset]
data-bs-reference=[reference]
data-bs-auto-close=[auto_close]
aria-expanded="false"
{
span class="visually-hidden" {
(L10n::t("dropdown_toggle", &LOCALES_BOOTSIER).using(cx))
}
}
};
// Orden según dirección (en `dropstart` el *toggle* se sitúa antes).
@match self.direction() {
bs::dropdown::Direction::Dropstart => {
(btn_toggle)
ul class=(&menu_classes) { (items) }
(btn)
}
_ => {
(btn)
(btn_toggle)
ul class=(&menu_classes) { (items) }
}
}
} @else {
// Botón único con funcionalidad de *toggle*.
@let btn_toggle_classes = util::join!(&btn_base, " dropdown-toggle");
button
type="button"
class=(&btn_toggle_classes)
data-bs-toggle="dropdown"
data-bs-offset=[offset]
data-bs-reference=[reference]
data-bs-auto-close=[auto_close]
aria-expanded="false"
{
(title)
}
ul class=(&menu_classes) { (items) }
}
} @else {
// Sin botón: sólo el listado como menú contextual.
ul class="dropdown-menu" { (items) }
}
}
})
}
}
impl Dropdown {
// **< Dropdown BUILDER >***********************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Establece el título del menú desplegable.
#[builder_fn]
pub fn with_title(mut self, title: L10n) -> Self {
self.title = title;
self
}
/// Ajusta el tamaño del botón.
#[builder_fn]
pub fn with_button_size(mut self, size: class::ButtonSize) -> Self {
self.button_size = size;
self
}
/// Define el color/estilo del botón.
#[builder_fn]
pub fn with_button_color(mut self, color: class::ButtonColor) -> Self {
self.button_color = color;
self
}
/// Activa/desactiva el modo *split* (botón de acción + *toggle*).
#[builder_fn]
pub fn with_button_split(mut self, split: bool) -> Self {
self.button_split = split;
self
}
/// Indica si el botón del menú está integrado en un grupo de botones.
#[builder_fn]
pub fn with_button_grouped(mut self, grouped: bool) -> Self {
self.button_grouped = grouped;
self
}
/// Establece la política de cierre automático del menú desplegable.
#[builder_fn]
pub fn with_auto_close(mut self, auto_close: bs::dropdown::AutoClose) -> Self {
self.auto_close = auto_close;
self
}
/// Establece la dirección de despliegue del menú.
#[builder_fn]
pub fn with_direction(mut self, direction: bs::dropdown::Direction) -> Self {
self.direction = direction;
self
}
/// Configura la alineación horizontal (con posible comportamiento *responsive* adicional).
#[builder_fn]
pub fn with_menu_align(mut self, align: bs::dropdown::MenuAlign) -> Self {
self.menu_align = align;
self
}
/// Configura la posición del menú.
#[builder_fn]
pub fn with_menu_position(mut self, position: bs::dropdown::MenuPosition) -> Self {
self.menu_position = position;
self
}
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
/// [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// dropdown.with_item(dropdown::Item::link("Opción", "/ruta"));
/// dropdown.with_item(ChildOp::AddMany(vec![
/// dropdown::Item::link(...).into(),
/// dropdown::Item::divider().into(),
/// dropdown::Item::link(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

@ -0,0 +1,274 @@
use pagetop::prelude::*;
// **< ItemKind >***********************************************************************************
/// Tipos de [`dropdown::Item`](crate::theme::bs::dropdown::Item) disponibles en un menú desplegable
/// [`Dropdown`](crate::theme::bs::Dropdown).
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault, Clone, Debug)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
Void,
/// Etiqueta sin comportamiento interactivo.
Label(L10n),
/// Elemento de navegación basado en una [`RoutePath`] dinámica devuelta por
/// [`FnPathByContext`]. Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente
/// deshabilitado.
Link {
label: L10n,
route: FnPathByContext,
blank: bool,
disabled: bool,
},
/// Acción ejecutable en la propia página, sin navegación asociada. Inicialmente puede estar
/// deshabilitado.
Button { label: L10n, disabled: bool },
/// Título o encabezado que separa grupos de opciones.
Header(L10n),
/// Separador visual entre bloques de elementos.
Divider,
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú desplegable
/// [`Dropdown`](crate::theme::bs::Dropdown).
///
/// Cada instancia de [`dropdown::Item`](crate::theme::bs::dropdown::Item) se traduce en un
/// componente visible que puede comportarse como texto, enlace, botón, encabezado o separador,
/// según su [`ItemKind`].
///
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => html! {
li (self.props()) {
span class="dropdown-item-text" {
(label.using(cx))
}
}
},
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "dropdown-item".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = disabled.then_some("true");
let tabindex = disabled.then_some("-1");
html! {
li (self.props()) {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
tabindex=[tabindex]
{
(label.using(cx))
}
}
}
}
ItemKind::Button { label, disabled } => {
let mut classes = "dropdown-item".to_string();
if *disabled {
classes.push_str(" disabled");
}
let aria_disabled = disabled.then_some("true");
let disabled_attr = disabled.then_some("disabled");
html! {
li (self.props()) {
button
class=(classes)
type="button"
aria-disabled=[aria_disabled]
disabled=[disabled_attr]
{
(label.using(cx))
}
}
}
}
ItemKind::Header(label) => html! {
li (self.props()) {
h6 class="dropdown-header" {
(label.using(cx))
}
}
},
ItemKind::Divider => html! {
li (self.props()) { hr class="dropdown-divider" {} }
},
})
}
}
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: L10n) -> Self {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
///
/// La ruta se obtiene invocando [`FnPathByContext`], que devuelve dinámicamente una
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta actual
/// del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
pub fn link(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: true,
},
..Default::default()
}
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: true,
},
..Default::default()
}
}
/// Crea un botón de acción local, sin navegación asociada.
pub fn button(label: L10n) -> Self {
Self {
item_kind: ItemKind::Button {
label,
disabled: false,
},
..Default::default()
}
}
/// Crea un botón deshabilitado.
pub fn button_disabled(label: L10n) -> Self {
Self {
item_kind: ItemKind::Button {
label,
disabled: true,
},
..Default::default()
}
}
/// Crea un encabezado para un grupo de elementos dentro del menú.
pub fn header(label: L10n) -> Self {
Self {
item_kind: ItemKind::Header(label),
..Default::default()
}
}
/// Crea un separador visual entre bloques de elementos.
pub fn divider() -> Self {
Self {
item_kind: ItemKind::Divider,
..Default::default()
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
}

View file

@ -0,0 +1,214 @@
use pagetop::prelude::*;
use crate::theme::*;
// **< AutoClose >**********************************************************************************
/// Estrategia para el cierre automático de un menú [`Dropdown`](crate::theme::bs::Dropdown).
///
/// Define cuándo se cierra el menú desplegado según la interacción del usuario.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum AutoClose {
/// Comportamiento por defecto, se cierra con clics dentro y fuera del menú, o pulsando `Esc`.
#[default]
Default,
/// Sólo se cierra con clics dentro del menú.
ClickableInside,
/// Sólo se cierra con clics fuera del menú.
ClickableOutside,
/// Cierre manual, no se cierra con clics; sólo al pulsar nuevamente el botón del menú
/// (*toggle*), o pulsando `Esc`.
ManualClose,
}
impl AutoClose {
/// Devuelve el valor para `data-bs-auto-close`, o `None` si es el comportamiento por defecto.
#[rustfmt::skip]
#[inline]
pub const fn opt_str(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::ClickableInside => Some("inside"),
Self::ClickableOutside => Some("outside"),
Self::ManualClose => Some("false"),
}
}
}
// **< Direction >**********************************************************************************
/// Dirección de despliegue de un menú [`Dropdown`](crate::theme::bs::Dropdown).
///
/// Controla desde qué posición se muestra el menú respecto al botón.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Direction {
/// Comportamiento por defecto (despliega el menú hacia abajo desde la posición inicial,
/// respetando LTR/RTL).
#[default]
Default,
/// Centra horizontalmente el menú respecto al botón.
Centered,
/// Despliega el menú hacia arriba.
Dropup,
/// Despliega el menú hacia arriba y centrado.
DropupCentered,
/// Despliega el menú desde el lateral final, respetando LTR/RTL.
Dropend,
/// Despliega el menú desde el lateral inicial, respetando LTR/RTL.
Dropstart,
}
impl Direction {
/// Devuelve la clase base asociada a la dirección del menú.
///
/// Cuando `grouped` es `true` y la variante es `Default`, devuelve `""` porque el grupo de
/// botones ya implica el contenedor; en caso contrario devuelve `"dropdown"`.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self, grouped: bool) -> &'static str {
match self {
Self::Default if grouped => "",
Self::Default => "dropdown",
Self::Centered => "dropdown-center",
Self::Dropup => "dropup",
Self::DropupCentered => "dropup-center",
Self::Dropend => "dropend",
Self::Dropstart => "dropstart",
}
}
/// Añade la clase asociada a la dirección del menú a la cadena de clases.
///
/// Ver [`as_str()`](Self::as_str) para el efecto del parámetro `grouped`.
#[inline]
pub fn push_to(self, classes: &mut String, grouped: bool) {
if grouped {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str("btn-group");
}
let class = self.as_str(grouped);
if !class.is_empty() {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
}
/// Devuelve la clase asociada a la dirección del menú.
///
/// Ver [`as_str()`](Self::as_str) para el efecto del parámetro `grouped`.
pub fn to_class(self, grouped: bool) -> String {
let mut classes = String::new();
self.push_to(&mut classes, grouped);
classes
}
}
// **< MenuAlign >**********************************************************************************
/// 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`]).
#[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(token::BreakPoint),
/// Alineación al inicio por defecto, y al final a partir de un punto de ruptura válido.
StartAndEnd(token::BreakPoint),
/// Alineación al final.
End,
/// Alineación al final a partir del punto de ruptura indicado.
EndAt(token::BreakPoint),
/// Alineación al final por defecto, y al inicio a partir de un punto de ruptura válido.
EndAndStart(token::BreakPoint),
}
impl MenuAlign {
/// Añade las clases de alineación a la cadena de clases (sin incluir la base `dropdown-menu`).
#[inline]
pub fn push_to(self, classes: &mut String) {
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");
}
// `dropdown-menu-start` + `dropdown-menu-{bp}-end`
Self::StartAndEnd(bp) => {
token::BreakPoint::None.push_to(classes, "dropdown-menu", "start");
bp.push_to(classes, "dropdown-menu", "end");
}
// `dropdown-menu-end`
Self::End => {
token::BreakPoint::None.push_to(classes, "dropdown-menu", "end");
}
// `dropdown-menu-{bp}-end`
Self::EndAt(bp) => {
bp.push_to(classes, "dropdown-menu", "end");
}
// `dropdown-menu-end` + `dropdown-menu-{bp}-start`
Self::EndAndStart(bp) => {
token::BreakPoint::None.push_to(classes, "dropdown-menu", "end");
bp.push_to(classes, "dropdown-menu", "start");
}
}
}
/// Devuelve las clases de alineación del menú (sin incluir la base `dropdown-menu`).
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_to(&mut classes);
classes
}
}
// **< MenuPosition >*******************************************************************************
/// Posición relativa del menú desplegable [`Dropdown`](crate::theme::bs::Dropdown).
///
/// Permite indicar un desplazamiento (*offset*) manual o referenciar al elemento padre para el
/// cálculo de la posición.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum MenuPosition {
/// Posicionamiento automático por defecto.
#[default]
Default,
/// Desplazamiento manual en píxeles `(x, y)` aplicado al menú. Se admiten valores negativos.
Offset(i8, i8),
/// Posiciona el menú tomando como referencia el botón padre. Especialmente útil cuando
/// [`button_split()`](crate::theme::bs::Dropdown::button_split) es `true`.
Parent,
}
impl MenuPosition {
// Devuelve el valor para `data-bs-offset` o `None` si no aplica.
#[inline]
pub(crate) fn data_offset(self) -> Option<String> {
match self {
Self::Offset(x, y) => Some(format!("{x},{y}")),
_ => None,
}
}
// Devuelve el valor para `data-bs-reference` o `None` si no aplica.
#[inline]
pub(crate) fn data_reference(self) -> Option<&'static str> {
match self {
Self::Parent => Some("parent"),
_ => None,
}
}
}

View file

@ -0,0 +1,24 @@
//! Definiciones para crear formularios ([`Form`]).
pub use pagetop::base::component::form::{Autocomplete, AutofillField, CheckboxKind, Method};
pub use pagetop::base::component::form::Form;
pub use pagetop::base::component::form::Fieldset;
pub use pagetop::base::component::form::Checkbox;
pub use pagetop::base::component::form::check;
pub use pagetop::base::component::form::radio;
pub mod select;
pub mod input;
pub mod textarea;
pub use textarea::Textarea;
pub use pagetop::base::component::form::Range;
pub use pagetop::base::component::form::Hidden;

View file

@ -0,0 +1,40 @@
//! Definiciones para crear campos de texto de una línea.
use pagetop::prelude::*;
pub use pagetop::base::component::form::input::{Field, Kind, Mode};
/// Extensión de Bootsier para [`form::input::Field`].
///
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
/// superpone al control mientras está vacío y permanece flotante cuando tiene contenido o está
/// enfocado.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let nombre = bs::form::input::Field::text()
/// .with_name("name")
/// .with_label(L10n::n("Name"))
/// .with_placeholder(L10n::n("Enter your name"))
/// .with_floating_label(true);
/// ```
pub trait InputBootsier {
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al campo y asciende al enfocarlo o cuando tiene
/// contenido. Requiere que el campo tenga un atributo `placeholder` definido; si no se
/// especifica, se fuerza `placeholder=""` antes del renderizado.
fn with_floating_label(self, floating: bool) -> Self;
}
impl InputBootsier for Field {
fn with_floating_label(self, floating: bool) -> Self {
if floating {
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
}
}

View file

@ -0,0 +1,45 @@
//! Definiciones para crear listas de selección.
use pagetop::prelude::*;
pub use pagetop::base::component::form::select::{Entry, Field, Group, Item};
/// Extensión de Bootsier para [`form::select::Field`].
///
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
/// superpone al control mientras no hay ninguna opción seleccionada y permanece flotante cuando hay
/// una selección activa.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let language = bs::form::select::Field::new()
/// .with_name("language")
/// .with_label(L10n::n("Language"))
/// .with_floating_label(true)
/// .with_item(bs::form::select::Item::new("", L10n::n("— Choose —")).with_selected(true))
/// .with_item(bs::form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(bs::form::select::Item::new("en", L10n::n("English")));
/// ```
pub trait SelectBootsier {
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al control y permanece flotante siempre que
/// haya una opción visible.
///
/// Si se usa la etiqueta flotante, se anulan los valores establecidos con
/// [`with_multiple()`](form::select::Field::with_multiple) y
/// [`with_rows()`](form::select::Field::with_rows) antes del renderizado.
fn with_floating_label(self, floating: bool) -> Self;
}
impl SelectBootsier for Field {
fn with_floating_label(self, floating: bool) -> Self {
if floating {
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
}
}

View file

@ -0,0 +1,43 @@
//! Definiciones para crear áreas de texto en formularios.
use pagetop::prelude::*;
pub use pagetop::base::component::form::Textarea;
/// Extensión de Bootsier para [`form::Textarea`].
///
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
/// superpone al control mientras no hay ninguna opción seleccionada y permanece flotante cuando hay
/// una selección activa.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let comentario = bs::form::Textarea::new()
/// .with_name("comment")
/// .with_label(L10n::n("Comment"))
/// .with_placeholder(L10n::n("Write here..."))
/// .with_floating_label(true);
/// ```
pub trait TextareaBootsier {
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al área de texto y asciende al enfocarlo o
/// cuando tiene contenido. Requiere que el campo tenga un atributo `placeholder` definido;
/// si no se especifica, se fuerza `placeholder=""` antes del renderizado.
///
/// Si se usa la etiqueta flotante, se anula el valor establecido con
/// [`with_rows()`](form::Textarea::with_rows) antes del renderizado.
fn with_floating_label(self, floating: bool) -> Self;
}
impl TextareaBootsier for Textarea {
fn with_floating_label(self, floating: bool) -> Self {
if floating {
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
}
}

View file

@ -0,0 +1,130 @@
use crate::theme::*;
const DEFAULT_VIEWBOX: &str = "0 0 16 16";
#[derive(AutoDefault, Clone)]
pub enum IconKind {
#[default]
None,
Font(FontSize),
Svg {
shapes: Markup,
viewbox: AttrValue,
},
}
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Icon {
/// Devuelve los atributos HTML y clases CSS del componente.
props: Props,
icon_kind: IconKind,
aria_label: AttrL10n,
}
impl Component for Icon {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
if !matches!(self.icon_kind(), IconKind::None) {
self.alter_prop(PropsOp::prepend_classes("icon"));
}
if let IconKind::Font(font_size) = self.icon_kind() {
self.alter_prop(PropsOp::add_classes(font_size.as_str()));
}
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.icon_kind() {
IconKind::None => html! {},
IconKind::Font(_) => {
let aria_label = self.aria_label().lookup(cx);
let has_label = aria_label.is_some();
html! {
i
(self.props())
role=[has_label.then_some("img")]
aria-label=[aria_label]
aria-hidden=[(!has_label).then_some("true")]
{}
}
}
IconKind::Svg { shapes, viewbox } => {
let aria_label = self.aria_label().lookup(cx);
let has_label = aria_label.is_some();
let viewbox = viewbox.get().unwrap_or_else(|| DEFAULT_VIEWBOX.to_string());
html! {
svg
xmlns="http://www.w3.org/2000/svg"
viewBox=(viewbox)
fill="currentColor"
focusable="false"
(self.props())
role=[has_label.then_some("img")]
aria-label=[aria_label]
aria-hidden=[(!has_label).then_some("true")]
{
(shapes)
}
}
}
})
}
}
impl Icon {
pub fn font() -> Self {
Self::default().with_icon_kind(IconKind::Font(FontSize::default()))
}
pub fn font_sized(font_size: FontSize) -> Self {
Self::default().with_icon_kind(IconKind::Font(font_size))
}
pub fn svg(shapes: Markup) -> Self {
Self::default().with_icon_kind(IconKind::Svg {
shapes,
viewbox: AttrValue::default(),
})
}
pub fn svg_with_viewbox(shapes: Markup, viewbox: impl AsRef<str>) -> Self {
Self::default().with_icon_kind(IconKind::Svg {
shapes,
viewbox: AttrValue::new(viewbox),
})
}
// **< Icon BUILDER >***************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
#[builder_fn]
pub fn with_icon_kind(mut self, icon_kind: IconKind) -> Self {
self.icon_kind = icon_kind;
self
}
#[builder_fn]
pub fn with_aria_label(mut self, label: L10n) -> Self {
self.aria_label.alter_value(label);
self
}
}

View file

@ -0,0 +1,7 @@
//! Definiciones para renderizar imágenes ([`Image`]).
mod props;
pub use props::{Size, Source};
mod component;
pub use component::Image;

View file

@ -0,0 +1,123 @@
use pagetop::prelude::*;
use crate::theme::*;
/// Componente para renderizar una **imagen** ([`image`](crate::theme::bs::image)).
///
/// A una imagen se le puede:
///
/// - Establecer su contenido a partir del origen definido en
/// [`image::Source`](crate::theme::bs::image::Source).
/// - Configurar sus **dimensiones** ([`with_size()`](Self::with_size)), **borde**
/// ([`Border`](crate::theme::class::Border)) y **redondeo de esquinas**
/// ([`Rounded`](crate::theme::class::Rounded)).
/// - Aplicar el texto alternativo `alt` con **localización** mediante [`L10n`].
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Image {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve las dimensiones de la imagen.
size: bs::image::Size,
/// Devuelve el origen de la imagen.
source: bs::image::Source,
/// Devuelve el texto alternativo localizado.
alternative: Attr<L10n>,
}
impl Component for Image {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
// Clases CSS por defecto para la imagen, según el origen seleccionado.
self.alter_prop(PropsOp::prepend_classes(self.source().to_class()));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let dimensions = self.size().to_style();
let alt_text = self.alternative().lookup(cx).unwrap_or_default();
let is_decorative = alt_text.is_empty();
let source = match self.source() {
bs::image::Source::Logo(logo) => {
return Ok(html! {
span
(self.props())
style=[dimensions]
role=[(!is_decorative).then_some("img")]
aria-label=[(!is_decorative).then_some(alt_text)]
aria-hidden=[is_decorative.then_some("true")]
{
(logo.render(cx))
}
});
}
bs::image::Source::Responsive(source) => Some(source),
bs::image::Source::Thumbnail(source) => Some(source),
bs::image::Source::Plain(source) => Some(source),
};
Ok(html! {
img
src=[source]
alt=(alt_text)
(self.props())
style=[dimensions] {}
})
}
}
impl Image {
/// Crea rápidamente una imagen especificando su origen.
pub fn with(source: bs::image::Source) -> Self {
Self::default().with_source(source)
}
// **< Image BUILDER >**************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
///
/// También acepta clases predefinidas para:
///
/// - Establecer bordes ([`Border`]).
/// - Redondear las esquinas ([`Rounded`]).
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Define las dimensiones de la imagen (auto, ancho/alto, ambos).
#[builder_fn]
pub fn with_size(mut self, size: bs::image::Size) -> Self {
self.size = size;
self
}
/// Establece el origen de la imagen, influyendo en su disposición en el contenido.
#[builder_fn]
pub fn with_source(mut self, source: bs::image::Source) -> Self {
self.source = source;
self
}
/// Define un *texto localizado* ([`L10n`]) alternativo para la imagen.
///
/// Se recomienda siempre aportar un texto alternativo salvo que la imagen sea puramente
/// decorativa.
#[builder_fn]
pub fn with_alternative(mut self, alt: L10n) -> Self {
self.alternative.alter_value(alt);
self
}
}

View file

@ -0,0 +1,126 @@
use pagetop::prelude::*;
// **< Size >***************************************************************************************
/// Define las **dimensiones** de una imagen ([`Image`](crate::theme::bs::Image)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Size {
/// Ajuste automático por defecto.
///
/// La imagen usa su tamaño natural o se ajusta al contenedor donde se publica.
#[default]
Auto,
/// Establece explícitamente el **ancho y alto** de la imagen.
///
/// Útil cuando se desea fijar ambas dimensiones de forma exacta. Ten en cuenta que la imagen
/// puede distorsionarse si no se mantiene la proporción original.
Dimensions(UnitValue, UnitValue),
/// Establece sólo el **ancho** de la imagen.
///
/// La altura se ajusta proporcionalmente de manera automática.
Width(UnitValue),
/// Establece sólo la **altura** de la imagen.
///
/// El ancho se ajusta proporcionalmente de manera automática.
Height(UnitValue),
/// Establece **el mismo valor** para el ancho y el alto de la imagen.
///
/// Práctico para forzar rápidamente un área cuadrada. Ten en cuenta que la imagen puede
/// distorsionarse si la original no es cuadrada.
Both(UnitValue),
}
impl Size {
/// Devuelve el valor del atributo `style` en función del tamaño, o `None` si no aplica.
#[inline]
pub fn to_style(self) -> Option<String> {
match self {
Self::Auto => None,
Self::Dimensions(w, h) => Some(format!("width: {w}; height: {h};")),
Self::Width(w) => Some(format!("width: {w};")),
Self::Height(h) => Some(format!("height: {h};")),
Self::Both(v) => Some(format!("width: {v}; height: {v};")),
}
}
}
// **< Source >*************************************************************************************
/// Especifica la **fuente** para publicar una imagen ([`Image`](crate::theme::bs::Image)).
#[derive(AutoDefault, Clone, Debug, PartialEq)]
pub enum Source {
/// Imagen con el logotipo de PageTop.
#[default]
Logo(PageTopSvg),
/// Imagen que se adapta automáticamente a su contenedor.
///
/// Lleva asociada la URL (o ruta) de la imagen.
Responsive(CowStr),
/// Imagen que aplica el estilo **miniatura** de Bootstrap.
///
/// Lleva asociada la URL (o ruta) de la imagen.
Thumbnail(CowStr),
/// Imagen sin clases específicas de Bootstrap, útil para controlar con CSS propio.
///
/// Lleva asociada la URL (o ruta) de la imagen.
Plain(CowStr),
}
impl Source {
const IMG_FLUID: &str = "img-fluid";
const IMG_THUMBNAIL: &str = "img-thumbnail";
/// Imagen con el logotipo de PageTop.
#[inline]
pub fn logo(svg: PageTopSvg) -> Self {
Self::Logo(svg)
}
/// Imagen responsive (`img-fluid`).
#[inline]
pub fn responsive(url: impl Into<CowStr>) -> Self {
Self::Responsive(url.into())
}
/// Imagen miniatura (`img-thumbnail`).
#[inline]
pub fn thumbnail(url: impl Into<CowStr>) -> Self {
Self::Thumbnail(url.into())
}
/// Imagen sin clases adicionales.
#[inline]
pub fn plain(url: impl Into<CowStr>) -> Self {
Self::Plain(url.into())
}
/// Devuelve la clase base asociada a la imagen según la fuente.
#[inline]
pub const fn as_str(&self) -> &'static str {
match self {
Source::Logo(_) | Source::Responsive(_) => Self::IMG_FLUID,
Source::Thumbnail(_) => Self::IMG_THUMBNAIL,
Source::Plain(_) => "",
}
}
/// Añade la clase asociada al tipo de imagen a la cadena de clases.
#[inline]
pub fn push_to(&self, classes: &mut String) {
let s = self.as_str();
if s.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(s);
}
/// Devuelve la clase asociada al tipo de imagen.
pub fn to_class(&self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}

View file

@ -0,0 +1,17 @@
//! Definiciones para crear menús ([`Nav`]).
//!
//! Cada [`nav::Item`](crate::theme::bs::nav::Item) representa un elemento individual del menú
//! [`Nav`], con distintos comportamientos según su finalidad, como enlaces de navegación o menús
//! desplegables [`Dropdown`](crate::theme::bs::Dropdown).
//!
//! Los ítems pueden estar activos, deshabilitados o abrirse en nueva ventana según su contexto y
//! configuración, y permiten incluir etiquetas localizables usando [`L10n`](pagetop::locale::L10n).
mod props;
pub use props::{Kind, Layout};
mod component;
pub use component::Nav;
mod item;
pub use item::{Item, ItemKind};

View file

@ -0,0 +1,143 @@
use pagetop::prelude::*;
use crate::theme::*;
/// Componente para crear un **menú** ([`nav`](crate::theme::bs::nav)).
///
/// Presenta un menú con una lista de elementos usando una vista básica, o alguna de sus variantes
/// ([`nav::Kind`](crate::theme::bs::nav::Kind)) como *pestañas* (`Tabs`), *botones* (`Pills`) o
/// *subrayado* (`Underline`).
/// También permite controlar su distribución y orientación
/// ([`nav::Layout`](crate::theme::bs::nav::Layout)).
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let nav = bs::Nav::tabs()
/// .with_layout(bs::nav::Layout::End)
/// .with_item(bs::nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(bs::nav::Item::link_blank(L10n::n("External"), |_| "https://docs.rs".into()))
/// .with_item(bs::nav::Item::dropdown(
/// bs::Dropdown::new()
/// .with_title(L10n::n("Options"))
/// .with_item(ChildOp::AddMany(vec![
/// bs::dropdown::Item::link(L10n::n("Action"), |_| "/action".into()).into(),
/// bs::dropdown::Item::link(L10n::n("Another"), |_| "/another".into()).into(),
/// ])),
/// ))
/// .with_item(bs::nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Nav {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el estilo visual seleccionado.
nav_kind: bs::nav::Kind,
/// Devuelve la distribución y orientación seleccionada.
nav_layout: bs::nav::Layout,
/// Devuelve la lista de elementos del menú.
items: Children,
}
impl Component for Nav {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
// Clases CSS por defecto para el menú, según el estilo y la distribución seleccionados.
self.alter_prop(PropsOp::prepend_classes({
let mut classes = "nav".to_string();
self.nav_kind().push_to(&mut classes);
self.nav_layout().push_to(&mut classes);
classes
}));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let items = self.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
Ok(html! {
ul (self.props()) {
(items)
}
})
}
}
impl Nav {
/// Crea un `Nav` usando pestañas para los elementos (*Tabs*).
pub fn tabs() -> Self {
Self::default().with_kind(bs::nav::Kind::Tabs)
}
/// Crea un `Nav` usando botones para los elementos (*Pills*).
pub fn pills() -> Self {
Self::default().with_kind(bs::nav::Kind::Pills)
}
/// Crea un `Nav` usando elementos subrayados (*Underline*).
pub fn underline() -> Self {
Self::default().with_kind(bs::nav::Kind::Underline)
}
// **< Nav BUILDER >****************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Cambia el estilo del menú (*Tabs*, *Pills*, *Underline* o *Default*).
#[builder_fn]
pub fn with_kind(mut self, kind: bs::nav::Kind) -> Self {
self.nav_kind = kind;
self
}
/// Selecciona la distribución y orientación del menú.
#[builder_fn]
pub fn with_layout(mut self, layout: bs::nav::Layout) -> Self {
self.nav_layout = layout;
self
}
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
/// [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// nav.with_item(nav::Item::link("Inicio", "/"));
/// nav.with_item(ChildOp::AddMany(vec![
/// nav::Item::link(...).into(),
/// nav::Item::link_disabled(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

@ -0,0 +1,279 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
// **< ItemKind >***********************************************************************************
/// Tipos de [`nav::Item`](crate::theme::bs::nav::Item) disponibles en un menú
/// [`Nav`](crate::theme::bs::Nav).
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault, Clone, Debug)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
Void,
/// Etiqueta sin comportamiento interactivo.
Label(L10n),
/// Elemento de navegación basado en una [`RoutePath`] dinámica devuelta por
/// [`FnPathByContext`]. Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente
/// deshabilitado.
Link {
label: L10n,
route: FnPathByContext,
blank: bool,
disabled: bool,
},
/// Contenido HTML arbitrario. El componente [`Html`] se renderiza tal cual como elemento del
/// menú, sin añadir ningún comportamiento de navegación adicional.
Html(Embed<Html>),
/// Elemento que despliega un menú [`Dropdown`](crate::theme::bs::Dropdown).
Dropdown(Embed<bs::Dropdown>),
}
impl ItemKind {
const ITEM: &str = "nav-item";
const DROPDOWN: &str = "nav-item dropdown";
/// Devuelve las clases base asociadas al tipo de elemento.
#[inline]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Void => "",
Self::Dropdown(_) => Self::DROPDOWN,
_ => Self::ITEM,
}
}
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú [`Nav`](crate::theme::bs::Nav).
///
/// Cada instancia de [`nav::Item`](crate::theme::bs::nav::Item) se traduce en un componente visible que
/// puede comportarse como texto, enlace, contenido HTML o menú desplegable, según su [`ItemKind`].
///
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
self.alter_prop(PropsOp::prepend_classes(self.item_kind().as_str()));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => html! {
li (self.props()) {
span class="nav-link disabled" aria-disabled="true" {
(label.using(cx))
}
}
},
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "nav-link".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = (*disabled).then_some("true");
html! {
li (self.props()) {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
{
(label.using(cx))
}
}
}
}
ItemKind::Html(html) => html! {
li (self.props()) {
(html.render(cx))
}
},
ItemKind::Dropdown(menu) => {
if let Some(dd) = menu.get() {
let items = dd.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
let title = dd.title().lookup(cx).unwrap_or_else(|| {
L10n::t("dropdown", &LOCALES_BOOTSIER)
.lookup(cx)
.unwrap_or_else(|| "Dropdown".to_string())
});
html! {
li (self.props()) {
a
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
href="#"
role="button"
aria-expanded="false"
{
(title)
}
ul class="dropdown-menu" {
(items)
}
}
}
} else {
html! {}
}
}
})
}
}
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: L10n) -> Self {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
///
/// La ruta se obtiene invocando [`FnPathByContext`], que devuelve dinámicamente una
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta actual
/// del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
pub fn link(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: true,
},
..Default::default()
}
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: true,
},
..Default::default()
}
}
/// Crea un elemento con contenido HTML arbitrario.
///
/// El contenido se renderiza tal cual lo devuelve el componente [`Html`], dentro de un `<li>`
/// con las clases de navegación asociadas a [`Item`].
pub fn html(html: Html) -> Self {
Self {
item_kind: ItemKind::Html(Embed::with(html)),
..Default::default()
}
}
/// Crea un elemento de navegación que contiene un menú desplegable
/// [`Dropdown`](crate::theme::bs::Dropdown).
///
/// Sólo se tienen en cuenta **el título** (si no existe, se asigna uno por defecto) y **la
/// lista de elementos** del [`Dropdown`](crate::theme::bs::Dropdown); el resto de propiedades
/// del componente no afectarán a su representación en [`Nav`](crate::theme::bs::Nav).
pub fn dropdown(menu: bs::Dropdown) -> Self {
Self {
item_kind: ItemKind::Dropdown(Embed::with(menu)),
..Default::default()
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
}

View file

@ -0,0 +1,121 @@
use pagetop::prelude::*;
// **< Kind >***************************************************************************************
/// Define la variante de presentación de un menú [`Nav`](crate::theme::bs::Nav).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// Estilo por defecto, lista de enlaces flexible y minimalista.
#[default]
Default,
/// Pestañas con borde para cambiar entre secciones.
Tabs,
/// Botones con fondo que resaltan el elemento activo.
Pills,
/// Variante con subrayado del elemento activo, estética ligera.
Underline,
}
impl Kind {
const TABS: &str = "nav-tabs";
const PILLS: &str = "nav-pills";
const UNDERLINE: &str = "nav-underline";
/// Devuelve la clase base asociada al tipo de menú, o una cadena vacía si no aplica.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::Default => "",
Self::Tabs => Self::TABS,
Self::Pills => Self::PILLS,
Self::Underline => Self::UNDERLINE,
}
}
/// Añade la clase asociada al tipo de menú a la cadena de clases.
#[inline]
pub fn push_to(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Devuelve la clase asociada al tipo de menú.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}
// **< Layout >*************************************************************************************
/// Distribución y orientación de un menú [`Nav`](crate::theme::bs::Nav).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Layout {
/// Comportamiento por defecto, ancho definido por el contenido y sin alineación forzada.
#[default]
Default,
/// Alinea los elementos al inicio de la fila.
Start,
/// Centra horizontalmente los elementos.
Center,
/// Alinea los elementos al final de la fila.
End,
/// Apila los elementos en columna.
Vertical,
/// Los elementos se expanden para rellenar la fila.
Fill,
/// Todos los elementos ocupan el mismo ancho rellenando la fila.
Justified,
}
impl Layout {
const START: &str = "justify-content-start";
const CENTER: &str = "justify-content-center";
const END: &str = "justify-content-end";
const VERTICAL: &str = "flex-column";
const FILL: &str = "nav-fill";
const JUSTIFIED: &str = "nav-justified";
/// Devuelve la clase base asociada a la distribución y orientación del menú.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::Default => "",
Self::Start => Self::START,
Self::Center => Self::CENTER,
Self::End => Self::END,
Self::Vertical => Self::VERTICAL,
Self::Fill => Self::FILL,
Self::Justified => Self::JUSTIFIED,
}
}
/// Añade la clase asociada a la distribución y orientación del menú a la cadena de clases.
#[inline]
pub fn push_to(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Devuelve la clase asociada a la distribución y orientación del menú.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}

View file

@ -0,0 +1,20 @@
//! Definiciones para crear barras de navegación ([`Navbar`]).
//!
//! Cada [`navbar::Item`](crate::theme::bs::navbar::Item) representa un elemento individual de la
//! barra de navegación [`Navbar`], con distintos comportamientos según su finalidad, como menús
//! [`Nav`](crate::theme::bs::Nav) o *textos localizados* usando [`L10n`](pagetop::locale::L10n).
//!
//! También puede añadir una marca de identidad ([`navbar::Brand`](crate::theme::bs::navbar::Brand))
//! que identifique la compañía, producto o nombre del proyecto asociado a la solución web.
mod props;
pub use props::{Layout, Position};
mod brand;
pub use brand::Brand;
mod component;
pub use component::Navbar;
mod item;
pub use item::Item;

View file

@ -0,0 +1,80 @@
use pagetop::prelude::*;
use crate::theme::*;
/// Marca de identidad para mostrar en una barra de navegación [`Navbar`](crate::theme::bs::Navbar).
///
/// Representa la identidad del sitio con una imagen, título y eslogan:
///
/// - Si hay URL ([`with_route()`](Self::with_route)), el bloque completo actúa como enlace. Por
/// defecto enlaza a la raíz del sitio (`/`).
/// - Si no hay imagen ([`with_image()`](Self::with_image)) ni título
/// ([`with_title()`](Self::with_title)), la marca de identidad no se renderiza.
/// - El eslogan ([`with_slogan()`](Self::with_slogan)) es opcional; por defecto no tiene contenido.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Brand {
/// Devuelve la imagen de marca (si la hay).
image: Embed<bs::Image>,
/// Devuelve el título de la identidad de marca.
#[default(_code = "L10n::n(&global::SETTINGS.app.name)")]
title: L10n,
/// Devuelve el eslogan de la marca.
slogan: L10n,
/// Devuelve la función que resuelve la URL asociada a la marca (si existe).
#[default(_code = "Some(|cx| cx.route(\"/\"))")]
route: Option<FnPathByContext>,
}
impl Component for Brand {
fn new() -> Self {
Self::default()
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let image = self.image().render(cx);
let title = self.title().using(cx);
if title.is_empty() && image.is_empty() {
return Ok(html! {});
}
let slogan = self.slogan().using(cx);
Ok(html! {
@if let Some(route) = self.route() {
a class="navbar-brand" href=(route(cx)) { (image) (title) (slogan) }
} @else {
span class="navbar-brand" { (image) (title) (slogan) }
}
})
}
}
impl Brand {
// **< Brand BUILDER >**************************************************************************
/// Asigna o quita la imagen de marca. Si se pasa `None`, no se mostrará.
#[builder_fn]
pub fn with_image(mut self, image: Option<bs::Image>) -> Self {
self.image.alter_component(image);
self
}
/// Establece el título de la identidad de marca.
#[builder_fn]
pub fn with_title(mut self, title: L10n) -> Self {
self.title = title;
self
}
/// Define el eslogan de la marca.
#[builder_fn]
pub fn with_slogan(mut self, slogan: L10n) -> Self {
self.slogan = slogan;
self
}
/// Define la URL de destino. Si es `None`, la marca no será un enlace.
#[builder_fn]
pub fn with_route(mut self, route: Option<FnPathByContext>) -> Self {
self.route = route;
self
}
}

View file

@ -0,0 +1,398 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
const TOGGLE_COLLAPSE: &str = "collapse";
const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// Componente para crear una **barra de navegación** ([`navbar`](crate::theme::bs::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).
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplos
///
/// Barra **simple**, sólo con un menú horizontal:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let navbar = bs::Navbar::simple()
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new()
/// .with_item(bs::nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(bs::nav::Item::link(L10n::n("About"), |_| "/about".into()))
/// .with_item(bs::nav::Item::link(L10n::n("Contact"), |_| "/contact".into()))
/// ));
/// ```
///
/// Barra **colapsable**, con botón de despliegue y contenido en el desplegable cuando colapsa:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let navbar = bs::Navbar::simple_toggle()
/// .with_expand(token::BreakPoint::MD)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new()
/// .with_item(bs::nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(bs::nav::Item::link_blank(L10n::n("Doc"), |_| "https://docs.rs".into()))
/// .with_item(bs::nav::Item::link(L10n::n("Support"), |_| "/support".into()))
/// ));
/// ```
///
/// Barra con **marca de identidad a la izquierda** y menú a la derecha, típica de una cabecera:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = bs::navbar::Brand::new()
/// .with_title(L10n::n("PageTop"))
/// .with_route(Some(|cx| cx.route("/")));
///
/// let navbar = bs::Navbar::brand_left(brand)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new()
/// .with_item(bs::nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(bs::nav::Item::dropdown(
/// bs::Dropdown::new()
/// .with_title(L10n::n("Tools"))
/// .with_item(bs::dropdown::Item::link(
/// L10n::n("Generator"), |_| "/tools/gen".into())
/// )
/// .with_item(bs::dropdown::Item::link(
/// L10n::n("Reports"), |_| "/tools/reports".into())
/// )
/// ))
/// .with_item(bs::nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()))
/// ));
/// ```
///
/// Barra con **botón de despliegue a la izquierda** y **marca de identidad a la derecha**:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = bs::navbar::Brand::new()
/// .with_title(L10n::n("Intranet"))
/// .with_route(Some(|cx| cx.route("/")));
///
/// let navbar = bs::Navbar::brand_right(brand)
/// .with_expand(token::BreakPoint::LG)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::pills()
/// .with_item(bs::nav::Item::link(L10n::n("Dashboard"), |_| "/dashboard".into()))
/// .with_item(bs::nav::Item::link(L10n::n("Users"), |_| "/users".into()))
/// ));
/// ```
///
/// Barra con el **contenido en un *offcanvas***, ideal para dispositivos móviles o menús largos:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let oc = bs::Offcanvas::new()
/// .with_id("main_offcanvas")
/// .with_title(L10n::n("Main menu"))
/// .with_placement(bs::offcanvas::Placement::Start)
/// .with_backdrop(bs::offcanvas::Backdrop::Enabled);
///
/// let navbar = bs::Navbar::offcanvas(oc)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new()
/// .with_item(bs::nav::Item::link(L10n::n("Home"), |_| "/".into()))
/// .with_item(bs::nav::Item::link(L10n::n("Profile"), |_| "/profile".into()))
/// .with_item(bs::nav::Item::dropdown(
/// bs::Dropdown::new()
/// .with_title(L10n::n("More"))
/// .with_item(bs::dropdown::Item::link(L10n::n("Settings"), |_| "/settings".into()))
/// .with_item(bs::dropdown::Item::link(L10n::n("Help"), |_| "/help".into()))
/// ))
/// ));
/// ```
///
/// Barra **fija arriba**:
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = bs::navbar::Brand::new()
/// .with_title(L10n::n("Main App"))
/// .with_route(Some(|cx| cx.route("/")));
///
/// let navbar = bs::Navbar::brand_left(brand)
/// .with_position(bs::navbar::Position::FixedTop)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::new()
/// .with_item(bs::nav::Item::link(L10n::n("Dashboard"), |_| "/".into()))
/// .with_item(bs::nav::Item::link(L10n::n("Donors"), |_| "/donors".into()))
/// .with_item(bs::nav::Item::link(L10n::n("Stock"), |_| "/stock".into()))
/// ));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Navbar {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el punto de ruptura configurado.
expand: token::BreakPoint,
/// Devuelve la disposición configurada para la barra de navegación.
layout: bs::navbar::Layout,
/// Devuelve la posición configurada para la barra de navegación.
position: bs::navbar::Position,
/// Devuelve la lista de contenidos.
items: Children,
}
impl Component for Navbar {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, cx: &Context) {
// Asegura que la barra de navegación tiene un identificador único.
self.alter_prop(PropsOp::ensure_id(cx.build_id::<Self>(1)));
// Clases CSS por defecto para la barra de navegación.
self.alter_prop(PropsOp::prepend_classes({
let mut classes = "navbar".to_string();
self.expand().push_to(&mut classes, "navbar-expand", "");
self.position().push_to(&mut classes);
classes
}));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Botón de despliegue (colapso u offcanvas) para la barra.
fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup {
let id_content_target = util::join!("#", id_content);
let aria_expanded = if data_bs_toggle == TOGGLE_COLLAPSE {
Some("false")
} else {
None
};
html! {
button
type="button"
class="navbar-toggler"
data-bs-toggle=(data_bs_toggle)
data-bs-target=(id_content_target)
aria-controls=(id_content)
aria-expanded=[aria_expanded]
aria-label=[L10n::t("toggle", &LOCALES_BOOTSIER).lookup(cx)]
{
span class="navbar-toggler-icon" {}
}
}
}
// Si no hay contenidos, no tiene sentido mostrar una barra vacía.
let items = self.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
// `setup()` garantiza que habrá un `id` antes de renderizar.
let id = self.id().unwrap();
Ok(html! {
nav (self.props()) {
div class="container-fluid" {
@match self.layout() {
// Barra más sencilla: sólo contenido.
bs::navbar::Layout::Simple => {
(items)
},
// 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)
}
},
// Barra con marca a la izquierda, siempre visible.
bs::navbar::Layout::SimpleBrandLeft(brand) => {
(brand.render(cx))
(items)
},
// 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))
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
// 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))
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
// Barra cuyo contenido se muestra en un offcanvas, sin marca.
bs::navbar::Layout::Offcanvas(offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
// Barra con marca a la izquierda y contenido en offcanvas.
bs::navbar::Layout::OffcanvasBrandLeft(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(brand.render(cx))
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
// Barra con contenido en offcanvas y marca a la derecha.
bs::navbar::Layout::OffcanvasBrandRight(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
(brand.render(cx))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
}
}
}
})
}
}
impl Navbar {
/// Crea una barra de navegación **simple**, sin marca y sin botón.
pub fn simple() -> Self {
Self::default().with_layout(bs::navbar::Layout::Simple)
}
/// Crea una barra de navegación **simple pero colapsable**, con botón a la izquierda.
pub fn simple_toggle() -> Self {
Self::default().with_layout(bs::navbar::Layout::SimpleToggle)
}
/// Crea una barra de navegación **con marca a la izquierda**, siempre visible.
pub fn simple_brand_left(brand: bs::navbar::Brand) -> Self {
Self::default().with_layout(bs::navbar::Layout::SimpleBrandLeft(Embed::with(brand)))
}
/// Crea una barra de navegación con **marca a la izquierda** y **botón a la derecha**.
pub fn brand_left(brand: bs::navbar::Brand) -> Self {
Self::default().with_layout(bs::navbar::Layout::BrandLeft(Embed::with(brand)))
}
/// Crea una barra de navegación con **botón a la izquierda** y **marca a la derecha**.
pub fn brand_right(brand: bs::navbar::Brand) -> Self {
Self::default().with_layout(bs::navbar::Layout::BrandRight(Embed::with(brand)))
}
/// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**.
pub fn offcanvas(oc: bs::Offcanvas) -> Self {
Self::default().with_layout(bs::navbar::Layout::Offcanvas(Embed::with(oc)))
}
/// Crea una barra de navegación con **marca a la izquierda** y contenido en **offcanvas**.
pub fn offcanvas_brand_left(brand: bs::navbar::Brand, oc: bs::Offcanvas) -> Self {
Self::default().with_layout(bs::navbar::Layout::OffcanvasBrandLeft(
Embed::with(brand),
Embed::with(oc),
))
}
/// Crea una barra de navegación con **marca a la derecha** y contenido en **offcanvas**.
pub fn offcanvas_brand_right(brand: bs::navbar::Brand, oc: bs::Offcanvas) -> Self {
Self::default().with_layout(bs::navbar::Layout::OffcanvasBrandRight(
Embed::with(brand),
Embed::with(oc),
))
}
// **< Navbar BUILDER >*************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
///
/// También acepta clases predefinidas para:
///
/// - Modificar el color de fondo ([`Background`]).
/// - Definir la apariencia del texto ([`Text`]).
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Define a partir de qué punto de ruptura la barra de navegación deja de colapsar.
#[builder_fn]
pub fn with_expand(mut self, bp: token::BreakPoint) -> Self {
self.expand = bp;
self
}
/// Define el tipo de disposición que tendrá la barra de navegación.
#[builder_fn]
pub fn with_layout(mut self, layout: bs::navbar::Layout) -> Self {
self.layout = layout;
self
}
/// Define dónde se mostrará la barra de navegación dentro del documento.
#[builder_fn]
pub fn with_position(mut self, position: bs::navbar::Position) -> Self {
self.position = position;
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 [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// navbar.with_item(navbar::Item::nav(...));
/// navbar.with_item(ChildOp::AddMany(vec![
/// navbar::Item::nav(...).into(),
/// navbar::Item::text(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

@ -0,0 +1,97 @@
use pagetop::prelude::*;
use crate::theme::*;
/// Elementos que puede contener una barra de navegación [`Navbar`](crate::theme::bs::Navbar).
///
/// Cada variante determina qué se renderiza y cómo. Estos elementos se colocan **dentro del
/// contenido** de la barra (la parte colapsable, el *offcanvas* o el bloque simple), por lo que son
/// independientes de la marca o del botón que ya pueda definir el propio
/// [`navbar::Layout`](crate::theme::bs::navbar::Layout).
#[derive(AutoDefault, Clone, Debug)]
pub enum Item {
/// Sin contenido, no produce salida.
#[default]
Void,
/// Marca de identidad mostrada dentro del contenido de la barra de navegación.
///
/// Útil cuando el [`navbar::Layout`](crate::theme::bs::navbar::Layout) no incluye marca, y se
/// quiere incluir dentro del área
/// colapsable/*offcanvas*. Si el *layout* ya muestra una marca, esta variante no la sustituye,
/// sólo añade otra dentro del bloque de contenidos.
Brand(Embed<bs::navbar::Brand>),
/// Representa un menú de navegación [`Nav`](crate::theme::bs::Nav).
Nav(Embed<bs::Nav>),
/// Representa un *texto localizado* libre.
Text(L10n),
}
impl Component for Item {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
match self {
Self::Void => None,
Self::Brand(brand) => brand.id(),
Self::Nav(nav) => nav.id(),
Self::Text(_) => None,
}
}
fn setup(&mut self, _cx: &Context) {
if let Self::Nav(nav) = self {
if let Some(mut nav) = nav.get() {
nav.alter_prop(PropsOp::prepend_classes("navbar-nav"));
}
}
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self {
Self::Void => html! {},
Self::Brand(brand) => html! { (brand.render(cx)) },
Self::Nav(nav) => {
if let Some(nav) = nav.get() {
let items = nav.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
html! {
ul id=[nav.id()] (nav.props()) {
(items)
}
}
} else {
html! {}
}
}
Self::Text(text) => html! {
span class="navbar-text" {
(text.using(cx))
}
},
})
}
}
impl Item {
/// Crea un elemento de tipo [`navbar::Brand`](crate::theme::bs::navbar::Brand) para añadir en el contenido de [`Navbar`](crate::theme::bs::Navbar).
///
/// Pensado para barras colapsables u offcanvas donde se quiere que la marca aparezca en la zona
/// desplegable.
pub fn brand(brand: bs::navbar::Brand) -> Self {
Self::Brand(Embed::with(brand))
}
/// Crea un elemento de tipo [`Nav`](crate::theme::bs::Nav) para añadir al contenido de [`Navbar`](crate::theme::bs::Navbar).
pub fn nav(item: bs::Nav) -> Self {
Self::Nav(Embed::with(item))
}
/// Crea un elemento con un *texto localizado*, mostrado sin interacción.
pub fn text(item: L10n) -> Self {
Self::Text(item)
}
}

View file

@ -0,0 +1,101 @@
use pagetop::prelude::*;
use crate::theme::*;
// **< Layout >*************************************************************************************
/// Representa los diferentes tipos de presentación de una barra de navegación
/// [`Navbar`](crate::theme::bs::Navbar).
#[derive(AutoDefault, Clone, Debug)]
pub enum Layout {
/// Barra simple, sin marca de identidad y sin botón de despliegue.
///
/// La barra de navegación no se colapsa.
#[default]
Simple,
/// Barra simple, con botón de despliegue a la izquierda y sin marca de identidad.
SimpleToggle,
/// Barra simple, con marca de identidad a la izquierda y sin botón de despliegue.
///
/// La barra de navegación no se colapsa.
SimpleBrandLeft(Embed<bs::navbar::Brand>),
/// Barra con marca de identidad a la izquierda y botón de despliegue a la derecha.
BrandLeft(Embed<bs::navbar::Brand>),
/// Barra con botón de despliegue a la izquierda y marca de identidad a la derecha.
BrandRight(Embed<bs::navbar::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 [`Offcanvas`](crate::theme::bs::Offcanvas), con marca de identidad a la
/// izquierda y botón de despliegue a la derecha.
OffcanvasBrandLeft(Embed<bs::navbar::Brand>, Embed<bs::Offcanvas>),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
/// izquierda y marca de identidad a la derecha.
OffcanvasBrandRight(Embed<bs::navbar::Brand>, Embed<bs::Offcanvas>),
}
// **< Position >***********************************************************************************
/// Posición global de una barra de navegación [`Navbar`](crate::theme::bs::Navbar) en el documento.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Position {
/// Barra normal, fluye con el documento.
#[default]
Static,
/// Barra fijada en la parte superior, siempre visible.
///
/// Puede ser necesario reservar espacio en la parte superior del contenido que fluye debajo
/// para evitar que quede oculto por la barra.
FixedTop,
/// Barra fijada en la parte inferior, siempre visible.
///
/// Puede ser necesario reservar espacio en la parte inferior del contenido que fluye debajo
/// para evitar que quede oculto por la barra.
FixedBottom,
/// La barra de navegación se fija en la parte superior al hacer *scroll*.
StickyTop,
/// La barra de navegación se fija en la parte inferior al hacer *scroll*.
StickyBottom,
}
impl Position {
/// Devuelve la clase base asociada a la posición de la barra de navegación.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::Static => "",
Self::FixedTop => "fixed-top",
Self::FixedBottom => "fixed-bottom",
Self::StickyTop => "sticky-top",
Self::StickyBottom => "sticky-bottom",
}
}
/// Añade la clase asociada a la posición de la barra de navegación a la cadena de clases.
#[inline]
pub fn push_to(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Devuelve la clase asociada a la posición de la barra de navegación.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}

View file

@ -0,0 +1,7 @@
//! Definiciones para crear paneles laterales deslizantes ([`Offcanvas`]).
mod props;
pub use props::{Backdrop, BodyScroll, Placement, Visibility};
mod component;
pub use component::Offcanvas;

View file

@ -0,0 +1,214 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
/// Componente para crear un **panel lateral deslizante** ([`offcanvas`](crate::theme::bs::offcanvas)).
///
/// Útil para navegación, filtros, formularios o menús contextuales. Incluye las siguientes
/// características principales:
///
/// - Puede mostrar una capa de fondo para centrar la atención del usuario en el panel
/// ([`with_backdrop()`](Self::with_backdrop)); o puede bloquear el desplazamiento del documento
/// principal ([`with_body_scroll()`](Self::with_body_scroll)).
/// - 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
/// ([`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.
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let panel = bs::Offcanvas::new()
/// .with_id("offcanvas_example")
/// .with_title(L10n::n("Offcanvas title"))
/// .with_placement(bs::offcanvas::Placement::End)
/// .with_backdrop(bs::offcanvas::Backdrop::Enabled)
/// .with_body_scroll(bs::offcanvas::BodyScroll::Enabled)
/// .with_visibility(bs::offcanvas::Visibility::Default)
/// .with_child(bs::Dropdown::new()
/// .with_title(L10n::n("Menu"))
/// .with_item(bs::dropdown::Item::label(L10n::n("Label")))
/// .with_item(bs::dropdown::Item::link_blank(L10n::n("Doc"), |_| "https://docs.rs".into()))
/// .with_item(bs::dropdown::Item::link(L10n::n("Sign out"), |_| "/signout".into()))
/// );
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Offcanvas {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el título del panel.
title: L10n,
/// Devuelve el punto de ruptura configurado para cambiar el comportamiento del panel.
breakpoint: token::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.
body_scroll: bs::offcanvas::BodyScroll,
/// Devuelve la posición de inicio del panel.
placement: bs::offcanvas::Placement,
/// Devuelve el estado inicial del panel.
visibility: bs::offcanvas::Visibility,
/// Devuelve la lista de componentes (`children`) del panel.
children: Children,
}
impl Component for Offcanvas {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, cx: &Context) {
// Asegura que el panel tiene un identificador único.
self.alter_prop(PropsOp::ensure_id(cx.build_id::<Self>(1)));
// 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", "");
self.placement().push_to(&mut classes);
self.visibility().push_to(&mut classes);
classes
}));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(self.render_offcanvas(cx, None))
}
}
impl Offcanvas {
// **< Offcanvas BUILDER >**********************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS o atributos HTML del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Establece el título del encabezado.
#[builder_fn]
pub fn with_title(mut self, title: L10n) -> Self {
self.title = title;
self
}
/// Establece el punto de ruptura 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`]).
/// - **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
/// *offcanvas* siempre.
#[builder_fn]
pub fn with_breakpoint(mut self, bp: token::BreakPoint) -> Self {
self.breakpoint = bp;
self
}
/// Ajusta la capa de fondo del panel para definir su comportamiento al hacer clic fuera del
/// panel.
#[builder_fn]
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.
#[builder_fn]
pub fn with_body_scroll(mut self, scrolling: bs::offcanvas::BodyScroll) -> Self {
self.body_scroll = scrolling;
self
}
/// Indica desde qué borde de la ventana entra y se ancla el panel.
#[builder_fn]
pub fn with_placement(mut self, placement: bs::offcanvas::Placement) -> Self {
self.placement = placement;
self
}
/// Fija el estado inicial del panel (oculto o visible al cargar).
#[builder_fn]
pub fn with_visibility(mut self, visibility: bs::offcanvas::Visibility) -> Self {
self.visibility = visibility;
self
}
/// Añade un nuevo componente al panel o modifica la lista de componentes (`children`) con una
/// operación [`ChildOp`].
#[builder_fn]
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
// **< Offcanvas HELPERS >**********************************************************************
pub(crate) fn render_offcanvas(&self, cx: &mut Context, extra: Option<&Children>) -> Markup {
let body = self.children().render(cx);
let body_extra = extra.map(|c| c.render(cx)).unwrap_or_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())
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=[L10n::t("offcanvas_close", &LOCALES_BOOTSIER).lookup(cx)]
{}
}
div class="offcanvas-body" {
(body)
(body_extra)
}
}
}
}
}

View file

@ -0,0 +1,145 @@
use pagetop::prelude::*;
// **< Backdrop >***********************************************************************************
/// Comportamiento de la capa de fondo (*backdrop*) de un panel
/// [`Offcanvas`](crate::theme::bs::Offcanvas) al deslizarse.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Backdrop {
/// Sin capa de fondo, la página principal permanece visible e interactiva.
Disabled,
/// Opción por defecto, se oscurece el fondo; un clic fuera del panel suele cerrarlo.
#[default]
Enabled,
/// Muestra la capa de fondo pero no se cierra al hacer clic fuera del panel. Útil si se
/// requiere completar una acción antes de salir.
Static,
}
impl Backdrop {
/// Devuelve el valor para `data-bs-backdrop`, o `None` si es el comportamiento por defecto.
#[rustfmt::skip]
#[inline]
pub const fn opt_str(self) -> Option<&'static str> {
match self {
Self::Disabled => Some("false"),
Self::Enabled => None,
Self::Static => Some("static"),
}
}
}
// **< BodyScroll >*********************************************************************************
/// Controla si la página principal puede desplazarse al abrir un panel
/// [`Offcanvas`](crate::theme::bs::Offcanvas).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum BodyScroll {
/// Opción por defecto, la página principal se bloquea centrando la interacción en el panel.
#[default]
Disabled,
/// Permite el desplazamiento de la página principal.
Enabled,
}
impl BodyScroll {
/// Devuelve el valor para `data-bs-scroll`, o `None` si es el comportamiento por defecto.
#[inline]
pub const fn opt_str(self) -> Option<&'static str> {
match self {
Self::Disabled => None,
Self::Enabled => Some("true"),
}
}
}
// **< Placement >**********************************************************************************
/// Posición de aparición de un panel [`Offcanvas`](crate::theme::bs::Offcanvas) al deslizarse.
///
/// Define desde qué borde de la ventana entra y se ancla el panel.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Placement {
/// Opción por defecto, desde el borde inicial según dirección de lectura (respetando LTR/RTL).
#[default]
Start,
/// Desde el borde final según dirección de lectura (respetando LTR/RTL).
End,
/// Desde la parte superior.
Top,
/// Desde la parte inferior.
Bottom,
}
impl Placement {
/// Devuelve la clase base asociada a la posición de aparición del panel.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Placement::Start => "offcanvas-start",
Placement::End => "offcanvas-end",
Placement::Top => "offcanvas-top",
Placement::Bottom => "offcanvas-bottom",
}
}
/// Añade la clase asociada a la posición de aparición del panel a la cadena de clases.
#[inline]
pub fn push_to(self, classes: &mut String) {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(self.as_str());
}
/// Devuelve la clase asociada a la posición de aparición del panel.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}
// **< Visibility >*********************************************************************************
/// Estado inicial de un panel [`Offcanvas`](crate::theme::bs::Offcanvas).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Visibility {
/// El panel permanece oculto desde el principio.
#[default]
Default,
/// El panel se muestra abierto al cargar.
Show,
}
impl Visibility {
/// Devuelve la clase base asociada al estado inicial del panel.
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Visibility::Default => "",
Visibility::Show => "show",
}
}
/// Añade la clase asociada al estado inicial del panel a la cadena de clases.
#[inline]
pub fn push_to(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Devuelve la clase asociada al estado inicial del panel.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}