`prepare()` de `Component`, `render()` de `Region` y `Template`, y la implementación de `ComponentRender` pasan a ser funciones async. Se actualizan todos los componentes base, helpers, tests y ejemplos.
276 lines
9.9 KiB
Rust
276 lines
9.9 KiB
Rust
//! Definiciones para crear grupos de botones de opción (*radio buttons*).
|
|
|
|
use crate::prelude::*;
|
|
|
|
// **< Item >***************************************************************************************
|
|
|
|
/// Botón de opción individual de un [`Field`].
|
|
///
|
|
/// 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,no_run
|
|
/// use pagetop::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<str>, 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
|
|
}
|
|
}
|
|
|
|
// **< Field >**************************************************************************************
|
|
|
|
/// Componente para crear un **grupo de botones de opción**.
|
|
///
|
|
/// Renderiza un grupo de botones de opción [`form::radio::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()`](Field::with_item).
|
|
///
|
|
/// Si se activa el modo en línea [`with_inline()`](Field::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,no_run
|
|
/// use pagetop::prelude::*;
|
|
///
|
|
/// let plan = form::radio::Field::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<String>`:
|
|
///
|
|
/// ```rust,ignore
|
|
/// #[derive(serde::Deserialize)]
|
|
/// struct FormData {
|
|
/// plan: Option<String>, // Some("monthly"), Some("annual"), ..., o None si no se seleccionó.
|
|
/// }
|
|
/// ```
|
|
#[derive(AutoDefault, Clone, Debug, Getters)]
|
|
pub struct Field {
|
|
/// Devuelve identificador, clases CSS y atributos HTML del componente.
|
|
props: Props,
|
|
/// Devuelve el nombre compartido por todos los botones de opción del grupo.
|
|
name: AttrName,
|
|
/// Devuelve la etiqueta del grupo.
|
|
label: Attr<L10n>,
|
|
/// Devuelve el texto de ayuda del grupo.
|
|
help_text: Attr<L10n>,
|
|
/// Devuelve las opciones del grupo.
|
|
items: Vec<Item>,
|
|
/// 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,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Component for Field {
|
|
fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
fn id(&self) -> Option<String> {
|
|
self.props.get_id()
|
|
}
|
|
|
|
fn setup(&mut self, cx: &Context) {
|
|
// Asegura `name` e `id`.
|
|
// Si falta uno se deriva del otro; si faltan ambos se genera un valor único.
|
|
let name = self
|
|
.name()
|
|
.get()
|
|
.unwrap_or_else(|| cx.required_id::<Self>(self.id(), 3));
|
|
self.alter_name(&name);
|
|
let container_id = self.id().unwrap_or_else(|| util::join!("edit-", &name));
|
|
self.alter_prop(PropsOp::ensure_id(container_id));
|
|
|
|
// Clases CSS del contenedor del grupo de opciones.
|
|
self.alter_prop(PropsOp::prepend_classes("form-field form-field-radios"));
|
|
}
|
|
|
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
|
// En `setup()` se garantiza que `name` e `id` están definidos antes del renderizado.
|
|
let name = self.name().get().unwrap();
|
|
let container_id = self.id().unwrap();
|
|
|
|
Ok(html! {
|
|
div (self.props()) {
|
|
@if let Some(label) = self.label().lookup(cx) {
|
|
label class="form-label" {
|
|
(label)
|
|
@if *self.required() {
|
|
span
|
|
class="form-required"
|
|
title=(L10n::l("field_required").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!(&container_id, "-radio-", &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 Field {
|
|
// **< Field 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
|
|
}
|
|
|
|
/// 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<str>) -> 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<Option<L10n>>) -> 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<Option<L10n>>) -> 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
|
|
}
|
|
}
|