From c49bf8f56a74cec3fd6b343a50b83ea8ac59004b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 2 Aug 2026 08:43:32 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(form):=20Blinda=20Field=20contra?= =?UTF-8?q?=20el=20autorrelleno?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade nuevas variantes `Kind::StrictText` y `StrictPassword` (`Field::strict_text()` y `strict_password()`) para forzar en el campo `autocomplete=off`, desactivar spellcheck y autocorrect, aplicar `readonly+onfocus`, y enmascarar las contraseñas vía CSS en vez de `type="password"`. Replicado en pagetop-bootsier. --- .../src/theme/bs/form/input.rs | 23 +++- src/base/component/form/input.rs | 104 +++++++++++++++++- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/extensions/pagetop-bootsier/src/theme/bs/form/input.rs b/extensions/pagetop-bootsier/src/theme/bs/form/input.rs index 701e7150..6c7261f6 100644 --- a/extensions/pagetop-bootsier/src/theme/bs/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/bs/form/input.rs @@ -55,14 +55,22 @@ pub(crate) fn setup(field: &mut Field) { pub(crate) fn render(field: &Field, cx: &mut Context) -> Result { let container_id = field.id(); let input_id = container_id.as_deref().map(|id| util::join!(id, "-input")); - let floating = field.props().extra_or(EXTRA_FLOATING_LABEL, false); let input_class = if *field.plaintext() { "form-control-plaintext" } else { "form-control" }; - // La etiqueta flotante requiere `placeholder` para animar la etiqueta; si no está definido, se - // fuerza `placeholder=""`. + let strict = field.kind().is_strict(); + let masked = *field.kind() == Kind::StrictPassword; + let autocomplete = if strict { + Some(form::Autocomplete::Off) + } else { + field.autocomplete().get() + }; + + // La etiqueta flotante requiere `placeholder` para animar la etiqueta. + let floating = field.props().extra_or(EXTRA_FLOATING_LABEL, false); + // Si no está definido, se fuerza `placeholder=""`. let placeholder = if floating { Some(field.placeholder().lookup(cx).unwrap_or_default()) } else { @@ -84,6 +92,7 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result html! {}, }; + Ok(html! { div (field.props()) { @if !floating { (label) } @@ -97,9 +106,13 @@ pub(crate) fn render(field: &Field, cx: &mut Context) -> Result bool { + matches!(self, Kind::StrictText | Kind::StrictPassword) + } +} + impl fmt::Display for Kind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { - Kind::Text => "text", + Kind::Text | Kind::StrictText | Kind::StrictPassword => "text", Kind::Password => "password", Kind::Search => "search", Kind::Email => "email", @@ -94,6 +121,8 @@ impl fmt::Display for Mode { /// /// - [`Field::text()`]: campo de texto genérico (`type="text"`, por defecto). /// - [`Field::password()`]: contraseña (`type="password"`). +/// - [`Field::strict_text()`]: texto genérico blindado contra el autorrelleno del navegador. +/// - [`Field::strict_password()`]: contraseña blindada contra el autorrelleno del navegador. /// - [`Field::search()`]: búsqueda (`type="search"`). /// - [`Field::email()`]: correo electrónico (`type="email"`). /// - [`Field::telephone()`]: teléfono (`type="tel"`). @@ -176,6 +205,9 @@ impl Component for Field { } // Clases CSS del contenedor del campo de texto. + if self.kind().is_strict() { + self.alter_prop(PropsOp::prepend_classes("form-field-strict")); + } self.alter_prop(PropsOp::prepend_classes(util::join!( "form-field form-field-", self.kind().to_string() @@ -190,6 +222,13 @@ impl Component for Field { } else { "form-control" }; + let strict = self.kind().is_strict(); + let masked = *self.kind() == Kind::StrictPassword; + let autocomplete = if strict { + Some(form::Autocomplete::Off) + } else { + self.autocomplete().get() + }; Ok(html! { div (self.props()) { @@ -216,9 +255,13 @@ impl Component for Field { maxlength=[self.maxlength().get()] placeholder=[self.placeholder().lookup(cx)] inputmode=[self.inputmode().get()] - autocomplete=[self.autocomplete().get()] + autocomplete=[autocomplete] + spellcheck=[strict.then_some("false")] + autocorrect=[strict.then_some("off")] + style=[masked.then_some("-webkit-text-security: disc; text-security: disc;")] autofocus[*self.autofocus()] - readonly[*self.readonly() || *self.plaintext()] + readonly[*self.readonly() || *self.plaintext() || strict] + onfocus=[strict.then_some("this.removeAttribute('readonly')")] required[*self.required()] disabled[*self.disabled()]; @if let Some(description) = self.help_text().lookup(cx) { @@ -250,6 +293,55 @@ impl Field { } } + /// Crea un campo de **texto genérico blindado** contra el autorrelleno del navegador. + /// + /// Algunos navegadores rellenan los campos de texto con datos ya guardados (como usuario, + /// email, etc.) aplicando heurísticas por nombre o posición, incluso con `autocomplete="off"`. + /// Este método fuerza `autocomplete="off"`, desactiva corrección ortográfica y autocorrección, + /// y permanece de sólo lectura hasta que el usuario hace foco (ya que normalmente el navegador + /// respeta el `readonly` en la carga aunque ignore `autocomplete="off"`). + /// + /// Soporte por motor de navegador (Blink: Chrome, Edge, Opera; Gecko: Firefox; WebKit: Safari): + /// + /// | Medida | Blink | Gecko | WebKit | + /// |-------------------------------|------------|------------|--------------------------| + /// | `autocomplete="off"` ignorado | Sí | Sí | Lo respeta mejor | + /// | `spellcheck="false"` | Sí | Sí | Sí | + /// | `autocorrect="off"` | Sin efecto | Sin efecto | Sí (origen del atributo) | + /// + /// El truco `readonly` + `onfocus` compensa específicamente la agresividad de Chrome/Blink al + /// autorrellenar campos nada más cargar la página; su fiabilidad varía entre versiones y no + /// está documentada de forma oficial. Ninguna de estas medidas es infalible frente a gestores + /// de contraseñas de terceros, que usan heurísticas propias. + /// + /// Ver también [`Field::strict_password()`]. + pub fn strict_text() -> Self { + Self { + kind: Kind::StrictText, + ..Default::default() + } + } + + /// Crea un campo de **contraseña blindada** contra el autorrelleno del navegador. + /// + /// Aplica las mismas medidas que [`Field::strict_text()`] y, además, enmascara el contenido + /// visualmente vía CSS (usando `-webkit-text-security`) en lugar de usar `type="password"`. Al + /// no ser un campo de contraseña para el navegador, evita el gestor nativo de contraseñas, el + /// icono de mostrar/ocultar y el aviso de guardar la contraseña. + /// + /// Útil en formularios de acceso donde ese comportamiento no es deseable (como puestos + /// compartidos o paneles administrativos sensibles). + /// + /// La propiedad `-webkit-text-security` está soportada de forma nativa en Blink (Chrome, Edge, + /// Opera) y en WebKit (Safari, su origen); Firefox lo soporta desde la versión 114 (2023) con + /// el mismo nombre. + pub fn strict_password() -> Self { + Self { + kind: Kind::StrictPassword, + ..Default::default() + } + } + /// Crea un campo de **búsqueda** (`type="search"`). /// /// Semánticamente equivalente a `text` pero optimizado para búsquedas: algunos navegadores