♻️ (bootsier): Ajusta componentes base

- Añade `#[builder_fn]` a `with_width()`/`with_floating_label()`.
- `Rounded::new()` pasa a aplicar el radio por defecto
  (`Rounded::default()` queda sin redondeo).
- `Form::action` usa `Route` en lugar de `AttrValue`, y `check::Item`
  expone `value` en vez de `name` para generar casillas con `name`
  compartido valores distintos.
This commit is contained in:
Manuel Cillero 2026-08-01 13:41:19 +02:00
parent 4cc72aac0e
commit 545a87fbb0
9 changed files with 118 additions and 111 deletions

View file

@ -1,38 +1,22 @@
//! Definiciones y componentes del tema. //! Definiciones y plantillas del tema Bootsier.
//!
//! 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.
mod attrs; pub mod bs;
pub use attrs::*;
pub mod classes; pub mod class;
// Button. mod token;
mod button; pub use token::*;
pub use button::{Button, ButtonAction};
// 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)] #[doc(hidden)]
pub use form::input::InputBootsier; pub use bs::badge::BadgeBootsier;
#[doc(hidden)] #[doc(hidden)]
pub use form::select::SelectBootsier; pub use bs::container::ContainerBootsier;
#[doc(hidden)] #[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. // Image.
pub mod image; pub mod image;

View file

@ -24,6 +24,12 @@ pub use dropdown::Dropdown;
pub mod form; pub mod form;
#[doc(inline)] #[doc(inline)]
pub use form::Form; 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. // Image.
pub mod image; pub mod image;

View file

@ -10,10 +10,12 @@ const EXTRA_WIDTH: &str = "bootsier.container.width";
/// Extensión de Bootsier para [`Container`]. /// Extensión de Bootsier para [`Container`].
/// ///
/// Permite definir el comportamiento del ancho del contenedor usando el método /// Permite establecer el comportamiento del ancho del contenedor usando el método
/// [`with_width()`](Self::with_width). También acepta clases predefinidas para: /// [`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)). /// - Definir la apariencia del texto ([`Text`](crate::theme::class::Text)).
/// - Establecer bordes ([`Border`](crate::theme::class::Border)). /// - Establecer bordes ([`Border`](crate::theme::class::Border)).
/// - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)). /// - Redondear las esquinas ([`Rounded`](crate::theme::class::Rounded)).
@ -24,11 +26,11 @@ const EXTRA_WIDTH: &str = "bootsier.container.width";
/// ///
/// let main = bs::Container::main() /// let main = bs::Container::main()
/// .with_id("main-page") /// .with_id("main-page")
/// .with_width(bs::container::Width::From(token::BreakPoint::LG)) /// .with_width(bs::container::Width::From(BreakPoint::LG))
/// .with_prop(PropsOp::add_classes(class::Background::with(token::Color::Light))) /// .with_prop(PropsOp::add_classes(class::Bg::with(ThemeColor::Light)))
/// .with_prop(PropsOp::add_classes(class::Text::with(token::Color::Dark))) /// .with_prop(PropsOp::add_classes(class::Text::with(ThemeColor::Dark)))
/// .with_prop(PropsOp::add_classes(class::Border::with(token::ScaleSize::One))) /// .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One)))
/// .with_prop(PropsOp::add_classes(class::Rounded::with(token::RoundedRadius::Default))); /// .with_prop(PropsOp::add_classes(class::Rounded::new()));
/// ``` /// ```
pub trait ContainerBootsier { pub trait ContainerBootsier {
/// Establece el comportamiento del ancho para el contenedor. /// 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 /// 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 /// 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. /// explícito. Ver [`Width`] para las variantes disponibles.
#[builder_fn]
fn with_width(self, width: Width) -> Self; fn with_width(self, width: Width) -> Self;
} }
impl ContainerBootsier for Container { impl ContainerBootsier for Container {
fn with_width(self, width: Width) -> Self { #[builder_fn]
self.with_prop(PropsOp::set_extra(EXTRA_WIDTH, width)) 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, Default,
/// Aplica los anchos máximos predefinidos a partir del punto de ruptura indicado. Por debajo de /// 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. /// ese punto de ruptura ocupa el 100% del ancho disponible.
From(token::BreakPoint), From(BreakPoint),
/// Ocupa el 100% del ancho disponible siempre. /// Ocupa el 100% del ancho disponible siempre.
Fluid, Fluid,
/// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito. /// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito.
@ -70,10 +75,10 @@ impl Width {
#[inline] #[inline]
pub fn push_to(self, classes: &mut String) { pub fn push_to(self, classes: &mut String) {
match self { 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::From(bp) => bp.push_to(classes, Self::CONTAINER, ""),
Self::Fluid | Self::FluidMax(_) => { Self::Fluid | Self::FluidMax(_) => {
token::BreakPoint::None.push_to(classes, Self::CONTAINER, "fluid") BreakPoint::None.push_to(classes, Self::CONTAINER, "fluid")
} }
} }
} }

View file

@ -28,12 +28,15 @@ pub trait InputBootsier {
/// Cuando está activo, la etiqueta se superpone al campo y asciende al enfocarlo o cuando tiene /// 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 /// contenido. Requiere que el campo tenga un atributo `placeholder` definido; si no se
/// especifica, se fuerza `placeholder=""` antes del renderizado. /// especifica, se fuerza `placeholder=""` antes del renderizado.
#[builder_fn]
fn with_floating_label(self, floating: bool) -> Self; fn with_floating_label(self, floating: bool) -> Self;
} }
impl InputBootsier for Field { impl InputBootsier for Field {
fn with_floating_label(self, floating: bool) -> Self { #[builder_fn]
self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)) fn with_floating_label(mut self, floating: bool) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating));
self
} }
} }

View file

@ -33,12 +33,15 @@ pub trait SelectBootsier {
/// Si se usa la etiqueta flotante, se anulan los valores establecidos con /// Si se usa la etiqueta flotante, se anulan los valores establecidos con
/// [`with_multiple()`](form::select::Field::with_multiple) y /// [`with_multiple()`](form::select::Field::with_multiple) y
/// [`with_rows()`](form::select::Field::with_rows) antes del renderizado. /// [`with_rows()`](form::select::Field::with_rows) antes del renderizado.
#[builder_fn]
fn with_floating_label(self, floating: bool) -> Self; fn with_floating_label(self, floating: bool) -> Self;
} }
impl SelectBootsier for Field { impl SelectBootsier for Field {
fn with_floating_label(self, floating: bool) -> Self { #[builder_fn]
self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)) fn with_floating_label(mut self, floating: bool) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating));
self
} }
} }

View file

@ -31,12 +31,15 @@ pub trait TextareaBootsier {
/// ///
/// Si se usa la etiqueta flotante, se anula el valor establecido con /// Si se usa la etiqueta flotante, se anula el valor establecido con
/// [`with_rows()`](form::Textarea::with_rows) antes del renderizado. /// [`with_rows()`](form::Textarea::with_rows) antes del renderizado.
#[builder_fn]
fn with_floating_label(self, floating: bool) -> Self; fn with_floating_label(self, floating: bool) -> Self;
} }
impl TextareaBootsier for Textarea { impl TextareaBootsier for Textarea {
fn with_floating_label(self, floating: bool) -> Self { #[builder_fn]
self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating)) fn with_floating_label(mut self, floating: bool) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating));
self
} }
} }

View file

@ -104,32 +104,48 @@ impl Into<CowStr> for RoundedRadius {
/// - Ajustar el radio de las **esquinas concretas** (`top-start`, `top-end`, `bottom-start`, /// - Ajustar el radio de las **esquinas concretas** (`top-start`, `top-end`, `bottom-start`,
/// `bottom-end`, **en este orden**, respetando LTR/RTL). /// `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 /// # Ejemplos
/// ///
/// ```rust /// ```rust
/// use pagetop_bootsier::theme::*; /// use pagetop_bootsier::theme::*;
/// ///
/// // Radio global: /// // Radio global por defecto, equivalente a `Rounded::with(RoundedRadius::Default)`:
/// let r = class::Rounded::with(class::RoundedRadius::Default); /// let r = class::Rounded::new();
/// assert_eq!(r.to_class(), "rounded"); /// assert_eq!(r.to_class(), "rounded");
/// ///
/// // Sin redondeo: /// // Radio global explícito:
/// let r = class::Rounded::new(); /// 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(), ""); /// assert_eq!(r.to_class(), "");
/// ///
/// // Radio en las esquinas de un lado lógico: /// // Aditivo (radio en las esquinas de un lado lógico):
/// let r = class::Rounded::new().with_end(class::RoundedRadius::Scale2); /// let r = class::Rounded::default().with_end(class::RoundedRadius::Scale2);
/// assert_eq!(r.to_class(), "rounded-end-2"); /// assert_eq!(r.to_class(), "rounded-end-2");
/// ///
/// // Radio en una esquina concreta: /// // Aditivo (radio en una esquina concreta):
/// let r = class::Rounded::new().with_top_start(class::RoundedRadius::Scale3); /// let r = class::Rounded::default().with_top_start(class::RoundedRadius::Scale3);
/// assert_eq!(r.to_class(), "rounded-top-start-3"); /// 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): /// // Combinado (ejemplo completo):
/// let r = class::Rounded::new() /// let r = class::Rounded::default()
/// .with_top(class::RoundedRadius::Default) // Añade redondeo arriba. /// .with_top(class::RoundedRadius::Default) // Añade redondeo arriba.
/// .with_bottom_start(class::RoundedRadius::Scale4) // Añade una esquina redondeada concreta. /// .with_bottom_start(class::RoundedRadius::Scale4) // Añade esquina redondeada concreta.
/// .with_bottom_end(class::RoundedRadius::Circle); // Añade redondeo extremo en otra esquina. /// .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"); /// assert_eq!(r.to_class(), "rounded-top rounded-bottom-start-4 rounded-bottom-end-circle");
/// ``` /// ```
#[rustfmt::skip] #[rustfmt::skip]
@ -147,12 +163,13 @@ pub struct Rounded {
} }
impl 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 { 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 { pub fn with(radius: RoundedRadius) -> Self {
Self::default().with_radius(radius) Self::default().with_radius(radius)
} }

View file

@ -9,10 +9,6 @@ use crate::prelude::*;
/// Representa cada casilla de un grupo de casillas de verificación, con una etiqueta localizable /// 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. /// 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 /// # Ejemplo
/// ///
/// ```rust,no_run /// ```rust,no_run
@ -22,8 +18,8 @@ use crate::prelude::*;
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item { pub struct Item {
/// Devuelve el nombre que se combina con el del grupo para componer el atributo `name`. /// Devuelve el valor enviado al servidor cuando la casilla está marcada.
name: AttrValue, value: AttrValue,
/// Devuelve la etiqueta de la casilla. /// Devuelve la etiqueta de la casilla.
label: L10n, label: L10n,
/// Devuelve si la casilla debe aparecer marcada por defecto. /// Devuelve si la casilla debe aparecer marcada por defecto.
@ -33,13 +29,10 @@ pub struct Item {
} }
impl Item { impl Item {
/// Crea una nueva casilla con el nombre y la etiqueta indicados. /// Crea una nueva casilla con el valor y la etiqueta indicados.
/// pub fn new(value: impl AsRef<str>, label: L10n) -> Self {
/// El parámetro `name` se combina con el del grupo para componer el atributo `name` de la
/// casilla.
pub fn new(name: impl AsRef<str>, label: L10n) -> Self {
Self { Self {
name: AttrValue::new(name), value: AttrValue::new(value),
label, label,
checked: false, checked: false,
disabled: false, disabled: false,
@ -65,15 +58,10 @@ impl Item {
/// Componente para crear un **grupo de casillas de verificación**. /// 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 /// Renderiza una lista de opciones de la que el usuario puede marcar cero, una o varias. Todas las
/// independiente. Las casillas se añaden con [`with_item()`](Field::with_item) usando instancias /// casillas comparten el mismo `name` (el del grupo); cada una envía como valor el de su
/// de [`form::check::Item`]. Si se activa el modo en línea con /// [`form::check::Item`] cuando está marcada. Las opciones se añaden con [`with_item()`]. Si se
/// [`with_inline()`](Field::with_inline), las casillas se disponen horizontalmente. /// activa el modo en línea con [`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`.
/// ///
/// # Ejemplo /// # Ejemplo
/// ///
@ -88,26 +76,29 @@ impl Item {
/// .with_item(form::check::Item::new("science", L10n::n("Science")).with_checked(true)); /// .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 /// El navegador envía una entrada por cada casilla marcada, todas bajo la misma clave (por ejemplo,
/// casilla, el navegador envía algo como `interests_tech=true`; mientras que si no la marca, no /// si el usuario marca "Technology" y "Science", `interests=tech&interests=science`) y ninguna si
/// envía nada. En el servidor cada campo se deserializa como `bool` con `#[serde(default)]`: /// 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<T>`; hace falta un extractor que sí lo haga, como [`serde_qs`]:
/// ///
/// ```rust,ignore /// ```rust,ignore
/// #[derive(serde::Deserialize)] /// #[derive(serde::Deserialize)]
/// struct FormData { /// struct FormData {
/// #[serde(default)] /// #[serde(default)]
/// interests_art: bool, /// interests: Vec<String>, // ["tech", "science"], o [] si no se marcó ninguna.
/// #[serde(default)]
/// interests_tech: bool,
/// #[serde(default)]
/// interests_science: bool,
/// } /// }
/// ``` /// ```
///
/// [`with_item()`]: Field::with_item
/// [`with_inline()`]: Field::with_inline
/// [`serde_qs`]: https://docs.rs/serde_qs
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field { pub struct Field {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props, 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, name: AttrName,
/// Devuelve la etiqueta del grupo. /// Devuelve la etiqueta del grupo.
label: Attr<L10n>, label: Attr<L10n>,
@ -164,18 +155,13 @@ impl Component for Field {
@for (item, i) in self.items().iter().zip(1..) { @for (item, i) in self.items().iter().zip(1..) {
@let i = i.to_string(); @let i = i.to_string();
@let item_id = util::join!(&container_id, "-check-", &i); @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) { div class=(item_classes) {
input input
type="checkbox" type="checkbox"
id=(&item_id) id=(&item_id)
class="form-check-input" class="form-check-input"
name=(&item_name) name=(&name)
value="true" value=[item.value().get()]
checked[*item.checked()] checked[*item.checked()]
disabled[*item.disabled() || *self.disabled()]; disabled[*item.disabled() || *self.disabled()];
label class="form-check-label" for=(&item_id) { label class="form-check-label" for=(&item_id) {
@ -208,14 +194,11 @@ impl Field {
self 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` /// Todas las casillas [`form::check::Item`](Item) del grupo llevarán este mismo `name`. Si se
/// de cada casilla de verificación. Por ejemplo, con `name=interests` en el grupo y `name=tech` /// omite, se asigna un nombre generado automáticamente. Para deserializar los campos en el
/// en el ítem, se genera `name=interests_tech`. /// servidor es recomendable establecer un `name` explícito.
///
/// 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] #[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self { pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name); self.name.alter_name(name);

View file

@ -46,8 +46,8 @@ use crate::base::component::form;
pub struct Form { pub struct Form {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props, props: Props,
/// Devuelve la URL/ruta de destino del formulario. /// Devuelve la ruta de destino del formulario.
action: AttrValue, action: Route,
/// Devuelve el método para enviar el formulario. /// Devuelve el método para enviar el formulario.
method: form::Method, method: form::Method,
/// Devuelve el juego de caracteres aceptado por el formulario. /// Devuelve el juego de caracteres aceptado por el formulario.
@ -79,7 +79,7 @@ impl Component for Form {
Ok(html! { Ok(html! {
form form
(self.props()) (self.props())
action=[self.action().get()] action=[self.action().try_resolve(cx)]
method=[method] method=[method]
accept-charset=[self.charset().get()] accept-charset=[self.charset().get()]
{ {
@ -106,10 +106,13 @@ impl Form {
self 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] #[builder_fn]
pub fn with_action(mut self, action: impl AsRef<str>) -> Self { pub fn with_action(mut self, action: impl Into<Route>) -> Self {
self.action.alter_str(action); self.action = action.into();
self self
} }