diff --git a/assets/css/basic.css b/assets/css/basic.css index b35b29ef..9b23b272 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -13,11 +13,6 @@ /* 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 { @@ -38,105 +33,6 @@ 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 172de633..1c7f066e 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -1,6 +1,5 @@ use pagetop::prelude::*; -use pagetop_bootsier::theme::form; use pagetop_bootsier::theme::*; include_locales!(LOC from "examples/locale"); @@ -144,20 +143,17 @@ 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::plain(L10n::t("btn_cancel", &LOC)).with_prop( - PropsOp::add_classes(classes::ButtonColor::link()), - ), + 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), ), ), ) @@ -179,7 +175,6 @@ 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( @@ -190,7 +185,6 @@ async fn form_controls(request: HttpRequest) -> Result { "placeholder_email", &LOC, )) - .with_help_text(L10n::t("help_email", &LOC)) .with_autocomplete( Some(form::Autocomplete::email()), ) @@ -268,175 +262,194 @@ 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::plain(L10n::t("btn_cancel", &LOC)).with_prop( - PropsOp::add_classes(classes::ButtonColor::link()), - ), + 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), ), ), ) // Bloque 3: listas de selección y etiquetas flotantes. .with_child( Block::new() - .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()), + .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), + ), + ), ), ) .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 ce6c76ca..7c2b3c96 100644 --- a/examples/locale/en-US/form-controls.ftl +++ b/examples/locale/en-US/form-controls.ftl @@ -2,8 +2,7 @@ title = Form controls slogan = Bootsier form components showcase block_selections = Checkboxes, switches and radio buttons block_text = Text fields, multiline and range -block_lists = Selection lists -block_lists_floating = Select lists and floating labels +block_lists = Select lists and floating labels fieldset_text = Text fields label_name = Full name @@ -17,8 +16,6 @@ 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 40781042..67a0fd2c 100644 --- a/examples/locale/es-ES/form-controls.ftl +++ b/examples/locale/es-ES/form-controls.ftl @@ -2,8 +2,7 @@ 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 -block_lists_floating = Listas de selección y etiquetas flotantes +block_lists = Listas de selección y etiquetas flotantes fieldset_text = Campos de texto label_name = Nombre completo @@ -17,8 +16,6 @@ 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 51f08dd8..9a4c7e53 100644 --- a/extensions/pagetop-bootsier/assets/_bootsier-custom.scss +++ b/extensions/pagetop-bootsier/assets/_bootsier-custom.scss @@ -53,13 +53,11 @@ 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-base) / 2; - font-weight: var(--bs-font-weight-normal); + font-size: $font-size-sm; 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 deleted file mode 100644 index 5846283d..00000000 --- a/extensions/pagetop-bootsier/src/handlers/button.rs +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 94f906c4..00000000 --- a/extensions/pagetop-bootsier/src/handlers/input.rs +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 03a48735..00000000 --- a/extensions/pagetop-bootsier/src/handlers/select.rs +++ /dev/null @@ -1,80 +0,0 @@ -use pagetop::prelude::*; - -pub fn setup(c: &mut form::select::Field) { - if c.props().has_class("form-floating") { - c.alter_multiple(false); - c.alter_rows(None::); - } -} - -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 deleted file mode 100644 index 2f41a765..00000000 --- a/extensions/pagetop-bootsier/src/handlers/textarea.rs +++ /dev/null @@ -1,63 +0,0 @@ -use pagetop::prelude::*; - -pub fn setup(c: &mut form::Textarea) { - if c.props().has_class("form-floating") { - c.alter_rows(None::); - } -} - -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 23e07722..bb7c09e0 100644 --- a/extensions/pagetop-bootsier/src/lib.rs +++ b/extensions/pagetop-bootsier/src/lib.rs @@ -89,11 +89,32 @@ pub mod config; pub mod theme; -mod handlers { - pub mod button; - pub mod input; - pub mod select; - pub mod textarea; +/// 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) + } } /// Implementa el tema. @@ -134,23 +155,6 @@ 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 22e55792..fb7dd0ed 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, ButtonAction}; +pub use button::Button; // Container. pub mod container; @@ -27,12 +27,6 @@ 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 3b3be43a..e5f0a82f 100644 --- a/extensions/pagetop-bootsier/src/theme/attrs.rs +++ b/extensions/pagetop-bootsier/src/theme/attrs.rs @@ -15,3 +15,6 @@ 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 new file mode 100644 index 00000000..82ce9471 --- /dev/null +++ b/extensions/pagetop-bootsier/src/theme/attrs/button.rs @@ -0,0 +1,145 @@ +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 eb705ce3..17fb7cf6 100644 --- a/extensions/pagetop-bootsier/src/theme/button.rs +++ b/extensions/pagetop-bootsier/src/theme/button.rs @@ -1 +1,211 @@ -pub use pagetop::base::component::{Button, ButtonAction}; +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 + } +} diff --git a/extensions/pagetop-bootsier/src/theme/classes.rs b/extensions/pagetop-bootsier/src/theme/classes.rs index 2009922a..9e6c234d 100644 --- a/extensions/pagetop-bootsier/src/theme/classes.rs +++ b/extensions/pagetop-bootsier/src/theme/classes.rs @@ -3,9 +3,6 @@ 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 deleted file mode 100644 index 93a8712e..00000000 --- a/extensions/pagetop-bootsier/src/theme/classes/button.rs +++ /dev/null @@ -1,170 +0,0 @@ -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 e0231b4a..88e3dcf9 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(classes::ButtonColor::solid(Color::Secondary)) +/// .with_button_color(ButtonColor::Background(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: classes::ButtonSize, + button_size: ButtonSize, /// Devuelve el color/estilo configurado del botón. - button_color: classes::ButtonColor, + button_color: 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,11 +91,9 @@ impl Component for Dropdown { div (self.props()) { @if !title.is_empty() { @let btn_base = { - 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); } + let mut classes = "btn".to_string(); + self.button_size().push_class(&mut classes); + self.button_color().push_class(&mut classes); classes }; @let pos = self.menu_position(); @@ -201,14 +199,14 @@ impl Dropdown { /// Ajusta el tamaño del botón. #[builder_fn] - pub fn with_button_size(mut self, size: classes::ButtonSize) -> Self { + pub fn with_button_size(mut self, size: ButtonSize) -> Self { self.button_size = size; self } /// Define el color/estilo del botón. #[builder_fn] - pub fn with_button_color(mut self, color: classes::ButtonColor) -> Self { + pub fn with_button_color(mut self, color: 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 c088a86c..82c603ef 100644 --- a/extensions/pagetop-bootsier/src/theme/form.rs +++ b/extensions/pagetop-bootsier/src/theme/form.rs @@ -1,24 +1,30 @@ //! Definiciones para crear formularios ([`Form`]). -pub use pagetop::base::component::form::{Autocomplete, AutofillField, CheckboxKind, Method}; +mod props; +pub use props::{Autocomplete, AutofillField, CheckboxKind, Method}; -pub use pagetop::base::component::form::Form; +mod component; +pub use component::Form; -pub use pagetop::base::component::form::Fieldset; +mod fieldset; +pub use fieldset::Fieldset; -pub use pagetop::base::component::form::Checkbox; +mod checkbox; +pub use checkbox::Checkbox; -pub use pagetop::base::component::form::check; +pub mod check; -pub use pagetop::base::component::form::radio; +pub mod radio; pub mod select; pub mod input; -pub mod textarea; +mod textarea; pub use textarea::Textarea; -pub use pagetop::base::component::form::Range; +mod range; +pub use range::Range; -pub use pagetop::base::component::form::Hidden; +mod hidden; +pub use hidden::Hidden; diff --git a/src/base/component/form/check.rs b/extensions/pagetop-bootsier/src/theme/form/check.rs similarity index 92% rename from src/base/component/form/check.rs rename to extensions/pagetop-bootsier/src/theme/form/check.rs index 2bbc1091..b692cefd 100644 --- a/src/base/component/form/check.rs +++ b/extensions/pagetop-bootsier/src/theme/form/check.rs @@ -1,10 +1,10 @@ //! Definiciones para crear grupos de casillas de verificación (*check buttons*). -use crate::prelude::*; +use pagetop::prelude::*; // **< Item >*************************************************************************************** -/// Casilla de verificación individual de un [`Field`]. +/// Casilla de verificación individual de un [`form::check::Field`](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 crate::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::theme::*; /// let item = form::check::Item::new("apple", L10n::n("Apple")).with_checked(true); /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] @@ -65,21 +65,24 @@ impl Item { /// Componente para crear un **grupo de casillas de verificación**. /// -/// Renderiza un conjunto de casillas de verificación donde cada casilla puede marcarse de forma -/// independiente. Las casillas se añaden con [`with_item()`](Field::with_item) usando instancias -/// de [`form::check::Item`]. Si se activa el modo en línea con +/// 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 /// [`with_inline()`](Field::with_inline), las casillas se disponen horizontalmente. /// /// El atributo `name` de cada casilla se construye automáticamente combinando el `name` del grupo -/// y el `name` del [`form::check::Item`] con un guion bajo. Por ejemplo, para el grupo con +/// y el `name` del [`form::check::Item`](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::prelude::*; +/// # use pagetop_bootsier::theme::*; /// let interests = form::check::Field::new() /// .with_name("interests") /// .with_label(L10n::n("Areas of interest")) diff --git a/src/base/component/form/checkbox.rs b/extensions/pagetop-bootsier/src/theme/form/checkbox.rs similarity index 93% rename from src/base/component/form/checkbox.rs rename to extensions/pagetop-bootsier/src/theme/form/checkbox.rs index cf382e27..74c3a1ff 100644 --- a/src/base/component/form/checkbox.rs +++ b/extensions/pagetop-bootsier/src/theme/form/checkbox.rs @@ -1,9 +1,13 @@ -use crate::prelude::*; +use pagetop::prelude::*; + +use crate::LOCALES_BOOTSIER; +use crate::theme::form; /// Componente para crear una **casilla de verificación** o un **interruptor** (*toggle switch*). /// -/// Renderiza un control binario (marcado/no marcado) en dos variantes, por defecto como casilla de -/// verificación estándar, y también como interruptor ([`Checkbox::switch()`]). +/// 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()`]). /// /// 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 @@ -12,9 +16,9 @@ use crate::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// -/// let accept_terms = form::Checkbox::new() +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::theme::*; +/// let accept_terms = form::Checkbox::check() // También sirve new() o default(). /// .with_name("terms_accepted") /// .with_label(L10n::n("I accept the terms and conditions")) /// .with_required(true); @@ -82,7 +86,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 = String::from("form-field form-check"); + let mut classes = "form-field form-check".to_string(); if *self.checkbox_kind() == form::CheckboxKind::Switch { classes.push_str(" form-switch"); } @@ -122,7 +126,7 @@ impl Component for Checkbox { @if *self.required() { span class="form-required" - title=(L10n::l("field_required").using(cx)) + title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx)) { "*" } diff --git a/src/base/component/form/component.rs b/extensions/pagetop-bootsier/src/theme/form/component.rs similarity index 91% rename from src/base/component/form/component.rs rename to extensions/pagetop-bootsier/src/theme/form/component.rs index dc222ee8..999d0dd0 100644 --- a/src/base/component/form/component.rs +++ b/extensions/pagetop-bootsier/src/theme/form/component.rs @@ -1,15 +1,16 @@ -use crate::prelude::*; +use pagetop::prelude::*; -use crate::base::component::form; +use crate::theme::form; -/// Componente para crear un **formulario** HTML ([`form`]). +/// Componente para crear un **formulario** ([`form`]). /// -/// Renderiza un formulario estándar con soporte para los atributos más habituales: +/// Este componente renderiza un formulario estándar con soporte para los atributos más habituales: /// /// - `id`: identificador opcional del formulario. /// - `classes`: clases CSS adicionales (p. ej. utilidades CSS). /// - `action`: URL/ruta de destino para el envío. -/// - `method`: método usado por el formulario para el envío de los datos (ver [`form::Method`]). +/// - `method`: método usado por el formulario para el envío de los datos (ver explicaciones en +/// [`form::Method`](crate::theme::form::Method)). /// - `accept-charset`: juego de caracteres aceptado (por defecto es `"UTF-8"`). /// - `children`: contenido del formulario. /// @@ -17,6 +18,7 @@ use crate::base::component::form; /// /// ```rust,no_run /// use pagetop::prelude::*; +/// use pagetop_bootsier::theme::*; /// /// let form_login = Form::new() /// .with_id("login") @@ -40,6 +42,7 @@ use crate::base::component::form; /// ) /// .with_child( /// Button::submit(L10n::n("Sign in")) +/// .with_color(ButtonColor::Background(Color::Primary)), /// ); /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] diff --git a/src/base/component/form/fieldset.rs b/extensions/pagetop-bootsier/src/theme/form/fieldset.rs similarity index 97% rename from src/base/component/form/fieldset.rs rename to extensions/pagetop-bootsier/src/theme/form/fieldset.rs index 1e420480..5a880376 100644 --- a/src/base/component/form/fieldset.rs +++ b/extensions/pagetop-bootsier/src/theme/form/fieldset.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use pagetop::prelude::*; /// Componente para crear un **grupo de controles relacionados** en un formulario. /// @@ -14,8 +14,8 @@ use crate::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::theme::*; /// 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/src/base/component/form/hidden.rs b/extensions/pagetop-bootsier/src/theme/form/hidden.rs similarity index 95% rename from src/base/component/form/hidden.rs rename to extensions/pagetop-bootsier/src/theme/form/hidden.rs index 5ad9e3ed..cc413252 100644 --- a/src/base/component/form/hidden.rs +++ b/extensions/pagetop-bootsier/src/theme/form/hidden.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use pagetop::prelude::*; /// Componente para crear un **campo oculto** del formulario. /// @@ -11,8 +11,8 @@ use crate::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::theme::*; /// let token = form::Hidden::new() /// .with_name("csrf_token") /// .with_value("a1b2c3d4e5"); diff --git a/extensions/pagetop-bootsier/src/theme/form/input.rs b/extensions/pagetop-bootsier/src/theme/form/input.rs index f20e8b28..d920a68c 100644 --- a/extensions/pagetop-bootsier/src/theme/form/input.rs +++ b/extensions/pagetop-bootsier/src/theme/form/input.rs @@ -2,39 +2,456 @@ use pagetop::prelude::*; -pub use pagetop::base::component::form::input::{Field, Kind, Mode}; +use crate::LOCALES_BOOTSIER; +use crate::theme::form; -/// Extensión de Bootsier para [`form::input::Field`]. +use std::fmt; + +// **< Kind >*************************************************************************************** + +/// Tipo de campo para un [`form::input::Field`]. /// -/// 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. +/// 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 /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// use pagetop_bootsier::theme::*; -/// -/// 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); +/// # 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); /// ``` -pub trait InputBootsier { +/// +/// 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 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 + } + /// 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. 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; -} + /// contenido. + #[builder_fn] + pub fn with_floating_label(mut self, floating_label: bool) -> Self { + self.floating_label = floating_label; + 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")) - } + /// 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 } } diff --git a/src/base/component/form/props.rs b/extensions/pagetop-bootsier/src/theme/form/props.rs similarity index 97% rename from src/base/component/form/props.rs rename to extensions/pagetop-bootsier/src/theme/form/props.rs index 4e69d924..85886569 100644 --- a/src/base/component/form/props.rs +++ b/extensions/pagetop-bootsier/src/theme/form/props.rs @@ -1,21 +1,20 @@ -use crate::prelude::*; +use pagetop::prelude::*; use std::borrow::Cow; use std::fmt; // **< CheckboxKind >******************************************************************************* -/// Variante visual para un [`form::Checkbox`] en un formulario. +/// Variante visual para [`form::Checkbox`](crate::theme::form::Checkbox) en un formulario. /// /// Determina si el control se renderiza como una casilla de verificación estándar o como un -/// interruptor (*toggle switch*). La variante [`Switch`](Self::Switch) añade la clase `form-switch` -/// al contenedor y el atributo `role="switch"` al control para accesibilidad. +/// interruptor (*toggle switch*). #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub enum CheckboxKind { /// Casilla de verificación estándar. Es el tipo por defecto. #[default] Check, - /// Interruptor de encendido/apagado (*toggle switch*). + /// Interruptor de encendido/apagado. 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 @@ -52,8 +51,8 @@ pub enum CheckboxKind { /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::theme::*; /// // Correo electrónico con sugerencia semántica del navegador. /// let ac = form::Autocomplete::email(); /// @@ -88,7 +87,7 @@ impl Autocomplete { // --< Secciones >------------------------------------------------------------------------------ /// Construye `autocomplete` con un prefijo de sección y un token o tokens del - /// [`AutofillField`] indicado. + /// [`form::AutofillField`](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. @@ -245,8 +244,7 @@ impl fmt::Display for Autocomplete { /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// +/// # use pagetop_bootsier::theme::*; /// 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); @@ -449,7 +447,7 @@ impl AutofillField { // **< Method >************************************************************************************* -/// Método HTTP usado por un [`Form`](super::Form) para el envío de los datos. +/// Método HTTP usado por un formulario ([`Form`](crate::theme::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/src/base/component/form/radio.rs b/extensions/pagetop-bootsier/src/theme/form/radio.rs similarity index 95% rename from src/base/component/form/radio.rs rename to extensions/pagetop-bootsier/src/theme/form/radio.rs index f06dd387..76b2dc32 100644 --- a/src/base/component/form/radio.rs +++ b/extensions/pagetop-bootsier/src/theme/form/radio.rs @@ -1,10 +1,12 @@ //! Definiciones para crear grupos de botones de opción (*radio buttons*). -use crate::prelude::*; +use pagetop::prelude::*; + +use crate::LOCALES_BOOTSIER; // **< Item >*************************************************************************************** -/// Botón de opción individual de un [`Field`]. +/// Botón de opción individual de un [`form::radio::Field`](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 @@ -13,8 +15,8 @@ use crate::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::theme::*; /// let item = form::radio::Item::new("monthly", L10n::n("Monthly")).with_checked(true); /// ``` #[derive(AutoDefault, Clone, Debug, Getters)] @@ -62,8 +64,8 @@ impl Item { /// Componente para crear un **grupo de botones de opción**. /// -/// Renderiza un grupo de botones de opción [`form::radio::Item`] que comparten el mismo atributo -/// `name`, por lo que sólo puede seleccionarse uno a la vez. Las opciones se añaden con +/// 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 /// [`with_item()`](Field::with_item). /// /// Si se activa el modo en línea [`with_inline()`](Field::with_inline), los botones se disponen @@ -73,8 +75,8 @@ impl Item { /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::theme::*; /// let plan = form::radio::Field::new() /// .with_name("plan") /// .with_label(L10n::n("Subscription plan")) @@ -149,7 +151,7 @@ impl Component for Field { @if *self.required() { span class="form-required" - title=(L10n::l("field_required").using(cx)) + title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx)) { "*" } diff --git a/src/base/component/form/range.rs b/extensions/pagetop-bootsier/src/theme/form/range.rs similarity index 98% rename from src/base/component/form/range.rs rename to extensions/pagetop-bootsier/src/theme/form/range.rs index 386768d7..37dfa68f 100644 --- a/src/base/component/form/range.rs +++ b/extensions/pagetop-bootsier/src/theme/form/range.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use pagetop::prelude::*; /// Componente para crear un **control deslizante** de rango. /// @@ -9,8 +9,8 @@ use crate::prelude::*; /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; -/// +/// # use pagetop::prelude::*; +/// # use pagetop_bootsier::theme::*; /// let volume = form::Range::new() /// .with_name("volume") /// .with_label(L10n::n("Volume")) diff --git a/extensions/pagetop-bootsier/src/theme/form/select.rs b/extensions/pagetop-bootsier/src/theme/form/select.rs index 051a538c..47b411aa 100644 --- a/extensions/pagetop-bootsier/src/theme/form/select.rs +++ b/extensions/pagetop-bootsier/src/theme/form/select.rs @@ -2,44 +2,462 @@ use pagetop::prelude::*; -pub use pagetop::base::component::form::select::{Entry, Field, Group, Item}; +use crate::LOCALES_BOOTSIER; +use crate::theme::form; -/// Extensión de Bootsier para [`form::select::Field`]. +// **< Item >*************************************************************************************** + +/// Elemento individual de [`form::select::Field`] o de [`form::select::Group`]. /// -/// 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. +/// 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::*; -/// use pagetop_bootsier::theme::*; +/// # 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`]. /// -/// let language = form::select::Field::new() +/// 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() /// .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_item(form::select::Item::new("es", L10n::n("Spanish"))) -/// .with_item(form::select::Item::new("en", L10n::n("English"))); +/// .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); /// ``` -pub trait SelectBootsier { +/// +/// 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 + } + /// 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, 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; -} + /// 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 + } -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")) - } + /// 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 } } diff --git a/extensions/pagetop-bootsier/src/theme/form/textarea.rs b/extensions/pagetop-bootsier/src/theme/form/textarea.rs index 455633fd..f47bc139 100644 --- a/extensions/pagetop-bootsier/src/theme/form/textarea.rs +++ b/extensions/pagetop-bootsier/src/theme/form/textarea.rs @@ -1,43 +1,294 @@ -//! Definiciones para crear áreas de texto en formularios. - use pagetop::prelude::*; -pub use pagetop::base::component::form::Textarea; +use crate::LOCALES_BOOTSIER; +use crate::theme::form; -/// Extensión de Bootsier para [`form::Textarea`]. +/// Componente para crear un **área de texto** de formulario. /// -/// 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. +/// 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::*; -/// use pagetop_bootsier::theme::*; -/// -/// let comentario = form::Textarea::new() -/// .with_name("comment") -/// .with_label(L10n::n("Comment")) +/// # 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)) /// .with_placeholder(L10n::n("Write here...")) -/// .with_floating_label(true); +/// .with_required(true); /// ``` -pub trait TextareaBootsier { +/// +/// 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 + } + /// 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. Requiere que el campo tenga un atributo `placeholder` definido; - /// si no se especifica, se fuerza `placeholder=""` antes del renderizado. + /// cuando tiene contenido. /// - /// 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; -} + /// 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 + } -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")) - } + /// 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 } } diff --git a/src/base/component.rs b/src/base/component.rs index 5c9fee3d..7ea596d3 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -1,18 +1,11 @@ //! Componentes nativos proporcionados por PageTop. -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 block; +pub use block::Block; + mod intro; pub use intro::{Intro, IntroOpening}; diff --git a/src/base/component/button.rs b/src/base/component/button.rs deleted file mode 100644 index 8d2f0fa7..00000000 --- a/src/base/component/button.rs +++ /dev/null @@ -1,207 +0,0 @@ -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 deleted file mode 100644 index a656bb6b..00000000 --- a/src/base/component/form.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! 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/src/base/component/form/input.rs b/src/base/component/form/input.rs deleted file mode 100644 index 7b57c648..00000000 --- a/src/base/component/form/input.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! 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/src/base/component/form/select.rs b/src/base/component/form/select.rs deleted file mode 100644 index fc94d06c..00000000 --- a/src/base/component/form/select.rs +++ /dev/null @@ -1,427 +0,0 @@ -//! 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 deleted file mode 100644 index 5b4cc1ff..00000000 --- a/src/base/component/form/textarea.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! 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/component/context.rs b/src/core/component/context.rs index d3b13cd3..d27de662 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -3,7 +3,7 @@ use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::{ChildrenInRegions, DefaultRegion, RegionRef, TemplateRef, ThemeRef}; use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; -use crate::html::{Markup, Props, PropsOp, RoutePath, html}; +use crate::html::{Markup, RoutePath, html}; use crate::locale::L10n; use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; use crate::web::HttpRequest; @@ -137,10 +137,6 @@ pub trait Contextual: LangId { #[builder_fn] fn with_assets(self, op: AssetsOp) -> Self; - /// Modifica identificador, clases CSS o atributos HTML del elemento ``. - #[builder_fn] - fn with_body_props(self, op: PropsOp) -> Self; - /// Añade un componente o aplica una operación [`ChildOp`] en la región por defecto del /// documento. #[builder_fn] @@ -210,9 +206,6 @@ pub trait Contextual: LangId { /// Devuelve los scripts JavaScript de los recursos del contexto. fn javascripts(&self) -> &Assets; - /// Devuelve identificador, clases CSS y atributos HTML del elemento ``. - fn body_props(&self) -> &Props; - // **< Contextual HELPERS >********************************************************************* /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. @@ -291,7 +284,6 @@ pub struct Context { favicon : Option, // Favicon, si se ha definido. stylesheets: Assets, // Hojas de estilo CSS. javascripts: Assets, // Scripts JavaScript. - body_props : Props, // Identificador, clases CSS y atributos del . regions : ChildrenInRegions, // Regiones de componentes para renderizar. params : HashMap<&'static str, (Box, &'static str)>, // Parámetros en ejecución. id_counters: RefCell>, // RefCell permite mutar desde build_id(&self). @@ -320,7 +312,6 @@ impl Context { favicon : None, stylesheets: Assets::::new(), javascripts: Assets::::new(), - body_props : Props::default(), regions : ChildrenInRegions::default(), params : HashMap::default(), id_counters: RefCell::new(HashMap::new()), @@ -530,12 +521,6 @@ impl Contextual for Context { self } - #[builder_fn] - fn with_body_props(mut self, op: PropsOp) -> Self { - self.body_props.alter_prop(op); - self - } - #[builder_fn] fn with_child(mut self, op: impl Into) -> Self { self.regions @@ -585,10 +570,6 @@ impl Contextual for Context { &self.javascripts } - fn body_props(&self) -> &Props { - &self.body_props - } - // **< Contextual HELPERS >********************************************************************* fn remove_param(&mut self, key: &'static str) -> bool { diff --git a/src/core/theme.rs b/src/core/theme.rs index 9f358c41..43649db1 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -238,11 +238,8 @@ 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) = __c.downcast_ref::<$type>() { + if let Some($var) = ($component).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 b244f9e2..76baa120 100644 --- a/src/locale/en-US/base.ftl +++ b/src/locale/en-US/base.ftl @@ -1,6 +1,3 @@ -# 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 50a459ec..09867d13 100644 --- a/src/locale/es-ES/base.ftl +++ b/src/locale/es-ES/base.ftl @@ -1,6 +1,3 @@ -# Form components. -field_required = Este campo es obligatorio - # Intro component. intro_default_title = ¡Hola, mundo! intro_default_slogan = Descubre⚡{ $app } diff --git a/src/response/page.rs b/src/response/page.rs index 129ccf03..57684365 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -89,6 +89,7 @@ pub struct Page { description : Attr, metadata : Vec<(&'static str, &'static str)>, properties : Vec<(&'static str, &'static str)>, + body_props : Props, context : Context, } @@ -104,6 +105,7 @@ impl Page { description : Attr::::default(), metadata : Vec::default(), properties : Vec::default(), + body_props : Props::default(), context : Context::new(Some(request)), } } @@ -138,6 +140,13 @@ impl Page { self } + /// Modifica identificador, clases CSS o atributos HTML del elemento ``. + #[builder_fn] + pub fn with_body_props(mut self, op: PropsOp) -> Self { + self.body_props.alter_prop(op); + self + } + // **< Page GETTERS >*************************************************************************** /// Devuelve el título traducido para el idioma de la página, si existe. @@ -160,6 +169,11 @@ impl Page { &self.properties } + /// Devuelve identificador, clases CSS y atributos HTML del elemento ``. + pub fn body_props(&self) -> &Props { + &self.body_props + } + /// Devuelve una referencia mutable al [`Context`] de la página. /// /// El [`Context`] actúa como intermediario para muchos métodos de `Page` (idioma, tema, @@ -291,12 +305,6 @@ impl Contextual for Page { self } - #[builder_fn] - fn with_body_props(mut self, op: PropsOp) -> Self { - self.context.alter_body_props(op); - self - } - #[builder_fn] fn with_child(mut self, op: impl Into) -> Self { self.context @@ -340,10 +348,6 @@ impl Contextual for Page { self.context.javascripts() } - fn body_props(&self) -> &Props { - self.context.body_props() - } - // **< Contextual HELPERS >********************************************************************* fn remove_param(&mut self, key: &'static str) -> bool {