♻️ (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:
parent
64db114eb0
commit
f37db56ec5
47 changed files with 1307 additions and 1201 deletions
143
extensions/pagetop-bootsier/src/theme/bs/nav/component.rs
Normal file
143
extensions/pagetop-bootsier/src/theme/bs/nav/component.rs
Normal 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
|
||||
}
|
||||
}
|
||||
279
extensions/pagetop-bootsier/src/theme/bs/nav/item.rs
Normal file
279
extensions/pagetop-bootsier/src/theme/bs/nav/item.rs
Normal 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
|
||||
}
|
||||
}
|
||||
121
extensions/pagetop-bootsier/src/theme/bs/nav/props.rs
Normal file
121
extensions/pagetop-bootsier/src/theme/bs/nav/props.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue