♻️ (form): Mueve componentes de formulario a base

This commit is contained in:
Manuel Cillero 2026-06-22 02:12:09 +02:00
parent 26f1cda831
commit 9435678e01
38 changed files with 2211 additions and 1826 deletions

View file

@ -13,6 +13,11 @@
/* Colors */
--val-color--bg: #fafafa;
--val-color--text: #212529;
--val-color--text--muted: color-mix(in srgb, var(--val-color--text) 50%, var(--val-color--bg));
--val-color--border: #212529;
--val-color--primary: #0d6efd;
--val-color--danger: #dc3545;
--val-color--switch-off: #adb5bd;
}
*, *::before, *::after {
@ -33,6 +38,105 @@ body {
-webkit-tap-highlight-color: transparent;
}
/*
* Form components
*/
.form-required {
color: var(--val-color--danger);
margin-left: 0.25rem;
}
.form-label {
display: block;
margin-top: 1em;
}
.form-text {
font-size: 0.9375rem;
color: var(--val-color--text--muted);
}
.form-field > select.form-select,
.form-field > input.form-control,
.form-field > input.form-range,
.form-field > textarea.form-control {
display: block;
padding: 0.25rem 0.5rem;
width: 100%;
}
fieldset {
margin: 2rem 0 1rem;
background: var(--val-color--bg);
}
fieldset > legend {
background-color: var(--val-color--bg);
border: 2px groove threedface;
padding: 0 0.5em;
}
.form-check {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 1.5rem;
margin-bottom: 0.25rem;
}
.form-check-inline {
display: inline-flex;
margin-right: 1rem;
}
.form-check-reverse {
flex-direction: row-reverse;
justify-content: flex-start;
text-align: right;
}
.form-check-input {
flex-shrink: 0;
cursor: pointer;
}
.form-check-label {
cursor: pointer;
}
.form-switch .form-check-input {
appearance: none;
-webkit-appearance: none;
width: 2em;
height: 1em;
background-color: var(--val-color--switch-off);
border-radius: 1em;
position: relative;
cursor: pointer;
transition: background-color .15s ease-in-out;
}
.form-switch .form-check-input::after {
content: "";
position: absolute;
width: 1em;
height: 1em;
background-color: white;
border-radius: 50%;
top: 0;
left: 0;
transition: transform .15s ease-in-out;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .25);
}
.form-switch .form-check-input:checked {
background-color: var(--val-color--primary);
}
.form-switch .form-check-input:checked::after {
transform: translateX(1em);
}
input:disabled,
input:disabled + label {
cursor: default !important;
}
input:disabled + label {
color: var(--val-color--text--muted);
}
/*
* Region Footer
*/

View file

@ -1,5 +1,6 @@
use pagetop::prelude::*;
use pagetop_bootsier::theme::form;
use pagetop_bootsier::theme::*;
include_locales!(LOC from "examples/locale");
@ -143,17 +144,20 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_value("form-selections"),
)
// Botones de acción.
.with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop(
PropsOp::add_classes(classes::ButtonColor::solid(
Color::Primary,
)),
))
.with_child(Button::reset(L10n::t("btn_reset", &LOC)).with_prop(
PropsOp::add_classes(classes::ButtonColor::outline(
Color::Secondary,
)),
))
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
Button::plain(L10n::t("btn_cancel", &LOC)).with_prop(
PropsOp::add_classes(classes::ButtonColor::link()),
),
),
),
)
@ -175,6 +179,7 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_name("name")
.with_label(L10n::t("label_name", &LOC))
.with_placeholder(L10n::t("placeholder_name", &LOC))
.with_help_text(L10n::t("help_name", &LOC))
.with_required(true),
)
.with_child(
@ -185,6 +190,7 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
"placeholder_email",
&LOC,
))
.with_help_text(L10n::t("help_email", &LOC))
.with_autocomplete(
Some(form::Autocomplete::email()),
)
@ -262,194 +268,175 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_value("form-text"),
)
// Botones de acción.
.with_child(Button::submit(L10n::t("btn_submit", &LOC)).with_prop(
PropsOp::add_classes(classes::ButtonColor::solid(
Color::Primary,
)),
))
.with_child(Button::reset(L10n::t("btn_reset", &LOC)).with_prop(
PropsOp::add_classes(classes::ButtonColor::outline(
Color::Secondary,
)),
))
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
Button::plain(L10n::t("btn_cancel", &LOC)).with_prop(
PropsOp::add_classes(classes::ButtonColor::link()),
),
),
),
)
// Bloque 3: listas de selección y etiquetas flotantes.
.with_child(
Block::new()
.with_title(L10n::t("block_lists", &LOC))
.with_child(
Form::new()
.with_id("form-lists")
.with_action("/")
.with_method(form::Method::Post)
// Listas de selección (form::select::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_select", &LOC))
.with_child(
form::select::Field::new()
.with_name("language")
.with_label(L10n::t("label_language", &LOC))
.with_item(
form::select::Item::new(
"",
L10n::t("select_choose", &LOC),
)
.with_selected(true),
)
.with_group(
form::select::Group::new(L10n::t(
"select_group_europe",
&LOC,
))
.with_item(form::select::Item::new(
"es",
L10n::t("select_spanish", &LOC),
))
.with_item(form::select::Item::new(
"fr",
L10n::t("select_french", &LOC),
)),
)
.with_group(
form::select::Group::new(L10n::t(
"select_group_americas",
&LOC,
))
.with_item(form::select::Item::new(
"en",
L10n::t("select_english", &LOC),
))
.with_item(form::select::Item::new(
"pt",
L10n::t("select_portuguese", &LOC),
)),
)
.with_item(
form::select::Item::new(
"xx",
L10n::t("select_disabled", &LOC),
)
.with_disabled(true),
)
.with_required(true),
)
.with_child(
form::select::Field::new()
.with_name("technologies")
.with_label(L10n::t("label_technologies", &LOC))
.with_item(
form::select::Item::new(
"rust",
L10n::n("Rust"),
)
.with_selected(true),
)
.with_item(
form::select::Item::new(
"python",
L10n::n("Python"),
)
.with_selected(true),
)
.with_item(form::select::Item::new(
"javascript",
L10n::n("JavaScript"),
))
.with_item(form::select::Item::new(
"go",
L10n::n("Go"),
))
.with_item(form::select::Item::new(
"typescript",
L10n::n("TypeScript"),
))
.with_multiple(true)
.with_rows(Some(4))
.with_help_text(L10n::t("help_technologies", &LOC)),
),
)
// Etiquetas flotantes.
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_floating", &LOC))
.with_child(
form::input::Field::text()
.with_name("fl_name")
.with_label(L10n::t("label_name", &LOC))
.with_placeholder(L10n::t("placeholder_name", &LOC))
.with_floating_label(true)
.with_required(true),
)
.with_child(
form::Textarea::new()
.with_name("fl_comment")
.with_label(L10n::t("label_comment", &LOC))
.with_placeholder(L10n::t(
"placeholder_comment",
&LOC,
))
.with_floating_label(true),
)
.with_child(
form::select::Field::new()
.with_name("fl_country")
.with_label(L10n::t("label_country", &LOC))
.with_item(
form::select::Item::new(
"",
L10n::t("select_choose", &LOC),
)
.with_selected(true),
)
.with_item(form::select::Item::new(
"de",
L10n::t("select_germany", &LOC),
))
.with_item(form::select::Item::new(
"es",
L10n::t("select_spain", &LOC),
))
.with_item(form::select::Item::new(
"fr",
L10n::t("select_france", &LOC),
))
.with_item(form::select::Item::new(
"pt",
L10n::t("select_portugal", &LOC),
))
.with_floating_label(true)
.with_required(true),
),
)
// Campo oculto (form::Hidden).
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-lists"),
)
// Botones de acción.
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
),
),
.with_title(
if global::SETTINGS.app.theme.eq_ignore_ascii_case("bootsier") {
L10n::t("block_lists_floating", &LOC)
} else {
L10n::t("block_lists", &LOC)
},
)
.with_child(form_lists()),
),
)
.render()
}
fn form_lists() -> Form {
let mut form = Form::new()
.with_id("form-lists")
.with_action("/")
.with_method(form::Method::Post)
// Listas de selección (form::select::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_select", &LOC))
.with_child(
form::select::Field::new()
.with_name("language")
.with_label(L10n::t("label_language", &LOC))
.with_item(
form::select::Item::new("", L10n::t("select_choose", &LOC))
.with_selected(true),
)
.with_group(
form::select::Group::new(L10n::t("select_group_europe", &LOC))
.with_item(form::select::Item::new(
"es",
L10n::t("select_spanish", &LOC),
))
.with_item(form::select::Item::new(
"fr",
L10n::t("select_french", &LOC),
)),
)
.with_group(
form::select::Group::new(L10n::t("select_group_americas", &LOC))
.with_item(form::select::Item::new(
"en",
L10n::t("select_english", &LOC),
))
.with_item(form::select::Item::new(
"pt",
L10n::t("select_portuguese", &LOC),
)),
)
.with_item(
form::select::Item::new("xx", L10n::t("select_disabled", &LOC))
.with_disabled(true),
)
.with_required(true),
)
.with_child(
form::select::Field::new()
.with_name("technologies")
.with_label(L10n::t("label_technologies", &LOC))
.with_item(
form::select::Item::new("rust", L10n::n("Rust")).with_selected(true),
)
.with_item(
form::select::Item::new("python", L10n::n("Python"))
.with_selected(true),
)
.with_item(form::select::Item::new("javascript", L10n::n("JavaScript")))
.with_item(form::select::Item::new("go", L10n::n("Go")))
.with_item(form::select::Item::new("typescript", L10n::n("TypeScript")))
.with_multiple(true)
.with_rows(Some(4))
.with_help_text(L10n::t("help_technologies", &LOC)),
),
);
// Etiquetas flotantes: solo disponibles con el tema Bootsier.
if global::SETTINGS.app.theme.eq_ignore_ascii_case("bootsier") {
form = form.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_floating", &LOC))
.with_child(
form::input::Field::text()
.with_name("fl_name")
.with_label(L10n::t("label_name", &LOC))
.with_placeholder(L10n::t("placeholder_name", &LOC))
.with_floating_label(true)
.with_required(true),
)
.with_child(
form::Textarea::new()
.with_name("fl_comment")
.with_label(L10n::t("label_comment", &LOC))
.with_placeholder(L10n::t("placeholder_comment", &LOC))
.with_floating_label(true),
)
.with_child(
form::select::Field::new()
.with_name("fl_country")
.with_label(L10n::t("label_country", &LOC))
.with_item(
form::select::Item::new("", L10n::t("select_choose", &LOC))
.with_selected(true),
)
.with_item(form::select::Item::new(
"de",
L10n::t("select_germany", &LOC),
))
.with_item(form::select::Item::new("es", L10n::t("select_spain", &LOC)))
.with_item(form::select::Item::new(
"fr",
L10n::t("select_france", &LOC),
))
.with_item(form::select::Item::new(
"pt",
L10n::t("select_portugal", &LOC),
))
.with_floating_label(true)
.with_required(true),
),
);
}
form
// Campo oculto (form::Hidden).
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-lists"),
)
// Botones de acción.
.with_child(
Button::submit(L10n::t("btn_submit", &LOC)).with_prop(PropsOp::add_classes(
classes::ButtonColor::solid(Color::Primary),
)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC)).with_prop(PropsOp::add_classes(
classes::ButtonColor::outline(Color::Secondary),
)),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_prop(PropsOp::add_classes(classes::ButtonColor::link())),
)
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&FormControls).run()?.await

View file

@ -2,7 +2,8 @@ title = Form controls
slogan = Bootsier form components showcase
block_selections = Checkboxes, switches and radio buttons
block_text = Text fields, multiline and range
block_lists = Select lists and floating labels
block_lists = Selection lists
block_lists_floating = Select lists and floating labels
fieldset_text = Text fields
label_name = Full name
@ -16,6 +17,8 @@ label_url = Website
placeholder_url = https://example.com
label_search = Search
placeholder_search = Search term...
help_name = Enter your full name as it appears on your ID.
help_email = We will only use your email to send important notifications.
fieldset_textarea = Multiline text
label_comment = Comment

View file

@ -2,7 +2,8 @@ title = Controles de formulario
slogan = Componentes Bootsier para formularios
block_selections = Casillas, interruptores y botones de opción
block_text = Campos de texto, multilínea y rango
block_lists = Listas de selección y etiquetas flotantes
block_lists = Listas de selección
block_lists_floating = Listas de selección y etiquetas flotantes
fieldset_text = Campos de texto
label_name = Nombre completo
@ -16,6 +17,8 @@ label_url = Sitio web
placeholder_url = https://ejemplo.com
label_search = Búsqueda
placeholder_search = Término de búsqueda...
help_name = Introduce tu nombre completo tal como aparece en tu documento de identidad.
help_email = Solo usaremos tu correo para enviarte notificaciones importantes.
fieldset_textarea = Texto multilínea
label_comment = Comentario

View file

@ -53,11 +53,13 @@ fieldset > legend {
top: 0;
left: 1rem;
transform: translateY(-50%);
color: var(--bs-secondary-color);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 0.125rem 0.75rem;
font-size: $font-size-sm;
font-size: ($font-size-sm + $font-size-base) / 2;
font-weight: var(--bs-font-weight-normal);
line-height: 1.25;
width: fit-content;
max-width: 75%;

View file

@ -0,0 +1,7 @@
use pagetop::prelude::*;
pub fn setup(button: &mut Button) {
button
.alter_prop(PropsOp::remove_classes("button"))
.alter_prop(PropsOp::prepend_classes("btn"));
}

View file

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

View file

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

View file

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

View file

@ -89,32 +89,11 @@ pub mod config;
pub mod theme;
/// Plantillas que Bootsier añade.
#[derive(AutoDefault)]
pub enum BootsierTemplate {
/// Plantilla predeterminada de Bootsier.
#[default]
Standard,
}
impl Template for BootsierTemplate {
fn render(&'static self, cx: &mut Context) -> Markup {
match self {
Self::Standard => theme::Container::new()
.with_classes(ClassesOp::Add, "container-wrapper")
.with_width(theme::container::Width::FluidMax(
config::SETTINGS.bootsier.max_width,
))
.with_child(Html::with(|cx| {
html! {
(DefaultRegion::Header.render(cx))
(DefaultRegion::Content.render(cx))
(DefaultRegion::Footer.render(cx))
}
})),
}
.render(cx)
}
mod handlers {
pub mod button;
pub mod input;
pub mod select;
pub mod textarea;
}
/// Implementa el tema.
@ -155,6 +134,23 @@ impl Theme for Bootsier {
&BootsierTemplate::Standard
}
fn handle_component(
&self,
component: &mut dyn Component,
cx: &mut Context,
) -> Option<Result<Markup, ComponentError>> {
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")

View file

@ -11,7 +11,7 @@ pub mod classes;
// Button.
mod button;
pub use button::Button;
pub use button::{Button, ButtonAction};
// Container.
pub mod container;
@ -27,6 +27,12 @@ pub use dropdown::Dropdown;
pub mod form;
#[doc(inline)]
pub use form::Form;
#[doc(hidden)]
pub use form::input::InputBootsier;
#[doc(hidden)]
pub use form::select::SelectBootsier;
#[doc(hidden)]
pub use form::textarea::TextareaBootsier;
// Image.
pub mod image;

View file

@ -15,6 +15,3 @@ pub use border::BorderColor;
mod rounded;
pub use rounded::RoundedRadius;
mod button;
pub use button::{ButtonAction, ButtonColor, ButtonSize};

View file

@ -1,145 +0,0 @@
use pagetop::prelude::*;
use crate::theme::attrs::Color;
// **< ButtonAction >*********************************************************************************
/// Comportamiento de un [`Button`](crate::theme::Button) al activarse.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonAction {
/// Envía un formulario al servidor. Es el **tipo por defecto**.
#[default]
Submit,
/// Restablece todos los campos de un formulario a sus valores iniciales.
Reset,
/// Botón de propósito general, sin efecto predeterminado. Su comportamiento podría definirse
/// mediante JavaScript.
Plain,
}
impl std::fmt::Display for ButtonAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ButtonAction::Submit => "submit",
ButtonAction::Reset => "reset",
ButtonAction::Plain => "button",
})
}
}
// **< ButtonColor >********************************************************************************
/// Esquema de color para [`Button`](crate::theme::Button).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonColor {
/// No define ninguna clase.
#[default]
Default,
/// Genera la clase `btn-{color}` (botón sólido).
Background(Color),
/// Genera la clase `btn-outline-{color}` (fondo transparente con contorno coloreado).
Outline(Color),
/// Aplica estilo de los enlaces (`btn-link`), sin caja ni fondo, heredando el color de texto.
Link,
}
impl ButtonColor {
const BTN_PREFIX: &str = "btn-";
const BTN_OUTLINE_PREFIX: &str = "btn-outline-";
const BTN_LINK: &str = "btn-link";
/// Añade la clase `btn-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Self::Default = self {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Background(c) => {
classes.push_str(Self::BTN_PREFIX);
classes.push_str(c.as_str());
}
Self::Outline(c) => {
classes.push_str(Self::BTN_OUTLINE_PREFIX);
classes.push_str(c.as_str());
}
Self::Link => classes.push_str(Self::BTN_LINK),
Self::Default => unreachable!(),
}
}
/// Devuelve la clase `btn-*` correspondiente al color del botón.
///
/// # Ejemplos
///
/// ```rust,no_run
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(
/// ButtonColor::Background(Color::Primary).to_class(),
/// "btn-primary"
/// );
/// assert_eq!(
/// ButtonColor::Outline(Color::Danger).to_class(),
/// "btn-outline-danger"
/// );
/// assert_eq!(ButtonColor::Link.to_class(), "btn-link");
/// assert_eq!(ButtonColor::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_class(&mut class);
class
}
}
// **< ButtonSize >*********************************************************************************
/// Tamaño visual de un botón.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonSize {
/// Tamaño por defecto del tema (no añade clase).
#[default]
Default,
/// Botón compacto.
Small,
/// Botón grande.
Large,
}
impl ButtonSize {
const BTN_SM: &str = "btn-sm";
const BTN_LG: &str = "btn-lg";
/// Añade la clase de tamaño `btn-sm` o `btn-lg` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = match self {
Self::Default => return,
Self::Small => Self::BTN_SM,
Self::Large => Self::BTN_LG,
};
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Devuelve la clase `btn-sm` o `btn-lg` correspondiente al tamaño del botón.
///
/// # Ejemplos
///
/// ```rust,no_run
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(ButtonSize::Small.to_class(), "btn-sm");
/// assert_eq!(ButtonSize::Large.to_class(), "btn-lg");
/// assert_eq!(ButtonSize::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_class(&mut class);
class
}
}

View file

@ -1,211 +1 @@
use pagetop::prelude::*;
use crate::theme::{ButtonAction, ButtonColor, ButtonSize};
/// Componente para crear un **botón**.
///
/// Renderiza un botón con soporte para las variantes disponibles en [`ButtonAction`] (`submit`,
/// `reset` y botón genérico) y con la variedad de estilos del tema a través de [`ButtonColor`] y
/// [`ButtonSize`].
///
/// El comportamiento del botón se establece al crearlo:
///
/// - [`Button::submit()`]: botón de envío (por defecto).
/// - [`Button::reset()`]: botón de restablecimiento de valores.
/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado.
///
/// El botón puede usarse dentro o fuera de un formulario.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let save = Button::submit(L10n::n("Save"))
/// .with_color(ButtonColor::Background(Color::Primary));
///
/// let cancel = Button::plain(L10n::n("Cancel"))
/// .with_color(ButtonColor::Outline(Color::Secondary));
///
/// let clear = Button::reset(L10n::n("Clear"))
/// .with_size(ButtonSize::Small);
/// ```
///
/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del
/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar
/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option<String>`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// action: Option<String>, // 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<L10n>,
/// 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<String> {
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<Markup, ComponentError> {
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<CowStr>) -> 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<str>) -> 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<str>) -> 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<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece si el botón recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el botón está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
pub use pagetop::base::component::{Button, ButtonAction};

View file

@ -3,6 +3,9 @@
mod color;
pub use color::{Background, Text};
mod button;
pub use button::{ButtonColor, ButtonSize};
mod border;
pub use border::Border;

View file

@ -0,0 +1,170 @@
use pagetop::prelude::*;
use crate::theme::attrs::Color;
// **< ButtonColor >********************************************************************************
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
enum ButtonColorStyle {
#[default]
None,
Solid,
Outline,
Link,
}
/// Clases para establecer el **color y estilo** de los botones.
///
/// # Ejemplos
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// // Botón sólido.
/// let save = Button::submit(L10n::n("Save"))
/// .with_prop(PropsOp::add_classes(classes::ButtonColor::solid(Color::Primary)));
///
/// // Botón con contorno.
/// let cancel = Button::plain(L10n::n("Cancel"))
/// .with_prop(PropsOp::add_classes(classes::ButtonColor::outline(Color::Secondary)));
///
/// // Botón tipo enlace.
/// let back = Button::plain(L10n::n("Back"))
/// .with_prop(PropsOp::add_classes(classes::ButtonColor::link()));
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct ButtonColor {
style: ButtonColorStyle,
color: Color,
}
impl ButtonColor {
/// Sin clase de color (estilo por defecto del tema).
pub fn new() -> Self {
Self::default()
}
/// Botón sólido: genera la clase `btn-{color}`.
pub fn solid(color: Color) -> Self {
Self {
style: ButtonColorStyle::Solid,
color,
..Default::default()
}
}
/// Botón con contorno: genera la clase `btn-outline-{color}`.
pub fn outline(color: Color) -> Self {
Self {
style: ButtonColorStyle::Outline,
color,
..Default::default()
}
}
/// Botón tipo enlace: genera la clase `btn-link`.
pub fn link() -> Self {
Self {
style: ButtonColorStyle::Link,
..Default::default()
}
}
// **< ButtonColor BUILDER >********************************************************************
/// Cambia el color aplicado al botón (`btn-*` o `btn-outline-*`).
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
// **< ButtonColor HELPERS >********************************************************************
/// Devuelve la clase `btn-*` correspondiente al color del botón.
///
/// Si no se ha definido ningún estilo, devuelve `""`.
pub fn to_class(self) -> String {
match self.style {
ButtonColorStyle::None => String::new(),
ButtonColorStyle::Solid => format!("btn-{}", self.color.as_str()),
ButtonColorStyle::Outline => format!("btn-outline-{}", self.color.as_str()),
ButtonColorStyle::Link => "btn-link".to_owned(),
}
}
}
impl Into<CowStr> 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<CowStr> for ButtonSize {
fn into(self) -> CowStr {
self.to_class().into()
}
}

View file

@ -27,7 +27,7 @@ use crate::theme::*;
///
/// let dd = Dropdown::new()
/// .with_title(L10n::n("Menu"))
/// .with_button_color(ButtonColor::Background(Color::Secondary))
/// .with_button_color(classes::ButtonColor::solid(Color::Secondary))
/// .with_auto_close(dropdown::AutoClose::ClickableInside)
/// .with_direction(dropdown::Direction::Dropend)
/// .with_item(dropdown::Item::link(L10n::n("Home"), |_| "/".into()))
@ -43,9 +43,9 @@ pub struct Dropdown {
/// Devuelve el título del menú desplegable.
title: L10n,
/// Devuelve el tamaño configurado del botón.
button_size: ButtonSize,
button_size: classes::ButtonSize,
/// Devuelve el color/estilo configurado del botón.
button_color: ButtonColor,
button_color: classes::ButtonColor,
/// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*).
button_split: bool,
/// Devuelve si el botón del menú está integrado en un grupo de botones.
@ -91,9 +91,11 @@ impl Component for Dropdown {
div (self.props()) {
@if !title.is_empty() {
@let btn_base = {
let mut classes = "btn".to_string();
self.button_size().push_class(&mut classes);
self.button_color().push_class(&mut classes);
let size = self.button_size().to_class();
let color = self.button_color().to_class();
let mut classes = String::from("btn");
if !size.is_empty() { classes.push(' '); classes.push_str(&size); }
if !color.is_empty() { classes.push(' '); classes.push_str(&color); }
classes
};
@let pos = self.menu_position();
@ -199,14 +201,14 @@ impl Dropdown {
/// Ajusta el tamaño del botón.
#[builder_fn]
pub fn with_button_size(mut self, size: ButtonSize) -> Self {
pub fn with_button_size(mut self, size: classes::ButtonSize) -> Self {
self.button_size = size;
self
}
/// Define el color/estilo del botón.
#[builder_fn]
pub fn with_button_color(mut self, color: ButtonColor) -> Self {
pub fn with_button_color(mut self, color: classes::ButtonColor) -> Self {
self.button_color = color;
self
}

View file

@ -1,30 +1,24 @@
//! Definiciones para crear formularios ([`Form`]).
mod props;
pub use props::{Autocomplete, AutofillField, CheckboxKind, Method};
pub use pagetop::base::component::form::{Autocomplete, AutofillField, CheckboxKind, Method};
mod component;
pub use component::Form;
pub use pagetop::base::component::form::Form;
mod fieldset;
pub use fieldset::Fieldset;
pub use pagetop::base::component::form::Fieldset;
mod checkbox;
pub use checkbox::Checkbox;
pub use pagetop::base::component::form::Checkbox;
pub mod check;
pub use pagetop::base::component::form::check;
pub mod radio;
pub use pagetop::base::component::form::radio;
pub mod select;
pub mod input;
mod textarea;
pub mod textarea;
pub use textarea::Textarea;
mod range;
pub use range::Range;
pub use pagetop::base::component::form::Range;
mod hidden;
pub use hidden::Hidden;
pub use pagetop::base::component::form::Hidden;

View file

@ -2,456 +2,39 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::form;
pub use pagetop::base::component::form::input::{Field, Kind, Mode};
use std::fmt;
// **< Kind >***************************************************************************************
/// Tipo de campo para un [`form::input::Field`].
/// Extensión de Bootsier para [`form::input::Field`].
///
/// Determina el tipo de entrada que acepta, así como el comportamiento del navegador al interactuar
/// con el campo. Implícitamente se aplica al crear el control: [`text()`](Field::text),
/// [`password()`](Field::password), [`search()`](Field::search), [`email()`](Field::email),
/// [`telephone()`](Field::telephone) o [`url()`](Field::url).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// Entrada de texto genérico (`type="text"`). Es el tipo por defecto.
#[default]
Text,
/// Entrada de una contraseña (`type="password"`). El contenido aparece enmascarado.
Password,
/// Campo de búsqueda (`type="search"`). Es un tipo semántico para los cuadros de búsqueda.
Search,
/// Entrada de un correo electrónico (`type="email"`). Permite validar el formato del correo.
Email,
/// Entrada de un teléfono (`type="tel"`). Activa el teclado de llamadas en móviles.
Telephone,
/// Entrada de una URL (`type="url"`). Comprueba que la entrada sea una URL bien formada.
Url,
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Kind::Text => "text",
Kind::Password => "password",
Kind::Search => "search",
Kind::Email => "email",
Kind::Telephone => "tel",
Kind::Url => "url",
})
}
}
// **< Mode >***************************************************************************************
/// Sugerencia para el teclado virtual de un [`form::input::Field`].
///
/// Indica al navegador qué tipo de teclado virtual mostrar en dispositivos móviles o táctiles al
/// editar el campo. A diferencia del atributo `type` ([`form::input::Kind`]), no restringe los
/// valores aceptados ni activa la validación del navegador; es sólo una sugerencia de presentación.
///
/// Se establece con [`form::input::Field::with_inputmode()`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Mode {
/// Suprime el teclado virtual. Útil en campos con teclado personalizado basado en JavaScript.
None,
/// Teclado de texto genérico.
Text,
/// Teclado decimal, con dígitos y separador decimal.
Decimal,
/// Teclado numérico, con sólo dígitos.
Numeric,
/// Teclado de teléfono, con dígitos y símbolos `+`, `*` y `#`.
Tel,
/// Teclado optimizado para búsquedas (puede incluir tecla de búsqueda).
Search,
/// Teclado optimizado para correo electrónico (incluye `@` y `.`).
Email,
/// Teclado optimizado para URL (incluye `/`, `.` y `.com`).
Url,
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Mode::None => "none",
Mode::Text => "text",
Mode::Decimal => "decimal",
Mode::Numeric => "numeric",
Mode::Tel => "tel",
Mode::Search => "search",
Mode::Email => "email",
Mode::Url => "url",
})
}
}
// **< Field >**************************************************************************************
/// Componente para crear un **campo de texto de una línea**.
///
/// Renderiza los tipos más habituales en formularios:
///
/// - [`form::input::Field::text()`]: campo de texto genérico (`type="text"`, por defecto).
/// - [`form::input::Field::password()`]: contraseña (`type="password"`).
/// - [`form::input::Field::search()`]: búsqueda (`type="search"`).
/// - [`form::input::Field::email()`]: correo electrónico (`type="email"`).
/// - [`form::input::Field::telephone()`]: teléfono (`type="tel"`).
/// - [`form::input::Field::url()`]: URL (`type="url"`).
///
/// # Ejemplo
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
/// superpone al control mientras está vacío y permanece flotante cuando tiene contenido o está
/// enfocado.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let email = form::input::Field::email()
/// .with_name("email")
/// .with_label(L10n::n("Email address"))
/// .with_placeholder(L10n::n("user@example.com"))
/// .with_autocomplete(Some(form::Autocomplete::email()))
/// .with_required(true);
/// ```
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un campo de texto siempre envía su
/// valor, incluso si está vacío. En el servidor se deserializa como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// email: String, // Siempre presente; cadena vacía si el usuario no escribió nada.
/// }
/// let nombre = form::input::Field::text()
/// .with_name("name")
/// .with_label(L10n::n("Name"))
/// .with_placeholder(L10n::n("Enter your name"))
/// .with_floating_label(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el tipo de campo.
kind: Kind,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve el valor inicial del campo.
value: AttrValue,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve si la etiqueta se muestra flotante sobre el campo.
floating_label: bool,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve la longitud mínima permitida en caracteres.
minlength: Attr<u16>,
/// Devuelve la longitud máxima permitida en caracteres.
maxlength: Attr<u16>,
/// Devuelve el texto indicativo del campo.
placeholder: Attr<L10n>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// 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<Mode>,
}
impl Component for Field {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
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<Markup, ComponentError> {
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<CowStr>) -> 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<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece el valor inicial del campo.
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> 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<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
pub trait InputBootsier {
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al campo y asciende al enfocarlo o cuando tiene
/// contenido.
#[builder_fn]
pub fn with_floating_label(mut self, floating_label: bool) -> Self {
self.floating_label = floating_label;
self
}
/// contenido. Requiere que el campo tenga un atributo `placeholder` definido; si no se
/// especifica, se fuerza `placeholder=""` antes del renderizado.
fn with_floating_label(self, floating: bool) -> Self;
}
/// Establece o elimina el texto de ayuda del campo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> 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<u16>) -> 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<u16>) -> 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<Option<L10n>>) -> 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<form::Autocomplete>) -> 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<Mode>) -> Self {
self.inputmode.alter_opt(inputmode);
self
impl InputBootsier for Field {
fn with_floating_label(self, floating: bool) -> Self {
if floating {
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
}
}

View file

@ -2,462 +2,44 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::form;
pub use pagetop::base::component::form::select::{Entry, Field, Group, Item};
// **< Item >***************************************************************************************
/// Elemento individual de [`form::select::Field`] o de [`form::select::Group`].
/// Extensión de Bootsier para [`form::select::Field`].
///
/// Representa un elemento dentro de una lista de selección o de un grupo de elementos de la lista.
/// Cada elemento tiene un valor que se envía al servidor y una etiqueta localizable visible para el
/// usuario.
///
/// Puede marcarse como seleccionado por defecto con [`with_selected()`](Self::with_selected) o
/// deshabilitado de forma independiente al resto usando [`with_disabled()`](Self::with_disabled).
///
/// # Ejemplo
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
/// superpone al control mientras no hay ninguna opción seleccionada y permanece flotante cuando hay
/// una selección activa.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let item = form::select::Item::new("es", L10n::n("Spanish")).with_selected(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve el valor enviado al servidor cuando se selecciona el elemento.
value: AttrValue,
/// Devuelve la etiqueta visible del elemento.
label: L10n,
/// Devuelve si el elemento debe aparecer seleccionado por defecto.
selected: bool,
/// Devuelve si el elemento está deshabilitado.
disabled: bool,
}
impl Item {
/// Crea un nuevo elemento con el valor y la etiqueta indicados.
pub fn new(value: impl AsRef<str>, label: L10n) -> Self {
Self {
value: AttrValue::new(value),
label,
selected: false,
disabled: false,
}
}
// **< Item BUILDER >***************************************************************************
/// Establece si el elemento aparece seleccionado por defecto.
///
/// En una lista de selección única, el navegador aplica la selección al último elemento marcado
/// si hay más de uno; mientras que en una lista múltiple se respetan todos los elementos
/// marcados.
pub fn with_selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Establece si el elemento está deshabilitado.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Group >**************************************************************************************
/// Grupo de elementos dentro de [`form::select::Field`].
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// Agrupa un conjunto de elementos dentro de una lista de selección con una etiqueta visible. El
/// grupo completo puede deshabilitarse en bloque con [`with_disabled()`](Self::with_disabled).
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let group = form::select::Group::new(L10n::n("Europe"))
/// .with_item(form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(form::select::Item::new("fr", L10n::n("French")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Group {
/// Devuelve la etiqueta visible del grupo de elementos.
label: L10n,
/// Devuelve los elementos del grupo.
items: Vec<Item>,
/// Devuelve si el grupo de elementos está deshabilitado.
disabled: bool,
}
impl Group {
/// Crea un nuevo grupo con la etiqueta indicada.
pub fn new(label: L10n) -> Self {
Self {
label,
..Self::default()
}
}
// **< Group BUILDER >**************************************************************************
/// Añade un elemento al grupo. Los elementos se muestran en el orden en que se añaden.
pub fn with_item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
/// Establece si el grupo de elementos está deshabilitado en bloque.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Entry >**************************************************************************************
/// Entrada de [`form::select::Field`] con un elemento o un grupo de elementos.
///
/// Cada entrada se crea implícitamente cuando se usa [`form::select::Field::with_item()`] para
/// añadir un elemento individual o [`form::select::Field::with_group()`] para añadir un grupo de
/// elementos a una lista de selección.
///
/// Con [`form::select::Field::entries()`] se pueden recuperar todas las entradas para su
/// renderizado.
#[derive(Clone, Debug)]
pub enum Entry {
/// Elemento individual.
Item(Item),
/// Grupo de elementos.
Group(Group),
}
// **< Field >**************************************************************************************
/// Componente para crear una **lista de selección**.
///
/// Renderiza un campo para mostrar una lista de elementos con una etiqueta opcional. Permite elegir
/// uno, o más de uno si se activa la selección múltiple con
/// [`with_multiple()`](Self::with_multiple).
///
/// Los elementos individuales se añaden con [`with_item()`](Self::with_item); los grupos de
/// elementos con un encabezado común se añaden con [`with_group()`](Self::with_group). Ambos
/// métodos pueden combinarse libremente.
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let idioma = form::select::Field::new()
/// let language = form::select::Field::new()
/// .with_name("language")
/// .with_label(L10n::n("Language"))
/// .with_floating_label(true)
/// .with_item(form::select::Item::new("", L10n::n("— Choose —")).with_selected(true))
/// .with_group(
/// form::select::Group::new(L10n::n("Europe"))
/// .with_item(form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(form::select::Item::new("fr", L10n::n("French"))),
/// )
/// .with_group(
/// form::select::Group::new(L10n::n("Americas"))
/// .with_item(form::select::Item::new("en", L10n::n("English")))
/// .with_item(form::select::Item::new("pt", L10n::n("Portuguese"))),
/// )
/// .with_required(true);
/// .with_item(form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(form::select::Item::new("en", L10n::n("English")));
/// ```
///
/// Cuando el usuario selecciona un elemento y envía el formulario, el navegador transmite
/// `name=valor`. Si el campo es obligatorio el valor siempre estará presente y puede deserializarse
/// como `String`; si es opcional, usa `Option<String>`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// language: String, // Siempre presente (campo obligatorio).
/// // language: Option<String>, // 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<String>` con `#[serde(default)]`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// interests: Vec<String>, // 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<L10n>,
/// Devuelve si la etiqueta se muestra flotante sobre el campo.
floating_label: bool,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve las entradas de la lista (elementos individuales y grupos de elementos).
entries: Vec<Entry>,
/// 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<u16>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// 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<String> {
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::<u16>);
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<Markup, ComponentError> {
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<CowStr>) -> 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<str>) -> 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<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
pub trait SelectBootsier {
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al control y permanece flotante siempre que
/// haya una opción visible.
///
/// Si se usa la etiqueta flotante, el [`setup()`](Self::setup) del componente anulará los
/// valores establecidos con [`with_multiple()`](Self::with_multiple) y
/// [`with_rows()`](Self::with_rows) antes del renderizado.
#[builder_fn]
pub fn with_floating_label(mut self, floating_label: bool) -> Self {
self.floating_label = floating_label;
self
}
/// Si se usa la etiqueta flotante, se anulan los valores establecidos con
/// [`with_multiple()`](form::select::Field::with_multiple) y
/// [`with_rows()`](form::select::Field::with_rows) antes del renderizado.
fn with_floating_label(self, floating: bool) -> Self;
}
/// Establece o elimina el texto de ayuda del campo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> 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<u16>) -> 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<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
/// Establece si el campo recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el campo está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
impl SelectBootsier for Field {
fn with_floating_label(self, floating: bool) -> Self {
if floating {
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
}
}

View file

@ -1,294 +1,43 @@
//! Definiciones para crear áreas de texto en formularios.
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::form;
pub use pagetop::base::component::form::Textarea;
/// Componente para crear un **área de texto** de formulario.
/// Extensión de Bootsier para [`form::Textarea`].
///
/// Permite escribir en un área de texto de más de una línea, con una etiqueta opcional y atributos
/// como el número de filas a presentar, longitud mínima (`minlength`) y máxima (`maxlength`), texto
/// indicativo (`placeholder`) o autocompletado (`autocomplete`).
///
/// # Ejemplo
/// Proporciona soporte para **etiquetas flotantes** (*floating label*). La etiqueta flotante se
/// superpone al control mientras no hay ninguna opción seleccionada y permanece flotante cuando hay
/// una selección activa.
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let descripcion = form::Textarea::new()
/// .with_name("description")
/// .with_label(L10n::n("Description"))
/// .with_rows(Some(8))
/// .with_maxlength(Some(500))
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let comentario = form::Textarea::new()
/// .with_name("comment")
/// .with_label(L10n::n("Comment"))
/// .with_placeholder(L10n::n("Write here..."))
/// .with_required(true);
/// .with_floating_label(true);
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un área de texto siempre envía su
/// valor, incluso si está vacía. En el servidor se deserializa como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// description: String, // Siempre presente; cadena vacía si el usuario no escribió nada.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Textarea {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve el valor inicial del área de texto.
value: AttrValue,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve si la etiqueta se muestra flotante sobre el campo.
floating_label: bool,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve el número de filas visibles del área de texto.
rows: Attr<u16>,
/// Devuelve la longitud mínima permitida en caracteres.
minlength: Attr<u16>,
/// Devuelve la longitud máxima permitida en caracteres.
maxlength: Attr<u16>,
/// Devuelve el texto indicativo del área de texto.
placeholder: Attr<L10n>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// 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<String> {
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::<u16>);
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<Markup, ComponentError> {
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<CowStr>) -> 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<str>) -> 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<str>) -> 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<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
pub trait TextareaBootsier {
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al área de texto y asciende al enfocarlo o
/// cuando tiene contenido.
/// cuando tiene contenido. Requiere que el campo tenga un atributo `placeholder` definido;
/// si no se especifica, se fuerza `placeholder=""` antes del renderizado.
///
/// Si se usa la etiqueta flotante, el [`setup()`](Self::setup) del componente anulará el valor
/// establecido con [`with_rows()`](Self::with_rows) antes del renderizado. Si es necesario, se
/// puede controlar la altura con estilos aplicados al componente.
#[builder_fn]
pub fn with_floating_label(mut self, floating_label: bool) -> Self {
self.floating_label = floating_label;
self
}
/// Si se usa la etiqueta flotante, se anula el valor establecido con
/// [`with_rows()`](form::Textarea::with_rows) antes del renderizado.
fn with_floating_label(self, floating: bool) -> Self;
}
/// Establece o elimina el texto de ayuda del campo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> 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<u16>) -> 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<u16>) -> 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<u16>) -> 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<Option<L10n>>) -> 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<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
/// Establece si el campo recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es de sólo lectura.
#[builder_fn]
pub fn with_readonly(mut self, readonly: bool) -> Self {
self.readonly = readonly;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el campo está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
impl TextareaBootsier for Textarea {
fn with_floating_label(self, floating: bool) -> Self {
if floating {
self.with_prop(PropsOp::add_classes("form-floating"))
} else {
self.with_prop(PropsOp::remove_classes("form-floating"))
}
}
}

View file

@ -1,11 +1,18 @@
//! Componentes nativos proporcionados por PageTop.
mod html;
pub use html::Html;
mod block;
pub use block::Block;
mod button;
pub use button::{Button, ButtonAction};
pub mod form;
#[doc(inline)]
pub use form::Form;
mod html;
pub use html::Html;
mod intro;
pub use intro::{Intro, IntroOpening};

View file

@ -0,0 +1,207 @@
use crate::prelude::*;
use std::fmt;
// **< ButtonAction >*******************************************************************************
/// Comportamiento de un [`Button`] al activarse.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonAction {
/// Envía un formulario al servidor. Es el **tipo por defecto**.
#[default]
Submit,
/// Restablece todos los campos de un formulario a sus valores iniciales.
Reset,
/// Botón de propósito general, sin efecto predeterminado. Su comportamiento podría definirse
/// mediante JavaScript.
Plain,
}
impl fmt::Display for ButtonAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
ButtonAction::Submit => "submit",
ButtonAction::Reset => "reset",
ButtonAction::Plain => "button",
})
}
}
// **< Button >*************************************************************************************
/// Componente para crear un **botón**.
///
/// Renderiza un botón con soporte para las variantes disponibles en [`ButtonAction`] (`submit`,
/// `reset` y botón genérico).
///
/// El comportamiento del botón se establece al crearlo:
///
/// - [`Button::submit()`]: botón de envío (por defecto).
/// - [`Button::reset()`]: botón de restablecimiento de valores.
/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado.
///
/// El botón puede usarse dentro o fuera de un formulario.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let save = Button::submit(L10n::n("Save"));
/// let cancel = Button::plain(L10n::n("Cancel"));
/// let clear = Button::reset(L10n::n("Clear"));
/// ```
///
/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del
/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar
/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option<String>`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// action: Option<String>, // 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<L10n>,
/// 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<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
self.alter_prop(PropsOp::prepend_classes("button"));
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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<CowStr>) -> 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<str>) -> 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<str>) -> 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<Option<L10n>>) -> 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
}
}

View file

@ -0,0 +1,30 @@
//! Componentes y tipos para crear formularios HTML ([`Form`]).
mod props;
pub use props::{Autocomplete, AutofillField, CheckboxKind, Method};
mod component;
pub use component::Form;
mod fieldset;
pub use fieldset::Fieldset;
mod checkbox;
pub use checkbox::Checkbox;
pub mod check;
pub mod radio;
pub mod select;
pub mod input;
mod textarea;
pub use textarea::Textarea;
mod range;
pub use range::Range;
mod hidden;
pub use hidden::Hidden;

View file

@ -1,10 +1,10 @@
//! Definiciones para crear grupos de casillas de verificación (*check buttons*).
use pagetop::prelude::*;
use crate::prelude::*;
// **< Item >***************************************************************************************
/// Casilla de verificación individual de un [`form::check::Field`](Field).
/// Casilla de verificación individual de un [`Field`].
///
/// Representa cada casilla de un grupo de casillas de verificación, con una etiqueta localizable
/// visible. Puede marcarse como seleccionada o deshabilitada de forma independiente al resto.
@ -16,8 +16,8 @@ use pagetop::prelude::*;
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// let item = form::check::Item::new("apple", L10n::n("Apple")).with_checked(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
@ -65,24 +65,21 @@ impl Item {
/// Componente para crear un **grupo de casillas de verificación**.
///
/// Renderiza un conjunto de casillas de verificación donde, a diferencia de un grupo de botones
/// [`form::radio::Field`](crate::theme::form::radio::Field), cada casilla puede marcarse de forma
/// independiente.
///
/// Las casillas se añaden mediante [`with_item()`](Field::with_item) usando instancias de
/// [`form::check::Item`](Item). Si se activa el modo en línea con
/// Renderiza un conjunto de casillas de verificación donde cada casilla puede marcarse de forma
/// independiente. Las casillas se añaden con [`with_item()`](Field::with_item) usando instancias
/// de [`form::check::Item`]. Si se activa el modo en línea con
/// [`with_inline()`](Field::with_inline), las casillas se disponen horizontalmente.
///
/// El atributo `name` de cada casilla se construye automáticamente combinando el `name` del grupo
/// y el `name` del [`form::check::Item`](Item) con un guion bajo. Por ejemplo, para el grupo con
/// y el `name` del [`form::check::Item`] con un guion bajo. Por ejemplo, para el grupo con
/// `name=interests` y casillas con `name=art` y `name=tech`, se genera `name=interests_art` y
/// `name=interests_tech`.
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// let interests = form::check::Field::new()
/// .with_name("interests")
/// .with_label(L10n::n("Areas of interest"))

View file

@ -1,13 +1,9 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::form;
use crate::prelude::*;
/// Componente para crear una **casilla de verificación** o un **interruptor** (*toggle switch*).
///
/// Renderiza un control binario (marcado/no marcado) en dos variantes visuales, por defecto se
/// muestra como una casilla de verificación estándar, pero también puede renderizarse como un
/// interruptor de encendido/apagado ([`Checkbox::switch()`]).
/// Renderiza un control binario (marcado/no marcado) en dos variantes, por defecto como casilla de
/// verificación estándar, y también como interruptor ([`Checkbox::switch()`]).
///
/// Se puede mostrar en línea con otros controles usando [`with_inline()`](Checkbox::with_inline), o
/// justificar a la derecha del contenedor invirtiendo el orden de la etiqueta y el control usando
@ -16,9 +12,9 @@ use crate::theme::form;
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let accept_terms = form::Checkbox::check() // También sirve new() o default().
/// use pagetop::prelude::*;
///
/// let accept_terms = form::Checkbox::new()
/// .with_name("terms_accepted")
/// .with_label(L10n::n("I accept the terms and conditions"))
/// .with_required(true);
@ -86,7 +82,7 @@ impl Component for Checkbox {
self.alter_prop(PropsOp::ensure_id(container_id));
// Clases CSS del contenedor de la casilla de verificación.
let mut classes = "form-field form-check".to_string();
let mut classes = String::from("form-field form-check");
if *self.checkbox_kind() == form::CheckboxKind::Switch {
classes.push_str(" form-switch");
}
@ -126,7 +122,7 @@ impl Component for Checkbox {
@if *self.required() {
span
class="form-required"
title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx))
title=(L10n::l("field_required").using(cx))
{
"*"
}

View file

@ -1,16 +1,15 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::theme::form;
use crate::base::component::form;
/// Componente para crear un **formulario** ([`form`]).
/// Componente para crear un **formulario** HTML ([`form`]).
///
/// Este componente renderiza un formulario estándar con soporte para los atributos más habituales:
/// Renderiza un formulario estándar con soporte para los atributos más habituales:
///
/// - `id`: identificador opcional del formulario.
/// - `classes`: clases CSS adicionales (p. ej. utilidades CSS).
/// - `action`: URL/ruta de destino para el envío.
/// - `method`: método usado por el formulario para el envío de los datos (ver explicaciones en
/// [`form::Method`](crate::theme::form::Method)).
/// - `method`: método usado por el formulario para el envío de los datos (ver [`form::Method`]).
/// - `accept-charset`: juego de caracteres aceptado (por defecto es `"UTF-8"`).
/// - `children`: contenido del formulario.
///
@ -18,7 +17,6 @@ use crate::theme::form;
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let form_login = Form::new()
/// .with_id("login")
@ -42,7 +40,6 @@ use crate::theme::form;
/// )
/// .with_child(
/// Button::submit(L10n::n("Sign in"))
/// .with_color(ButtonColor::Background(Color::Primary)),
/// );
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]

View file

@ -1,4 +1,4 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Componente para crear un **grupo de controles relacionados** en un formulario.
///
@ -14,8 +14,8 @@ use pagetop::prelude::*;
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// let personal_data = form::Fieldset::new()
/// .with_legend(L10n::n("Personal data"))
/// .with_description(L10n::n("Enter your full name and contact email."))

View file

@ -1,4 +1,4 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Componente para crear un **campo oculto** del formulario.
///
@ -11,8 +11,8 @@ use pagetop::prelude::*;
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// let token = form::Hidden::new()
/// .with_name("csrf_token")
/// .with_value("a1b2c3d4e5");

View file

@ -0,0 +1,424 @@
//! Definiciones para crear campos de texto de una línea.
use crate::prelude::*;
use std::fmt;
// **< Kind >***************************************************************************************
/// Tipo de campo para un [`form::input::Field`].
///
/// Determina el tipo de entrada que acepta, así como el comportamiento del navegador al interactuar
/// con el campo. Implícitamente se aplica al crear el control: [`text()`](Field::text),
/// [`password()`](Field::password), [`search()`](Field::search), [`email()`](Field::email),
/// [`telephone()`](Field::telephone) o [`url()`](Field::url).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// Entrada de texto genérico (`type="text"`). Es el tipo por defecto.
#[default]
Text,
/// Entrada de una contraseña (`type="password"`). El contenido aparece enmascarado.
Password,
/// Campo de búsqueda (`type="search"`). Es un tipo semántico para los cuadros de búsqueda.
Search,
/// Entrada de un correo electrónico (`type="email"`). Permite validar el formato del correo.
Email,
/// Entrada de un teléfono (`type="tel"`). Activa el teclado de llamadas en móviles.
Telephone,
/// Entrada de una URL (`type="url"`). Comprueba que la entrada sea una URL bien formada.
Url,
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Kind::Text => "text",
Kind::Password => "password",
Kind::Search => "search",
Kind::Email => "email",
Kind::Telephone => "tel",
Kind::Url => "url",
})
}
}
// **< Mode >***************************************************************************************
/// Sugerencia para el teclado virtual de un [`form::input::Field`].
///
/// Indica al navegador qué tipo de teclado virtual mostrar en dispositivos móviles o táctiles al
/// editar el campo. A diferencia del atributo `type` ([`form::input::Kind`]), no restringe los
/// valores aceptados ni activa la validación del navegador; es sólo una sugerencia de presentación.
///
/// Se establece con [`form::input::Field::with_inputmode()`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Mode {
/// Suprime el teclado virtual. Útil en campos con teclado personalizado basado en JavaScript.
None,
/// Teclado de texto genérico.
Text,
/// Teclado decimal, con dígitos y separador decimal.
Decimal,
/// Teclado numérico, con sólo dígitos.
Numeric,
/// Teclado de teléfono, con dígitos y símbolos `+`, `*` y `#`.
Tel,
/// Teclado optimizado para búsquedas (puede incluir tecla de búsqueda).
Search,
/// Teclado optimizado para correo electrónico (incluye `@` y `.`).
Email,
/// Teclado optimizado para URL (incluye `/`, `.` y `.com`).
Url,
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Mode::None => "none",
Mode::Text => "text",
Mode::Decimal => "decimal",
Mode::Numeric => "numeric",
Mode::Tel => "tel",
Mode::Search => "search",
Mode::Email => "email",
Mode::Url => "url",
})
}
}
// **< Field >**************************************************************************************
/// Componente para crear un **campo de texto de una línea**.
///
/// Renderiza los tipos más habituales en formularios:
///
/// - [`Field::text()`]: campo de texto genérico (`type="text"`, por defecto).
/// - [`Field::password()`]: contraseña (`type="password"`).
/// - [`Field::search()`]: búsqueda (`type="search"`).
/// - [`Field::email()`]: correo electrónico (`type="email"`).
/// - [`Field::telephone()`]: teléfono (`type="tel"`).
/// - [`Field::url()`]: URL (`type="url"`).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let email = form::input::Field::email()
/// .with_name("email")
/// .with_label(L10n::n("Email address"))
/// .with_placeholder(L10n::n("user@example.com"))
/// .with_autocomplete(Some(form::Autocomplete::email()))
/// .with_required(true);
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un campo de texto siempre envía su
/// valor, incluso si está vacío. En el servidor se deserializa como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// email: String, // Siempre presente; cadena vacía si el usuario no escribió nada.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el tipo de campo.
kind: Kind,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve el valor inicial del campo.
value: AttrValue,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve la longitud mínima permitida en caracteres.
minlength: Attr<u16>,
/// Devuelve la longitud máxima permitida en caracteres.
maxlength: Attr<u16>,
/// Devuelve el texto indicativo del campo.
placeholder: Attr<L10n>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// 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<Mode>,
}
impl Component for Field {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
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<Markup, ComponentError> {
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<CowStr>) -> 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<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece el valor inicial del campo.
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> 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<Option<L10n>>) -> 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<Option<L10n>>) -> 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<u16>) -> 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<u16>) -> 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<Option<L10n>>) -> 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<form::Autocomplete>) -> 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<Mode>) -> Self {
self.inputmode.alter_opt(inputmode);
self
}
}

View file

@ -1,20 +1,21 @@
use pagetop::prelude::*;
use crate::prelude::*;
use std::borrow::Cow;
use std::fmt;
// **< CheckboxKind >*******************************************************************************
/// Variante visual para [`form::Checkbox`](crate::theme::form::Checkbox) en un formulario.
/// Variante visual para un [`form::Checkbox`] en un formulario.
///
/// Determina si el control se renderiza como una casilla de verificación estándar o como un
/// interruptor (*toggle switch*).
/// interruptor (*toggle switch*). La variante [`Switch`](Self::Switch) añade la clase `form-switch`
/// al contenedor y el atributo `role="switch"` al control para accesibilidad.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum CheckboxKind {
/// Casilla de verificación estándar. Es el tipo por defecto.
#[default]
Check,
/// Interruptor de encendido/apagado.
/// Interruptor de encendido/apagado (*toggle switch*).
Switch,
// TODO: Añadir variante `NativeSwitch` cuando el atributo `switch` de la propuesta WHATWG
// (https://github.com/whatwg/html/issues/9546) sea estándar y tenga soporte amplio. Safari ya
@ -51,8 +52,8 @@ pub enum CheckboxKind {
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// // Correo electrónico con sugerencia semántica del navegador.
/// let ac = form::Autocomplete::email();
///
@ -87,7 +88,7 @@ impl Autocomplete {
// --< Secciones >------------------------------------------------------------------------------
/// Construye `autocomplete` con un prefijo de sección y un token o tokens del
/// [`form::AutofillField`](AutofillField) indicado.
/// [`AutofillField`] indicado.
///
/// Genera `autocomplete="section-<name> <field>"`. Si `name` no es ASCII o contiene espacios,
/// se ignora la sección y se genera sólo el token indicado.
@ -244,7 +245,8 @@ impl fmt::Display for Autocomplete {
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// let ac = form::Autocomplete::token(form::AutofillField::Username);
/// let ac = form::Autocomplete::shipping(form::AutofillField::StreetAddress);
/// let ac = form::Autocomplete::section("job", form::AutofillField::Email);
@ -447,7 +449,7 @@ impl AutofillField {
// **< Method >*************************************************************************************
/// Método HTTP usado por un formulario ([`Form`](crate::theme::Form)) para el envío de los datos.
/// Método HTTP usado por un [`Form`](super::Form) para el envío de los datos.
///
/// En HTML, el atributo `method` del formulario indica **cómo** se envían los datos:
///

View file

@ -1,12 +1,10 @@
//! Definiciones para crear grupos de botones de opción (*radio buttons*).
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::prelude::*;
// **< Item >***************************************************************************************
/// Botón de opción individual de un [`form::radio::Field`](Field).
/// Botón de opción individual de un [`Field`].
///
/// Representa cada opción de un grupo de opciones exclusivas entre sí, con un valor (el que se
/// envía al servidor), una etiqueta localizable visible y puede marcarse como seleccionada o
@ -15,8 +13,8 @@ use crate::LOCALES_BOOTSIER;
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// let item = form::radio::Item::new("monthly", L10n::n("Monthly")).with_checked(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
@ -64,8 +62,8 @@ impl Item {
/// Componente para crear un **grupo de botones de opción**.
///
/// Renderiza un grupo de botones de opción [`form::radio::Item`](Item) que comparten el mismo
/// atributo `name`, por lo que sólo puede seleccionarse uno a la vez. Las opciones se añaden con
/// Renderiza un grupo de botones de opción [`form::radio::Item`] que comparten el mismo atributo
/// `name`, por lo que sólo puede seleccionarse uno a la vez. Las opciones se añaden con
/// [`with_item()`](Field::with_item).
///
/// Si se activa el modo en línea [`with_inline()`](Field::with_inline), los botones se disponen
@ -75,8 +73,8 @@ impl Item {
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// let plan = form::radio::Field::new()
/// .with_name("plan")
/// .with_label(L10n::n("Subscription plan"))
@ -151,7 +149,7 @@ impl Component for Field {
@if *self.required() {
span
class="form-required"
title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx))
title=(L10n::l("field_required").using(cx))
{
"*"
}

View file

@ -1,4 +1,4 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Componente para crear un **control deslizante** de rango.
///
@ -9,8 +9,8 @@ use pagetop::prelude::*;
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// use pagetop::prelude::*;
///
/// let volume = form::Range::new()
/// .with_name("volume")
/// .with_label(L10n::n("Volume"))

View file

@ -0,0 +1,427 @@
//! Definiciones para crear listas de selección.
use crate::prelude::*;
// **< Item >***************************************************************************************
/// Elemento individual de [`form::select::Field`] o de [`form::select::Group`].
///
/// Representa un elemento dentro de una lista de selección o de un grupo de elementos de la lista.
/// Cada elemento tiene un valor que se envía al servidor y una etiqueta localizable visible para el
/// usuario.
///
/// Puede marcarse como seleccionado por defecto con [`with_selected()`](Self::with_selected) o
/// deshabilitado de forma independiente al resto usando [`with_disabled()`](Self::with_disabled).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let item = form::select::Item::new("es", L10n::n("Spanish")).with_selected(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve el valor enviado al servidor cuando se selecciona el elemento.
value: AttrValue,
/// Devuelve la etiqueta visible del elemento.
label: L10n,
/// Devuelve si el elemento debe aparecer seleccionado por defecto.
selected: bool,
/// Devuelve si el elemento está deshabilitado.
disabled: bool,
}
impl Item {
/// Crea un nuevo elemento con el valor y la etiqueta indicados.
pub fn new(value: impl AsRef<str>, 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<Item>,
/// 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<String>`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// language: String, // Siempre presente (campo obligatorio).
/// // language: Option<String>, // 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<String>` con `#[serde(default)]`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// interests: Vec<String>, // 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<L10n>,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve las entradas de la lista (elementos individuales y grupos de elementos).
entries: Vec<Entry>,
/// 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<u16>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// 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<String> {
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<Markup, ComponentError> {
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<CowStr>) -> 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<str>) -> 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<Option<L10n>>) -> 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<Option<L10n>>) -> 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<u16>) -> 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<form::Autocomplete>) -> 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
}
}

View file

@ -0,0 +1,256 @@
//! Definiciones para crear áreas de texto en formularios.
use crate::prelude::*;
/// Componente para crear un **área de texto** de formulario.
///
/// Permite escribir en un área de texto de más de una línea, con una etiqueta opcional y atributos
/// como el número de filas a presentar, longitud mínima (`minlength`) y máxima (`maxlength`), texto
/// indicativo (`placeholder`) o autocompletado (`autocomplete`).
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let descripcion = form::Textarea::new()
/// .with_name("description")
/// .with_label(L10n::n("Description"))
/// .with_rows(Some(8))
/// .with_maxlength(Some(500))
/// .with_placeholder(L10n::n("Write here..."))
/// .with_required(true);
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un área de texto siempre envía su
/// valor, incluso si está vacía. En el servidor se deserializa como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// description: String, // Siempre presente; cadena vacía si el usuario no escribió nada.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Textarea {
/// Devuelve identificador, clases CSS y atributos HTML del componente.
props: Props,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve el valor inicial del área de texto.
value: AttrValue,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve el número de filas visibles del área de texto.
rows: Attr<u16>,
/// Devuelve la longitud mínima permitida en caracteres.
minlength: Attr<u16>,
/// Devuelve la longitud máxima permitida en caracteres.
maxlength: Attr<u16>,
/// Devuelve el texto indicativo del área de texto.
placeholder: Attr<L10n>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// 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<String> {
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<Markup, ComponentError> {
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<CowStr>) -> 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<str>) -> 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<str>) -> 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<Option<L10n>>) -> 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<Option<L10n>>) -> 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<u16>) -> 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<u16>) -> 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<u16>) -> 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<Option<L10n>>) -> 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<form::Autocomplete>) -> 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
}
}

View file

@ -238,8 +238,11 @@ impl Template for DefaultTemplate {}
macro_rules! render_component {
($component:expr, { $($type:ty => |$var:ident| $body:expr),* $(,)? }) => {
'render_component: {
// Reborrow explícito como referencia compartida para que `downcast_ref` funcione
// correctamente con `&mut dyn Component` (limitación del compilador con trait objects).
let __c = &*($component);
$(
if let Some($var) = ($component).downcast_ref::<$type>() {
if let Some($var) = __c.downcast_ref::<$type>() {
break 'render_component Some($body);
}
)*

View file

@ -1,3 +1,6 @@
# Form components.
field_required = This field is required
# Intro component.
intro_default_title = Hello, world!
intro_default_slogan = Discover⚡{ $app }

View file

@ -1,3 +1,6 @@
# Form components.
field_required = Este campo es obligatorio
# Intro component.
intro_default_title = ¡Hola, mundo!
intro_default_slogan = Descubre⚡{ $app }