From 2f41f166f315fd39eeb10a1fb722ee853d72d429 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 29 Oct 2025 13:47:59 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20[bootsier]=20A=C3=B1ade=20component?= =?UTF-8?q?e=20`Nav`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adapta `Dropdown` para poder integrarlo en los menús `Nav`. - Actualiza `Children` para soportar operaciones múltiples. --- extensions/pagetop-bootsier/src/theme.rs | 5 + .../pagetop-bootsier/src/theme/dropdown.rs | 5 +- .../src/theme/dropdown/component.rs | 32 ++- .../src/theme/dropdown/item.rs | 8 +- extensions/pagetop-bootsier/src/theme/nav.rs | 17 ++ .../src/theme/nav/component.rs | 168 ++++++++++++ .../pagetop-bootsier/src/theme/nav/item.rs | 257 ++++++++++++++++++ .../pagetop-bootsier/src/theme/nav/props.rs | 39 +++ .../pagetop-bootsier/src/theme/navbar.rs | 6 - .../pagetop-bootsier/src/theme/navbar/item.rs | 113 -------- .../pagetop-bootsier/src/theme/navbar/nav.rs | 75 ----- src/core/component/children.rs | 103 ++++++- 12 files changed, 602 insertions(+), 226 deletions(-) create mode 100644 extensions/pagetop-bootsier/src/theme/nav.rs create mode 100644 extensions/pagetop-bootsier/src/theme/nav/component.rs create mode 100644 extensions/pagetop-bootsier/src/theme/nav/item.rs create mode 100644 extensions/pagetop-bootsier/src/theme/nav/props.rs delete mode 100644 extensions/pagetop-bootsier/src/theme/navbar/item.rs delete mode 100644 extensions/pagetop-bootsier/src/theme/navbar/nav.rs diff --git a/extensions/pagetop-bootsier/src/theme.rs b/extensions/pagetop-bootsier/src/theme.rs index ece34f0..d34c0a9 100644 --- a/extensions/pagetop-bootsier/src/theme.rs +++ b/extensions/pagetop-bootsier/src/theme.rs @@ -16,6 +16,11 @@ pub mod image; #[doc(inline)] pub use image::Image; +// Nav. +pub mod nav; +#[doc(inline)] +pub use nav::Nav; + // Navbar. pub mod navbar; #[doc(inline)] diff --git a/extensions/pagetop-bootsier/src/theme/dropdown.rs b/extensions/pagetop-bootsier/src/theme/dropdown.rs index 02df7fd..eb00e4c 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown.rs @@ -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}; diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs index e9d36d7..984a3e0 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs @@ -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. diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/item.rs b/extensions/pagetop-bootsier/src/theme/dropdown/item.rs index 548024c..1aed83b 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown/item.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown/item.rs @@ -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 } diff --git a/extensions/pagetop-bootsier/src/theme/nav.rs b/extensions/pagetop-bootsier/src/theme/nav.rs new file mode 100644 index 0000000..b540c32 --- /dev/null +++ b/extensions/pagetop-bootsier/src/theme/nav.rs @@ -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}; diff --git a/extensions/pagetop-bootsier/src/theme/nav/component.rs b/extensions/pagetop-bootsier/src/theme/nav/component.rs new file mode 100644 index 0000000..34c33b9 --- /dev/null +++ b/extensions/pagetop-bootsier/src/theme/nav/component.rs @@ -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 { + 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) -> 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) -> 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) -> 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 + } +} diff --git a/extensions/pagetop-bootsier/src/theme/nav/item.rs b/extensions/pagetop-bootsier/src/theme/nav/item.rs new file mode 100644 index 0000000..63248f8 --- /dev/null +++ b/extensions/pagetop-bootsier/src/theme/nav/item.rs @@ -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), +} + +// **< 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 { + 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) -> 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) -> 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 + } +} diff --git a/extensions/pagetop-bootsier/src/theme/nav/props.rs b/extensions/pagetop-bootsier/src/theme/nav/props.rs new file mode 100644 index 0000000..bd8ac1e --- /dev/null +++ b/extensions/pagetop-bootsier/src/theme/nav/props.rs @@ -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, +} diff --git a/extensions/pagetop-bootsier/src/theme/navbar.rs b/extensions/pagetop-bootsier/src/theme/navbar.rs index 3745b11..72a3af3 100644 --- a/extensions/pagetop-bootsier/src/theme/navbar.rs +++ b/extensions/pagetop-bootsier/src/theme/navbar.rs @@ -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}; diff --git a/extensions/pagetop-bootsier/src/theme/navbar/item.rs b/extensions/pagetop-bootsier/src/theme/navbar/item.rs deleted file mode 100644 index 08a2aee..0000000 --- a/extensions/pagetop-bootsier/src/theme/navbar/item.rs +++ /dev/null @@ -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), -} - -// 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 = 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 - } -} diff --git a/extensions/pagetop-bootsier/src/theme/navbar/nav.rs b/extensions/pagetop-bootsier/src/theme/navbar/nav.rs deleted file mode 100644 index bc03a29..0000000 --- a/extensions/pagetop-bootsier/src/theme/navbar/nav.rs +++ /dev/null @@ -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 { - 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) -> Self { - self.id.alter_value(id); - self - } - - #[builder_fn] - pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> 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) -> Self { - self.items.alter_typed(op); - self - } - - // Nav GETTERS. - - pub fn classes(&self) -> &AttrClasses { - &self.classes - } - - pub fn items(&self) -> &Children { - &self.items - } -} diff --git a/src/core/component/children.rs b/src/core/component/children.rs index c0c8841..3b8f2ab 100644 --- a/src/core/component/children.rs +++ b/src/core/component/children.rs @@ -4,6 +4,9 @@ use crate::{builder_fn, AutoDefault, UniqueId}; use parking_lot::RwLock; +pub use parking_lot::RwLockReadGuard as ComponentReadGuard; +pub use parking_lot::RwLockWriteGuard as ComponentWriteGuard; + use std::sync::Arc; use std::vec::IntoIter; @@ -93,6 +96,57 @@ impl Typed { self.0.as_ref().and_then(|c| c.read().id()) } + /// Devuelve una **referencia inmutable** al componente interno. + /// + /// - Devuelve `Some(ComponentReadGuard)` si existe el componente, o `None` si está vacío. + /// - Permite realizar **múltiples lecturas concurrentes**. + /// - Mientras el *guard* esté activo, no se pueden realizar escrituras concurrentes (ver + /// [`borrow_mut`](Self::borrow_mut)). + /// - Se recomienda mantener el *guard* **el menor tiempo posible** para evitar bloqueos + /// innecesarios. + /// + /// # Ejemplo + /// + /// Lectura del nombre del componente: + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let typed = Typed::with(Html::with(|_| html! { "Prueba" })); + /// { + /// if let Some(component) = typed.borrow() { + /// assert_eq!(component.name(), "Html"); + /// } + /// }; // El *guard* se libera aquí, antes del *drop* de `typed`. + /// ``` + pub fn borrow(&self) -> Option> { + self.0.as_ref().map(|a| a.read()) + } + + /// Obtiene una **referencia mutable exclusiva** al componente interno. + /// + /// - Devuelve `Some(ComponentWriteGuard)` si existe el componente, o `None` si está vacío. + /// - **Exclusivo**: mientras el *guard* esté activo, no habrá otros lectores ni escritores. + /// - Usar sólo para operaciones que **modifican** el estado interno. + /// - Igual que con [`borrow`](Self::borrow), se recomienda mantener el *guard* en un **ámbito + /// reducido**. + /// + /// # Ejemplo + /// + /// Acceso mutable (ámbito corto): + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let typed = Typed::with(Block::new().with_title(L10n::n("Título"))); + /// { + /// if let Some(mut component) = typed.borrow_mut() { + /// component.alter_title(L10n::n("Nuevo título")); + /// } + /// }; // El *guard* se libera aquí, antes del *drop* de `typed`. + /// ``` + pub fn borrow_mut(&self) -> Option> { + self.0.as_ref().map(|a| a.write()) + } + // **< Typed RENDER >*************************************************************************** /// Renderiza el componente con el contexto proporcionado. @@ -102,9 +156,9 @@ impl Typed { // **< Typed HELPERS >************************************************************************** - // Convierte el componente tipado en un [`Child`]. + // Método interno para convertir un componente tipado en un [`Child`]. #[inline] - fn into_child(self) -> Child { + fn into(self) -> Child { if let Some(c) = &self.0 { Child(Some(c.clone())) } else { @@ -115,12 +169,14 @@ impl Typed { // ************************************************************************************************* -/// Operaciones con un componente hijo [`Child`] en una lista [`Children`]. +/// Operaciones para componentes hijo [`Child`] en una lista [`Children`]. pub enum ChildOp { Add(Child), + AddMany(Vec), InsertAfterId(&'static str, Child), InsertBeforeId(&'static str, Child), Prepend(Child), + PrependMany(Vec), RemoveById(&'static str), ReplaceById(&'static str, Child), Reset, @@ -129,9 +185,11 @@ pub enum ChildOp { /// Operaciones con un componente hijo tipado [`Typed`] en una lista [`Children`]. pub enum TypedOp { Add(Typed), + AddMany(Vec>), InsertAfterId(&'static str, Typed), InsertBeforeId(&'static str, Typed), Prepend(Typed), + PrependMany(Vec>), RemoveById(&'static str), ReplaceById(&'static str, Typed), Reset, @@ -172,9 +230,11 @@ impl Children { pub fn with_child(mut self, op: ChildOp) -> Self { match op { ChildOp::Add(any) => self.add(any), + ChildOp::AddMany(many) => self.add_many(many), ChildOp::InsertAfterId(id, any) => self.insert_after_id(id, any), ChildOp::InsertBeforeId(id, any) => self.insert_before_id(id, any), ChildOp::Prepend(any) => self.prepend(any), + ChildOp::PrependMany(many) => self.prepend_many(many), ChildOp::RemoveById(id) => self.remove_by_id(id), ChildOp::ReplaceById(id, any) => self.replace_by_id(id, any), ChildOp::Reset => self.reset(), @@ -183,14 +243,16 @@ impl Children { /// Ejecuta una operación con [`TypedOp`] en la lista. #[builder_fn] - pub fn with_typed(mut self, op: TypedOp) -> Self { + pub fn with_typed(mut self, op: TypedOp) -> Self { match op { - TypedOp::Add(typed) => self.add(typed.into_child()), - TypedOp::InsertAfterId(id, typed) => self.insert_after_id(id, typed.into_child()), - TypedOp::InsertBeforeId(id, typed) => self.insert_before_id(id, typed.into_child()), - TypedOp::Prepend(typed) => self.prepend(typed.into_child()), + TypedOp::Add(typed) => self.add(typed.into()), + TypedOp::AddMany(many) => self.add_many(many.into_iter().map(Typed::::into)), + TypedOp::InsertAfterId(id, typed) => self.insert_after_id(id, typed.into()), + TypedOp::InsertBeforeId(id, typed) => self.insert_before_id(id, typed.into()), + TypedOp::Prepend(typed) => self.prepend(typed.into()), + TypedOp::PrependMany(many) => self.prepend_many(many.into_iter().map(Typed::::into)), TypedOp::RemoveById(id) => self.remove_by_id(id), - TypedOp::ReplaceById(id, typed) => self.replace_by_id(id, typed.into_child()), + TypedOp::ReplaceById(id, typed) => self.replace_by_id(id, typed.into()), TypedOp::Reset => self.reset(), } } @@ -230,7 +292,7 @@ impl Children { /// Devuelve un iterador sobre los componentes hijo con el identificador de tipo ([`UniqueId`]) /// indicado. pub fn iter_by_type_id(&self, type_id: UniqueId) -> impl Iterator { - self.0.iter().filter(move |&c| c.type_id() == Some(type_id)) + self.0.iter().filter(move |c| c.type_id() == Some(type_id)) } // **< Children RENDER >************************************************************************ @@ -246,6 +308,16 @@ impl Children { // **< Children HELPERS >*********************************************************************** + // Añade más de un componente hijo al final de la lista (en el orden recibido). + #[inline] + fn add_many(&mut self, iter: I) -> &mut Self + where + I: IntoIterator, + { + self.0.extend(iter); + self + } + // Inserta un hijo después del componente con el `id` dado, o al final si no se encuentra. #[inline] fn insert_after_id(&mut self, id: impl AsRef, child: Child) -> &mut Self { @@ -275,6 +347,17 @@ impl Children { self } + // Inserta más de un componente hijo al principio de la lista (manteniendo el orden recibido). + #[inline] + fn prepend_many(&mut self, iter: I) -> &mut Self + where + I: IntoIterator, + { + let buf: Vec = iter.into_iter().collect(); + self.0.splice(0..0, buf); + self + } + // Elimina el primer hijo con el `id` dado. #[inline] fn remove_by_id(&mut self, id: impl AsRef) -> &mut Self {