diff --git a/extensions/pagetop-bootsier/src/theme/form.rs b/extensions/pagetop-bootsier/src/theme/form.rs index b8282b54..8ccb0c2d 100644 --- a/extensions/pagetop-bootsier/src/theme/form.rs +++ b/extensions/pagetop-bootsier/src/theme/form.rs @@ -17,3 +17,5 @@ mod checkbox; pub use checkbox::Checkbox; pub mod check; + +pub mod radio; diff --git a/extensions/pagetop-bootsier/src/theme/form/check.rs b/extensions/pagetop-bootsier/src/theme/form/check.rs index 883b2f46..59208382 100644 --- a/extensions/pagetop-bootsier/src/theme/form/check.rs +++ b/extensions/pagetop-bootsier/src/theme/form/check.rs @@ -78,6 +78,19 @@ impl Item { /// `name=interests` y casillas con `name=art` y `name=tech`, se genera `name=interests_art` y /// `name=interests_tech`. /// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::prelude::*; +/// let interests = form::check::Group::new() +/// .with_name("interests") +/// .with_label(L10n::n("Areas of interest")) +/// .with_item(form::check::Item::new("art", L10n::n("Art"))) +/// .with_item(form::check::Item::new("tech", L10n::n("Technology"))) +/// .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)]`: @@ -93,19 +106,6 @@ impl Item { /// interests_science: bool, /// } /// ``` -/// -/// # Ejemplo -/// -/// ```rust -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::prelude::*; -/// let interests = form::check::Group::new() -/// .with_name("interests") -/// .with_label(L10n::n("Areas of interest")) -/// .with_item(form::check::Item::new("art", L10n::n("Art"))) -/// .with_item(form::check::Item::new("tech", L10n::n("Technology"))) -/// .with_item(form::check::Item::new("science", L10n::n("Science")).with_checked(true)); -/// ``` #[derive(AutoDefault, Clone, Debug, Getters)] pub struct Group { #[getters(skip)] @@ -204,12 +204,12 @@ impl Group { /// Establece el nombre base para el grupo de casillas. /// - /// Se combina con el `name` de cada [`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`. + /// 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 identificador generado automáticamente. Para deserializar los - /// campos en el 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] pub fn with_name(mut self, name: impl AsRef) -> Self { self.name.alter_name(name); diff --git a/extensions/pagetop-bootsier/src/theme/form/radio.rs b/extensions/pagetop-bootsier/src/theme/form/radio.rs new file mode 100644 index 00000000..377710f2 --- /dev/null +++ b/extensions/pagetop-bootsier/src/theme/form/radio.rs @@ -0,0 +1,269 @@ +//! Definiciones para crear grupos de botones de opción (*radio buttons*). + +use pagetop::prelude::*; + +use crate::LOCALES_BOOTSIER; + +// **< Item >*************************************************************************************** + +/// Botón de opción individual de un [`form::radio::Group`](Group). +/// +/// Representa cada opción de un grupo de opciones exclusivas entre sí, con un valor (el que se +/// envía al servidor), una etiqueta localizable visible y puede marcarse como seleccionada o +/// inicialmente deshabilitada de forma independiente. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::prelude::*; +/// let item = form::radio::Item::new("monthly", L10n::n("Monthly")).with_checked(true); +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Item { + /// Devuelve el valor enviado al servidor cuando la opción está seleccionada. + value: AttrValue, + /// Devuelve la etiqueta de la opción. + label: L10n, + /// Devuelve si la opción debe aparecer seleccionada por defecto. + checked: bool, + /// Devuelve si la opción está deshabilitada. + disabled: bool, +} + +impl Item { + /// Crea una nueva opción con el valor y la etiqueta indicados. + pub fn new(value: impl AsRef, label: L10n) -> Self { + Self { + value: AttrValue::new(value), + label, + checked: false, + disabled: false, + } + } + + // **< Item BUILDER >*************************************************************************** + + /// Establece si la opción aparece seleccionada por defecto. + /// + /// Si varias opciones del grupo tienen `checked` activo, sólo la primera se renderizará como + /// seleccionada; las demás se ignorarán. + pub fn with_checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + /// Establece si la opción está inicialmente deshabilitada. + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +// **< Group >************************************************************************************** + +/// Componente para crear un **grupo de botones de opción**. +/// +/// Renderiza un grupo de botones de opción [`form::radio::Item`](Item) que comparten el mismo +/// atributo `name`, por lo que sólo puede seleccionarse uno a la vez. Las opciones se añaden con +/// [`with_item()`](Group::with_item). +/// +/// Si se activa el modo en línea [`with_inline()`](Group::with_inline), los botones se +/// disponen horizontalmente. El atributo `required` se propaga a todos los botones del grupo para +/// cumplir con la especificación HTML. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::prelude::*; +/// let plan = form::radio::Group::new() +/// .with_name("plan") +/// .with_label(L10n::n("Subscription plan")) +/// .with_item(form::radio::Item::new("monthly", L10n::n("Monthly"))) +/// .with_item(form::radio::Item::new("annual", L10n::n("Annual")).with_checked(true)) +/// .with_required(true); +/// ``` +/// +/// Cuando el usuario selecciona un botón, el navegador envía algo como `plan=monthly`; si no +/// selecciona ninguno, no envía nada. En el servidor el campo se deserializa como `Option`: +/// +/// ```rust,ignore +/// #[derive(serde::Deserialize)] +/// struct FormData { +/// plan: Option, // Some("monthly"), Some("annual"), ..., o None si no se seleccionó. +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Group { + #[getters(skip)] + id: AttrId, + /// Devuelve las clases CSS del contenedor del grupo. + classes: Classes, + /// Devuelve el nombre compartido por todos los botones de opción del grupo. + name: AttrName, + /// Devuelve la etiqueta del grupo. + label: Attr, + /// Devuelve el texto de ayuda del grupo. + help_text: Attr, + /// Devuelve las opciones del grupo. + items: Vec, + /// Devuelve si la selección de alguna opción del grupo es obligatoria. + required: bool, + /// Devuelve si todo el grupo está deshabilitado. + disabled: bool, + /// Devuelve si los botones se muestran en línea horizontalmente. + inline: bool, +} + +impl Component for Group { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.id.get() + } + + fn setup(&mut self, _cx: &Context) { + self.alter_classes(ClassesOp::Prepend, "form-item form-item-radios"); + } + + fn prepare(&self, cx: &mut Context) -> Result { + let name = self + .name() + .get() + .unwrap_or_else(|| cx.required_id::(self.id(), 3)); + let group_id = self.id().unwrap_or_else(|| util::join!("edit-", &name)); + Ok(html! { + div id=(&group_id) class=[self.classes().get()] { + @if let Some(label) = self.label().lookup(cx) { + label class="form-label" { + (label) + @if *self.required() { + span + class="form-required" + title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx)) + { + "*" + } + } + } + } + @let item_classes = if *self.inline() { + "form-check form-check-inline" + } else { + "form-check" + }; + @let mut do_check = true; + @for (item, i) in self.items().iter().zip(1..) { + @let checked = { + let c = *item.checked() && do_check; + if c { do_check = false; } + c + }; + @let i = i.to_string(); + @let item_id = util::join!(&group_id, "-", &i); + div class=(item_classes) { + input + type="radio" + id=(&item_id) + class="form-check-input" + name=(&name) + value=[item.value().get()] + checked[checked] + required[*self.required()] + disabled[*item.disabled() || *self.disabled()]; + label class="form-check-label" for=(&item_id) { + (item.label().using(cx)) + } + } + } + @if let Some(description) = self.help_text().lookup(cx) { + div class="form-text" { (description) } + } + } + }) + } +} + +impl Group { + // **< Group BUILDER >************************************************************************** + + /// Establece el identificador único (`id`) del grupo de opciones. + #[builder_fn] + pub fn with_id(mut self, id: impl AsRef) -> Self { + self.id.alter_id(id); + self + } + + /// Modifica la lista de clases CSS aplicadas al contenedor del grupo de opciones. + #[builder_fn] + pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef) -> Self { + self.classes.alter_classes(op, classes); + self + } + + /// Establece el nombre compartido por todos los botones de opción del grupo. + /// + /// Todas las opciones [`form::radio::Item`](Item) del grupo llevarán este mismo `name`, lo que + /// garantiza la exclusividad de la selección. Es imprescindible establecer un `name`; sin él + /// los botones no se envían al servidor. + /// + /// 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); + self + } + + /// Establece o elimina la etiqueta visible del grupo (basta pasar `None` para quitarla). + #[builder_fn] + pub fn with_label(mut self, label: impl Into>) -> Self { + self.label.alter_opt(label.into()); + self + } + + /// Establece o elimina el texto de ayuda del grupo (basta pasar `None` para quitarlo). + #[builder_fn] + pub fn with_help_text(mut self, help_text: impl Into>) -> Self { + self.help_text.alter_opt(help_text.into()); + self + } + + /// Añade una opción al grupo. Las opciones se muestran en el orden en que se añaden. + #[builder_fn] + pub fn with_item(mut self, item: Item) -> Self { + self.items.push(item); + self + } + + /// Establece si la selección de alguna opción del grupo es obligatoria. + /// + /// El atributo `required` se propaga a todos los botones del grupo para cumplir con la + /// especificación HTML. + #[builder_fn] + pub fn with_required(mut self, required: bool) -> Self { + self.required = required; + self + } + + /// Establece si todo el grupo está deshabilitado. + /// + /// Cuando está activo, se combina con el estado `disabled` de cada [`Item`]. + #[builder_fn] + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Establece si los botones se muestran en línea horizontalmente. + /// + /// Al activar este modo, se añade la clase `form-check-inline` al contenedor de cada opción. + #[builder_fn] + pub fn with_inline(mut self, inline: bool) -> Self { + self.inline = inline; + self + } +}