From 9435678e01fee30ed0b54ee5f7a19cbb3c332d42 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 22 Jun 2026 02:12:09 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(form):=20Mueve=20componen?= =?UTF-8?q?tes=20de=20formulario=20a=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/basic.css | 104 ++++ examples/form-controls.rs | 359 +++++++------- examples/locale/en-US/form-controls.ftl | 5 +- examples/locale/es-ES/form-controls.ftl | 5 +- .../assets/_bootsier-custom.scss | 4 +- .../pagetop-bootsier/src/handlers/button.rs | 7 + .../pagetop-bootsier/src/handlers/input.rs | 59 +++ .../pagetop-bootsier/src/handlers/select.rs | 80 +++ .../pagetop-bootsier/src/handlers/textarea.rs | 63 +++ extensions/pagetop-bootsier/src/lib.rs | 48 +- extensions/pagetop-bootsier/src/theme.rs | 8 +- .../pagetop-bootsier/src/theme/attrs.rs | 3 - .../src/theme/attrs/button.rs | 145 ------ .../pagetop-bootsier/src/theme/button.rs | 212 +------- .../pagetop-bootsier/src/theme/classes.rs | 3 + .../src/theme/classes/button.rs | 170 +++++++ .../src/theme/dropdown/component.rs | 18 +- extensions/pagetop-bootsier/src/theme/form.rs | 24 +- .../pagetop-bootsier/src/theme/form/input.rs | 465 +---------------- .../pagetop-bootsier/src/theme/form/select.rs | 466 +----------------- .../src/theme/form/textarea.rs | 307 ++---------- src/base/component.rs | 13 +- src/base/component/button.rs | 207 ++++++++ src/base/component/form.rs | 30 ++ .../base/component}/form/check.rs | 23 +- .../base/component}/form/checkbox.rs | 20 +- .../base/component}/form/component.rs | 13 +- .../base/component}/form/fieldset.rs | 6 +- .../base/component}/form/hidden.rs | 6 +- src/base/component/form/input.rs | 424 ++++++++++++++++ .../base/component}/form/props.rs | 20 +- .../base/component}/form/radio.rs | 20 +- .../base/component}/form/range.rs | 6 +- src/base/component/form/select.rs | 427 ++++++++++++++++ src/base/component/form/textarea.rs | 256 ++++++++++ src/core/theme.rs | 5 +- src/locale/en-US/base.ftl | 3 + src/locale/es-ES/base.ftl | 3 + 38 files changed, 2211 insertions(+), 1826 deletions(-) create mode 100644 extensions/pagetop-bootsier/src/handlers/button.rs create mode 100644 extensions/pagetop-bootsier/src/handlers/input.rs create mode 100644 extensions/pagetop-bootsier/src/handlers/select.rs create mode 100644 extensions/pagetop-bootsier/src/handlers/textarea.rs delete mode 100644 extensions/pagetop-bootsier/src/theme/attrs/button.rs create mode 100644 extensions/pagetop-bootsier/src/theme/classes/button.rs create mode 100644 src/base/component/button.rs create mode 100644 src/base/component/form.rs rename {extensions/pagetop-bootsier/src/theme => src/base/component}/form/check.rs (92%) rename {extensions/pagetop-bootsier/src/theme => src/base/component}/form/checkbox.rs (93%) rename {extensions/pagetop-bootsier/src/theme => src/base/component}/form/component.rs (91%) rename {extensions/pagetop-bootsier/src/theme => src/base/component}/form/fieldset.rs (97%) rename {extensions/pagetop-bootsier/src/theme => src/base/component}/form/hidden.rs (95%) create mode 100644 src/base/component/form/input.rs rename {extensions/pagetop-bootsier/src/theme => src/base/component}/form/props.rs (97%) rename {extensions/pagetop-bootsier/src/theme => src/base/component}/form/radio.rs (95%) rename {extensions/pagetop-bootsier/src/theme => src/base/component}/form/range.rs (98%) create mode 100644 src/base/component/form/select.rs create mode 100644 src/base/component/form/textarea.rs diff --git a/assets/css/basic.css b/assets/css/basic.css index 9b23b272..b35b29ef 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -13,6 +13,11 @@ /* Colors */ --val-color--bg: #fafafa; --val-color--text: #212529; + --val-color--text--muted: color-mix(in srgb, var(--val-color--text) 50%, var(--val-color--bg)); + --val-color--border: #212529; + --val-color--primary: #0d6efd; + --val-color--danger: #dc3545; + --val-color--switch-off: #adb5bd; } *, *::before, *::after { @@ -33,6 +38,105 @@ body { -webkit-tap-highlight-color: transparent; } +/* + * Form components + */ + +.form-required { + color: var(--val-color--danger); + margin-left: 0.25rem; +} + +.form-label { + display: block; + margin-top: 1em; +} + +.form-text { + font-size: 0.9375rem; + color: var(--val-color--text--muted); +} + +.form-field > select.form-select, +.form-field > input.form-control, +.form-field > input.form-range, +.form-field > textarea.form-control { + display: block; + padding: 0.25rem 0.5rem; + width: 100%; +} + +fieldset { + margin: 2rem 0 1rem; + background: var(--val-color--bg); +} +fieldset > legend { + background-color: var(--val-color--bg); + border: 2px groove threedface; + padding: 0 0.5em; +} + +.form-check { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 1.5rem; + margin-bottom: 0.25rem; +} +.form-check-inline { + display: inline-flex; + margin-right: 1rem; +} +.form-check-reverse { + flex-direction: row-reverse; + justify-content: flex-start; + text-align: right; +} +.form-check-input { + flex-shrink: 0; + cursor: pointer; +} +.form-check-label { + cursor: pointer; +} +.form-switch .form-check-input { + appearance: none; + -webkit-appearance: none; + width: 2em; + height: 1em; + background-color: var(--val-color--switch-off); + border-radius: 1em; + position: relative; + cursor: pointer; + transition: background-color .15s ease-in-out; +} +.form-switch .form-check-input::after { + content: ""; + position: absolute; + width: 1em; + height: 1em; + background-color: white; + border-radius: 50%; + top: 0; + left: 0; + transition: transform .15s ease-in-out; + box-shadow: 0 0 0 1px rgba(0, 0, 0, .25); +} +.form-switch .form-check-input:checked { + background-color: var(--val-color--primary); +} +.form-switch .form-check-input:checked::after { + transform: translateX(1em); +} + +input:disabled, +input:disabled + label { + cursor: default !important; +} +input:disabled + label { + color: var(--val-color--text--muted); +} + /* * Region Footer */ diff --git a/examples/form-controls.rs b/examples/form-controls.rs index 1c7f066e..172de633 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -1,5 +1,6 @@ use pagetop::prelude::*; +use pagetop_bootsier::theme::form; use pagetop_bootsier::theme::*; include_locales!(LOC from "examples/locale"); @@ -143,17 +144,20 @@ async fn form_controls(request: HttpRequest) -> Result { .with_value("form-selections"), ) // Botones de acción. + .with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop( + PropsOp::add_classes(classes::ButtonColor::solid( + Color::Primary, + )), + )) + .with_child(Button::reset(L10n::t("btn_reset", &LOC)).with_prop( + PropsOp::add_classes(classes::ButtonColor::outline( + Color::Secondary, + )), + )) .with_child( - Button::submit(L10n::t("btn_submit", &LOC)) - .with_color(ButtonColor::Background(Color::Primary)), - ) - .with_child( - Button::reset(L10n::t("btn_reset", &LOC)) - .with_color(ButtonColor::Outline(Color::Secondary)), - ) - .with_child( - Button::plain(L10n::t("btn_cancel", &LOC)) - .with_color(ButtonColor::Link), + Button::plain(L10n::t("btn_cancel", &LOC)).with_prop( + PropsOp::add_classes(classes::ButtonColor::link()), + ), ), ), ) @@ -175,6 +179,7 @@ async fn form_controls(request: HttpRequest) -> Result { .with_name("name") .with_label(L10n::t("label_name", &LOC)) .with_placeholder(L10n::t("placeholder_name", &LOC)) + .with_help_text(L10n::t("help_name", &LOC)) .with_required(true), ) .with_child( @@ -185,6 +190,7 @@ async fn form_controls(request: HttpRequest) -> Result { "placeholder_email", &LOC, )) + .with_help_text(L10n::t("help_email", &LOC)) .with_autocomplete( Some(form::Autocomplete::email()), ) @@ -262,194 +268,175 @@ async fn form_controls(request: HttpRequest) -> Result { .with_value("form-text"), ) // Botones de acción. + .with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop( + PropsOp::add_classes(classes::ButtonColor::solid( + Color::Primary, + )), + )) + .with_child(Button::reset(L10n::t("btn_reset", &LOC)).with_prop( + PropsOp::add_classes(classes::ButtonColor::outline( + Color::Secondary, + )), + )) .with_child( - Button::submit(L10n::t("btn_submit", &LOC)) - .with_color(ButtonColor::Background(Color::Primary)), - ) - .with_child( - Button::reset(L10n::t("btn_reset", &LOC)) - .with_color(ButtonColor::Outline(Color::Secondary)), - ) - .with_child( - Button::plain(L10n::t("btn_cancel", &LOC)) - .with_color(ButtonColor::Link), + Button::plain(L10n::t("btn_cancel", &LOC)).with_prop( + PropsOp::add_classes(classes::ButtonColor::link()), + ), ), ), ) // Bloque 3: listas de selección y etiquetas flotantes. .with_child( Block::new() - .with_title(L10n::t("block_lists", &LOC)) - .with_child( - Form::new() - .with_id("form-lists") - .with_action("/") - .with_method(form::Method::Post) - // Listas de selección (form::select::Field). - .with_child( - form::Fieldset::new() - .with_legend(L10n::t("fieldset_select", &LOC)) - .with_child( - form::select::Field::new() - .with_name("language") - .with_label(L10n::t("label_language", &LOC)) - .with_item( - form::select::Item::new( - "", - L10n::t("select_choose", &LOC), - ) - .with_selected(true), - ) - .with_group( - form::select::Group::new(L10n::t( - "select_group_europe", - &LOC, - )) - .with_item(form::select::Item::new( - "es", - L10n::t("select_spanish", &LOC), - )) - .with_item(form::select::Item::new( - "fr", - L10n::t("select_french", &LOC), - )), - ) - .with_group( - form::select::Group::new(L10n::t( - "select_group_americas", - &LOC, - )) - .with_item(form::select::Item::new( - "en", - L10n::t("select_english", &LOC), - )) - .with_item(form::select::Item::new( - "pt", - L10n::t("select_portuguese", &LOC), - )), - ) - .with_item( - form::select::Item::new( - "xx", - L10n::t("select_disabled", &LOC), - ) - .with_disabled(true), - ) - .with_required(true), - ) - .with_child( - form::select::Field::new() - .with_name("technologies") - .with_label(L10n::t("label_technologies", &LOC)) - .with_item( - form::select::Item::new( - "rust", - L10n::n("Rust"), - ) - .with_selected(true), - ) - .with_item( - form::select::Item::new( - "python", - L10n::n("Python"), - ) - .with_selected(true), - ) - .with_item(form::select::Item::new( - "javascript", - L10n::n("JavaScript"), - )) - .with_item(form::select::Item::new( - "go", - L10n::n("Go"), - )) - .with_item(form::select::Item::new( - "typescript", - L10n::n("TypeScript"), - )) - .with_multiple(true) - .with_rows(Some(4)) - .with_help_text(L10n::t("help_technologies", &LOC)), - ), - ) - // Etiquetas flotantes. - .with_child( - form::Fieldset::new() - .with_legend(L10n::t("fieldset_floating", &LOC)) - .with_child( - form::input::Field::text() - .with_name("fl_name") - .with_label(L10n::t("label_name", &LOC)) - .with_placeholder(L10n::t("placeholder_name", &LOC)) - .with_floating_label(true) - .with_required(true), - ) - .with_child( - form::Textarea::new() - .with_name("fl_comment") - .with_label(L10n::t("label_comment", &LOC)) - .with_placeholder(L10n::t( - "placeholder_comment", - &LOC, - )) - .with_floating_label(true), - ) - .with_child( - form::select::Field::new() - .with_name("fl_country") - .with_label(L10n::t("label_country", &LOC)) - .with_item( - form::select::Item::new( - "", - L10n::t("select_choose", &LOC), - ) - .with_selected(true), - ) - .with_item(form::select::Item::new( - "de", - L10n::t("select_germany", &LOC), - )) - .with_item(form::select::Item::new( - "es", - L10n::t("select_spain", &LOC), - )) - .with_item(form::select::Item::new( - "fr", - L10n::t("select_france", &LOC), - )) - .with_item(form::select::Item::new( - "pt", - L10n::t("select_portugal", &LOC), - )) - .with_floating_label(true) - .with_required(true), - ), - ) - // Campo oculto (form::Hidden). - .with_child( - form::Hidden::new() - .with_name("origin") - .with_value("form-lists"), - ) - // Botones de acción. - .with_child( - Button::submit(L10n::t("btn_submit", &LOC)) - .with_color(ButtonColor::Background(Color::Primary)), - ) - .with_child( - Button::reset(L10n::t("btn_reset", &LOC)) - .with_color(ButtonColor::Outline(Color::Secondary)), - ) - .with_child( - Button::plain(L10n::t("btn_cancel", &LOC)) - .with_color(ButtonColor::Link), - ), - ), + .with_title( + if global::SETTINGS.app.theme.eq_ignore_ascii_case("bootsier") { + L10n::t("block_lists_floating", &LOC) + } else { + L10n::t("block_lists", &LOC) + }, + ) + .with_child(form_lists()), ), ) .render() } +fn form_lists() -> Form { + let mut form = Form::new() + .with_id("form-lists") + .with_action("/") + .with_method(form::Method::Post) + // Listas de selección (form::select::Field). + .with_child( + form::Fieldset::new() + .with_legend(L10n::t("fieldset_select", &LOC)) + .with_child( + form::select::Field::new() + .with_name("language") + .with_label(L10n::t("label_language", &LOC)) + .with_item( + form::select::Item::new("", L10n::t("select_choose", &LOC)) + .with_selected(true), + ) + .with_group( + form::select::Group::new(L10n::t("select_group_europe", &LOC)) + .with_item(form::select::Item::new( + "es", + L10n::t("select_spanish", &LOC), + )) + .with_item(form::select::Item::new( + "fr", + L10n::t("select_french", &LOC), + )), + ) + .with_group( + form::select::Group::new(L10n::t("select_group_americas", &LOC)) + .with_item(form::select::Item::new( + "en", + L10n::t("select_english", &LOC), + )) + .with_item(form::select::Item::new( + "pt", + L10n::t("select_portuguese", &LOC), + )), + ) + .with_item( + form::select::Item::new("xx", L10n::t("select_disabled", &LOC)) + .with_disabled(true), + ) + .with_required(true), + ) + .with_child( + form::select::Field::new() + .with_name("technologies") + .with_label(L10n::t("label_technologies", &LOC)) + .with_item( + form::select::Item::new("rust", L10n::n("Rust")).with_selected(true), + ) + .with_item( + form::select::Item::new("python", L10n::n("Python")) + .with_selected(true), + ) + .with_item(form::select::Item::new("javascript", L10n::n("JavaScript"))) + .with_item(form::select::Item::new("go", L10n::n("Go"))) + .with_item(form::select::Item::new("typescript", L10n::n("TypeScript"))) + .with_multiple(true) + .with_rows(Some(4)) + .with_help_text(L10n::t("help_technologies", &LOC)), + ), + ); + + // Etiquetas flotantes: solo disponibles con el tema Bootsier. + if global::SETTINGS.app.theme.eq_ignore_ascii_case("bootsier") { + form = form.with_child( + form::Fieldset::new() + .with_legend(L10n::t("fieldset_floating", &LOC)) + .with_child( + form::input::Field::text() + .with_name("fl_name") + .with_label(L10n::t("label_name", &LOC)) + .with_placeholder(L10n::t("placeholder_name", &LOC)) + .with_floating_label(true) + .with_required(true), + ) + .with_child( + form::Textarea::new() + .with_name("fl_comment") + .with_label(L10n::t("label_comment", &LOC)) + .with_placeholder(L10n::t("placeholder_comment", &LOC)) + .with_floating_label(true), + ) + .with_child( + form::select::Field::new() + .with_name("fl_country") + .with_label(L10n::t("label_country", &LOC)) + .with_item( + form::select::Item::new("", L10n::t("select_choose", &LOC)) + .with_selected(true), + ) + .with_item(form::select::Item::new( + "de", + L10n::t("select_germany", &LOC), + )) + .with_item(form::select::Item::new("es", L10n::t("select_spain", &LOC))) + .with_item(form::select::Item::new( + "fr", + L10n::t("select_france", &LOC), + )) + .with_item(form::select::Item::new( + "pt", + L10n::t("select_portugal", &LOC), + )) + .with_floating_label(true) + .with_required(true), + ), + ); + } + + form + // Campo oculto (form::Hidden). + .with_child( + form::Hidden::new() + .with_name("origin") + .with_value("form-lists"), + ) + // Botones de acción. + .with_child( + Button::submit(L10n::t("btn_submit", &LOC)).with_prop(PropsOp::add_classes( + classes::ButtonColor::solid(Color::Primary), + )), + ) + .with_child( + Button::reset(L10n::t("btn_reset", &LOC)).with_prop(PropsOp::add_classes( + classes::ButtonColor::outline(Color::Secondary), + )), + ) + .with_child( + Button::plain(L10n::t("btn_cancel", &LOC)) + .with_prop(PropsOp::add_classes(classes::ButtonColor::link())), + ) +} + #[pagetop::main] async fn main() -> std::io::Result<()> { Application::prepare(&FormControls).run()?.await diff --git a/examples/locale/en-US/form-controls.ftl b/examples/locale/en-US/form-controls.ftl index 7c2b3c96..ce6c76ca 100644 --- a/examples/locale/en-US/form-controls.ftl +++ b/examples/locale/en-US/form-controls.ftl @@ -2,7 +2,8 @@ title = Form controls slogan = Bootsier form components showcase block_selections = Checkboxes, switches and radio buttons block_text = Text fields, multiline and range -block_lists = Select lists and floating labels +block_lists = Selection lists +block_lists_floating = Select lists and floating labels fieldset_text = Text fields label_name = Full name @@ -16,6 +17,8 @@ label_url = Website placeholder_url = https://example.com label_search = Search placeholder_search = Search term... +help_name = Enter your full name as it appears on your ID. +help_email = We will only use your email to send important notifications. fieldset_textarea = Multiline text label_comment = Comment diff --git a/examples/locale/es-ES/form-controls.ftl b/examples/locale/es-ES/form-controls.ftl index 67a0fd2c..40781042 100644 --- a/examples/locale/es-ES/form-controls.ftl +++ b/examples/locale/es-ES/form-controls.ftl @@ -2,7 +2,8 @@ title = Controles de formulario slogan = Componentes Bootsier para formularios block_selections = Casillas, interruptores y botones de opción block_text = Campos de texto, multilínea y rango -block_lists = Listas de selección y etiquetas flotantes +block_lists = Listas de selección +block_lists_floating = Listas de selección y etiquetas flotantes fieldset_text = Campos de texto label_name = Nombre completo @@ -16,6 +17,8 @@ label_url = Sitio web placeholder_url = https://ejemplo.com label_search = Búsqueda placeholder_search = Término de búsqueda... +help_name = Introduce tu nombre completo tal como aparece en tu documento de identidad. +help_email = Solo usaremos tu correo para enviarte notificaciones importantes. fieldset_textarea = Texto multilínea label_comment = Comentario diff --git a/extensions/pagetop-bootsier/assets/_bootsier-custom.scss b/extensions/pagetop-bootsier/assets/_bootsier-custom.scss index 9a4c7e53..51f08dd8 100644 --- a/extensions/pagetop-bootsier/assets/_bootsier-custom.scss +++ b/extensions/pagetop-bootsier/assets/_bootsier-custom.scss @@ -53,11 +53,13 @@ fieldset > legend { top: 0; left: 1rem; transform: translateY(-50%); + color: var(--bs-secondary-color); background-color: var(--bs-body-bg); border: var(--bs-border-width) solid var(--bs-border-color); border-radius: var(--bs-border-radius); padding: 0.125rem 0.75rem; - font-size: $font-size-sm; + font-size: ($font-size-sm + $font-size-base) / 2; + font-weight: var(--bs-font-weight-normal); line-height: 1.25; width: fit-content; max-width: 75%; diff --git a/extensions/pagetop-bootsier/src/handlers/button.rs b/extensions/pagetop-bootsier/src/handlers/button.rs new file mode 100644 index 00000000..5846283d --- /dev/null +++ b/extensions/pagetop-bootsier/src/handlers/button.rs @@ -0,0 +1,7 @@ +use pagetop::prelude::*; + +pub fn setup(button: &mut Button) { + button + .alter_prop(PropsOp::remove_classes("button")) + .alter_prop(PropsOp::prepend_classes("btn")); +} diff --git a/extensions/pagetop-bootsier/src/handlers/input.rs b/extensions/pagetop-bootsier/src/handlers/input.rs new file mode 100644 index 00000000..94f906c4 --- /dev/null +++ b/extensions/pagetop-bootsier/src/handlers/input.rs @@ -0,0 +1,59 @@ +use pagetop::prelude::*; + +pub fn render(c: &form::input::Field, cx: &mut Context) -> Result { + 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) } + } + } + }) +} diff --git a/extensions/pagetop-bootsier/src/handlers/select.rs b/extensions/pagetop-bootsier/src/handlers/select.rs new file mode 100644 index 00000000..03a48735 --- /dev/null +++ b/extensions/pagetop-bootsier/src/handlers/select.rs @@ -0,0 +1,80 @@ +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::); + } +} + +pub fn render(c: &form::select::Field, cx: &mut Context) -> Result { + 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) } + } + } + }) +} diff --git a/extensions/pagetop-bootsier/src/handlers/textarea.rs b/extensions/pagetop-bootsier/src/handlers/textarea.rs new file mode 100644 index 00000000..2f41a765 --- /dev/null +++ b/extensions/pagetop-bootsier/src/handlers/textarea.rs @@ -0,0 +1,63 @@ +use pagetop::prelude::*; + +pub fn setup(c: &mut form::Textarea) { + if c.props().has_class("form-floating") { + c.alter_rows(None::); + } +} + +pub fn render(c: &form::Textarea, cx: &mut Context) -> Result { + 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) } + } + } + }) +} diff --git a/extensions/pagetop-bootsier/src/lib.rs b/extensions/pagetop-bootsier/src/lib.rs index bb7c09e0..23e07722 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -89,32 +89,11 @@ pub mod config; pub mod theme; -/// Plantillas que Bootsier añade. -#[derive(AutoDefault)] -pub enum BootsierTemplate { - /// Plantilla predeterminada de Bootsier. - #[default] - Standard, -} - -impl Template for BootsierTemplate { - fn render(&'static self, cx: &mut Context) -> Markup { - match self { - Self::Standard => theme::Container::new() - .with_classes(ClassesOp::Add, "container-wrapper") - .with_width(theme::container::Width::FluidMax( - config::SETTINGS.bootsier.max_width, - )) - .with_child(Html::with(|cx| { - html! { - (DefaultRegion::Header.render(cx)) - (DefaultRegion::Content.render(cx)) - (DefaultRegion::Footer.render(cx)) - } - })), - } - .render(cx) - } +mod handlers { + pub mod button; + pub mod input; + pub mod select; + pub mod textarea; } /// Implementa el tema. @@ -155,6 +134,23 @@ impl Theme for Bootsier { &BootsierTemplate::Standard } + fn handle_component( + &self, + component: &mut dyn Component, + cx: &mut Context, + ) -> Option> { + setup_component!(component, { + Button => |c| handlers::button::setup(c), + form::select::Field => |c| handlers::select::setup(c), + form::Textarea => |c| handlers::textarea::setup(c), + }); + render_component!(component, { + form::input::Field => |c| handlers::input::render(c, cx), + form::select::Field => |c| handlers::select::render(c, cx), + form::Textarea => |c| handlers::textarea::render(c, cx), + }) + } + fn before_render_page_body(&self, page: &mut Page) { page.alter_assets(AssetsOp::AddStyleSheet( StyleSheet::from("/bootsier/css/bootsier.min.css") diff --git a/extensions/pagetop-bootsier/src/theme.rs b/extensions/pagetop-bootsier/src/theme.rs index fb7dd0ed..22e55792 100644 --- a/extensions/pagetop-bootsier/src/theme.rs +++ b/extensions/pagetop-bootsier/src/theme.rs @@ -11,7 +11,7 @@ pub mod classes; // Button. mod button; -pub use button::Button; +pub use button::{Button, ButtonAction}; // Container. pub mod container; @@ -27,6 +27,12 @@ pub use dropdown::Dropdown; pub mod form; #[doc(inline)] pub use form::Form; +#[doc(hidden)] +pub use form::input::InputBootsier; +#[doc(hidden)] +pub use form::select::SelectBootsier; +#[doc(hidden)] +pub use form::textarea::TextareaBootsier; // Image. pub mod image; diff --git a/extensions/pagetop-bootsier/src/theme/attrs.rs b/extensions/pagetop-bootsier/src/theme/attrs.rs index e5f0a82f..3b3be43a 100644 --- a/extensions/pagetop-bootsier/src/theme/attrs.rs +++ b/extensions/pagetop-bootsier/src/theme/attrs.rs @@ -15,6 +15,3 @@ pub use border::BorderColor; mod rounded; pub use rounded::RoundedRadius; - -mod button; -pub use button::{ButtonAction, ButtonColor, ButtonSize}; diff --git a/extensions/pagetop-bootsier/src/theme/attrs/button.rs b/extensions/pagetop-bootsier/src/theme/attrs/button.rs deleted file mode 100644 index 82ce9471..00000000 --- a/extensions/pagetop-bootsier/src/theme/attrs/button.rs +++ /dev/null @@ -1,145 +0,0 @@ -use pagetop::prelude::*; - -use crate::theme::attrs::Color; - -// **< ButtonAction >********************************************************************************* - -/// Comportamiento de un [`Button`](crate::theme::Button) al activarse. -#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] -pub enum ButtonAction { - /// Envía un formulario al servidor. Es el **tipo por defecto**. - #[default] - Submit, - /// Restablece todos los campos de un formulario a sus valores iniciales. - Reset, - /// Botón de propósito general, sin efecto predeterminado. Su comportamiento podría definirse - /// mediante JavaScript. - Plain, -} - -impl std::fmt::Display for ButtonAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - ButtonAction::Submit => "submit", - ButtonAction::Reset => "reset", - ButtonAction::Plain => "button", - }) - } -} - -// **< ButtonColor >******************************************************************************** - -/// Esquema de color para [`Button`](crate::theme::Button). -#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] -pub enum ButtonColor { - /// No define ninguna clase. - #[default] - Default, - /// Genera la clase `btn-{color}` (botón sólido). - Background(Color), - /// Genera la clase `btn-outline-{color}` (fondo transparente con contorno coloreado). - Outline(Color), - /// Aplica estilo de los enlaces (`btn-link`), sin caja ni fondo, heredando el color de texto. - Link, -} - -impl ButtonColor { - const BTN_PREFIX: &str = "btn-"; - const BTN_OUTLINE_PREFIX: &str = "btn-outline-"; - const BTN_LINK: &str = "btn-link"; - - /// Añade la clase `btn-*` a la cadena de clases. - #[inline] - pub(crate) fn push_class(self, classes: &mut String) { - if let Self::Default = self { - return; - } - if !classes.is_empty() { - classes.push(' '); - } - match self { - Self::Background(c) => { - classes.push_str(Self::BTN_PREFIX); - classes.push_str(c.as_str()); - } - Self::Outline(c) => { - classes.push_str(Self::BTN_OUTLINE_PREFIX); - classes.push_str(c.as_str()); - } - Self::Link => classes.push_str(Self::BTN_LINK), - Self::Default => unreachable!(), - } - } - - /// Devuelve la clase `btn-*` correspondiente al color del botón. - /// - /// # Ejemplos - /// - /// ```rust,no_run - /// # use pagetop_bootsier::theme::*; - /// assert_eq!( - /// ButtonColor::Background(Color::Primary).to_class(), - /// "btn-primary" - /// ); - /// assert_eq!( - /// ButtonColor::Outline(Color::Danger).to_class(), - /// "btn-outline-danger" - /// ); - /// assert_eq!(ButtonColor::Link.to_class(), "btn-link"); - /// assert_eq!(ButtonColor::Default.to_class(), ""); - /// ``` - pub fn to_class(self) -> String { - let mut class = String::new(); - self.push_class(&mut class); - class - } -} - -// **< ButtonSize >********************************************************************************* - -/// Tamaño visual de un botón. -#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] -pub enum ButtonSize { - /// Tamaño por defecto del tema (no añade clase). - #[default] - Default, - /// Botón compacto. - Small, - /// Botón grande. - Large, -} - -impl ButtonSize { - const BTN_SM: &str = "btn-sm"; - const BTN_LG: &str = "btn-lg"; - - /// Añade la clase de tamaño `btn-sm` o `btn-lg` a la cadena de clases. - #[inline] - pub(crate) fn push_class(self, classes: &mut String) { - let class = match self { - Self::Default => return, - Self::Small => Self::BTN_SM, - Self::Large => Self::BTN_LG, - }; - if !classes.is_empty() { - classes.push(' '); - } - classes.push_str(class); - } - - /// Devuelve la clase `btn-sm` o `btn-lg` correspondiente al tamaño del botón. - /// - /// # Ejemplos - /// - /// ```rust,no_run - /// # use pagetop_bootsier::theme::*; - /// assert_eq!(ButtonSize::Small.to_class(), "btn-sm"); - /// assert_eq!(ButtonSize::Large.to_class(), "btn-lg"); - /// assert_eq!(ButtonSize::Default.to_class(), ""); - /// ``` - pub fn to_class(self) -> String { - let mut class = String::new(); - self.push_class(&mut class); - class - } -} diff --git a/extensions/pagetop-bootsier/src/theme/button.rs b/extensions/pagetop-bootsier/src/theme/button.rs index 17fb7cf6..eb705ce3 100644 --- a/extensions/pagetop-bootsier/src/theme/button.rs +++ b/extensions/pagetop-bootsier/src/theme/button.rs @@ -1,211 +1 @@ -use pagetop::prelude::*; - -use crate::theme::{ButtonAction, ButtonColor, ButtonSize}; - -/// Componente para crear un **botón**. -/// -/// Renderiza un botón con soporte para las variantes disponibles en [`ButtonAction`] (`submit`, -/// `reset` y botón genérico) y con la variedad de estilos del tema a través de [`ButtonColor`] y -/// [`ButtonSize`]. -/// -/// El comportamiento del botón se establece al crearlo: -/// -/// - [`Button::submit()`]: botón de envío (por defecto). -/// - [`Button::reset()`]: botón de restablecimiento de valores. -/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado. -/// -/// El botón puede usarse dentro o fuera de un formulario. -/// -/// # Ejemplo -/// -/// ```rust,no_run -/// use pagetop::prelude::*; -/// use pagetop_bootsier::theme::*; -/// -/// let save = Button::submit(L10n::n("Save")) -/// .with_color(ButtonColor::Background(Color::Primary)); -/// -/// let cancel = Button::plain(L10n::n("Cancel")) -/// .with_color(ButtonColor::Outline(Color::Secondary)); -/// -/// let clear = Button::reset(L10n::n("Clear")) -/// .with_size(ButtonSize::Small); -/// ``` -/// -/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del -/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar -/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option`: -/// -/// ```rust,ignore -/// #[derive(serde::Deserialize)] -/// struct FormData { -/// #[serde(default)] -/// action: Option, // p. ej., "save" o "delete"; `None` si el botón no tenía `name`. -/// } -/// ``` -#[derive(AutoDefault, Clone, Debug, Getters)] -pub struct Button { - /// Devuelve identificador, clases CSS y atributos HTML del componente. - props: Props, - /// Devuelve el comportamiento del botón al activarse. - kind: ButtonAction, - /// Devuelve el esquema de color del botón. - color: ButtonColor, - /// Devuelve el tamaño visual del botón. - size: ButtonSize, - /// Devuelve el nombre del botón. - name: AttrName, - /// Devuelve el valor del botón. - value: AttrValue, - /// Devuelve la etiqueta del botón. - label: Attr, - /// Devuelve si el botón recibe el foco automáticamente al cargar la página. - autofocus: bool, - /// Devuelve si el botón está deshabilitado. - disabled: bool, -} - -impl Component for Button { - fn new() -> Self { - Self::default() - } - - fn id(&self) -> Option { - self.props.get_id() - } - - fn setup(&mut self, _cx: &Context) { - let mut classes = "btn".to_string(); - (*self.color()).push_class(&mut classes); - (*self.size()).push_class(&mut classes); - self.alter_prop(PropsOp::prepend_classes(classes)); - } - - fn prepare(&self, cx: &mut Context) -> Result { - Ok(html! { - button - type=(self.kind()) - (self.props()) - name=[self.name().get()] - value=[self.value().get()] - autofocus[*self.autofocus()] - disabled[*self.disabled()] - { - @if let Some(label) = self.label().lookup(cx) { - (label) - } - } - }) - } -} - -impl Button { - /// Crea un botón de **envío** (`type="submit"`). - /// - /// Es la acción predeterminada al pulsar un botón en la mayoría de los formularios: envía los - /// datos al servidor. - pub fn submit(label: L10n) -> Self { - Self { - kind: ButtonAction::Submit, - label: Attr::some(label), - ..Default::default() - } - } - - /// Crea un botón de **restablecimiento** (`type="reset"`). - /// - /// Al pulsarlo, devuelve todos los campos del formulario a sus valores iniciales. - pub fn reset(label: L10n) -> Self { - Self { - kind: ButtonAction::Reset, - label: Attr::some(label), - ..Default::default() - } - } - - /// Crea un **botón genérico** (`type="button"`). - /// - /// No tiene un comportamiento predeterminado sobre el formulario. Su comportamiento puede - /// definirse mediante JavaScript. - pub fn plain(label: L10n) -> Self { - Self { - kind: ButtonAction::Plain, - label: Attr::some(label), - ..Default::default() - } - } - - // **< Button 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) -> 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 esquema de color del botón. - /// - /// Usa [`ButtonColor::Background`] para botones sólidos o [`ButtonColor::Outline`] para - /// variantes con contorno. - #[builder_fn] - pub fn with_color(mut self, color: ButtonColor) -> Self { - self.color = color; - self - } - - /// Establece el tamaño visual del botón. - #[builder_fn] - pub fn with_size(mut self, size: ButtonSize) -> Self { - self.size = size; - self - } - - /// Establece el nombre del botón (atributo `name`). - /// - /// Cuando el formulario tiene varios botones de envío, el navegador incluye en el envío el par - /// `name=value` sólo del botón que activó el formulario. Permite identificar cuál fue pulsado. - #[builder_fn] - pub fn with_name(mut self, name: impl AsRef) -> Self { - self.name.alter_name(name); - self - } - - /// Establece el valor del botón (atributo `value`). - /// - /// Es el dato que el navegador transmite al servidor junto con el `name` cuando este botón - /// activa el envío. Útil para distinguir entre varios botones de envío en un mismo formulario. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - self.value.alter_str(value); - self - } - - /// Establece o elimina la etiqueta visible del botón (basta pasar `None` para quitarla). - #[builder_fn] - pub fn with_label(mut self, label: impl Into>) -> Self { - self.label.alter_opt(label.into()); - self - } - - /// Establece si el botón 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 botón está deshabilitado. - #[builder_fn] - pub fn with_disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } -} +pub use pagetop::base::component::{Button, ButtonAction}; diff --git a/extensions/pagetop-bootsier/src/theme/classes.rs b/extensions/pagetop-bootsier/src/theme/classes.rs index 9e6c234d..2009922a 100644 --- a/extensions/pagetop-bootsier/src/theme/classes.rs +++ b/extensions/pagetop-bootsier/src/theme/classes.rs @@ -3,6 +3,9 @@ mod color; pub use color::{Background, Text}; +mod button; +pub use button::{ButtonColor, ButtonSize}; + mod border; pub use border::Border; diff --git a/extensions/pagetop-bootsier/src/theme/classes/button.rs b/extensions/pagetop-bootsier/src/theme/classes/button.rs new file mode 100644 index 00000000..93a8712e --- /dev/null +++ b/extensions/pagetop-bootsier/src/theme/classes/button.rs @@ -0,0 +1,170 @@ +use pagetop::prelude::*; + +use crate::theme::attrs::Color; + +// **< ButtonColor >******************************************************************************** + +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +enum ButtonColorStyle { + #[default] + None, + Solid, + Outline, + Link, +} + +/// Clases para establecer el **color y estilo** de los botones. +/// +/// # Ejemplos +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// use pagetop_bootsier::theme::*; +/// +/// // Botón sólido. +/// let save = Button::submit(L10n::n("Save")) +/// .with_prop(PropsOp::add_classes(classes::ButtonColor::solid(Color::Primary))); +/// +/// // Botón con contorno. +/// let cancel = Button::plain(L10n::n("Cancel")) +/// .with_prop(PropsOp::add_classes(classes::ButtonColor::outline(Color::Secondary))); +/// +/// // Botón tipo enlace. +/// let back = Button::plain(L10n::n("Back")) +/// .with_prop(PropsOp::add_classes(classes::ButtonColor::link())); +/// ``` +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub struct ButtonColor { + style: ButtonColorStyle, + color: Color, +} + +impl ButtonColor { + /// Sin clase de color (estilo por defecto del tema). + pub fn new() -> Self { + Self::default() + } + + /// Botón sólido: genera la clase `btn-{color}`. + pub fn solid(color: Color) -> Self { + Self { + style: ButtonColorStyle::Solid, + color, + ..Default::default() + } + } + + /// Botón con contorno: genera la clase `btn-outline-{color}`. + pub fn outline(color: Color) -> Self { + Self { + style: ButtonColorStyle::Outline, + color, + ..Default::default() + } + } + + /// Botón tipo enlace: genera la clase `btn-link`. + pub fn link() -> Self { + Self { + style: ButtonColorStyle::Link, + ..Default::default() + } + } + + // **< ButtonColor BUILDER >******************************************************************** + + /// Cambia el color aplicado al botón (`btn-*` o `btn-outline-*`). + pub fn with_color(mut self, color: Color) -> Self { + self.color = color; + self + } + + // **< ButtonColor HELPERS >******************************************************************** + + /// Devuelve la clase `btn-*` correspondiente al color del botón. + /// + /// Si no se ha definido ningún estilo, devuelve `""`. + pub fn to_class(self) -> String { + match self.style { + ButtonColorStyle::None => String::new(), + ButtonColorStyle::Solid => format!("btn-{}", self.color.as_str()), + ButtonColorStyle::Outline => format!("btn-outline-{}", self.color.as_str()), + ButtonColorStyle::Link => "btn-link".to_owned(), + } + } +} + +impl Into for ButtonColor { + fn into(self) -> CowStr { + self.to_class().into() + } +} + +// **< ButtonSize >********************************************************************************* + +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +enum ButtonSizeVariant { + #[default] + None, + Small, + Large, +} + +/// Clases para establecer el **tamaño** de los botones. +/// +/// # Ejemplos +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// use pagetop_bootsier::theme::*; +/// +/// let small = Button::submit(L10n::n("Save")) +/// .with_prop(PropsOp::add_classes(classes::ButtonSize::small())); +/// +/// let large = Button::submit(L10n::n("Save")) +/// .with_prop(PropsOp::add_classes(classes::ButtonSize::large())); +/// ``` +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub struct ButtonSize { + size: ButtonSizeVariant, +} + +impl ButtonSize { + /// Sin clase de tamaño (tamaño por defecto del tema). + pub fn new() -> Self { + Self::default() + } + + /// Botón compacto: genera la clase `btn-sm`. + pub fn small() -> Self { + Self { + size: ButtonSizeVariant::Small, + } + } + + /// Botón grande: genera la clase `btn-lg`. + pub fn large() -> Self { + Self { + size: ButtonSizeVariant::Large, + } + } + + // **< ButtonSize HELPERS >********************************************************************* + + /// Devuelve la clase `btn-sm` o `btn-lg` correspondiente al tamaño del botón. + /// + /// Si no se ha definido ningún tamaño, devuelve `""`. + pub fn to_class(self) -> String { + match self.size { + ButtonSizeVariant::None => String::new(), + ButtonSizeVariant::Small => "btn-sm".to_owned(), + ButtonSizeVariant::Large => "btn-lg".to_owned(), + } + } +} + +impl Into for ButtonSize { + fn into(self) -> CowStr { + self.to_class().into() + } +} diff --git a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs index 88e3dcf9..e0231b4a 100644 --- a/extensions/pagetop-bootsier/src/theme/dropdown/component.rs +++ b/extensions/pagetop-bootsier/src/theme/dropdown/component.rs @@ -27,7 +27,7 @@ use crate::theme::*; /// /// let dd = Dropdown::new() /// .with_title(L10n::n("Menu")) -/// .with_button_color(ButtonColor::Background(Color::Secondary)) +/// .with_button_color(classes::ButtonColor::solid(Color::Secondary)) /// .with_auto_close(dropdown::AutoClose::ClickableInside) /// .with_direction(dropdown::Direction::Dropend) /// .with_item(dropdown::Item::link(L10n::n("Home"), |_| "/".into())) @@ -43,9 +43,9 @@ pub struct Dropdown { /// Devuelve el título del menú desplegable. title: L10n, /// Devuelve el tamaño configurado del botón. - button_size: ButtonSize, + button_size: classes::ButtonSize, /// Devuelve el color/estilo configurado del botón. - button_color: ButtonColor, + button_color: classes::ButtonColor, /// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*). button_split: bool, /// Devuelve si el botón del menú está integrado en un grupo de botones. @@ -91,9 +91,11 @@ impl Component for Dropdown { div (self.props()) { @if !title.is_empty() { @let btn_base = { - let mut classes = "btn".to_string(); - self.button_size().push_class(&mut classes); - self.button_color().push_class(&mut classes); + let size = self.button_size().to_class(); + let color = self.button_color().to_class(); + let mut classes = String::from("btn"); + if !size.is_empty() { classes.push(' '); classes.push_str(&size); } + if !color.is_empty() { classes.push(' '); classes.push_str(&color); } classes }; @let pos = self.menu_position(); @@ -199,14 +201,14 @@ impl Dropdown { /// Ajusta el tamaño del botón. #[builder_fn] - pub fn with_button_size(mut self, size: ButtonSize) -> Self { + pub fn with_button_size(mut self, size: classes::ButtonSize) -> Self { self.button_size = size; self } /// Define el color/estilo del botón. #[builder_fn] - pub fn with_button_color(mut self, color: ButtonColor) -> Self { + pub fn with_button_color(mut self, color: classes::ButtonColor) -> Self { self.button_color = color; self } diff --git a/extensions/pagetop-bootsier/src/theme/form.rs b/extensions/pagetop-bootsier/src/theme/form.rs index 82c603ef..c088a86c 100644 --- a/extensions/pagetop-bootsier/src/theme/form.rs +++ b/extensions/pagetop-bootsier/src/theme/form.rs @@ -1,30 +1,24 @@ //! Definiciones para crear formularios ([`Form`]). -mod props; -pub use props::{Autocomplete, AutofillField, CheckboxKind, Method}; +pub use pagetop::base::component::form::{Autocomplete, AutofillField, CheckboxKind, Method}; -mod component; -pub use component::Form; +pub use pagetop::base::component::form::Form; -mod fieldset; -pub use fieldset::Fieldset; +pub use pagetop::base::component::form::Fieldset; -mod checkbox; -pub use checkbox::Checkbox; +pub use pagetop::base::component::form::Checkbox; -pub mod check; +pub use pagetop::base::component::form::check; -pub mod radio; +pub use pagetop::base::component::form::radio; pub mod select; pub mod input; -mod textarea; +pub mod textarea; pub use textarea::Textarea; -mod range; -pub use range::Range; +pub use pagetop::base::component::form::Range; -mod hidden; -pub use hidden::Hidden; +pub use pagetop::base::component::form::Hidden; diff --git a/extensions/pagetop-bootsier/src/theme/form/input.rs b/extensions/pagetop-bootsier/src/theme/form/input.rs index d920a68c..f20e8b28 100644 --- a/extensions/pagetop-bootsier/src/theme/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/form/input.rs @@ -2,456 +2,39 @@ use pagetop::prelude::*; -use crate::LOCALES_BOOTSIER; -use crate::theme::form; +pub use pagetop::base::component::form::input::{Field, Kind, Mode}; -use std::fmt; - -// **< Kind >*************************************************************************************** - -/// Tipo de campo para un [`form::input::Field`]. +/// Extensión de Bootsier para [`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: -/// -/// - [`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 +/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se +/// superpone al control mientras está vacío y permanece flotante cuando tiene contenido o está +/// enfocado. /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; -/// 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); -/// ``` +/// use pagetop::prelude::*; +/// use pagetop_bootsier::theme::*; /// -/// 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. -/// } +/// let nombre = form::input::Field::text() +/// .with_name("name") +/// .with_label(L10n::n("Name")) +/// .with_placeholder(L10n::n("Enter your name")) +/// .with_floating_label(true); /// ``` -#[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, - /// 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. - minlength: Attr, - /// Devuelve la longitud máxima permitida en caracteres. - maxlength: Attr, - /// Devuelve el texto indicativo del campo. - placeholder: Attr, - /// Devuelve la configuración de autocompletado del campo. - autocomplete: Attr, - /// 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, -} - -impl Component for Field { - fn new() -> Self { - Self::default() - } - - fn id(&self) -> Option { - 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. - if *self.floating_label() { - self.alter_prop(PropsOp::prepend_classes("form-floating")); - } - self.alter_prop(PropsOp::prepend_classes(util::join!( - "form-field form-field-", - self.kind().to_string() - ))); - } - - fn prepare(&self, cx: &mut Context) -> Result { - 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" - }; - // 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 (self.props()) { - @if !*self.floating_label() { - (label) - } - 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=[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) } - } - } - }) - } -} - -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) -> 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) -> Self { - self.name.alter_name(name); - self - } - - /// Establece el valor inicial del campo. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> 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>) -> Self { - self.label.alter_opt(label.into()); - self - } - +pub trait InputBootsier { /// 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 - } + /// contenido. Requiere que el campo tenga un atributo `placeholder` definido; si no se + /// especifica, se fuerza `placeholder=""` antes del renderizado. + fn with_floating_label(self, floating: bool) -> 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 { - 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) -> 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) -> 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>) -> 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) -> 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. - #[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) -> Self { - self.inputmode.alter_opt(inputmode); - self +impl InputBootsier for Field { + fn with_floating_label(self, floating: bool) -> Self { + if floating { + self.with_prop(PropsOp::add_classes("form-floating")) + } else { + self.with_prop(PropsOp::remove_classes("form-floating")) + } } } diff --git a/extensions/pagetop-bootsier/src/theme/form/select.rs b/extensions/pagetop-bootsier/src/theme/form/select.rs index 47b411aa..051a538c 100644 --- a/extensions/pagetop-bootsier/src/theme/form/select.rs +++ b/extensions/pagetop-bootsier/src/theme/form/select.rs @@ -2,462 +2,44 @@ use pagetop::prelude::*; -use crate::LOCALES_BOOTSIER; -use crate::theme::form; +pub use pagetop::base::component::form::select::{Entry, Field, Group, Item}; -// **< Item >*************************************************************************************** - -/// Elemento individual de [`form::select::Field`] o de [`form::select::Group`]. +/// Extensión de Bootsier para [`form::select::Field`]. /// -/// 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 +/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se +/// superpone al control mientras no hay ninguna opción seleccionada y permanece flotante cuando hay +/// una selección activa. /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; -/// 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, 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`]. +/// use pagetop::prelude::*; +/// use pagetop_bootsier::theme::*; /// -/// 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::*; -/// # use pagetop_bootsier::theme::*; -/// 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, - /// 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::*; -/// # use pagetop_bootsier::theme::*; -/// let idioma = form::select::Field::new() +/// let language = form::select::Field::new() /// .with_name("language") /// .with_label(L10n::n("Language")) +/// .with_floating_label(true) /// .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); +/// .with_item(form::select::Item::new("es", L10n::n("Spanish"))) +/// .with_item(form::select::Item::new("en", L10n::n("English"))); /// ``` -/// -/// 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`: -/// -/// ```rust,ignore -/// #[derive(serde::Deserialize)] -/// struct FormData { -/// language: String, // Siempre presente (campo obligatorio). -/// // language: Option, // 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` con `#[serde(default)]`: -/// -/// ```rust,ignore -/// #[derive(serde::Deserialize)] -/// struct FormData { -/// #[serde(default)] -/// interests: Vec, // 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, - /// 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 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. - 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 { - 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. - if *self.floating_label() { - self.alter_multiple(false); - self.alter_rows(None::); - self.alter_prop(PropsOp::prepend_classes("form-floating")); - } - self.alter_prop(PropsOp::prepend_classes("form-field form-field-select")); - } - - fn prepare(&self, cx: &mut Context) -> Result { - let container_id = self.id(); - let select_id = container_id.as_deref().map(|id| util::join!(id, "-select")); - 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 (self.props()) { - @if !*self.floating_label() { - (label) - } - 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 *self.floating_label() { - (label) - } - @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) -> 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) -> 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>) -> Self { - self.label.alter_opt(label.into()); - self - } - +pub trait SelectBootsier { /// 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 - } + /// Si se usa la etiqueta flotante, se anulan los valores establecidos con + /// [`with_multiple()`](form::select::Field::with_multiple) y + /// [`with_rows()`](form::select::Field::with_rows) antes del renderizado. + fn with_floating_label(self, floating: bool) -> 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 { - 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. - /// - /// 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 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. - /// - /// Se anula si se usa con [`with_floating_label(true)`](Self::with_floating_label). - #[builder_fn] - pub fn with_rows(mut self, rows: Option) -> 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) -> 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 +impl SelectBootsier for Field { + fn with_floating_label(self, floating: bool) -> Self { + if floating { + self.with_prop(PropsOp::add_classes("form-floating")) + } else { + self.with_prop(PropsOp::remove_classes("form-floating")) + } } } diff --git a/extensions/pagetop-bootsier/src/theme/form/textarea.rs b/extensions/pagetop-bootsier/src/theme/form/textarea.rs index f47bc139..455633fd 100644 --- a/extensions/pagetop-bootsier/src/theme/form/textarea.rs +++ b/extensions/pagetop-bootsier/src/theme/form/textarea.rs @@ -1,294 +1,43 @@ +//! Definiciones para crear áreas de texto en formularios. + use pagetop::prelude::*; -use crate::LOCALES_BOOTSIER; -use crate::theme::form; +pub use pagetop::base::component::form::Textarea; -/// Componente para crear un **área de texto** de formulario. +/// Extensión de Bootsier para [`form::Textarea`]. /// -/// 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 +/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se +/// superpone al control mientras no hay ninguna opción seleccionada y permanece flotante cuando hay +/// una selección activa. /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; -/// let descripcion = form::Textarea::new() -/// .with_name("description") -/// .with_label(L10n::n("Description")) -/// .with_rows(Some(8)) -/// .with_maxlength(Some(500)) +/// use pagetop::prelude::*; +/// use pagetop_bootsier::theme::*; +/// +/// let comentario = form::Textarea::new() +/// .with_name("comment") +/// .with_label(L10n::n("Comment")) /// .with_placeholder(L10n::n("Write here...")) -/// .with_required(true); +/// .with_floating_label(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, - /// Devuelve si la etiqueta se muestra flotante sobre el campo. - floating_label: bool, - /// Devuelve el texto de ayuda del campo. - help_text: Attr, - /// Devuelve el número de filas visibles del área de texto. - rows: Attr, - /// Devuelve la longitud mínima permitida en caracteres. - minlength: Attr, - /// Devuelve la longitud máxima permitida en caracteres. - maxlength: Attr, - /// Devuelve el texto indicativo del área de texto. - placeholder: Attr, - /// Devuelve la configuración de autocompletado del campo. - autocomplete: Attr, - /// 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 { - 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. - if *self.floating_label() { - self.alter_rows(None::); - self.alter_prop(PropsOp::prepend_classes("form-floating")); - } - self.alter_prop(PropsOp::prepend_classes("form-field form-field-textarea")); - } - - fn prepare(&self, cx: &mut Context) -> Result { - let container_id = self.id(); - let textarea_id = container_id - .as_deref() - .map(|id| util::join!(id, "-textarea")); - // 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=[textarea_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 (self.props()) { - @if !*self.floating_label() { - (label) - } - 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=[placeholder] - 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 *self.floating_label() { - (label) - } - @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) -> 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) -> 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) -> 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>) -> Self { - self.label.alter_opt(label.into()); - self - } - +pub trait TextareaBootsier { /// Establece si la etiqueta se muestra flotante sobre el campo. /// /// Cuando está activo, la etiqueta se superpone al área de texto y asciende al enfocarlo o - /// cuando tiene contenido. + /// cuando tiene contenido. Requiere que el campo tenga un atributo `placeholder` definido; + /// si no se especifica, se fuerza `placeholder=""` antes del renderizado. /// - /// Si se usa la etiqueta flotante, el [`setup()`](Self::setup) del componente anulará el valor - /// establecido con [`with_rows()`](Self::with_rows) antes del renderizado. Si es necesario, se - /// puede controlar la altura con estilos aplicados al componente. - #[builder_fn] - pub fn with_floating_label(mut self, floating_label: bool) -> Self { - self.floating_label = floating_label; - self - } + /// Si se usa la etiqueta flotante, se anula el valor establecido con + /// [`with_rows()`](form::Textarea::with_rows) antes del renderizado. + fn with_floating_label(self, floating: bool) -> 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 { - 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. - /// - /// Se anula si se usa con [`with_floating_label(true)`](Self::with_floating_label). - #[builder_fn] - pub fn with_rows(mut self, rows: Option) -> 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) -> 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) -> 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>) -> 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) -> 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 +impl TextareaBootsier for Textarea { + fn with_floating_label(self, floating: bool) -> Self { + if floating { + self.with_prop(PropsOp::add_classes("form-floating")) + } else { + self.with_prop(PropsOp::remove_classes("form-floating")) + } } } diff --git a/src/base/component.rs b/src/base/component.rs index 7ea596d3..5c9fee3d 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -1,11 +1,18 @@ //! Componentes nativos proporcionados por PageTop. -mod html; -pub use html::Html; - mod block; pub use block::Block; +mod button; +pub use button::{Button, ButtonAction}; + +pub mod form; +#[doc(inline)] +pub use form::Form; + +mod html; +pub use html::Html; + mod intro; pub use intro::{Intro, IntroOpening}; diff --git a/src/base/component/button.rs b/src/base/component/button.rs new file mode 100644 index 00000000..8d2f0fa7 --- /dev/null +++ b/src/base/component/button.rs @@ -0,0 +1,207 @@ +use crate::prelude::*; + +use std::fmt; + +// **< ButtonAction >******************************************************************************* + +/// Comportamiento de un [`Button`] al activarse. +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum ButtonAction { + /// Envía un formulario al servidor. Es el **tipo por defecto**. + #[default] + Submit, + /// Restablece todos los campos de un formulario a sus valores iniciales. + Reset, + /// Botón de propósito general, sin efecto predeterminado. Su comportamiento podría definirse + /// mediante JavaScript. + Plain, +} + +impl fmt::Display for ButtonAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + ButtonAction::Submit => "submit", + ButtonAction::Reset => "reset", + ButtonAction::Plain => "button", + }) + } +} + +// **< Button >************************************************************************************* + +/// Componente para crear un **botón**. +/// +/// Renderiza un botón con soporte para las variantes disponibles en [`ButtonAction`] (`submit`, +/// `reset` y botón genérico). +/// +/// El comportamiento del botón se establece al crearlo: +/// +/// - [`Button::submit()`]: botón de envío (por defecto). +/// - [`Button::reset()`]: botón de restablecimiento de valores. +/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado. +/// +/// El botón puede usarse dentro o fuera de un formulario. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let save = Button::submit(L10n::n("Save")); +/// let cancel = Button::plain(L10n::n("Cancel")); +/// let clear = Button::reset(L10n::n("Clear")); +/// ``` +/// +/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del +/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar +/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option`: +/// +/// ```rust,ignore +/// #[derive(serde::Deserialize)] +/// struct FormData { +/// #[serde(default)] +/// action: Option, // p. ej., "save" o "delete"; `None` si el botón no tenía `name`. +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Button { + /// Devuelve identificador, clases CSS y atributos HTML del componente. + props: Props, + /// Devuelve el comportamiento del botón al activarse. + kind: ButtonAction, + /// Devuelve el nombre del botón. + name: AttrName, + /// Devuelve el valor del botón. + value: AttrValue, + /// Devuelve la etiqueta del botón. + label: Attr, + /// Devuelve si el botón recibe el foco automáticamente al cargar la página. + autofocus: bool, + /// Devuelve si el botón está deshabilitado. + disabled: bool, +} + +impl Component for Button { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + self.alter_prop(PropsOp::prepend_classes("button")); + } + + fn prepare(&self, cx: &mut Context) -> Result { + Ok(html! { + button + type=(self.kind()) + (self.props()) + name=[self.name().get()] + value=[self.value().get()] + autofocus[*self.autofocus()] + disabled[*self.disabled()] + { + @if let Some(label) = self.label().lookup(cx) { + (label) + } + } + }) + } +} + +impl Button { + /// Crea un botón de **envío** (`type="submit"`). + /// + /// Es la acción predeterminada al pulsar un botón en la mayoría de los formularios: envía los + /// datos al servidor. + pub fn submit(label: L10n) -> Self { + Self { + kind: ButtonAction::Submit, + label: Attr::some(label), + ..Default::default() + } + } + + /// Crea un botón de **restablecimiento** (`type="reset"`). + /// + /// Al pulsarlo, devuelve todos los campos del formulario a sus valores iniciales. + pub fn reset(label: L10n) -> Self { + Self { + kind: ButtonAction::Reset, + label: Attr::some(label), + ..Default::default() + } + } + + /// Crea un **botón genérico** (`type="button"`). + /// + /// No tiene un comportamiento predeterminado sobre el formulario. Su comportamiento puede + /// definirse mediante JavaScript. + pub fn plain(label: L10n) -> Self { + Self { + kind: ButtonAction::Plain, + label: Attr::some(label), + ..Default::default() + } + } + + // **< Button 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) -> 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 botón (atributo `name`). + /// + /// Cuando el formulario tiene varios botones de envío, el navegador incluye en el envío el par + /// `name=value` sólo del botón que activó el formulario. Permite identificar cuál fue pulsado. + #[builder_fn] + pub fn with_name(mut self, name: impl AsRef) -> Self { + self.name.alter_name(name); + self + } + + /// Establece el valor del botón (atributo `value`). + /// + /// Es el dato que el navegador transmite al servidor junto con el `name` cuando este botón + /// activa el envío. Útil para distinguir entre varios botones de envío en un mismo formulario. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + self.value.alter_str(value); + self + } + + /// Establece o elimina la etiqueta visible del botón (basta pasar `None` para quitarla). + #[builder_fn] + pub fn with_label(mut self, label: impl Into>) -> Self { + self.label.alter_opt(label.into()); + self + } + + /// Establece si el botón 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 botón está deshabilitado. + #[builder_fn] + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} diff --git a/src/base/component/form.rs b/src/base/component/form.rs new file mode 100644 index 00000000..a656bb6b --- /dev/null +++ b/src/base/component/form.rs @@ -0,0 +1,30 @@ +//! Componentes y tipos para crear formularios HTML ([`Form`]). + +mod props; +pub use props::{Autocomplete, AutofillField, CheckboxKind, Method}; + +mod component; +pub use component::Form; + +mod fieldset; +pub use fieldset::Fieldset; + +mod checkbox; +pub use checkbox::Checkbox; + +pub mod check; + +pub mod radio; + +pub mod select; + +pub mod input; + +mod textarea; +pub use textarea::Textarea; + +mod range; +pub use range::Range; + +mod hidden; +pub use hidden::Hidden; diff --git a/extensions/pagetop-bootsier/src/theme/form/check.rs b/src/base/component/form/check.rs similarity index 92% rename from extensions/pagetop-bootsier/src/theme/form/check.rs rename to src/base/component/form/check.rs index b692cefd..2bbc1091 100644 --- a/extensions/pagetop-bootsier/src/theme/form/check.rs +++ b/src/base/component/form/check.rs @@ -1,10 +1,10 @@ //! Definiciones para crear grupos de casillas de verificación (*check buttons*). -use pagetop::prelude::*; +use crate::prelude::*; // **< Item >*************************************************************************************** -/// Casilla de verificación individual de un [`form::check::Field`](Field). +/// 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. @@ -16,8 +16,8 @@ use pagetop::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; +/// use pagetop::prelude::*; +/// /// let item = form::check::Item::new("apple", L10n::n("Apple")).with_checked(true); /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] @@ -65,24 +65,21 @@ impl Item { /// Componente para crear un **grupo de casillas de verificación**. /// -/// Renderiza un conjunto de casillas de verificación donde, a diferencia de un grupo de botones -/// [`form::radio::Field`](crate::theme::form::radio::Field), cada casilla puede marcarse de forma -/// independiente. -/// -/// Las casillas se añaden mediante [`with_item()`](Field::with_item) usando instancias de -/// [`form::check::Item`](Item). Si se activa el modo en línea con +/// 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`](Item) con un guion bajo. Por ejemplo, para el grupo con +/// 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::*; -/// # use pagetop_bootsier::theme::*; +/// use pagetop::prelude::*; +/// /// let interests = form::check::Field::new() /// .with_name("interests") /// .with_label(L10n::n("Areas of interest")) diff --git a/extensions/pagetop-bootsier/src/theme/form/checkbox.rs b/src/base/component/form/checkbox.rs similarity index 93% rename from extensions/pagetop-bootsier/src/theme/form/checkbox.rs rename to src/base/component/form/checkbox.rs index 74c3a1ff..cf382e27 100644 --- a/extensions/pagetop-bootsier/src/theme/form/checkbox.rs +++ b/src/base/component/form/checkbox.rs @@ -1,13 +1,9 @@ -use pagetop::prelude::*; - -use crate::LOCALES_BOOTSIER; -use crate::theme::form; +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 visuales, por defecto se -/// muestra como una casilla de verificación estándar, pero también puede renderizarse como un -/// interruptor de encendido/apagado ([`Checkbox::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 @@ -16,9 +12,9 @@ use crate::theme::form; /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; -/// let accept_terms = form::Checkbox::check() // También sirve new() o default(). +/// 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); @@ -86,7 +82,7 @@ impl Component for Checkbox { self.alter_prop(PropsOp::ensure_id(container_id)); // Clases CSS del contenedor de la casilla de verificación. - let mut classes = "form-field form-check".to_string(); + let mut classes = String::from("form-field form-check"); if *self.checkbox_kind() == form::CheckboxKind::Switch { classes.push_str(" form-switch"); } @@ -126,7 +122,7 @@ impl Component for Checkbox { @if *self.required() { span class="form-required" - title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx)) + title=(L10n::l("field_required").using(cx)) { "*" } diff --git a/extensions/pagetop-bootsier/src/theme/form/component.rs b/src/base/component/form/component.rs similarity index 91% rename from extensions/pagetop-bootsier/src/theme/form/component.rs rename to src/base/component/form/component.rs index 999d0dd0..dc222ee8 100644 --- a/extensions/pagetop-bootsier/src/theme/form/component.rs +++ b/src/base/component/form/component.rs @@ -1,16 +1,15 @@ -use pagetop::prelude::*; +use crate::prelude::*; -use crate::theme::form; +use crate::base::component::form; -/// Componente para crear un **formulario** ([`form`]). +/// Componente para crear un **formulario** HTML ([`form`]). /// -/// Este componente renderiza un formulario estándar con soporte para los atributos más habituales: +/// 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 explicaciones en -/// [`form::Method`](crate::theme::form::Method)). +/// - `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. /// @@ -18,7 +17,6 @@ use crate::theme::form; /// /// ```rust,no_run /// use pagetop::prelude::*; -/// use pagetop_bootsier::theme::*; /// /// let form_login = Form::new() /// .with_id("login") @@ -42,7 +40,6 @@ use crate::theme::form; /// ) /// .with_child( /// Button::submit(L10n::n("Sign in")) -/// .with_color(ButtonColor::Background(Color::Primary)), /// ); /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] diff --git a/extensions/pagetop-bootsier/src/theme/form/fieldset.rs b/src/base/component/form/fieldset.rs similarity index 97% rename from extensions/pagetop-bootsier/src/theme/form/fieldset.rs rename to src/base/component/form/fieldset.rs index 5a880376..1e420480 100644 --- a/extensions/pagetop-bootsier/src/theme/form/fieldset.rs +++ b/src/base/component/form/fieldset.rs @@ -1,4 +1,4 @@ -use pagetop::prelude::*; +use crate::prelude::*; /// Componente para crear un **grupo de controles relacionados** en un formulario. /// @@ -14,8 +14,8 @@ use pagetop::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; +/// 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.")) diff --git a/extensions/pagetop-bootsier/src/theme/form/hidden.rs b/src/base/component/form/hidden.rs similarity index 95% rename from extensions/pagetop-bootsier/src/theme/form/hidden.rs rename to src/base/component/form/hidden.rs index cc413252..5ad9e3ed 100644 --- a/extensions/pagetop-bootsier/src/theme/form/hidden.rs +++ b/src/base/component/form/hidden.rs @@ -1,4 +1,4 @@ -use pagetop::prelude::*; +use crate::prelude::*; /// Componente para crear un **campo oculto** del formulario. /// @@ -11,8 +11,8 @@ use pagetop::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; +/// use pagetop::prelude::*; +/// /// let token = form::Hidden::new() /// .with_name("csrf_token") /// .with_value("a1b2c3d4e5"); diff --git a/src/base/component/form/input.rs b/src/base/component/form/input.rs new file mode 100644 index 00000000..7b57c648 --- /dev/null +++ b/src/base/component/form/input.rs @@ -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, + /// Devuelve el texto de ayuda del campo. + help_text: Attr, + /// Devuelve la longitud mínima permitida en caracteres. + minlength: Attr, + /// Devuelve la longitud máxima permitida en caracteres. + maxlength: Attr, + /// Devuelve el texto indicativo del campo. + placeholder: Attr, + /// Devuelve la configuración de autocompletado del campo. + autocomplete: Attr, + /// 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, +} + +impl Component for Field { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + 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 { + 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) -> 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) -> Self { + self.name.alter_name(name); + self + } + + /// Establece el valor inicial del campo. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> 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>) -> 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>) -> 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) -> 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) -> 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>) -> 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) -> 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) -> Self { + self.inputmode.alter_opt(inputmode); + self + } +} diff --git a/extensions/pagetop-bootsier/src/theme/form/props.rs b/src/base/component/form/props.rs similarity index 97% rename from extensions/pagetop-bootsier/src/theme/form/props.rs rename to src/base/component/form/props.rs index 85886569..4e69d924 100644 --- a/extensions/pagetop-bootsier/src/theme/form/props.rs +++ b/src/base/component/form/props.rs @@ -1,20 +1,21 @@ -use pagetop::prelude::*; +use crate::prelude::*; use std::borrow::Cow; use std::fmt; // **< CheckboxKind >******************************************************************************* -/// Variante visual para [`form::Checkbox`](crate::theme::form::Checkbox) en un formulario. +/// 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*). +/// 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. + /// 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 @@ -51,8 +52,8 @@ pub enum CheckboxKind { /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; +/// use pagetop::prelude::*; +/// /// // Correo electrónico con sugerencia semántica del navegador. /// let ac = form::Autocomplete::email(); /// @@ -87,7 +88,7 @@ impl Autocomplete { // --< Secciones >------------------------------------------------------------------------------ /// Construye `autocomplete` con un prefijo de sección y un token o tokens del - /// [`form::AutofillField`](AutofillField) indicado. + /// [`AutofillField`] indicado. /// /// Genera `autocomplete="section- "`. Si `name` no es ASCII o contiene espacios, /// se ignora la sección y se genera sólo el token indicado. @@ -244,7 +245,8 @@ impl fmt::Display for Autocomplete { /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop_bootsier::theme::*; +/// 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); @@ -447,7 +449,7 @@ impl AutofillField { // **< Method >************************************************************************************* -/// Método HTTP usado por un formulario ([`Form`](crate::theme::Form)) para el envío de los datos. +/// 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: /// diff --git a/extensions/pagetop-bootsier/src/theme/form/radio.rs b/src/base/component/form/radio.rs similarity index 95% rename from extensions/pagetop-bootsier/src/theme/form/radio.rs rename to src/base/component/form/radio.rs index 76b2dc32..f06dd387 100644 --- a/extensions/pagetop-bootsier/src/theme/form/radio.rs +++ b/src/base/component/form/radio.rs @@ -1,12 +1,10 @@ //! Definiciones para crear grupos de botones de opción (*radio buttons*). -use pagetop::prelude::*; - -use crate::LOCALES_BOOTSIER; +use crate::prelude::*; // **< Item >*************************************************************************************** -/// Botón de opción individual de un [`form::radio::Field`](Field). +/// 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 @@ -15,8 +13,8 @@ use crate::LOCALES_BOOTSIER; /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; +/// use pagetop::prelude::*; +/// /// let item = form::radio::Item::new("monthly", L10n::n("Monthly")).with_checked(true); /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] @@ -64,8 +62,8 @@ impl Item { /// Componente para crear un **grupo de botones de opción**. /// -/// Renderiza un grupo de botones de opción [`form::radio::Item`](Item) que comparten el mismo -/// atributo `name`, por lo que sólo puede seleccionarse uno a la vez. Las opciones se añaden con +/// 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 @@ -75,8 +73,8 @@ impl Item { /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; +/// use pagetop::prelude::*; +/// /// let plan = form::radio::Field::new() /// .with_name("plan") /// .with_label(L10n::n("Subscription plan")) @@ -151,7 +149,7 @@ impl Component for Field { @if *self.required() { span class="form-required" - title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx)) + title=(L10n::l("field_required").using(cx)) { "*" } diff --git a/extensions/pagetop-bootsier/src/theme/form/range.rs b/src/base/component/form/range.rs similarity index 98% rename from extensions/pagetop-bootsier/src/theme/form/range.rs rename to src/base/component/form/range.rs index 37dfa68f..386768d7 100644 --- a/extensions/pagetop-bootsier/src/theme/form/range.rs +++ b/src/base/component/form/range.rs @@ -1,4 +1,4 @@ -use pagetop::prelude::*; +use crate::prelude::*; /// Componente para crear un **control deslizante** de rango. /// @@ -9,8 +9,8 @@ use pagetop::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::prelude::*; -/// # use pagetop_bootsier::theme::*; +/// use pagetop::prelude::*; +/// /// let volume = form::Range::new() /// .with_name("volume") /// .with_label(L10n::n("Volume")) diff --git a/src/base/component/form/select.rs b/src/base/component/form/select.rs new file mode 100644 index 00000000..fc94d06c --- /dev/null +++ b/src/base/component/form/select.rs @@ -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, 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, + /// 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`: +/// +/// ```rust,ignore +/// #[derive(serde::Deserialize)] +/// struct FormData { +/// language: String, // Siempre presente (campo obligatorio). +/// // language: Option, // 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` con `#[serde(default)]`: +/// +/// ```rust,ignore +/// #[derive(serde::Deserialize)] +/// struct FormData { +/// #[serde(default)] +/// interests: Vec, // 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, + /// 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 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. + 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 { + 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 { + 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) -> 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) -> 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>) -> 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>) -> 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) -> 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) -> 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 + } +} diff --git a/src/base/component/form/textarea.rs b/src/base/component/form/textarea.rs new file mode 100644 index 00000000..5b4cc1ff --- /dev/null +++ b/src/base/component/form/textarea.rs @@ -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, + /// Devuelve el texto de ayuda del campo. + help_text: Attr, + /// Devuelve el número de filas visibles del área de texto. + rows: Attr, + /// Devuelve la longitud mínima permitida en caracteres. + minlength: Attr, + /// Devuelve la longitud máxima permitida en caracteres. + maxlength: Attr, + /// Devuelve el texto indicativo del área de texto. + placeholder: Attr, + /// Devuelve la configuración de autocompletado del campo. + autocomplete: Attr, + /// 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 { + 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 { + 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) -> 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) -> 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) -> 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>) -> 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>) -> 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) -> 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) -> 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) -> 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>) -> 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) -> 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 + } +} diff --git a/src/core/theme.rs b/src/core/theme.rs index 43649db1..9f358c41 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -238,8 +238,11 @@ impl Template for DefaultTemplate {} macro_rules! render_component { ($component:expr, { $($type:ty => |$var:ident| $body:expr),* $(,)? }) => { 'render_component: { + // Reborrow explícito como referencia compartida para que `downcast_ref` funcione + // correctamente con `&mut dyn Component` (limitación del compilador con trait objects). + let __c = &*($component); $( - if let Some($var) = ($component).downcast_ref::<$type>() { + if let Some($var) = __c.downcast_ref::<$type>() { break 'render_component Some($body); } )* diff --git a/src/locale/en-US/base.ftl b/src/locale/en-US/base.ftl index 76baa120..b244f9e2 100644 --- a/src/locale/en-US/base.ftl +++ b/src/locale/en-US/base.ftl @@ -1,3 +1,6 @@ +# Form components. +field_required = This field is required + # Intro component. intro_default_title = Hello, world! intro_default_slogan = Discover⚡{ $app } diff --git a/src/locale/es-ES/base.ftl b/src/locale/es-ES/base.ftl index 09867d13..50a459ec 100644 --- a/src/locale/es-ES/base.ftl +++ b/src/locale/es-ES/base.ftl @@ -1,3 +1,6 @@ +# Form components. +field_required = Este campo es obligatorio + # Intro component. intro_default_title = ¡Hola, mundo! intro_default_slogan = Descubre⚡{ $app }