♻️ (bootsier): Mueve input/select/textarea a theme

Consolida los handlers de estos campos en la capa del tema. Además,
`with_floating_label()` usa un valor extra que lee en `setup()` para
forzar `multiple`/`rows` en 'select` y `textarea`.
This commit is contained in:
Manuel Cillero 2026-07-09 01:26:52 +02:00
parent bff7cf1b38
commit 87ad78778d
7 changed files with 241 additions and 223 deletions

View file

@ -1,59 +0,0 @@
use pagetop::prelude::*;
pub fn render(c: &form::input::Field, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = c.id();
let input_id = container_id.as_deref().map(|id| util::join!(id, "-input"));
let floating = c.props().has_class("form-floating");
let input_class = if *c.plaintext() {
"form-control-plaintext"
} else {
"form-control"
};
// La etiqueta flotante requiere `placeholder` para animar la etiqueta; si no está definido, se
// fuerza `placeholder=""`.
let placeholder = if floating {
Some(c.placeholder().lookup(cx).unwrap_or_default())
} else {
c.placeholder().lookup(cx)
};
let label = match c.label().lookup(cx) {
Some(text) => html! {
label for=[input_id.as_deref()] class="form-label" {
(text)
@if *c.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div (c.props()) {
@if !floating { (label) }
input
type=(c.kind())
id=[input_id.as_deref()]
class=(input_class)
name=[c.name().get()]
value=[c.value().get()]
minlength=[c.minlength().get()]
maxlength=[c.maxlength().get()]
placeholder=[placeholder]
inputmode=[c.inputmode().get()]
autocomplete=[c.autocomplete().get()]
autofocus[*c.autofocus()]
readonly[*c.readonly() || *c.plaintext()]
required[*c.required()]
disabled[*c.disabled()];
@if floating { (label) }
@if let Some(description) = c.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}

View file

@ -1,80 +0,0 @@
use pagetop::prelude::*;
pub fn setup(c: &mut form::select::Field) {
if c.props().has_class("form-floating") {
c.alter_multiple(false);
c.alter_rows(None::<u16>);
}
}
pub fn render(c: &form::select::Field, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = c.id();
let select_id = container_id.as_deref().map(|id| util::join!(id, "-select"));
let floating = c.props().has_class("form-floating");
let label = match c.label().lookup(cx) {
Some(text) => html! {
label for=[select_id.as_deref()] class="form-label" {
(text)
@if *c.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div (c.props()) {
@if !floating { (label) }
select
id=[select_id.as_deref()]
class="form-select"
name=[c.name().get()]
multiple[*c.multiple()]
size=[c.rows().get()]
autocomplete=[c.autocomplete().get()]
autofocus[*c.autofocus()]
required[*c.required()]
disabled[*c.disabled()]
{
@for entry in c.entries() {
@match entry {
form::select::Entry::Item(opt) => {
option
value=(opt.value().as_str().unwrap_or(""))
selected[*opt.selected()]
disabled[*opt.disabled()]
{
(opt.label().using(cx))
}
}
form::select::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 floating { (label) }
@if let Some(description) = c.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}

View file

@ -1,63 +0,0 @@
use pagetop::prelude::*;
pub fn setup(c: &mut form::Textarea) {
if c.props().has_class("form-floating") {
c.alter_rows(None::<u16>);
}
}
pub fn render(c: &form::Textarea, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = c.id();
let textarea_id = container_id
.as_deref()
.map(|id| util::join!(id, "-textarea"));
let floating = c.props().has_class("form-floating");
// La etiqueta flotante requiere `placeholder` para animar la etiqueta; si no está definido se
// fuerza `placeholder=""`.
let placeholder = if floating {
Some(c.placeholder().lookup(cx).unwrap_or_default())
} else {
c.placeholder().lookup(cx)
};
let label = match c.label().lookup(cx) {
Some(text) => html! {
label for=[textarea_id.as_deref()] class="form-label" {
(text)
@if *c.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div (c.props()) {
@if !floating { (label) }
textarea
id=[textarea_id.as_deref()]
class="form-control"
name=[c.name().get()]
rows=[c.rows().get()]
minlength=[c.minlength().get()]
maxlength=[c.maxlength().get()]
placeholder=[placeholder]
autocomplete=[c.autocomplete().get()]
autofocus[*c.autofocus()]
readonly[*c.readonly()]
required[*c.required()]
disabled[*c.disabled()]
{
@if let Some(value) = c.value().get() { (value) }
}
@if floating { (label) }
@if let Some(description) = c.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}

View file

@ -143,14 +143,15 @@ impl Theme for Bootsier {
cx: &mut Context, cx: &mut Context,
) -> Option<Result<Markup, ComponentError>> { ) -> Option<Result<Markup, ComponentError>> {
setup_component!(component, { setup_component!(component, {
Button => |c| handlers::button::setup(c), Button => |c| theme::bs::button::setup(c),
form::select::Field => |c| handlers::select::setup(c), form::input::Field => |c| theme::bs::form::input::setup(c),
form::Textarea => |c| handlers::textarea::setup(c), form::select::Field => |c| theme::bs::form::select::setup(c),
form::Textarea => |c| theme::bs::form::textarea::setup(c),
}); });
render_component!(component, { render_component!(component, {
form::input::Field => |c| handlers::input::render(c, cx), form::input::Field => |c| theme::bs::form::input::render(c, cx),
form::select::Field => |c| handlers::select::render(c, cx), form::select::Field => |c| theme::bs::form::select::render(c, cx),
form::Textarea => |c| handlers::textarea::render(c, cx), form::Textarea => |c| theme::bs::form::textarea::render(c, cx),
}) })
} }

View file

@ -4,6 +4,8 @@ use pagetop::prelude::*;
pub use pagetop::base::component::form::input::{Field, Kind, Mode}; pub use pagetop::base::component::form::input::{Field, Kind, Mode};
const EXTRA_FLOATING_LABEL: &str = "bootsier.form.input.floating_label";
/// Extensión de Bootsier para [`form::input::Field`]. /// Extensión de Bootsier para [`form::input::Field`].
/// ///
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se /// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
@ -31,10 +33,76 @@ pub trait InputBootsier {
impl InputBootsier for Field { impl InputBootsier for Field {
fn with_floating_label(self, floating: bool) -> Self { fn with_floating_label(self, floating: bool) -> Self {
if floating { self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating))
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
} }
} }
// **< Field SETUP >********************************************************************************
pub(crate) fn setup(field: &mut Field) {
if field.props().extra_or(EXTRA_FLOATING_LABEL, false) {
field.alter_prop(PropsOp::add_classes("form-floating"));
} else {
field.alter_prop(PropsOp::remove_classes("form-floating"));
}
}
// **< Field RENDER >*******************************************************************************
pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, ComponentError> {
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 placeholder = if floating {
Some(field.placeholder().lookup(cx).unwrap_or_default())
} else {
field.placeholder().lookup(cx)
};
let label = match field.label().lookup(cx) {
Some(text) => html! {
label for=[input_id.as_deref()] class="form-label" {
(text)
@if *field.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div (field.props()) {
@if !floating { (label) }
input
type=(field.kind())
id=[input_id.as_deref()]
class=(input_class)
name=[field.name().get()]
value=[field.value().get()]
minlength=[field.minlength().get()]
maxlength=[field.maxlength().get()]
placeholder=[placeholder]
inputmode=[field.inputmode().get()]
autocomplete=[field.autocomplete().get()]
autofocus[*field.autofocus()]
readonly[*field.readonly() || *field.plaintext()]
required[*field.required()]
disabled[*field.disabled()];
@if floating { (label) }
@if let Some(description) = field.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}

View file

@ -4,6 +4,8 @@ use pagetop::prelude::*;
pub use pagetop::base::component::form::select::{Entry, Field, Group, Item}; pub use pagetop::base::component::form::select::{Entry, Field, Group, Item};
const EXTRA_FLOATING_LABEL: &str = "bootsier.form.select.floating_label";
/// Extensión de Bootsier para [`form::select::Field`]. /// Extensión de Bootsier para [`form::select::Field`].
/// ///
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se /// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
@ -36,10 +38,92 @@ pub trait SelectBootsier {
impl SelectBootsier for Field { impl SelectBootsier for Field {
fn with_floating_label(self, floating: bool) -> Self { fn with_floating_label(self, floating: bool) -> Self {
if floating { self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating))
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
} }
} }
// **< Field SETUP >********************************************************************************
pub(crate) fn setup(field: &mut Field) {
if field.props().extra_or(EXTRA_FLOATING_LABEL, false) {
field.alter_prop(PropsOp::add_classes("form-floating"));
field.alter_multiple(false);
field.alter_rows(None::<u16>);
} else {
field.alter_prop(PropsOp::remove_classes("form-floating"));
}
}
// **< Field RENDER >*******************************************************************************
pub(crate) fn render(field: &Field, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = field.id();
let select_id = container_id.as_deref().map(|id| util::join!(id, "-select"));
let floating = field.props().extra_or(EXTRA_FLOATING_LABEL, false);
let label = match field.label().lookup(cx) {
Some(text) => html! {
label for=[select_id.as_deref()] class="form-label" {
(text)
@if *field.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div (field.props()) {
@if !floating { (label) }
select
id=[select_id.as_deref()]
class="form-select"
name=[field.name().get()]
multiple[*field.multiple()]
size=[field.rows().get()]
autocomplete=[field.autocomplete().get()]
autofocus[*field.autofocus()]
required[*field.required()]
disabled[*field.disabled()]
{
@for entry in field.entries() {
@match entry {
form::select::Entry::Item(opt) => {
option
value=(opt.value().as_str().unwrap_or(""))
selected[*opt.selected()]
disabled[*opt.disabled()]
{
(opt.label().using(cx))
}
}
form::select::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 floating { (label) }
@if let Some(description) = field.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}

View file

@ -4,6 +4,8 @@ use pagetop::prelude::*;
pub use pagetop::base::component::form::Textarea; pub use pagetop::base::component::form::Textarea;
const EXTRA_FLOATING_LABEL: &str = "bootsier.form.textarea.floating_label";
/// Extensión de Bootsier para [`form::Textarea`]. /// Extensión de Bootsier para [`form::Textarea`].
/// ///
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se /// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
@ -34,10 +36,75 @@ pub trait TextareaBootsier {
impl TextareaBootsier for Textarea { impl TextareaBootsier for Textarea {
fn with_floating_label(self, floating: bool) -> Self { fn with_floating_label(self, floating: bool) -> Self {
if floating { self.with_prop(PropsOp::set_extra(EXTRA_FLOATING_LABEL, floating))
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
} }
} }
// **< Textarea SETUP >*****************************************************************************
pub(crate) fn setup(field: &mut Textarea) {
if field.props().extra_or(EXTRA_FLOATING_LABEL, false) {
field.alter_prop(PropsOp::add_classes("form-floating"));
field.alter_rows(None::<u16>);
} else {
field.alter_prop(PropsOp::remove_classes("form-floating"));
}
}
// **< Textarea RENDER >****************************************************************************
pub(crate) fn render(field: &Textarea, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = field.id();
let textarea_id = container_id
.as_deref()
.map(|id| util::join!(id, "-textarea"));
let floating = field.props().extra_or(EXTRA_FLOATING_LABEL, false);
// La etiqueta flotante requiere `placeholder` para animar la etiqueta; si no está definido se
// fuerza `placeholder=""`.
let placeholder = if floating {
Some(field.placeholder().lookup(cx).unwrap_or_default())
} else {
field.placeholder().lookup(cx)
};
let label = match field.label().lookup(cx) {
Some(text) => html! {
label for=[textarea_id.as_deref()] class="form-label" {
(text)
@if *field.required() {
span
class="form-required"
title=(L10n::l("field_required").using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div (field.props()) {
@if !floating { (label) }
textarea
id=[textarea_id.as_deref()]
class="form-control"
name=[field.name().get()]
rows=[field.rows().get()]
minlength=[field.minlength().get()]
maxlength=[field.maxlength().get()]
placeholder=[placeholder]
autocomplete=[field.autocomplete().get()]
autofocus[*field.autofocus()]
readonly[*field.readonly()]
required[*field.required()]
disabled[*field.disabled()]
{
@if let Some(value) = field.value().get() { (value) }
}
@if floating { (label) }
@if let Some(description) = field.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}