[bootsier] Añade componente Nav

- Adapta `Dropdown` para poder integrarlo en los menús `Nav`.
- Actualiza `Children` para soportar operaciones múltiples.
This commit is contained in:
Manuel Cillero 2025-10-29 13:47:59 +01:00
parent 42860c6dae
commit 2f41f166f3
12 changed files with 602 additions and 226 deletions

View file

@ -1,14 +1,11 @@
//! Definiciones para crear menús desplegables [`Dropdown`].
//!
//! Cada [`dropdown::Item`](crate::theme::dropdown::Item) representa un elemento individual del
//! desplegable [`Dropdown`], con distintos comportamientos según su finalidad: enlaces de
//! 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).
//!
//! Su propósito es ofrecer una base uniforme sobre la que construir menús consistentes, adaptados
//! al contexto de cada aplicación.
mod props;
pub use props::{AutoClose, Direction, MenuAlign, MenuPosition};

View file

@ -10,8 +10,12 @@ use crate::LOCALES_BOOTSIER;
/// interacción del usuario. Admite variaciones de tamaño/color del botón, también dirección de
/// apertura, alineación o política de cierre.
///
/// Sin título para el botón (ver [`with_button_title()`](Self::with_button_title)) se muestra
/// únicamente la lista de elementos sin ningún botón para interactuar.
/// 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`] (ver [`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`].
///
/// # Ejemplo
///
@ -19,7 +23,7 @@ use crate::LOCALES_BOOTSIER;
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let dd = Dropdown::new()
/// .with_button_title(L10n::n("Menu"))
/// .with_title(L10n::n("Menu"))
/// .with_button_color(ButtonColor::Background(Color::Secondary))
/// .with_auto_close(dropdown::AutoClose::ClickableInside)
/// .with_direction(dropdown::Direction::Dropend)
@ -34,7 +38,7 @@ use crate::LOCALES_BOOTSIER;
pub struct Dropdown {
id : AttrId,
classes : AttrClasses,
button_title : L10n,
title : L10n,
button_size : ButtonSize,
button_color : ButtonColor,
button_split : bool,
@ -80,11 +84,11 @@ impl Component for Dropdown {
}
// Título opcional para el menú desplegable.
let button_title = self.button_title().using(cx);
let title = self.title().using(cx);
PrepareMarkup::With(html! {
div id=[self.id()] class=[self.classes().get()] {
@if !button_title.is_empty() {
@if !title.is_empty() {
@let mut btn_classes = AttrClasses::new([
"btn",
&self.button_size().to_string(),
@ -129,7 +133,7 @@ impl Component for Dropdown {
type="button"
class=[btn_classes.get()]
{
(button_title)
(title)
}
};
// Botón *toggle* que abre/cierra el menú asociado.
@ -176,7 +180,7 @@ impl Component for Dropdown {
data-bs-auto-close=[auto_close]
aria-expanded="false"
{
(button_title)
(title)
}
ul class=[menu_classes.get()] { (items) }
}
@ -206,10 +210,10 @@ impl Dropdown {
self
}
/// Establece el título del botón.
/// Establece el título del menú desplegable.
#[builder_fn]
pub fn with_button_title(mut self, title: L10n) -> Self {
self.button_title = title;
pub fn with_title(mut self, title: L10n) -> Self {
self.title = title;
self
}
@ -290,9 +294,9 @@ impl Dropdown {
&self.classes
}
/// Devuelve el título del botón.
pub fn button_title(&self) -> &L10n {
&self.button_title
/// Devuelve el título del menú desplegable.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve el tamaño configurado del botón.

View file

@ -41,7 +41,7 @@ pub enum ItemKind {
/// [`ItemKind`].
///
/// Permite definir identificador, clases de estilo adicionales o tipo de interacción asociada,
/// manteniendo una interfaz común para renderizar todos los ítems del menú.
/// manteniendo una interfaz común para renderizar todos los elementos del menú.
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Item {
@ -79,7 +79,7 @@ impl Component for Item {
} => {
let path = path(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && current_path.map(|p| p == path).unwrap_or(false);
let is_current = !*disabled && current_path.map_or(false, |p| p == path);
let mut classes = "dropdown-item".to_string();
if is_current {
@ -200,7 +200,7 @@ impl Item {
}
}
/// Crea un enlace deshabilitado que se abriría en una nueva ventana.
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
@ -274,7 +274,7 @@ impl Item {
&self.classes
}
/// Devuelve el tipo de elemento representado por este ítem.
/// Devuelve el tipo de elemento representado por este elemento.
pub fn item_kind(&self) -> &ItemKind {
&self.item_kind
}

View file

@ -0,0 +1,17 @@
//! Definiciones para crear menús [`Nav`] o alguna de sus variantes de presentación.
//!
//! Cada [`nav::Item`](crate::theme::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::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,168 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Componente para crear un **menú** o alguna de sus variantes ([`nav::Kind`]).
///
/// Presenta un menú con una lista de elementos usando una vista básica, o alguna de sus variantes
/// como *pestañas* (`Tabs`), *botones* (`Pills`) o *subrayado* (`Underline`). También permite
/// controlar su distribución y orientación ([`nav::Layout`](crate::theme::nav::Layout)).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let nav = Nav::tabs()
/// .with_layout(nav::Layout::End)
/// .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
/// .add_item(nav::Item::link_blank(L10n::n("External"), |_| "https://www.google.es"))
/// .add_item(nav::Item::dropdown(
/// Dropdown::new()
/// .with_title(L10n::n("Options"))
/// .with_items(TypedOp::AddMany(vec![
/// Typed::with(dropdown::Item::link(L10n::n("Action"), |_| "/action")),
/// Typed::with(dropdown::Item::link(L10n::n("Another action"), |_| "/another")),
/// ])),
/// ))
/// .add_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#"));
/// ```
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Nav {
id : AttrId,
classes : AttrClasses,
items : Children,
nav_kind : nav::Kind,
nav_layout: nav::Layout,
}
impl Component for Nav {
fn new() -> Self {
Nav::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(
ClassesOp::Prepend,
[
"nav",
match self.nav_kind() {
nav::Kind::Default => "",
nav::Kind::Tabs => "nav-tabs",
nav::Kind::Pills => "nav-pills",
nav::Kind::Underline => "nav-underline",
},
match self.nav_layout() {
nav::Layout::Default => "",
nav::Layout::Start => "justify-content-start",
nav::Layout::Center => "justify-content-center",
nav::Layout::End => "justify-content-end",
nav::Layout::Vertical => "flex-column",
nav::Layout::Fill => "nav-fill",
nav::Layout::Justified => "nav-justified",
},
]
.join(" "),
);
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
let items = self.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
}
PrepareMarkup::With(html! {
ul id=[self.id()] class=[self.classes().get()] {
(items)
}
})
}
}
impl Nav {
/// Crea un `Nav` usando pestañas para los elementos (*Tabs*).
pub fn tabs() -> Self {
Nav::default().with_kind(nav::Kind::Tabs)
}
/// Crea un `Nav` usando botones para los elementos (*Pills*).
pub fn pills() -> Self {
Nav::default().with_kind(nav::Kind::Pills)
}
/// Crea un `Nav` usando elementos subrayados (*Underline*).
pub fn underline() -> Self {
Nav::default().with_kind(nav::Kind::Underline)
}
// **< Nav BUILDER >****************************************************************************
/// Establece el identificador único (`id`) del menú.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self
}
/// Modifica la lista de clases CSS aplicadas al menú.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self
}
/// Cambia el estilo del menú (*Tabs*, *Pills*, *Underline* o *Default*).
#[builder_fn]
pub fn with_kind(mut self, kind: 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: nav::Layout) -> Self {
self.nav_layout = layout;
self
}
/// Añade un nuevo elemento hijo al menú.
pub fn add_item(mut self, item: nav::Item) -> Self {
self.items.add(Child::with(item));
self
}
/// Modifica la lista de elementos (`children`) aplicando una operación [`TypedOp`].
#[builder_fn]
pub fn with_items(mut self, op: TypedOp<nav::Item>) -> Self {
self.items.alter_typed(op);
self
}
// **< Nav GETTERS >****************************************************************************
/// Devuelve las clases CSS asociadas al menú.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el estilo visual seleccionado.
pub fn nav_kind(&self) -> &nav::Kind {
&self.nav_kind
}
/// Devuelve la distribución y orientación seleccionada.
pub fn nav_layout(&self) -> &nav::Layout {
&self.nav_layout
}
/// Devuelve la lista de elementos (`children`) del menú.
pub fn items(&self) -> &Children {
&self.items
}
}

View file

@ -0,0 +1,257 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
// **< ItemKind >***********************************************************************************
/// Tipos de [`nav::Item`](crate::theme::nav::Item) disponibles en un menú
/// [`Nav`](crate::theme::Nav).
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
Void,
/// Etiqueta sin comportamiento interactivo.
Label(L10n),
/// Elemento de navegación. Opcionalmente puede abrirse en una nueva ventana y estar
/// inicialmente deshabilitado.
Link {
label: L10n,
path: FnPathByContext,
blank: bool,
disabled: bool,
},
/// Elemento que despliega un menú [`Dropdown`].
Dropdown(Typed<Dropdown>),
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú [`Nav`](crate::theme::Nav).
///
/// Cada instancia de [`nav::Item`](crate::theme::nav::Item) se traduce en un componente visible que
/// puede comportarse como texto, enlace, botón o menú desplegable según su [`ItemKind`].
///
/// Permite definir identificador, clases de estilo adicionales o tipo de interacción asociada,
/// manteniendo una interfaz común para renderizar todos los elementos del menú.
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Item {
id : AttrId,
classes : AttrClasses,
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Item::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(
ClassesOp::Prepend,
if matches!(self.item_kind(), ItemKind::Dropdown(_)) {
"nav-item dropdown"
} else {
"nav-item"
},
);
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
match self.item_kind() {
ItemKind::Void => PrepareMarkup::None,
ItemKind::Label(label) => PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
span {
(label.using(cx))
}
}
}),
ItemKind::Link {
label,
path,
blank,
disabled,
} => {
let path = path(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && current_path.map_or(false, |p| p == 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(path);
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");
PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
{
(label.using(cx))
}
}
})
}
ItemKind::Dropdown(menu) => {
if let Some(dd) = menu.borrow() {
let items = dd.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
}
let title = dd.title().lookup(cx).unwrap_or_else(|| {
L10n::t("dropdown", &LOCALES_BOOTSIER)
.lookup(cx)
.unwrap_or_else(|| "Dropdown".to_string())
});
PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
a
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
href="#"
role="button"
aria-expanded="false"
{
(title)
}
ul class="dropdown-menu" {
(items)
}
}
})
} else {
PrepareMarkup::None
}
}
}
}
}
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: L10n) -> Self {
Item {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
pub fn link(label: L10n, path: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
path,
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
path,
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, path: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
path,
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, path: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
path,
blank: true,
disabled: true,
},
..Default::default()
}
}
/// Crea un elemento de navegación que contiene un menú desplegable [`Dropdown`].
///
/// Sólo se tienen en cuenta **el título** (si no existe le asigna uno por defecto) y **la lista
/// de elementos** del [`Dropdown`]; el resto de propiedades del componente no afectarán a su
/// representación en [`Nav`].
pub fn dropdown(menu: Dropdown) -> Self {
Item {
item_kind: ItemKind::Dropdown(Typed::with(menu)),
..Default::default()
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self
}
/// Modifica la lista de clases CSS aplicadas al elemento.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self
}
// **< Item GETTERS >***************************************************************************
/// Devuelve las clases CSS asociadas al elemento.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el tipo de elemento representado por este elemento.
pub fn item_kind(&self) -> &ItemKind {
&self.item_kind
}
}

View file

@ -0,0 +1,39 @@
use pagetop::prelude::*;
// **< Kind >***************************************************************************************
/// Define la variante de presentación de un menú [`Nav`](crate::theme::Nav).
#[derive(AutoDefault)]
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,
}
// **< Layout >*************************************************************************************
/// Distribución y orientación de un menú [`Nav`](crate::theme::Nav).
#[derive(AutoDefault)]
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,
}

View file

@ -9,9 +9,3 @@ pub use content::{Content, ContentType};
mod brand;
pub use brand::Brand;
mod nav;
pub use nav::Nav;
mod item;
pub use item::{Item, ItemType};

View file

@ -1,113 +0,0 @@
use pagetop::prelude::*;
use crate::theme::Dropdown;
type Label = L10n;
#[derive(AutoDefault)]
pub enum ItemType {
#[default]
Void,
Label(Label),
Link(Label, FnPathByContext),
LinkBlank(Label, FnPathByContext),
Dropdown(Typed<Dropdown>),
}
// Item.
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Item {
item_type: ItemType,
}
impl Component for Item {
fn new() -> Self {
Item::default()
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
let description: Option<String> = None;
// Obtiene la URL actual desde `cx.request`.
let current_path = cx.request().map(|request| request.path());
match self.item_type() {
ItemType::Void => PrepareMarkup::None,
ItemType::Label(label) => PrepareMarkup::With(html! {
li class="nav-item" {
span title=[description] {
//(left_icon)
(label.using(cx))
//(right_icon)
}
}
}),
ItemType::Link(label, path) => {
let item_path = path(cx);
let (class, aria) = if current_path == Some(item_path) {
("nav-item active", Some("page"))
} else {
("nav-item", None)
};
PrepareMarkup::With(html! {
li class=(class) aria-current=[aria] {
a class="nav-link" href=(item_path) title=[description] {
//(left_icon)
(label.using(cx))
//(right_icon)
}
}
})
}
ItemType::LinkBlank(label, path) => {
let item_path = path(cx);
let (class, aria) = if current_path == Some(item_path) {
("nav-item active", Some("page"))
} else {
("nav-item", None)
};
PrepareMarkup::With(html! {
li class=(class) aria-current=[aria] {
a class="nav-link" href=(item_path) title=[description] target="_blank" {
//(left_icon)
(label.using(cx))
//(right_icon)
}
}
})
}
ItemType::Dropdown(menu) => PrepareMarkup::With(html! { (menu.render(cx)) }),
}
}
}
impl Item {
pub fn label(label: L10n) -> Self {
Item {
item_type: ItemType::Label(label),
..Default::default()
}
}
pub fn link(label: L10n, path: FnPathByContext) -> Self {
Item {
item_type: ItemType::Link(label, path),
..Default::default()
}
}
pub fn link_blank(label: L10n, path: FnPathByContext) -> Self {
Item {
item_type: ItemType::LinkBlank(label, path),
..Default::default()
}
}
// Item GETTERS.
pub fn item_type(&self) -> &ItemType {
&self.item_type
}
}

View file

@ -1,75 +0,0 @@
use pagetop::prelude::*;
use crate::theme::navbar;
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Nav {
id : AttrId,
classes: AttrClasses,
items : Children,
}
impl Component for Nav {
fn new() -> Self {
Nav::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(ClassesOp::Prepend, "navbar-nav");
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
let items = self.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
}
PrepareMarkup::With(html! {
ul id=[self.id()] class=[self.classes().get()] {
(items)
}
})
}
}
impl Nav {
// Nav BUILDER.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self
}
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self
}
pub fn with_item(mut self, item: navbar::Item) -> Self {
self.items.add(Child::with(item));
self
}
#[builder_fn]
pub fn with_items(mut self, op: TypedOp<navbar::Item>) -> Self {
self.items.alter_typed(op);
self
}
// Nav GETTERS.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
pub fn items(&self) -> &Children {
&self.items
}
}