diff --git a/extensions/pagetop-bootsier/src/theme.rs b/extensions/pagetop-bootsier/src/theme.rs index 22e55792..aa9a3ed8 100644 --- a/extensions/pagetop-bootsier/src/theme.rs +++ b/extensions/pagetop-bootsier/src/theme.rs @@ -1,38 +1,22 @@ -//! Definiciones y componentes del tema. -//! -//! En esta página, el apartado **Modules** incluye las definiciones necesarias para los componentes -//! que se muestran en el apartado **Structs**, mientras que en **Enums** se listan los elementos -//! auxiliares del tema utilizados en clases y componentes. +//! Definiciones y plantillas del tema Bootsier. -mod attrs; -pub use attrs::*; +pub mod bs; -pub mod classes; +pub mod class; -// Button. -mod button; -pub use button::{Button, ButtonAction}; +mod token; +pub use token::*; -// Container. -pub mod container; -#[doc(inline)] -pub use container::Container; - -// Dropdown. -pub mod dropdown; -#[doc(inline)] -pub use dropdown::Dropdown; - -// Form. -pub mod form; -#[doc(inline)] -pub use form::Form; #[doc(hidden)] -pub use form::input::InputBootsier; +pub use bs::badge::BadgeBootsier; #[doc(hidden)] -pub use form::select::SelectBootsier; +pub use bs::container::ContainerBootsier; #[doc(hidden)] -pub use form::textarea::TextareaBootsier; +pub use bs::form::input::InputBootsier; +#[doc(hidden)] +pub use bs::form::select::SelectBootsier; +#[doc(hidden)] +pub use bs::form::textarea::TextareaBootsier; // Image. pub mod image; diff --git a/extensions/pagetop-bootsier/src/theme/bs.rs b/extensions/pagetop-bootsier/src/theme/bs.rs index 2070aeb2..34270f08 100644 --- a/extensions/pagetop-bootsier/src/theme/bs.rs +++ b/extensions/pagetop-bootsier/src/theme/bs.rs @@ -24,6 +24,12 @@ pub use dropdown::Dropdown; pub mod form; #[doc(inline)] pub use form::Form; +#[doc(inline)] +pub use form::input::InputBootsier; +#[doc(inline)] +pub use form::select::SelectBootsier; +#[doc(inline)] +pub use form::textarea::TextareaBootsier; // Image. pub mod image; diff --git a/extensions/pagetop-bootsier/src/theme/bs/container.rs b/extensions/pagetop-bootsier/src/theme/bs/container.rs index e780c9a3..891888cb 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/container.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/container.rs @@ -10,10 +10,12 @@ const EXTRA_WIDTH: &str = "bootsier.container.width"; /// Extensión de Bootsier para [`Container`]. /// -/// Permite definir el comportamiento del ancho del contenedor usando el método -/// [`with_width()`](Self::with_width). También acepta clases predefinidas para: +/// Permite establecer el comportamiento del ancho del contenedor usando el método +/// [`with_width()`](Self::with_width). /// -/// - Modificar el color de fondo ([`Background`](crate::theme::class::Background)). +/// También habilita al componente para aceptar clases predefinidas para: +/// +/// - Modificar el color de fondo ([`Bg`](crate::theme::class::Bg)). /// - Definir la apariencia del texto ([`Text`](crate::theme::class::Text)). /// - Establecer bordes ([`Border`](crate::theme::class::Border)). /// - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)). @@ -24,11 +26,11 @@ const EXTRA_WIDTH: &str = "bootsier.container.width"; /// /// let main = bs::Container::main() /// .with_id("main-page") -/// .with_width(bs::container::Width::From(token::BreakPoint::LG)) -/// .with_prop(PropsOp::add_classes(class::Background::with(token::Color::Light))) -/// .with_prop(PropsOp::add_classes(class::Text::with(token::Color::Dark))) -/// .with_prop(PropsOp::add_classes(class::Border::with(token::ScaleSize::One))) -/// .with_prop(PropsOp::add_classes(class::Rounded::with(token::RoundedRadius::Default))); +/// .with_width(bs::container::Width::From(BreakPoint::LG)) +/// .with_prop(PropsOp::add_classes(class::Bg::with(ThemeColor::Light))) +/// .with_prop(PropsOp::add_classes(class::Text::with(ThemeColor::Dark))) +/// .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One))) +/// .with_prop(PropsOp::add_classes(class::Rounded::new())); /// ``` pub trait ContainerBootsier { /// Establece el comportamiento del ancho para el contenedor. @@ -36,12 +38,15 @@ pub trait ContainerBootsier { /// Determina si el contenedor aplica los anchos máximos predefinidos para cada punto de /// ruptura, o si ocupa siempre el 100% del ancho disponible, o lo hace hasta un ancho máximo /// explícito. Ver [`Width`] para las variantes disponibles. + #[builder_fn] fn with_width(self, width: Width) -> Self; } impl ContainerBootsier for Container { - fn with_width(self, width: Width) -> Self { - self.with_prop(PropsOp::set_extra(EXTRA_WIDTH, width)) + #[builder_fn] + fn with_width(mut self, width: Width) -> Self { + self.alter_prop(PropsOp::set_extra(EXTRA_WIDTH, width)); + self } } @@ -56,7 +61,7 @@ pub enum Width { 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), + From(BreakPoint), /// Ocupa el 100% del ancho disponible siempre. Fluid, /// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito. @@ -70,10 +75,10 @@ impl Width { #[inline] pub fn push_to(self, classes: &mut String) { match self { - Self::Default => token::BreakPoint::None.push_to(classes, Self::CONTAINER, ""), + Self::Default => 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") + BreakPoint::None.push_to(classes, Self::CONTAINER, "fluid") } } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/input.rs b/extensions/pagetop-bootsier/src/theme/bs/form/input.rs index 4c85a1ce..701e7150 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/input.rs @@ -28,12 +28,15 @@ pub trait InputBootsier { /// 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. + #[builder_fn] fn with_floating_label(self, floating: bool) -> Self; } impl InputBootsier for Field { - fn with_floating_label(self, floating: bool) -> Self { - self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)) + #[builder_fn] + fn with_floating_label(mut self, floating: bool) -> Self { + self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)); + self } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/select.rs b/extensions/pagetop-bootsier/src/theme/bs/form/select.rs index 76ffadf5..a2677caf 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/select.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/select.rs @@ -33,12 +33,15 @@ pub trait SelectBootsier { /// 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. + #[builder_fn] fn with_floating_label(self, floating: bool) -> Self; } impl SelectBootsier for Field { - fn with_floating_label(self, floating: bool) -> Self { - self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)) + #[builder_fn] + fn with_floating_label(mut self, floating: bool) -> Self { + self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)); + self } } diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs b/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs index 04892cfb..0238d832 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/textarea.rs @@ -31,12 +31,15 @@ pub trait TextareaBootsier { /// /// Si se usa la etiqueta flotante, se anula el valor establecido con /// [`with_rows()`](form::Textarea::with_rows) antes del renderizado. + #[builder_fn] fn with_floating_label(self, floating: bool) -> Self; } impl TextareaBootsier for Textarea { - fn with_floating_label(self, floating: bool) -> Self { - self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)) + #[builder_fn] + fn with_floating_label(mut self, floating: bool) -> Self { + self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)); + self } } diff --git a/extensions/pagetop-bootsier/src/theme/class/rounded.rs b/extensions/pagetop-bootsier/src/theme/class/rounded.rs index 4bd497eb..426763d3 100644 --- a/extensions/pagetop-bootsier/src/theme/class/rounded.rs +++ b/extensions/pagetop-bootsier/src/theme/class/rounded.rs @@ -104,32 +104,48 @@ impl Into for RoundedRadius { /// - Ajustar el radio de las **esquinas concretas** (`top-start`, `top-end`, `bottom-start`, /// `bottom-end`, **en este orden**, respetando LTR/RTL). /// +/// # Comportamiento aditivo / sustractivo +/// +/// - **Aditivo**: se parte de [`Rounded::default()`] (sin redondeo) y se van añadiendo lados o +/// esquinas concretas con el radio deseado. +/// +/// - **Sustractivo**: se parte de [`Rounded::new()`] o [`Rounded::with`] (radio global ya aplicado) +/// y se anulan lados o esquinas concretas con [`RoundedRadius::Zero`]. +/// /// # Ejemplos /// /// ```rust /// use pagetop_bootsier::theme::*; /// -/// // Radio global: -/// let r = class::Rounded::with(class::RoundedRadius::Default); +/// // Radio global por defecto, equivalente a `Rounded::with(RoundedRadius::Default)`: +/// let r = class::Rounded::new(); /// assert_eq!(r.to_class(), "rounded"); /// -/// // Sin redondeo: -/// let r = class::Rounded::new(); +/// // Radio global explícito: +/// let r = class::Rounded::with(class::RoundedRadius::Scale3); +/// assert_eq!(r.to_class(), "rounded-3"); +/// +/// // Sin redondeo (comportamiento de `Default`): +/// let r = class::Rounded::default(); /// assert_eq!(r.to_class(), ""); /// -/// // Radio en las esquinas de un lado lógico: -/// let r = class::Rounded::new().with_end(class::RoundedRadius::Scale2); +/// // Aditivo (radio en las esquinas de un lado lógico): +/// let r = class::Rounded::default().with_end(class::RoundedRadius::Scale2); /// assert_eq!(r.to_class(), "rounded-end-2"); /// -/// // Radio en una esquina concreta: -/// let r = class::Rounded::new().with_top_start(class::RoundedRadius::Scale3); +/// // Aditivo (radio en una esquina concreta): +/// let r = class::Rounded::default().with_top_start(class::RoundedRadius::Scale3); /// assert_eq!(r.to_class(), "rounded-top-start-3"); /// +/// // Sustractivo (radio global menos la esquina superior-inicial): +/// let r = class::Rounded::new().with_top_start(class::RoundedRadius::Zero); +/// assert_eq!(r.to_class(), "rounded rounded-top-start-0"); +/// /// // Combinado (ejemplo completo): -/// let r = class::Rounded::new() +/// let r = class::Rounded::default() /// .with_top(class::RoundedRadius::Default) // Añade redondeo arriba. -/// .with_bottom_start(class::RoundedRadius::Scale4) // Añade una esquina redondeada concreta. -/// .with_bottom_end(class::RoundedRadius::Circle); // Añade redondeo extremo en otra esquina. +/// .with_bottom_start(class::RoundedRadius::Scale4) // Añade esquina redondeada concreta. +/// .with_bottom_end(class::RoundedRadius::Circle); // Añade redondeo máximo en otra esquina. /// assert_eq!(r.to_class(), "rounded-top rounded-bottom-start-4 rounded-bottom-end-circle"); /// ``` #[rustfmt::skip] @@ -147,12 +163,13 @@ pub struct Rounded { } impl Rounded { - /// Prepara las esquinas **sin redondeo global** de partida. + /// Prepara las esquinas con el **radio de redondeo por defecto** (`rounded`), como + /// [`Self::with`] con [`RoundedRadius::Default`]. pub fn new() -> Self { - Self::default() + Self::default().with_radius(RoundedRadius::Default) } - /// Crea las esquinas **con redondeo global** (`radius`). + /// Crea las esquinas con un **radio global** explícito (`radius`). pub fn with(radius: RoundedRadius) -> Self { Self::default().with_radius(radius) } diff --git a/src/base/component/form/check.rs b/src/base/component/form/check.rs index d7718044..03e9249d 100644 --- a/src/base/component/form/check.rs +++ b/src/base/component/form/check.rs @@ -9,10 +9,6 @@ use crate::prelude::*; /// Representa cada casilla de un grupo de casillas de verificación, con una etiqueta localizable /// visible. Puede marcarse como seleccionada o deshabilitada de forma independiente al resto. /// -/// El parámetro `name` de [`form::check::Item::new()`](Item::new) se combina con el `name` del -/// grupo para componer el atributo `name` de la casilla. Por ejemplo, si el grupo tiene -/// `name=interests` y el ítem se crea con `name=tech`, la casilla tendrá `name=interests_tech`. -/// /// # Ejemplo /// /// ```rust,no_run @@ -22,8 +18,8 @@ use crate::prelude::*; /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Item { - /// Devuelve el nombre que se combina con el del grupo para componer el atributo `name`. - name: AttrValue, + /// Devuelve el valor enviado al servidor cuando la casilla está marcada. + value: AttrValue, /// Devuelve la etiqueta de la casilla. label: L10n, /// Devuelve si la casilla debe aparecer marcada por defecto. @@ -33,13 +29,10 @@ pub struct Item { } impl Item { - /// Crea una nueva casilla con el nombre y la etiqueta indicados. - /// - /// El parámetro `name` se combina con el del grupo para componer el atributo `name` de la - /// casilla. - pub fn new(name: impl AsRef, label: L10n) -> Self { + /// Crea una nueva casilla con el valor y la etiqueta indicados. + pub fn new(value: impl AsRef, label: L10n) -> Self { Self { - name: AttrValue::new(name), + value: AttrValue::new(value), label, checked: false, disabled: false, @@ -65,15 +58,10 @@ impl Item { /// Componente para crear un **grupo de casillas de verificación**. /// -/// Renderiza un conjunto de casillas de verificación donde cada casilla puede marcarse de forma -/// independiente. Las casillas se añaden con [`with_item()`](Field::with_item) usando instancias -/// de [`form::check::Item`]. Si se activa el modo en línea con -/// [`with_inline()`](Field::with_inline), las casillas se disponen horizontalmente. -/// -/// El atributo `name` de cada casilla se construye automáticamente combinando el `name` del grupo -/// y el `name` del [`form::check::Item`] con un guion bajo. Por ejemplo, para el grupo con -/// `name=interests` y casillas con `name=art` y `name=tech`, se genera `name=interests_art` y -/// `name=interests_tech`. +/// Renderiza una lista de opciones de la que el usuario puede marcar cero, una o varias. Todas las +/// casillas comparten el mismo `name` (el del grupo); cada una envía como valor el de su +/// [`form::check::Item`] cuando está marcada. Las opciones se añaden con [`with_item()`]. Si se +/// activa el modo en línea con [`with_inline()`], las casillas se disponen horizontalmente. /// /// # Ejemplo /// @@ -88,26 +76,29 @@ impl Item { /// .with_item(form::check::Item::new("science", L10n::n("Science")).with_checked(true)); /// ``` /// -/// Cada `name` debe ser único y válido como identificador de campo. Cuando el usuario marca una -/// casilla, el navegador envía algo como `interests_tech=true`; mientras que si no la marca, no -/// envía nada. En el servidor cada campo se deserializa como `bool` con `#[serde(default)]`: +/// El navegador envía una entrada por cada casilla marcada, todas bajo la misma clave (por ejemplo, +/// si el usuario marca "Technology" y "Science", `interests=tech&interests=science`) y ninguna si +/// no marca ninguna. El servidor no necesita conocer de antemano qué opciones existían, lo que hace +/// de `Field` la opción adecuada también para listas de opciones dinámicas (por ejemplo, cargadas +/// de una base de datos). `axum::extract::Form` (basado en `serde_urlencoded`) no deserializa +/// claves repetidas en un `Vec`; hace falta un extractor que sí lo haga, como [`serde_qs`]: /// /// ```rust,ignore /// #[derive(serde::Deserialize)] /// struct FormData { /// #[serde(default)] -/// interests_art: bool, -/// #[serde(default)] -/// interests_tech: bool, -/// #[serde(default)] -/// interests_science: bool, +/// interests: Vec, // ["tech", "science"], o [] si no se marcó ninguna. /// } /// ``` +/// +/// [`with_item()`]: Field::with_item +/// [`with_inline()`]: Field::with_inline +/// [`serde_qs`]: https://docs.rs/serde_qs #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Field { /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, - /// Devuelve el nombre base compartido por todas las casillas del grupo. + /// Devuelve el nombre compartido por todas las casillas del grupo. name: AttrName, /// Devuelve la etiqueta del grupo. label: Attr, @@ -164,18 +155,13 @@ impl Component for Field { @for (item, i) in self.items().iter().zip(1..) { @let i = i.to_string(); @let item_id = util::join!(&container_id, "-check-", &i); - @let item_name = if let Some(item_name) = item.name().get() { - util::join!(&name, "_", &item_name) - } else { - util::join!(&name, "_", &i) - }; div class=(item_classes) { input type="checkbox" id=(&item_id) class="form-check-input" - name=(&item_name) - value="true" + name=(&name) + value=[item.value().get()] checked[*item.checked()] disabled[*item.disabled() || *self.disabled()]; label class="form-check-label" for=(&item_id) { @@ -208,14 +194,11 @@ impl Field { self } - /// Establece el nombre base para el grupo de casillas. + /// Establece el nombre compartido por todas las casillas del grupo. /// - /// Se combina con el `name` de cada [`form::check::Item`](Item) para generar el atributo `name` - /// de cada casilla de verificación. Por ejemplo, con `name=interests` en el grupo y `name=tech` - /// en el ítem, se genera `name=interests_tech`. - /// - /// Si se omite, se asigna un nombre generado automáticamente. Para deserializar los campos en - /// el servidor es recomendable establecer un `name` explícito. + /// Todas las casillas [`form::check::Item`](Item) del grupo llevarán este mismo `name`. Si se + /// omite, se asigna un nombre generado automáticamente. Para deserializar los campos en el + /// servidor es recomendable establecer un `name` explícito. #[builder_fn] pub fn with_name(mut self, name: impl AsRef) -> Self { self.name.alter_name(name); diff --git a/src/base/component/form/component.rs b/src/base/component/form/component.rs index 7d9ca342..8745f497 100644 --- a/src/base/component/form/component.rs +++ b/src/base/component/form/component.rs @@ -46,8 +46,8 @@ use crate::base::component::form; pub struct Form { /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. props: Props, - /// Devuelve la URL/ruta de destino del formulario. - action: AttrValue, + /// Devuelve la ruta de destino del formulario. + action: Route, /// Devuelve el método para enviar el formulario. method: form::Method, /// Devuelve el juego de caracteres aceptado por el formulario. @@ -79,7 +79,7 @@ impl Component for Form { Ok(html! { form (self.props()) - action=[self.action().get()] + action=[self.action().try_resolve(cx)] method=[method] accept-charset=[self.charset().get()] { @@ -106,10 +106,13 @@ impl Form { self } - /// Establece la URL/ruta de destino del formulario. + /// Establece la ruta de destino del formulario. + /// + /// Acepta un literal, un `String`, o una [`Route`] explícita construida con [`Route::with()`] + /// para rutas que dependan del contexto de renderizado. #[builder_fn] - pub fn with_action(mut self, action: impl AsRef) -> Self { - self.action.alter_str(action); + pub fn with_action(mut self, action: impl Into) -> Self { + self.action = action.into(); self }