♻️ (form): Mueve componentes de formulario a base

This commit is contained in:
Manuel Cillero 2026-06-22 02:12:09 +02:00
parent 26f1cda831
commit 9435678e01
38 changed files with 2211 additions and 1826 deletions

View file

@ -0,0 +1,262 @@
//! Definiciones para crear grupos de casillas de verificación (*check buttons*).
use crate::prelude::*;
// **< Item >***************************************************************************************
/// Casilla de verificación individual de un [`Field`].
///
/// 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
/// use pagetop::prelude::*;
///
/// let item = form::check::Item::new("apple", L10n::n("Apple")).with_checked(true);
/// ```
#[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 la etiqueta de la casilla.
label: L10n,
/// Devuelve si la casilla debe aparecer marcada por defecto.
checked: bool,
/// Devuelve si la casilla está deshabilitada.
disabled: bool,
}
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<str>, label: L10n) -> Self {
Self {
name: AttrValue::new(name),
label,
checked: false,
disabled: false,
}
}
// **< Item BUILDER >***************************************************************************
/// Establece si la casilla debe aparecer marcada por defecto.
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
/// Establece si la casilla está deshabilitada.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Field >**************************************************************************************
/// 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`.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let interests = form::check::Field::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)]`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// interests_art: bool,
/// #[serde(default)]
/// interests_tech: bool,
/// #[serde(default)]
/// interests_science: bool,
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el nombre base compartido por todas las casillas 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 casillas del grupo.
items: Vec<Item>,
/// Devuelve si todo el grupo está deshabilitado.
disabled: bool,
/// Devuelve si las casillas se muestran en línea horizontalmente.
inline: bool,
}
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 casillas.
self.alter_prop(PropsOp::prepend_classes("form-field form-field-checkboxes"));
}
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) }
}
@let item_classes = if *self.inline() {
"form-check form-check-inline"
} else {
"form-check"
};
@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"
checked[*item.checked()]
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 base para el grupo de casillas.
///
/// 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.
#[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 casilla al grupo. Las casillas 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 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 las casillas se muestran en línea horizontalmente.
///
/// Al activar este modo, se añade la clase `form-check-inline` al contenedor de cada casilla.
#[builder_fn]
pub fn with_inline(mut self, inline: bool) -> Self {
self.inline = inline;
self
}
}

View file

@ -0,0 +1,237 @@
use crate::prelude::*;
/// Componente para crear una **casilla de verificación** o un **interruptor** (*toggle switch*).
///
/// Renderiza un control binario (marcado/no marcado) en dos variantes, por defecto como casilla de
/// verificación estándar, y también como interruptor ([`Checkbox::switch()`]).
///
/// Se puede mostrar en línea con otros controles usando [`with_inline()`](Checkbox::with_inline), o
/// justificar a la derecha del contenedor invirtiendo el orden de la etiqueta y el control usando
/// [`with_reverse()`](Checkbox::with_reverse).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let accept_terms = form::Checkbox::new()
/// .with_name("terms_accepted")
/// .with_label(L10n::n("I accept the terms and conditions"))
/// .with_required(true);
///
/// let notifications = form::Checkbox::switch()
/// .with_name("notifications_enabled")
/// .with_label(L10n::n("Receive email notifications"))
/// .with_checked(true);
/// ```
///
/// Cuando el control está activo, el navegador envía `name=true`; si no lo está, no envía nada.
/// En el servidor el campo se deserializa como `bool` con `#[serde(default)]`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// terms_accepted: bool, // true = marcada, false = no marcada.
/// #[serde(default)]
/// notifications_enabled: bool, // true = activo, false = inactivo.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Checkbox {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve la variante visual del control.
checkbox_kind: form::CheckboxKind,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve la etiqueta del control.
label: Attr<L10n>,
/// Devuelve si el control debe estar marcado/activo por defecto.
checked: bool,
/// Devuelve si el control recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el campo es obligatorio.
required: bool,
/// Devuelve si el control está deshabilitado.
disabled: bool,
/// Devuelve si el control se muestra en línea con otros controles.
inline: bool,
/// Devuelve si el control y su etiqueta se justifican a la derecha del contenedor.
reverse: bool,
}
impl Component for Checkbox {
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(), 1));
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 de la casilla de verificación.
let mut classes = String::from("form-field form-check");
if *self.checkbox_kind() == form::CheckboxKind::Switch {
classes.push_str(" form-switch");
}
if *self.inline() {
classes.push_str(" form-check-inline");
}
if *self.reverse() {
classes.push_str(" form-check-reverse");
}
self.alter_prop(PropsOp::prepend_classes(classes));
}
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();
let checkbox_id = util::join!(&container_id, "-checkbox");
let is_switch = *self.checkbox_kind() == form::CheckboxKind::Switch;
Ok(html! {
div (self.props()) {
input
type="checkbox"
role=[is_switch.then_some("switch")]
id=(&checkbox_id)
class="form-check-input"
name=(&name)
value="true"
checked[*self.checked()]
autofocus[*self.autofocus()]
required[*self.required()]
disabled[*self.disabled()];
@if let Some(label) = self.label().lookup(cx) {
label class="form-check-label" for=(&checkbox_id) {
(label)
@if *self.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
}
}
})
}
}
impl Checkbox {
/// Crea una casilla de verificación estándar.
pub fn check() -> Self {
Self::default()
}
/// Crea un interruptor de encendido/apagado (*toggle switch*).
pub fn switch() -> Self {
Self {
checkbox_kind: form::CheckboxKind::Switch,
..Self::default()
}
}
// **< Checkbox 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 la variante visual del control.
#[builder_fn]
pub fn with_kind(mut self, kind: form::CheckboxKind) -> Self {
self.checkbox_kind = kind;
self
}
/// Establece el nombre del campo (atributo `name`).
///
/// Si se omite, se asigna un identificador generado automáticamente. Para deserializar el campo
/// 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 control (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 si el control debe aparecer marcado/activo por defecto.
#[builder_fn]
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
/// Establece si el control recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el control está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Establece si el control se muestra en línea con otros controles.
///
/// Al activar este modo, se añade la clase `form-check-inline` al contenedor, lo que permite
/// alinear varios controles horizontalmente.
#[builder_fn]
pub fn with_inline(mut self, inline: bool) -> Self {
self.inline = inline;
self
}
/// Establece si el control y su etiqueta se justifican a la derecha del contenedor.
///
/// Al activar este modo, se añade la clase `form-check-reverse` al contenedor.
#[builder_fn]
pub fn with_reverse(mut self, reverse: bool) -> Self {
self.reverse = reverse;
self
}
}

View file

@ -0,0 +1,141 @@
use crate::prelude::*;
use crate::base::component::form;
/// Componente para crear un **formulario** HTML ([`form`]).
///
/// Renderiza un formulario estándar con soporte para los atributos más habituales:
///
/// - `id`: identificador opcional del formulario.
/// - `classes`: clases CSS adicionales (p. ej. utilidades CSS).
/// - `action`: URL/ruta de destino para el envío.
/// - `method`: método usado por el formulario para el envío de los datos (ver [`form::Method`]).
/// - `accept-charset`: juego de caracteres aceptado (por defecto es `"UTF-8"`).
/// - `children`: contenido del formulario.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let form_login = Form::new()
/// .with_id("login")
/// .with_action("/login")
/// .with_child(
/// form::input::Field::email()
/// .with_name("email")
/// .with_label(L10n::n("Email"))
/// .with_required(true),
/// )
/// .with_child(
/// form::input::Field::password()
/// .with_name("password")
/// .with_label(L10n::n("Password"))
/// .with_required(true),
/// )
/// .with_child(
/// form::Checkbox::check()
/// .with_name("remember")
/// .with_label(L10n::n("Remember me")),
/// )
/// .with_child(
/// Button::submit(L10n::n("Sign in"))
/// );
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Form {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve la URL/ruta de destino del formulario.
action: AttrValue,
/// Devuelve el método para enviar el formulario.
method: form::Method,
/// Devuelve el juego de caracteres aceptado por el formulario.
#[default(_code = "AttrValue::new(\"UTF-8\")")]
charset: AttrValue,
/// Devuelve la lista de componentes del formulario.
children: Children,
}
impl Component for Form {
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("form"));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let method = match self.method() {
form::Method::Post => Some("post"),
form::Method::Get => None,
};
Ok(html! {
form
(self.props())
action=[self.action().get()]
method=[method]
accept-charset=[self.charset().get()]
{
(self.children().render(cx))
}
})
}
}
impl Form {
// **< Form 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 la URL/ruta de destino del formulario.
#[builder_fn]
pub fn with_action(mut self, action: impl AsRef<str>) -> Self {
self.action.alter_str(action);
self
}
/// Establece el método para enviar el formulario.
///
/// - `GET`: el atributo `method` se omite.
/// - `POST`: se establece `method="post"`.
#[builder_fn]
pub fn with_method(mut self, method: form::Method) -> Self {
self.method = method;
self
}
/// Establece el juego de caracteres aceptado por el formulario.
///
/// Por defecto se utiliza `"UTF-8"`.
#[builder_fn]
pub fn with_charset(mut self, charset: impl AsRef<str>) -> Self {
self.charset.alter_str(charset);
self
}
/// Añade un nuevo componente al formulario o modifica la lista de componentes (`children`) con
/// una operación [`ChildOp`].
#[builder_fn]
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}

View file

@ -0,0 +1,114 @@
use crate::prelude::*;
/// Componente para crear un **grupo de controles relacionados** en un formulario.
///
/// Renderiza un `<fieldset>` con una leyenda opcional que sirve de encabezado y una descripción
/// también opcional que aparece justo antes de los controles. Es un elemento semántico que mejora
/// la accesibilidad porque los lectores de pantalla anuncian la leyenda antes de leer cada control
/// del contenido.
///
/// Los componentes del grupo se añaden con [`with_child()`](Fieldset::with_child). Si no hay
/// contenido para renderizar, el `fieldset` no se genera. Si está deshabilitado, todos sus
/// controles hijos quedan deshabilitados automáticamente por el navegador.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let personal_data = form::Fieldset::new()
/// .with_legend(L10n::n("Personal data"))
/// .with_description(L10n::n("Enter your full name and contact email."))
/// .with_child(form::input::Field::text().with_name("name").with_label(L10n::n("Full name")))
/// .with_child(form::input::Field::email().with_name("email").with_label(L10n::n("Email")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Fieldset {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve la leyenda del `fieldset`.
legend: Attr<L10n>,
/// Devuelve la descripción del `fieldset`.
description: Attr<L10n>,
/// Devuelve si el `fieldset` está deshabilitado.
disabled: bool,
/// Devuelve la lista de componentes del `fieldset`.
children: Children,
}
impl Component for Fieldset {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let children = self.children().render(cx);
if children.is_empty() {
return Ok(html! {});
}
Ok(html! {
fieldset (self.props()) disabled[*self.disabled()] {
@if let Some(legend) = self.legend().lookup(cx) {
legend { (legend) }
}
@if let Some(description) = self.description().lookup(cx) {
p class="fieldset-description" { (description) }
}
(children)
}
})
}
}
impl Fieldset {
// **< Fieldset 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 o elimina la leyenda del `fieldset` (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_legend(mut self, legend: impl Into<Option<L10n>>) -> Self {
self.legend.alter_opt(legend.into());
self
}
/// Establece o elimina la descripción del `fieldset` (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_description(mut self, description: impl Into<Option<L10n>>) -> Self {
self.description.alter_opt(description.into());
self
}
/// Establece si el `fieldset` está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Añade un nuevo componente al `fieldset`, o aplica una operación [`ChildOp`] sobre la lista
/// de componentes (`children`).
#[builder_fn]
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}

View file

@ -0,0 +1,72 @@
use crate::prelude::*;
/// Componente para crear un **campo oculto** del formulario.
///
/// Renderiza un campo sin ningún marcado visible. Su valor se envía al servidor junto con el resto
/// del formulario, pero el usuario no puede verlo ni modificarlo.
///
/// Es útil para transportar datos de estado, tokens CSRF, identificadores o cualquier valor que
/// deba incluirse en el envío sin ser accesible al usuario.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let token = form::Hidden::new()
/// .with_name("csrf_token")
/// .with_value("a1b2c3d4e5");
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. En el servidor se deserializa
/// como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// csrf_token: String,
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Hidden {
/// Devuelve el nombre del campo oculto.
name: AttrName,
/// Devuelve el valor del campo oculto.
value: AttrValue,
}
impl Component for Hidden {
fn new() -> Self {
Self::default()
}
fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
input
type="hidden"
name=[self.name().get()]
value=[self.value().get()];
})
}
}
impl Hidden {
// **< Hidden BUILDER >*************************************************************************
/// Establece el nombre del campo oculto (atributo `name`).
///
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo 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 el valor del campo oculto (atributo `value`).
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
self.value.alter_str(value);
self
}
}

View file

@ -0,0 +1,424 @@
//! Definiciones para crear campos de texto de una línea.
use crate::prelude::*;
use std::fmt;
// **< Kind >***************************************************************************************
/// Tipo de campo para un [`form::input::Field`].
///
/// Determina el tipo de entrada que acepta, así como el comportamiento del navegador al interactuar
/// con el campo. Implícitamente se aplica al crear el control: [`text()`](Field::text),
/// [`password()`](Field::password), [`search()`](Field::search), [`email()`](Field::email),
/// [`telephone()`](Field::telephone) o [`url()`](Field::url).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// Entrada de texto genérico (`type="text"`). Es el tipo por defecto.
#[default]
Text,
/// Entrada de una contraseña (`type="password"`). El contenido aparece enmascarado.
Password,
/// Campo de búsqueda (`type="search"`). Es un tipo semántico para los cuadros de búsqueda.
Search,
/// Entrada de un correo electrónico (`type="email"`). Permite validar el formato del correo.
Email,
/// Entrada de un teléfono (`type="tel"`). Activa el teclado de llamadas en móviles.
Telephone,
/// Entrada de una URL (`type="url"`). Comprueba que la entrada sea una URL bien formada.
Url,
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Kind::Text => "text",
Kind::Password => "password",
Kind::Search => "search",
Kind::Email => "email",
Kind::Telephone => "tel",
Kind::Url => "url",
})
}
}
// **< Mode >***************************************************************************************
/// Sugerencia para el teclado virtual de un [`form::input::Field`].
///
/// Indica al navegador qué tipo de teclado virtual mostrar en dispositivos móviles o táctiles al
/// editar el campo. A diferencia del atributo `type` ([`form::input::Kind`]), no restringe los
/// valores aceptados ni activa la validación del navegador; es sólo una sugerencia de presentación.
///
/// Se establece con [`form::input::Field::with_inputmode()`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Mode {
/// Suprime el teclado virtual. Útil en campos con teclado personalizado basado en JavaScript.
None,
/// Teclado de texto genérico.
Text,
/// Teclado decimal, con dígitos y separador decimal.
Decimal,
/// Teclado numérico, con sólo dígitos.
Numeric,
/// Teclado de teléfono, con dígitos y símbolos `+`, `*` y `#`.
Tel,
/// Teclado optimizado para búsquedas (puede incluir tecla de búsqueda).
Search,
/// Teclado optimizado para correo electrónico (incluye `@` y `.`).
Email,
/// Teclado optimizado para URL (incluye `/`, `.` y `.com`).
Url,
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Mode::None => "none",
Mode::Text => "text",
Mode::Decimal => "decimal",
Mode::Numeric => "numeric",
Mode::Tel => "tel",
Mode::Search => "search",
Mode::Email => "email",
Mode::Url => "url",
})
}
}
// **< Field >**************************************************************************************
/// Componente para crear un **campo de texto de una línea**.
///
/// Renderiza los tipos más habituales en formularios:
///
/// - [`Field::text()`]: campo de texto genérico (`type="text"`, por defecto).
/// - [`Field::password()`]: contraseña (`type="password"`).
/// - [`Field::search()`]: búsqueda (`type="search"`).
/// - [`Field::email()`]: correo electrónico (`type="email"`).
/// - [`Field::telephone()`]: teléfono (`type="tel"`).
/// - [`Field::url()`]: URL (`type="url"`).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let email = form::input::Field::email()
/// .with_name("email")
/// .with_label(L10n::n("Email address"))
/// .with_placeholder(L10n::n("user@example.com"))
/// .with_autocomplete(Some(form::Autocomplete::email()))
/// .with_required(true);
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un campo de texto siempre envía su
/// valor, incluso si está vacío. En el servidor se deserializa como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// email: String, // Siempre presente; cadena vacía si el usuario no escribió nada.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el tipo de campo.
kind: Kind,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve el valor inicial del campo.
value: AttrValue,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve la longitud mínima permitida en caracteres.
minlength: Attr<u16>,
/// Devuelve la longitud máxima permitida en caracteres.
maxlength: Attr<u16>,
/// Devuelve el texto indicativo del campo.
placeholder: Attr<L10n>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// Devuelve si el campo recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el campo es de sólo lectura.
readonly: bool,
/// Devuelve si el campo es obligatorio.
required: bool,
/// Devuelve si el campo está deshabilitado.
disabled: bool,
/// Devuelve si el campo se muestra como texto plano sin bordes ni fondo.
plaintext: bool,
/// Devuelve la sugerencia de teclado virtual para el campo.
inputmode: Attr<Mode>,
}
impl Component for Field {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
if let Some(container_id) = self
.id()
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)))
{
self.alter_prop(PropsOp::ensure_id(container_id));
}
// Clases CSS del contenedor del campo de texto.
self.alter_prop(PropsOp::prepend_classes(util::join!(
"form-field form-field-",
self.kind().to_string()
)));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self.id();
let input_id = container_id.as_deref().map(|id| util::join!(id, "-input"));
let input_class = if *self.plaintext() {
"form-control-plaintext"
} else {
"form-control"
};
Ok(html! {
div (self.props()) {
@if let Some(label) = self.label().lookup(cx) {
label for=[input_id.as_deref()] class="form-label" {
(label)
@if *self.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
}
input
type=(self.kind())
id=[input_id.as_deref()]
class=(input_class)
name=[self.name().get()]
value=[self.value().get()]
minlength=[self.minlength().get()]
maxlength=[self.maxlength().get()]
placeholder=[self.placeholder().lookup(cx)]
inputmode=[self.inputmode().get()]
autocomplete=[self.autocomplete().get()]
autofocus[*self.autofocus()]
readonly[*self.readonly() || *self.plaintext()]
required[*self.required()]
disabled[*self.disabled()];
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Field {
/// Crea un campo de **texto genérico** (`type="text"`).
///
/// Es el tipo por defecto. Adecuado para nombres, apellidos, ciudades y cualquier entrada
/// textual sin restricciones de formato específicas.
pub fn text() -> Self {
Self::default()
}
/// Crea un campo de **contraseña** (`type="password"`).
///
/// El navegador oculta los caracteres introducidos. Se recomienda usar con
/// [`with_autocomplete()`](Self::with_autocomplete) para permitir autorrellenar con una
/// contraseña guardada o dejar al usuario recibir sugerencias o crear una nueva.
pub fn password() -> Self {
Self {
kind: Kind::Password,
..Default::default()
}
}
/// Crea un campo de **búsqueda** (`type="search"`).
///
/// Semánticamente equivalente a `text` pero optimizado para búsquedas: algunos navegadores
/// añaden un botón para borrar el contenido.
pub fn search() -> Self {
Self {
kind: Kind::Search,
..Default::default()
}
}
/// Crea un campo de **correo electrónico** (`type="email"`).
///
/// El navegador valida el formato de la dirección antes de enviar el formulario. En
/// dispositivos móviles muestra un teclado adaptado para introducir direcciones de correo.
pub fn email() -> Self {
Self {
kind: Kind::Email,
..Default::default()
}
}
/// Crea un campo de **teléfono** (`type="tel"`).
///
/// No impone ninguna restricción de formato (los formatos de teléfono varían por país), pero
/// en dispositivos móviles muestra el teclado numérico de llamadas.
pub fn telephone() -> Self {
Self {
kind: Kind::Telephone,
..Default::default()
}
}
/// Crea un campo de **URL** (`type="url"`).
///
/// El navegador valida que el valor sea una URL bien formada antes de enviar el formulario.
pub fn url() -> Self {
Self {
kind: Kind::Url,
..Default::default()
}
}
// **< 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 del campo (atributo `name`).
///
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo 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 el valor inicial del campo.
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
self.value.alter_str(value);
self
}
/// Establece o elimina la etiqueta visible del campo (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 campo (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
}
/// Establece la longitud mínima permitida en caracteres (`None` para no imponer mínimo).
#[builder_fn]
pub fn with_minlength(mut self, minlength: Option<u16>) -> Self {
self.minlength.alter_opt(minlength);
self
}
/// Establece la longitud máxima permitida en caracteres (`None` para no imponer límite).
#[builder_fn]
pub fn with_maxlength(mut self, maxlength: Option<u16>) -> Self {
self.maxlength.alter_opt(maxlength);
self
}
/// Establece o elimina el texto indicativo del campo (`None` para quitarlo).
///
/// Este texto aparece en el mismo campo y desaparece en cuanto el usuario empieza a escribir.
/// Al ser texto visible para el usuario se acepta [`L10n`] para poder localizarlo.
#[builder_fn]
pub fn with_placeholder(mut self, placeholder: impl Into<Option<L10n>>) -> Self {
self.placeholder.alter_opt(placeholder.into());
self
}
/// Establece la configuración de autocompletado del campo.
///
/// Usar los métodos de [`form::Autocomplete`] para los valores más habituales (p. ej.
/// [`Autocomplete::email()`](form::Autocomplete::email) o
/// [`Autocomplete::current_password()`](form::Autocomplete::current_password)).
#[builder_fn]
pub fn with_autocomplete(mut self, autocomplete: Option<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
/// Establece si el campo recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es de sólo lectura.
#[builder_fn]
pub fn with_readonly(mut self, readonly: bool) -> Self {
self.readonly = readonly;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el campo está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Establece si el campo se muestra como texto plano (sin bordes ni fondo).
///
/// Útil para mostrar un valor no editable en pantalla que sí se envía al servidor con el
/// formulario. El efecto visual depende del tema activo.
#[builder_fn]
pub fn with_plaintext(mut self, plaintext: bool) -> Self {
self.plaintext = plaintext;
self
}
/// Establece el modo de entrada sugerido para el teclado virtual en dispositivos móviles.
///
/// A diferencia del atributo `type` ([`form::input::Kind`]), no restringe los valores aceptados
/// ni activa la validación del navegador; es sólo una sugerencia de presentación.
#[builder_fn]
pub fn with_inputmode(mut self, inputmode: Option<Mode>) -> Self {
self.inputmode.alter_opt(inputmode);
self
}
}

View file

@ -0,0 +1,486 @@
use crate::prelude::*;
use std::borrow::Cow;
use std::fmt;
// **< CheckboxKind >*******************************************************************************
/// Variante visual para un [`form::Checkbox`] en un formulario.
///
/// Determina si el control se renderiza como una casilla de verificación estándar o como un
/// interruptor (*toggle switch*). La variante [`Switch`](Self::Switch) añade la clase `form-switch`
/// al contenedor y el atributo `role="switch"` al control para accesibilidad.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum CheckboxKind {
/// Casilla de verificación estándar. Es el tipo por defecto.
#[default]
Check,
/// Interruptor de encendido/apagado (*toggle switch*).
Switch,
// TODO: Añadir variante `NativeSwitch` cuando el atributo `switch` de la propuesta WHATWG
// (https://github.com/whatwg/html/issues/9546) sea estándar y tenga soporte amplio. Safari ya
// lo soporta. También se añadiría el constructor `Checkbox::native_switch()`.
}
// **< Autocomplete / AutofillField >***************************************************************
/// Configuración para el autocompletado de controles en un formulario.
///
/// Indica al navegador si puede sugerir o rellenar automáticamente el valor del control usando
/// datos que el usuario haya introducido antes (credenciales guardadas, datos de contacto, etc.).
///
/// Lo habitual es usar uno de los **métodos predefinidos**, que generan el token canónico adecuado
/// para cada tipo de dato:
///
/// - Identidad y credenciales: [`username()`](Autocomplete::username),
/// [`email()`](Autocomplete::email), [`current_password()`](Autocomplete::current_password),
/// [`new_password()`](Autocomplete::new_password), [`otp()`](Autocomplete::otp).
/// - Token o tokens directos: [`token(field)`](Autocomplete::token) con una variante de
/// [`AutofillField`].
/// - Direcciones: [`shipping(field)`](Autocomplete::shipping),
/// [`billing(field)`](Autocomplete::billing).
/// - Datos de contacto: [`home(field)`](Autocomplete::home), [`work(field)`](Autocomplete::work),
/// [`mobile(field)`](Autocomplete::mobile), [`fax(field)`](Autocomplete::fax),
/// [`pager(field)`](Autocomplete::pager).
/// - Sección personalizada: [`section(name, field)`](Autocomplete::section).
///
/// Para activar o inhibir el autocompletado sin especificar el tipo de dato basta con usar las
/// variantes [`form::Autocomplete::On`](Autocomplete::On) o
/// [`form::Autocomplete::Off`](Autocomplete::Off). Para combinaciones no cubiertas por los métodos
/// anteriores, [`custom()`](Autocomplete::custom) acepta cualquier cadena ASCII válida.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// // Correo electrónico con sugerencia semántica del navegador.
/// let ac = form::Autocomplete::email();
///
/// // Contraseña nueva en un formulario de registro.
/// let ac = form::Autocomplete::new_password();
///
/// // Teléfono de contacto del trabajo.
/// let ac = form::Autocomplete::work(form::AutofillField::Tel);
/// ```
#[derive(Clone, Debug, PartialEq)]
pub enum Autocomplete {
/// Genera `autocomplete="on"`.
On,
/// Genera `autocomplete="off"`.
Off,
/// Contiene el valor literal del atributo `autocomplete` tal como se enviará al navegador.
///
/// Debe contener un token o lista de tokens separados por espacios (p. ej. `"username"` o
/// `"username webauthn"`).
Custom(CowStr),
}
impl Autocomplete {
// --< Token >----------------------------------------------------------------------------------
/// Genera `autocomplete` a partir del token o tokens del [`AutofillField`] indicado.
#[inline]
pub fn token(field: AutofillField) -> Self {
Self::Custom(Cow::Borrowed(field.as_str()))
}
// --< Secciones >------------------------------------------------------------------------------
/// Construye `autocomplete` con un prefijo de sección y un token o tokens del
/// [`AutofillField`] indicado.
///
/// Genera `autocomplete="section-<name> <field>"`. Si `name` no es ASCII o contiene espacios,
/// se ignora la sección y se genera sólo el token indicado.
///
/// El prefijo `section-*` sirve para distinguir entre varios grupos del mismo tipo en una misma
/// página (p. ej. una dirección de envío y otra de facturación).
pub fn section(name: impl AsRef<str>, field: AutofillField) -> Self {
match util::normalize_ascii(name.as_ref()) {
Ok(n) if !n.as_ref().contains(' ') => {
Self::custom(util::join!("section-", n.as_ref(), " ", field.as_str()))
}
_ => Self::token(field),
}
}
// --< Comunes >--------------------------------------------------------------------------------
/// Genera `autocomplete="username"`.
pub fn username() -> Self {
Self::token(AutofillField::Username)
}
/// Genera `autocomplete="username webauthn"` (Passkeys / WebAuthn).
pub fn username_webauthn() -> Self {
Self::custom("username webauthn")
}
/// Genera `autocomplete="email"`.
pub fn email() -> Self {
Self::token(AutofillField::Email)
}
/// Genera `autocomplete="current-password"`.
pub fn current_password() -> Self {
Self::token(AutofillField::CurrentPassword)
}
/// Genera `autocomplete="current-password webauthn"` (Passkeys / WebAuthn).
pub fn current_password_webauthn() -> Self {
Self::custom("current-password webauthn")
}
/// Genera `autocomplete="new-password"`.
pub fn new_password() -> Self {
Self::token(AutofillField::NewPassword)
}
/// Genera `autocomplete="one-time-code"`.
pub fn otp() -> Self {
Self::token(AutofillField::OneTimeCode)
}
// --< Direcciones >----------------------------------------------------------------------------
/// Contexto de dirección de envío. Genera `autocomplete="shipping <field>"`.
pub fn shipping(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("shipping ", field.as_str())))
}
/// Contexto de dirección de facturación. Genera `autocomplete="billing <field>"`.
pub fn billing(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("billing ", field.as_str())))
}
// --< Contacto >-------------------------------------------------------------------------------
/// Detalle de contacto: `autocomplete="home <field>"`.
pub fn home(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("home ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="work <field>"`.
pub fn work(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("work ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="mobile <field>"`.
pub fn mobile(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("mobile ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="fax <field>"`.
pub fn fax(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("fax ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="pager <field>"`.
pub fn pager(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("pager ", field.as_str())))
}
// --< Tokens personalizados >------------------------------------------------------------------
/// Crea un valor de `autocomplete` a partir de una cadena de texto libre.
///
/// Normaliza la entrada recortando espacios extra, compactando separadores y convirtiendo a
/// minúsculas. Si el resultado es `"on"` u `"off"`, devuelve la variante correspondiente; si la
/// entrada contiene caracteres no ASCII o queda vacía tras normalizar, devuelve
/// [`form::Autocomplete::On`](Autocomplete::On).
///
/// Para los casos habituales se recomienda usar los métodos predefinidos de
/// [`form::Autocomplete`](Autocomplete).
pub fn custom(autocomplete: impl Into<CowStr>) -> Self {
let value: CowStr = autocomplete.into();
let raw = value.as_ref();
// Normaliza la entrada.
let Some(normalized) = util::normalize_ascii_or_empty(raw, "Autocomplete::custom") else {
return Self::On;
};
let autocomplete = normalized.as_ref();
// Identifica valores especiales.
if autocomplete == "on" {
return Self::On;
}
if autocomplete == "off" {
return Self::Off;
}
// Mantiene el `Cow` original si no cambia nada (no reserva espacio).
if autocomplete == raw {
return Self::Custom(value);
}
// En otro caso asigna espacio para la normalización.
Self::Custom(Cow::Owned(normalized.into_owned()))
}
}
impl fmt::Display for Autocomplete {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Autocomplete::On => f.write_str("on"),
Autocomplete::Off => f.write_str("off"),
Autocomplete::Custom(c) => f.write_str(c),
}
}
}
/// Tokens para el autocompletado de formularios con [`form::Autocomplete`](Autocomplete).
///
/// Representa los tokens de autorrelleno (*autofill field*) definidos por la
/// [especificación WHATWG](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-field)
/// para el atributo `autocomplete`. Cada variante corresponde exactamente a un token canónico
/// de dicha especificación.
///
/// Los valores se usan en combinación con [`form::Autocomplete`](Autocomplete) para construir el
/// valor completo del atributo `autocomplete` de un control de formulario. Los métodos de
/// [`form::Autocomplete`](Autocomplete) como [`token()`](Autocomplete::token),
/// [`email()`](Autocomplete::email), [`shipping()`](Autocomplete::shipping) o
/// [`section()`](Autocomplete::section) aceptan variantes de `AutofillField` para generar el token
/// correspondiente.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let ac = form::Autocomplete::token(form::AutofillField::Username);
/// let ac = form::Autocomplete::shipping(form::AutofillField::StreetAddress);
/// let ac = form::Autocomplete::section("job", form::AutofillField::Email);
/// ```
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AutofillField {
// --< Identidad / cuenta >---------------------------------------------------------------------
/// Nombre completo.
Name,
/// Tratamiento o título (p. ej. "Sr.", "Sra.", "Dra.").
HonorificPrefix,
/// Nombre de pila.
GivenName,
/// Nombre adicional (p. ej. segundo nombre).
AdditionalName,
/// Apellidos.
FamilyName,
/// Sufijo honorífico (p. ej. "Jr.", "PhD").
HonorificSuffix,
/// Apodo.
Nickname,
/// Identificador de usuario (login).
Username,
// --< Credenciales >---------------------------------------------------------------------------
/// Contraseña actual.
CurrentPassword,
/// Nueva contraseña.
NewPassword,
/// Código de un solo uso (OTP).
OneTimeCode,
// --< Organización >---------------------------------------------------------------------------
/// Cargo o título dentro de una organización.
OrganizationTitle,
/// Nombre de la organización.
Organization,
// --< Contacto >-------------------------------------------------------------------------------
/// Correo electrónico.
Email,
/// Teléfono.
Tel,
/// Prefijo/código de país del teléfono (incluye `+`).
TelCountryCode,
/// Teléfono sin el código de país.
TelNational,
/// Código de área (si aplica).
TelAreaCode,
/// Teléfono sin código de país ni de área.
TelLocal,
/// Prefijo local (primera parte tras el área).
TelLocalPrefix,
/// Sufijo local (segunda parte tras el área).
TelLocalSuffix,
/// Extensión interna.
TelExtension,
/// URL personal o de contacto.
Url,
/// Referencia de mensajería instantánea (URL).
Impp,
// --< Dirección >------------------------------------------------------------------------------
/// Dirección postal completa (una sola línea/textarea).
StreetAddress,
/// Línea 1 de dirección.
AddressLine1,
/// Línea 2 de dirección.
AddressLine2,
/// Línea 3 de dirección.
AddressLine3,
/// Nivel administrativo 4 (el más específico).
AddressLevel4,
/// Nivel administrativo 3.
AddressLevel3,
/// Nivel administrativo 2 (p. ej. ciudad/municipio).
AddressLevel2,
/// Nivel administrativo 1 (p. ej. provincia/estado).
AddressLevel1,
/// Código postal.
PostalCode,
/// País (el navegador rellena el código de país).
Country,
/// Nombre del país.
CountryName,
// --< Pago >-----------------------------------------------------------------------------------
/// Nombre del titular de la tarjeta.
CcName,
/// Nombre de pila del titular de la tarjeta.
CcGivenName,
/// Nombre adicional del titular de la tarjeta.
CcAdditionalName,
/// Apellidos del titular de la tarjeta.
CcFamilyName,
/// Número de tarjeta.
CcNumber,
/// Fecha de caducidad (completa).
CcExp,
/// Mes de caducidad.
CcExpMonth,
/// Año de caducidad.
CcExpYear,
/// Código de seguridad (CVC/CVV).
CcCsc,
/// Tipo de tarjeta (p. ej. visa/mastercard).
CcType,
// --< Transacción / preferencias >-------------------------------------------------------------
/// Moneda preferida para la transacción (código ISO 4217).
TransactionCurrency,
/// Cantidad de la transacción (número).
TransactionAmount,
/// Idioma preferido (BCP 47).
Language,
// --< Datos personales >-----------------------------------------------------------------------
/// Fecha de nacimiento completa.
Bday,
/// Día de nacimiento.
BdayDay,
/// Mes de nacimiento.
BdayMonth,
/// Año de nacimiento.
BdayYear,
/// Sexo (valor libre guardado por el navegador).
Sex,
/// Foto (URL o referencia guardada por el navegador).
Photo,
}
impl AutofillField {
/// Devuelve el token exacto definido por HTML para `autocomplete`.
pub(crate) fn as_str(&self) -> &'static str {
match self {
AutofillField::Name => "name",
AutofillField::HonorificPrefix => "honorific-prefix",
AutofillField::GivenName => "given-name",
AutofillField::AdditionalName => "additional-name",
AutofillField::FamilyName => "family-name",
AutofillField::HonorificSuffix => "honorific-suffix",
AutofillField::Nickname => "nickname",
AutofillField::Username => "username",
AutofillField::CurrentPassword => "current-password",
AutofillField::NewPassword => "new-password",
AutofillField::OneTimeCode => "one-time-code",
AutofillField::OrganizationTitle => "organization-title",
AutofillField::Organization => "organization",
AutofillField::Email => "email",
AutofillField::Tel => "tel",
AutofillField::TelCountryCode => "tel-country-code",
AutofillField::TelNational => "tel-national",
AutofillField::TelAreaCode => "tel-area-code",
AutofillField::TelLocal => "tel-local",
AutofillField::TelLocalPrefix => "tel-local-prefix",
AutofillField::TelLocalSuffix => "tel-local-suffix",
AutofillField::TelExtension => "tel-extension",
AutofillField::Url => "url",
AutofillField::Impp => "impp",
AutofillField::StreetAddress => "street-address",
AutofillField::AddressLine1 => "address-line1",
AutofillField::AddressLine2 => "address-line2",
AutofillField::AddressLine3 => "address-line3",
AutofillField::AddressLevel4 => "address-level4",
AutofillField::AddressLevel3 => "address-level3",
AutofillField::AddressLevel2 => "address-level2",
AutofillField::AddressLevel1 => "address-level1",
AutofillField::PostalCode => "postal-code",
AutofillField::Country => "country",
AutofillField::CountryName => "country-name",
AutofillField::CcName => "cc-name",
AutofillField::CcGivenName => "cc-given-name",
AutofillField::CcAdditionalName => "cc-additional-name",
AutofillField::CcFamilyName => "cc-family-name",
AutofillField::CcNumber => "cc-number",
AutofillField::CcExp => "cc-exp",
AutofillField::CcExpMonth => "cc-exp-month",
AutofillField::CcExpYear => "cc-exp-year",
AutofillField::CcCsc => "cc-csc",
AutofillField::CcType => "cc-type",
AutofillField::TransactionCurrency => "transaction-currency",
AutofillField::TransactionAmount => "transaction-amount",
AutofillField::Language => "language",
AutofillField::Bday => "bday",
AutofillField::BdayDay => "bday-day",
AutofillField::BdayMonth => "bday-month",
AutofillField::BdayYear => "bday-year",
AutofillField::Sex => "sex",
AutofillField::Photo => "photo",
}
}
}
// **< Method >*************************************************************************************
/// Método HTTP usado por un [`Form`](super::Form) para el envío de los datos.
///
/// En HTML, el atributo `method` del formulario indica **cómo** se envían los datos:
///
/// - **GET**: los pares `name=value` se codifican en la **URL** añadiendo una cadena de consulta
/// como `?a=1&b=2`. Es el método por defecto en HTML cuando no se especifica. Suele ser apropiado
/// para **búsquedas** o formularios que no modifican datos ni el estado del sistema.
///
/// - **POST**: los datos se envían en el **cuerpo** de la petición (*request body*). Es apropiado
/// para acciones que **modifican el estado** o cuando hay formularios grandes. Es el **método por
/// defecto** en PageTop.
///
/// # Consideraciones prácticas
///
/// - **Visibilidad y privacidad**: con GET los datos quedan visibles en la URL (historial, *logs*,
/// marcadores). No se recomienda para datos sensibles. Con POST no van en la URL, pero **no se
/// cifran** por sí mismos; por eso es esencial el uso de HTTPS.
/// - **Tamaño**: GET está limitado por la longitud máxima de URL que acepten el navegador y el
/// servidor. POST es más flexible para cargas grandes.
/// - **Ficheros**: la subida de ficheros requiere `method="post"` y un `enctype` adecuado
/// (habitualmente `multipart/form-data`).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Method {
/// Envía los datos en el cuerpo de la petición.
///
/// Es el **método por defecto** en PageTop. Recomendado para operaciones que modifican el
/// estado o para envíos grandes.
#[default]
Post,
/// Envía los datos en la URL como una cadena *query*.
///
/// Recomendado para búsquedas y operaciones que no modifican datos ni el estado del sistema.
Get,
}

View file

@ -0,0 +1,275 @@
//! 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,
}
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"));
}
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
}
}

View file

@ -0,0 +1,196 @@
use crate::prelude::*;
/// Componente para crear un **control deslizante** de rango.
///
/// Renderiza una barra deslizante con una etiqueta opcional y un texto de ayuda. Permite
/// seleccionar un valor de entre una lista de valores posibles, acotados por un valor mínimo y
/// máximo, con un paso opcional entre valores.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let volume = form::Range::new()
/// .with_name("volume")
/// .with_label(L10n::n("Volume"))
/// .with_min(Some(0.0))
/// .with_max(Some(100.0))
/// .with_step(Some(5.0))
/// .with_value(Some(50.0));
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un control deslizante siempre
/// envía su valor. En el servidor se deserializa como `f64`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// volume: f64, // Siempre presente con el valor numérico seleccionado.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Range {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve el valor mínimo permitido.
min: Attr<f64>,
/// Devuelve el valor máximo permitido.
max: Attr<f64>,
/// Devuelve el incremento entre valores del campo.
step: Attr<f64>,
/// Devuelve el valor inicial del campo.
value: Attr<f64>,
/// Devuelve si el control recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el control está deshabilitado.
disabled: bool,
}
impl Component for Range {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
if let Some(container_id) = self
.id()
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)))
{
self.alter_prop(PropsOp::ensure_id(container_id));
};
// Clases CSS del contenedor del control deslizante.
self.alter_prop(PropsOp::prepend_classes("form-field form-field-range"));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self.id();
let range_id = container_id.as_deref().map(|id| util::join!(id, "-range"));
Ok(html! {
div (self.props()) {
@if let Some(label) = self.label().lookup(cx) {
label for=[range_id.as_deref()] class="form-label" { (label) }
}
input
type="range"
id=[range_id.as_deref()]
class="form-range"
name=[self.name().get()]
min=[self.min().get()]
max=[self.max().get()]
step=[self.step().get()]
value=[self.value().get()]
autofocus[*self.autofocus()]
disabled[*self.disabled()];
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Range {
// **< Range 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 del campo (atributo `name`).
///
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo 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 campo (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 campo (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
}
/// Establece el valor mínimo del rango.
///
/// Pasar `None` omite el atributo `min` y deja que el navegador aplique su valor por defecto.
#[builder_fn]
pub fn with_min(mut self, min: Option<f64>) -> Self {
self.min.alter_opt(min);
self
}
/// Establece el valor máximo del rango.
///
/// Pasar `None` omite el atributo `max` y deja que el navegador aplique su valor por defecto.
#[builder_fn]
pub fn with_max(mut self, max: Option<f64>) -> Self {
self.max.alter_opt(max);
self
}
/// Establece el incremento entre valores del campo.
///
/// Pasar `None` omite el atributo `step` y deja que el navegador aplique su valor por defecto
/// (normalmente `1`).
#[builder_fn]
pub fn with_step(mut self, step: Option<f64>) -> Self {
self.step.alter_opt(step);
self
}
/// Establece el valor inicial del campo.
///
/// Pasar `None` omite el atributo `value` y deja que el navegador aplique su valor por defecto
/// (normalmente el punto medio del rango).
#[builder_fn]
pub fn with_value(mut self, value: Option<f64>) -> Self {
self.value.alter_opt(value);
self
}
/// Establece si el control recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el control está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}

View file

@ -0,0 +1,427 @@
//! Definiciones para crear listas de selección.
use crate::prelude::*;
// **< Item >***************************************************************************************
/// Elemento individual de [`form::select::Field`] o de [`form::select::Group`].
///
/// Representa un elemento dentro de una lista de selección o de un grupo de elementos de la lista.
/// Cada elemento tiene un valor que se envía al servidor y una etiqueta localizable visible para el
/// usuario.
///
/// Puede marcarse como seleccionado por defecto con [`with_selected()`](Self::with_selected) o
/// deshabilitado de forma independiente al resto usando [`with_disabled()`](Self::with_disabled).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let item = form::select::Item::new("es", L10n::n("Spanish")).with_selected(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve el valor enviado al servidor cuando se selecciona el elemento.
value: AttrValue,
/// Devuelve la etiqueta visible del elemento.
label: L10n,
/// Devuelve si el elemento debe aparecer seleccionado por defecto.
selected: bool,
/// Devuelve si el elemento está deshabilitado.
disabled: bool,
}
impl Item {
/// Crea un nuevo elemento con el valor y la etiqueta indicados.
pub fn new(value: impl AsRef<str>, label: L10n) -> Self {
Self {
value: AttrValue::new(value),
label,
selected: false,
disabled: false,
}
}
// **< Item BUILDER >***************************************************************************
/// Establece si el elemento aparece seleccionado por defecto.
///
/// En una lista de selección única, el navegador aplica la selección al último elemento marcado
/// si hay más de uno; mientras que en una lista múltiple se respetan todos los elementos
/// marcados.
pub fn with_selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Establece si el elemento está deshabilitado.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Group >**************************************************************************************
/// Grupo de elementos dentro de [`form::select::Field`].
///
/// Agrupa un conjunto de elementos dentro de una lista de selección con una etiqueta visible. El
/// grupo completo puede deshabilitarse en bloque con [`with_disabled()`](Self::with_disabled).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let group = form::select::Group::new(L10n::n("Europe"))
/// .with_item(form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(form::select::Item::new("fr", L10n::n("French")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Group {
/// Devuelve la etiqueta visible del grupo de elementos.
label: L10n,
/// Devuelve los elementos del grupo.
items: Vec<Item>,
/// Devuelve si el grupo de elementos está deshabilitado.
disabled: bool,
}
impl Group {
/// Crea un nuevo grupo con la etiqueta indicada.
pub fn new(label: L10n) -> Self {
Self {
label,
..Self::default()
}
}
// **< Group BUILDER >**************************************************************************
/// Añade un elemento al grupo. Los elementos se muestran en el orden en que se añaden.
pub fn with_item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
/// Establece si el grupo de elementos está deshabilitado en bloque.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Entry >**************************************************************************************
/// Entrada de [`form::select::Field`] con un elemento o un grupo de elementos.
///
/// Cada entrada se crea implícitamente cuando se usa [`form::select::Field::with_item()`] para
/// añadir un elemento individual o [`form::select::Field::with_group()`] para añadir un grupo de
/// elementos a una lista de selección.
///
/// Con [`form::select::Field::entries()`] se pueden recuperar todas las entradas para su
/// renderizado.
#[derive(Clone, Debug)]
pub enum Entry {
/// Elemento individual.
Item(Item),
/// Grupo de elementos.
Group(Group),
}
// **< Field >**************************************************************************************
/// Componente para crear una **lista de selección**.
///
/// Renderiza un campo para mostrar una lista de elementos con una etiqueta opcional. Permite elegir
/// uno, o más de uno si se activa la selección múltiple con
/// [`with_multiple()`](Self::with_multiple).
///
/// Los elementos individuales se añaden con [`with_item()`](Self::with_item); los grupos de
/// elementos con un encabezado común se añaden con [`with_group()`](Self::with_group). Ambos
/// métodos pueden combinarse libremente.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let idioma = form::select::Field::new()
/// .with_name("language")
/// .with_label(L10n::n("Language"))
/// .with_item(form::select::Item::new("", L10n::n("— Choose —")).with_selected(true))
/// .with_group(
/// form::select::Group::new(L10n::n("Europe"))
/// .with_item(form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(form::select::Item::new("fr", L10n::n("French"))),
/// )
/// .with_group(
/// form::select::Group::new(L10n::n("Americas"))
/// .with_item(form::select::Item::new("en", L10n::n("English")))
/// .with_item(form::select::Item::new("pt", L10n::n("Portuguese"))),
/// )
/// .with_required(true);
/// ```
///
/// Cuando el usuario selecciona un elemento y envía el formulario, el navegador transmite
/// `name=valor`. Si el campo es obligatorio el valor siempre estará presente y puede deserializarse
/// como `String`; si es opcional, usa `Option<String>`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// language: String, // Siempre presente (campo obligatorio).
/// // language: Option<String>, // None si no se selecciona ninguna opción.
/// }
/// ```
///
/// Con selección múltiple activa, el navegador envía un valor por cada elemento marcado; si no se
/// marca ninguno, no envía nada. Usa `Vec<String>` con `#[serde(default)]`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// interests: Vec<String>, // p. ej. ["art", "tech"] o [] si no se marcó ninguna.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve las entradas de la lista (elementos individuales y grupos de elementos).
entries: Vec<Entry>,
/// Devuelve si la lista permite selección múltiple.
multiple: bool,
/// Devuelve el número de filas visibles de la lista de selección.
rows: Attr<u16>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// Devuelve si la lista recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si la selección de un elemento es obligatoria.
required: bool,
/// Devuelve si la lista está deshabilitada.
disabled: bool,
}
impl Component for Field {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
if let Some(container_id) = self
.id()
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)))
{
self.alter_prop(PropsOp::ensure_id(container_id));
}
// Clases CSS del contenedor de la lista de selección.
self.alter_prop(PropsOp::prepend_classes("form-field form-field-select"));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self.id();
let select_id = container_id.as_deref().map(|id| util::join!(id, "-select"));
Ok(html! {
div (self.props()) {
@if let Some(label) = self.label().lookup(cx) {
label for=[select_id.as_deref()] class="form-label" {
(label)
@if *self.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
}
select
id=[select_id.as_deref()]
class="form-select"
name=[self.name().get()]
multiple[*self.multiple()]
size=[self.rows().get()]
autocomplete=[self.autocomplete().get()]
autofocus[*self.autofocus()]
required[*self.required()]
disabled[*self.disabled()]
{
@for entry in self.entries() {
@match entry {
Entry::Item(opt) => {
option
value=(opt.value().as_str().unwrap_or(""))
selected[*opt.selected()]
disabled[*opt.disabled()]
{
(opt.label().using(cx))
}
}
Entry::Group(group) => {
optgroup
label=(group.label().using(cx))
disabled[*group.disabled()]
{
@for opt in group.items() {
option
value=(opt.value().as_str().unwrap_or(""))
selected[*opt.selected()]
disabled[*opt.disabled()]
{
(opt.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 del campo (atributo `name`).
///
/// Sin él, el valor seleccionado no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo 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 campo (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 campo (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 un elemento individual a la lista de selección.
///
/// Los elementos y grupos se muestran en el orden en que se añaden.
#[builder_fn]
pub fn with_item(mut self, item: Item) -> Self {
self.entries.push(Entry::Item(item));
self
}
/// Añade un grupo de elementos a la lista de selección.
///
/// Los elementos y grupos se muestran en el orden en que se añaden.
#[builder_fn]
pub fn with_group(mut self, group: Group) -> Self {
self.entries.push(Entry::Group(group));
self
}
/// Establece si el control permite seleccionar varios elementos.
///
/// Al activar la selección múltiple, se muestra una lista en lugar de un desplegable. Se
/// recomienda combinar con [`with_rows()`](Self::with_rows) para controlar el número de filas
/// visibles.
///
/// Para un número reducido de elementos con etiquetas descriptivas considera usar
/// [`form::check::Field`] en su lugar, ofrece una presentación más clara y es más accesible en
/// pantallas pequeñas.
#[builder_fn]
pub fn with_multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;
self
}
/// Establece el número de filas visibles de la lista de selección.
///
/// Cuando se establece un valor mayor que 1, el control se muestra como lista en lugar de
/// desplegable, tanto en modo simple como múltiple. Con `None` se omite el atributo y presenta
/// el control como desplegable (comportamiento por defecto).
///
/// Es especialmente útil con selección múltiple para controlar el número de filas visibles sin
/// necesidad de recurrir al desplazamiento.
#[builder_fn]
pub fn with_rows(mut self, rows: Option<u16>) -> Self {
self.rows.alter_opt(rows);
self
}
/// Establece la configuración de autocompletado del campo.
///
/// Permite al navegador rellenar automáticamente el elemento seleccionado en listas de países
/// (`"country"`), idiomas (`"language"`), sexo (`"sex"`) u otros campos con valores
/// predefinidos. En listas de selección múltiples no es útil en la práctica, ya que los
/// navegadores no gestionan selecciones múltiples con autocompletado.
///
/// Usa los métodos de [`form::Autocomplete`] para los valores más habituales. Pasa `None` para
/// omitir el atributo.
#[builder_fn]
pub fn with_autocomplete(mut self, autocomplete: Option<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
/// Establece si el campo recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el campo está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}

View file

@ -0,0 +1,256 @@
//! Definiciones para crear áreas de texto en formularios.
use crate::prelude::*;
/// Componente para crear un **área de texto** de formulario.
///
/// Permite escribir en un área de texto de más de una línea, con una etiqueta opcional y atributos
/// como el número de filas a presentar, longitud mínima (`minlength`) y máxima (`maxlength`), texto
/// indicativo (`placeholder`) o autocompletado (`autocomplete`).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let descripcion = form::Textarea::new()
/// .with_name("description")
/// .with_label(L10n::n("Description"))
/// .with_rows(Some(8))
/// .with_maxlength(Some(500))
/// .with_placeholder(L10n::n("Write here..."))
/// .with_required(true);
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un área de texto siempre envía su
/// valor, incluso si está vacía. En el servidor se deserializa como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// description: String, // Siempre presente; cadena vacía si el usuario no escribió nada.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Textarea {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve el valor inicial del área de texto.
value: AttrValue,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve el número de filas visibles del área de texto.
rows: Attr<u16>,
/// Devuelve la longitud mínima permitida en caracteres.
minlength: Attr<u16>,
/// Devuelve la longitud máxima permitida en caracteres.
maxlength: Attr<u16>,
/// Devuelve el texto indicativo del área de texto.
placeholder: Attr<L10n>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// Devuelve si el campo recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el campo es de sólo lectura.
readonly: bool,
/// Devuelve si el campo es obligatorio.
required: bool,
/// Devuelve si el campo está deshabilitado.
disabled: bool,
}
impl Component for Textarea {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
if let Some(container_id) = self
.id()
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)))
{
self.alter_prop(PropsOp::ensure_id(container_id));
}
// Clases CSS del contenedor del área de texto.
self.alter_prop(PropsOp::prepend_classes("form-field form-field-textarea"));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self.id();
let textarea_id = container_id
.as_deref()
.map(|id| util::join!(id, "-textarea"));
Ok(html! {
div (self.props()) {
@if let Some(label) = self.label().lookup(cx) {
label for=[textarea_id.as_deref()] class="form-label" {
(label)
@if *self.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
}
textarea
id=[textarea_id.as_deref()]
class="form-control"
name=[self.name().get()]
rows=[self.rows().get()]
minlength=[self.minlength().get()]
maxlength=[self.maxlength().get()]
placeholder=[self.placeholder().lookup(cx)]
autocomplete=[self.autocomplete().get()]
autofocus[*self.autofocus()]
readonly[*self.readonly()]
required[*self.required()]
disabled[*self.disabled()]
{
@if let Some(value) = self.value().get() {
(value)
}
}
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Textarea {
// **< Textarea 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 del campo (atributo `name`).
///
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo 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 el valor inicial del área de texto.
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
self.value.alter_str(value);
self
}
/// Establece o elimina la etiqueta visible del campo (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 campo (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
}
/// Establece el número de filas visibles del área de texto.
///
/// Sin valor o pasando `None`, el área muestra su altura predeterminada, dos filas según el
/// estándar.
#[builder_fn]
pub fn with_rows(mut self, rows: Option<u16>) -> Self {
self.rows.alter_opt(rows);
self
}
/// Establece la longitud mínima permitida en caracteres.
#[builder_fn]
pub fn with_minlength(mut self, minlength: Option<u16>) -> Self {
self.minlength.alter_opt(minlength);
self
}
/// Establece la longitud máxima permitida en caracteres.
#[builder_fn]
pub fn with_maxlength(mut self, maxlength: Option<u16>) -> Self {
self.maxlength.alter_opt(maxlength);
self
}
/// Establece o elimina el texto indicativo del área de texto (`None` para quitarlo).
///
/// Este texto aparece en el área de texto y desaparece en cuanto el usuario empieza a escribir.
/// Al ser texto visible para el usuario se acepta [`L10n`] para poder localizarlo.
#[builder_fn]
pub fn with_placeholder(mut self, placeholder: impl Into<Option<L10n>>) -> Self {
self.placeholder.alter_opt(placeholder.into());
self
}
/// Establece la configuración de autocompletado del campo.
///
/// Permite al navegador sugerir o rellenar automáticamente el contenido del área de texto
/// con valores guardados. Es especialmente útil en áreas con contenido semántico predefinido.
///
/// Usa los métodos de [`form::Autocomplete`] para los valores más habituales. Pasa `None` para
/// omitir el atributo.
#[builder_fn]
pub fn with_autocomplete(mut self, autocomplete: Option<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
/// Establece si el campo recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es de sólo lectura.
#[builder_fn]
pub fn with_readonly(mut self, readonly: bool) -> Self {
self.readonly = readonly;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el campo está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}