From 9173aee22c42dba76a258345f3dad6a7eeeb0d9f Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 1 May 2026 07:56:25 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(bootsier):=20A=C3=B1ade=20etiqueta?= =?UTF-8?q?s=20flotantes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los componentes `input::Field` y `select::Field` admiten ahora `with_floating_label()` para habilitar etiquetas flotantes en la visualización de los componentes. --- .../pagetop-bootsier/src/theme/form/input.rs | 75 ++++++++++----- .../pagetop-bootsier/src/theme/form/select.rs | 92 +++++++++++++------ 2 files changed, 117 insertions(+), 50 deletions(-) diff --git a/extensions/pagetop-bootsier/src/theme/form/input.rs b/extensions/pagetop-bootsier/src/theme/form/input.rs index 7cf4b6fc..a6dc34ea 100644 --- a/extensions/pagetop-bootsier/src/theme/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/form/input.rs @@ -95,12 +95,12 @@ impl fmt::Display for Mode { /// /// 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"`). +/// - [`form::input::Field::text()`]: campo de texto genérico (`type="text"`, por defecto). +/// - [`form::input::Field::password()`]: contraseña (`type="password"`). +/// - [`form::input::Field::search()`]: búsqueda (`type="search"`). +/// - [`form::input::Field::email()`]: correo electrónico (`type="email"`). +/// - [`form::input::Field::telephone()`]: teléfono (`type="tel"`). +/// - [`form::input::Field::url()`]: URL (`type="url"`). /// /// # Ejemplo /// @@ -138,6 +138,8 @@ pub struct Field { value: AttrValue, /// Devuelve la etiqueta del campo. label: Attr, + /// Devuelve si la etiqueta se muestra flotante sobre el campo. + floating_label: bool, /// Devuelve el texto de ayuda del campo. help_text: Attr, /// Devuelve la longitud mínima permitida en caracteres. @@ -172,6 +174,9 @@ impl Component for Field { } fn setup(&mut self, _cx: &Context) { + if *self.floating_label() { + self.alter_classes(ClassesOp::Prepend, "form-floating"); + } self.alter_classes( ClassesOp::Prepend, util::join!("form-field form-field-", self.kind().to_string()), @@ -188,21 +193,34 @@ impl Component for Field { } else { "form-control" }; - Ok(html! { - div id=[container_id.as_deref()] class=[self.classes().get()] { - @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::t("input_required", &LOCALES_BOOTSIER).using(cx)) - { - "*" - } + // La etiqueta flotante requiere el atributo `placeholder` para detectar cuándo el campo + // está vacío y animar la etiqueta; si no está definido, se fuerza `placeholder=""`. + let placeholder = if *self.floating_label() { + Some(self.placeholder().lookup(cx).unwrap_or_default()) + } else { + self.placeholder().lookup(cx) + }; + let label = match self.label().lookup(cx) { + Some(text) => html! { + label for=[input_id.as_deref()] class="form-label" { + (text) + @if *self.required() { + span + class="form-required" + title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx)) + { + "*" } } } + }, + None => html! {}, + }; + Ok(html! { + div id=[container_id.as_deref()] class=[self.classes().get()] { + @if !*self.floating_label() { + (label) + } input type=(self.kind()) id=[input_id.as_deref()] @@ -211,13 +229,16 @@ impl Component for Field { value=[self.value().get()] minlength=[self.minlength().get()] maxlength=[self.maxlength().get()] - placeholder=[self.placeholder().lookup(cx)] + placeholder=[placeholder] inputmode=[self.inputmode().get()] autocomplete=[self.autocomplete().get()] autofocus[*self.autofocus()] readonly[*self.readonly() || *self.plaintext()] required[*self.required()] disabled[*self.disabled()]; + @if *self.floating_label() { + (label) + } @if let Some(description) = self.help_text().lookup(cx) { div class="form-text" { (description) } } @@ -232,14 +253,14 @@ impl Field { /// Es el tipo por defecto. Adecuado para nombres, apellidos, ciudades y cualquier entrada /// textual sin restricciones de formato específicas. pub fn text() -> Self { - Field::default() + Self::default() } /// Crea un campo de **contraseña** (`type="password"`). /// /// El navegador oculta los caracteres introducidos. Se recomienda usar con - /// [`with_autocomplete()`](Field::with_autocomplete) para indicar si acepta la contraseña - /// actual o una nueva. + /// [`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, @@ -330,6 +351,16 @@ impl Field { self } + /// Establece si la etiqueta se muestra flotante sobre el campo. + /// + /// Cuando está activo, la etiqueta se superpone al campo y asciende al enfocarlo o cuando tiene + /// contenido. + #[builder_fn] + pub fn with_floating_label(mut self, floating_label: bool) -> Self { + self.floating_label = floating_label; + 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>) -> Self { diff --git a/extensions/pagetop-bootsier/src/theme/form/select.rs b/extensions/pagetop-bootsier/src/theme/form/select.rs index 7fef083c..1fa74f09 100644 --- a/extensions/pagetop-bootsier/src/theme/form/select.rs +++ b/extensions/pagetop-bootsier/src/theme/form/select.rs @@ -13,8 +13,8 @@ use crate::LOCALES_BOOTSIER; /// 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()`](Item::with_selected) o -/// deshabilitado de forma independiente al resto usando [`with_disabled()`](Item::with_disabled). +/// 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 /// @@ -70,7 +70,7 @@ impl Item { /// 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()`](Group::with_disabled). +/// grupo completo puede deshabilitarse en bloque con [`with_disabled()`](Self::with_disabled). /// /// # Ejemplo /// @@ -139,10 +139,10 @@ pub enum Entry { /// /// 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()`](Field::with_multiple). +/// [`with_multiple()`](Self::with_multiple). /// -/// Los elementos individuales se añaden con [`with_item()`](Field::with_item); los grupos de -/// elementos con un encabezado común se añaden con [`with_group()`](Field::with_group). Ambos +/// 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 @@ -199,14 +199,16 @@ pub struct Field { name: AttrName, /// Devuelve la etiqueta del campo. label: Attr, + /// Devuelve si la etiqueta se muestra flotante sobre el campo. + floating_label: bool, /// Devuelve el texto de ayuda del campo. help_text: Attr, /// Devuelve las entradas de la lista (elementos individuales y grupos de elementos). entries: Vec, /// Devuelve si la lista permite selección múltiple. multiple: bool, - /// Devuelve el número de filas visibles (relevante con selección múltiple o en modo lista). - size: Attr, + /// Devuelve el número de filas visibles de la lista de selección. + rows: Attr, /// Devuelve la configuración de autocompletado del campo. autocomplete: Attr, /// Devuelve si la lista recibe el foco automáticamente al cargar la página. @@ -227,6 +229,11 @@ impl Component for Field { } fn setup(&mut self, _cx: &Context) { + if *self.floating_label() { + self.multiple = false; + self.rows.alter_opt(None::); + self.alter_classes(ClassesOp::Prepend, "form-floating"); + } self.alter_classes(ClassesOp::Prepend, "form-field form-field-select"); } @@ -235,27 +242,33 @@ impl Component for Field { .id() .or_else(|| self.name().get().map(|n| util::join!("edit-", n))); let select_id = container_id.as_deref().map(|id| util::join!(id, "-select")); - Ok(html! { - div id=[container_id.as_deref()] class=[self.classes().get()] { - @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::t("input_required", &LOCALES_BOOTSIER).using(cx)) - { - "*" - } + let label = match self.label().lookup(cx) { + Some(text) => html! { + label for=[select_id.as_deref()] class="form-label" { + (text) + @if *self.required() { + span + class="form-required" + title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx)) + { + "*" } } } + }, + None => html! {}, + }; + Ok(html! { + div id=[container_id.as_deref()] class=[self.classes().get()] { + @if !*self.floating_label() { + (label) + } select id=[select_id.as_deref()] class="form-select" name=[self.name().get()] multiple[*self.multiple()] - size=[self.size().get()] + size=[self.rows().get()] autocomplete=[self.autocomplete().get()] autofocus[*self.autofocus()] required[*self.required()] @@ -291,6 +304,9 @@ impl Component for Field { } } } + @if *self.floating_label() { + (label) + } @if let Some(description) = self.help_text().lookup(cx) { div class="form-text" { (description) } } @@ -333,6 +349,20 @@ impl Field { self } + /// Establece si la etiqueta se muestra flotante sobre el campo. + /// + /// Cuando está activo, la etiqueta se superpone al control y permanece flotante siempre que + /// haya una opción visible. + /// + /// Si se usa la etiqueta flotante, el [`setup()`](Self::setup) del componente anulará los + /// valores establecidos con [`with_multiple()`](Self::with_multiple) y + /// [`with_rows()`](Self::with_rows) antes del renderizado. + #[builder_fn] + pub fn with_floating_label(mut self, floating_label: bool) -> Self { + self.floating_label = floating_label; + 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>) -> Self { @@ -361,27 +391,33 @@ impl Field { /// 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_size()`](Field::with_size) para controlar el número de filas + /// 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. + /// + /// Se anula si se usa con [`with_floating_label(true)`](Self::with_floating_label). #[builder_fn] pub fn with_multiple(mut self, multiple: bool) -> Self { self.multiple = multiple; self } - /// Establece el número de filas visibles en la lista de selección. + /// 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. Es especialmente útil con selección - /// múltiple para controlar el número de filas visibles sin necesidad de recurrir al - /// desplazamiento. + /// 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. + /// + /// Se anula si se usa con [`with_floating_label(true)`](Self::with_floating_label). #[builder_fn] - pub fn with_size(mut self, size: Option) -> Self { - self.size.alter_opt(size); + pub fn with_rows(mut self, rows: Option) -> Self { + self.rows.alter_opt(rows); self }