From 99a4837e1174379f958c2144f1f68522ff90522b Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 24 Aug 2026 20:18:29 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(button):=20A=C3=B1ade=20componente?= =?UTF-8?q?s=20Button=20y=20ButtonSet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/basic.css | 97 +++++++++++ examples/form-controls.rs | 86 ++++----- examples/locale/en-US/form-controls.ftl | 2 + examples/locale/es-ES/form-controls.ftl | 2 + src/base/component.rs | 5 +- src/base/component/button.rs | 222 +----------------------- src/base/component/button/component.rs | 210 ++++++++++++++++++++++ src/base/component/button/props.rs | 44 +++++ src/base/component/button/set.rs | 73 ++++++++ tests/component_button.rs | 83 +++++++++ 10 files changed, 566 insertions(+), 258 deletions(-) create mode 100644 src/base/component/button/component.rs create mode 100644 src/base/component/button/props.rs create mode 100644 src/base/component/button/set.rs diff --git a/assets/css/basic.css b/assets/css/basic.css index 818db21a..1a3f5c18 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -87,6 +87,103 @@ body { .badge-warning { background-color: var(--val-color--warning); } .badge-danger { background-color: var(--val-color--danger); } +/* + * Buttons + */ + +.button { + display: inline-block; + padding: 0.5rem 1.25rem; + font-size: var(--val-fs--base); + font-weight: 600; + line-height: var(--val-lh--base); + color: #fff; + background-color: var(--val-color--primary); + border: 1px solid var(--val-color--primary); + border-radius: 0.375rem; + cursor: pointer; + transition: background-color .15s ease-in-out, border-color .15s ease-in-out; +} +.button:hover { + background-color: color-mix(in srgb, var(--val-color--primary) 85%, black); + border-color: color-mix(in srgb, var(--val-color--primary) 85%, black); +} +.button:disabled { + opacity: 0.65; + cursor: default; +} +.button::-moz-focus-inner { + border: 0; +} + +.button-primary, +.button-secondary, +.button-success, +.button-danger { + color: #fff; +} +.button-info, +.button-warning { + color: #000; +} +.button-primary { background-color: var(--val-color--primary); border-color: var(--val-color--primary); } +.button-secondary { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); } +.button-success { background-color: var(--val-color--success); border-color: var(--val-color--success); } +.button-info { background-color: var(--val-color--info); border-color: var(--val-color--info); } +.button-warning { background-color: var(--val-color--warning); border-color: var(--val-color--warning); } +.button-danger { background-color: var(--val-color--danger); border-color: var(--val-color--danger); } + +.button-primary:hover { background-color: color-mix(in srgb, var(--val-color--primary) 85%, black); border-color: color-mix(in srgb, var(--val-color--primary) 85%, black); } +.button-secondary:hover { background-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); border-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); } +.button-success:hover { background-color: color-mix(in srgb, var(--val-color--success) 85%, black); border-color: color-mix(in srgb, var(--val-color--success) 85%, black); } +.button-info:hover { background-color: color-mix(in srgb, var(--val-color--info) 85%, black); border-color: color-mix(in srgb, var(--val-color--info) 85%, black); } +.button-warning:hover { background-color: color-mix(in srgb, var(--val-color--warning) 85%, black); border-color: color-mix(in srgb, var(--val-color--warning) 85%, black); } +.button-danger:hover { background-color: color-mix(in srgb, var(--val-color--danger) 85%, black); border-color: color-mix(in srgb, var(--val-color--danger) 85%, black); } + +.button-outline-primary, +.button-outline-secondary, +.button-outline-success, +.button-outline-info, +.button-outline-warning, +.button-outline-danger { + background-color: transparent; +} +.button-outline-primary { border-color: var(--val-color--primary); color: var(--val-color--primary); } +.button-outline-secondary { border-color: var(--val-color--secondary); color: var(--val-color--secondary); } +.button-outline-success { border-color: var(--val-color--success); color: var(--val-color--success); } +.button-outline-info { border-color: var(--val-color--info); color: var(--val-color--info); } +.button-outline-warning { border-color: var(--val-color--warning); color: var(--val-color--warning); } +.button-outline-danger { border-color: var(--val-color--danger); color: var(--val-color--danger); } + +.button-outline-primary:hover { background-color: var(--val-color--primary); border-color: var(--val-color--primary); color: #fff; } +.button-outline-secondary:hover { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); color: #fff; } +.button-outline-success:hover { background-color: var(--val-color--success); border-color: var(--val-color--success); color: #fff; } +.button-outline-info:hover { background-color: var(--val-color--info); border-color: var(--val-color--info); color: #000; } +.button-outline-warning:hover { background-color: var(--val-color--warning); border-color: var(--val-color--warning); color: #000; } +.button-outline-danger:hover { background-color: var(--val-color--danger); border-color: var(--val-color--danger); color: #fff; } + +.button-link { + font-weight: 400; + color: var(--val-color--primary); + background-color: transparent; + border-color: transparent; + text-decoration: underline; +} +.button-link:hover { + color: color-mix(in srgb, var(--val-color--primary) 85%, black); + background-color: transparent; + border-color: transparent; +} +.button-link:disabled { + color: var(--val-color--text--muted); +} + +.button-set { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + /* * Form components */ diff --git a/examples/form-controls.rs b/examples/form-controls.rs index 1b495440..e40b7af0 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -138,21 +138,23 @@ async fn form_controls(request: HttpRequest) -> Result { ) // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-selections")) - // Botones de acción. - .with_child(Button::submit(Lc::t("btn_submit", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::solid( - ThemeColor::Primary, - )), - )) - .with_child(Button::reset(Lc::t("btn_reset", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::outline( - ThemeColor::Secondary, - )), - )) + // Botonera de acciones. .with_child( - Button::plain(Lc::t("btn_cancel", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::link()), - ), + button::ButtonSet::new() + .with_button( + Button::submit(Lc::t("btn_submit", &LOC)).with_style( + button::ButtonStyle::Solid(Intent::Primary), + ), + ) + .with_button( + Button::reset(Lc::t("btn_reset", &LOC)).with_style( + button::ButtonStyle::Outline(Intent::Secondary), + ), + ) + .with_button( + Button::plain(Lc::t("btn_cancel", &LOC)) + .with_style(button::ButtonStyle::Link), + ), ), ), ) @@ -252,21 +254,23 @@ async fn form_controls(request: HttpRequest) -> Result { ) // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-text")) - // Botones de acción. - .with_child(Button::submit(Lc::t("btn_submit", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::solid( - ThemeColor::Primary, - )), - )) - .with_child(Button::reset(Lc::t("btn_reset", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::outline( - ThemeColor::Secondary, - )), - )) + // Botonera de acciones. .with_child( - Button::plain(Lc::t("btn_cancel", &LOC)).with_prop( - PropsOp::add_classes(class::ButtonColor::link()), - ), + button::ButtonSet::new() + .with_button( + Button::submit(Lc::t("btn_submit", &LOC)).with_style( + button::ButtonStyle::Solid(Intent::Primary), + ), + ) + .with_button( + Button::reset(Lc::t("btn_reset", &LOC)).with_style( + button::ButtonStyle::Outline(Intent::Secondary), + ), + ) + .with_button( + Button::plain(Lc::t("btn_cancel", &LOC)) + .with_style(button::ButtonStyle::Link), + ), ), ), ) @@ -395,20 +399,20 @@ fn form_lists() -> Form { form // Campo oculto (form::Hidden). .with_child(form::Hidden::field("origin", "form-lists")) - // Botones de acción. + // Botonera de acciones. .with_child( - Button::submit(Lc::t("btn_submit", &LOC)).with_prop(PropsOp::add_classes( - class::ButtonColor::solid(ThemeColor::Primary), - )), - ) - .with_child( - Button::reset(Lc::t("btn_reset", &LOC)).with_prop(PropsOp::add_classes( - class::ButtonColor::outline(ThemeColor::Secondary), - )), - ) - .with_child( - Button::plain(Lc::t("btn_cancel", &LOC)) - .with_prop(PropsOp::add_classes(class::ButtonColor::link())), + button::ButtonSet::new() + .with_button( + Button::submit(Lc::t("btn_submit", &LOC)) + .with_style(button::ButtonStyle::Solid(Intent::Primary)), + ) + .with_button( + Button::reset(Lc::t("btn_reset", &LOC)) + .with_style(button::ButtonStyle::Outline(Intent::Secondary)), + ) + .with_button( + Button::plain(Lc::t("btn_cancel", &LOC)).with_style(button::ButtonStyle::Link), + ), ) } diff --git a/examples/locale/en-US/form-controls.ftl b/examples/locale/en-US/form-controls.ftl index ce6c76ca..05e345c6 100644 --- a/examples/locale/en-US/form-controls.ftl +++ b/examples/locale/en-US/form-controls.ftl @@ -75,3 +75,5 @@ help_rating = From 1 (very poor) to 10 (excellent). btn_submit = Submit btn_reset = Reset btn_cancel = Cancel +btn_ok = Ok +btn_delete = Delete record diff --git a/examples/locale/es-ES/form-controls.ftl b/examples/locale/es-ES/form-controls.ftl index 40781042..57ce3318 100644 --- a/examples/locale/es-ES/form-controls.ftl +++ b/examples/locale/es-ES/form-controls.ftl @@ -75,3 +75,5 @@ help_rating = De 1 (muy malo) a 10 (excelente). btn_submit = Enviar btn_reset = Restablecer btn_cancel = Cancelar +btn_ok = Aceptar +btn_delete = Eliminar registro diff --git a/src/base/component.rs b/src/base/component.rs index e01ed9af..fbabe173 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -15,8 +15,9 @@ pub use breadcrumb::Breadcrumb; mod block; pub use block::Block; -mod button; -pub use button::{Button, ButtonAction}; +pub mod button; +#[doc(inline)] +pub use button::{Button, ButtonSet}; pub mod container; #[doc(inline)] diff --git a/src/base/component/button.rs b/src/base/component/button.rs index 4ecc95d3..fdfc22ea 100644 --- a/src/base/component/button.rs +++ b/src/base/component/button.rs @@ -1,218 +1,10 @@ -use crate::prelude::*; +//! Definiciones para crear botones ([`Button`]) y conjuntos de botones ([`ButtonSet`]). -use std::fmt; +mod props; +pub use props::{ButtonKind, ButtonStyle}; -// **< ButtonAction >******************************************************************************* +mod component; +pub use component::Button; -/// 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(Lc::n("Save")); -/// let cancel = Button::plain(Lc::n("Cancel")); -/// let clear = Button::reset(Lc::n("Clear")); -/// ``` -/// -/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del -/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar -/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option`: -/// -/// ```rust,ignore -/// #[derive(serde::Deserialize)] -/// struct FormData { -/// #[serde(default)] -/// action: Option, // p. ej., "save" o "delete"; `None` si el botón no tenía `name`. -/// } -/// ``` -#[derive(AutoDefault, Clone, Debug, Getters)] -pub struct Button { - /// Devuelve identificador, clases CSS, atributos HTML y valores extra 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: Lc, - /// Devuelve el texto emergente del botón (atributo `title`). - title: Lc, - /// 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, -} - -#[async_trait] -impl Component for Button { - fn new() -> Self { - Self::default() - } - - fn id(&self) -> Option { - self.props.get_id() - } - - fn setup(&mut self, _cx: &Context) { - self.alter_prop(PropsOp::prepend_classes("button")); - } - - async fn prepare(&self, cx: &mut Context) -> Result { - Ok(html! { - button - type=(self.kind()) - (self.props()) - name=[self.name().as_deref()] - value=[self.value().as_deref()] - title=[self.title().lookup(cx)] - 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: Lc) -> Self { - Self { - kind: ButtonAction::Submit, - 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: Lc) -> Self { - Self { - kind: ButtonAction::Reset, - 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: Lc) -> Self { - Self { - kind: ButtonAction::Plain, - label, - ..Default::default() - } - } - - // **< Button BUILDER >************************************************************************* - - /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. - #[builder_fn] - pub fn with_id(mut self, id: impl Into) -> Self { - self.props.alter_id(id); - self - } - - /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. - #[builder_fn] - pub fn with_prop(mut self, op: PropsOp) -> Self { - self.props.alter_prop(op); - self - } - - /// Establece el nombre del botón (atributo `name`). - /// - /// Cuando el formulario tiene varios botones de envío, el navegador incluye en el envío el par - /// `name=value` sólo del botón que activó el formulario. Permite identificar cuál fue pulsado. - #[builder_fn] - pub fn with_name(mut self, name: impl AsRef) -> Self { - self.name.alter_name(name); - self - } - - /// Establece el valor del botón (atributo `value`). - /// - /// Es el dato que el navegador transmite al servidor junto con el `name` cuando este botón - /// activa el envío. Útil para distinguir entre varios botones de envío en un mismo formulario. - #[builder_fn] - pub fn with_value(mut self, value: impl AsRef) -> Self { - self.value.alter_str(value); - self - } - - /// Establece la etiqueta visible del botón (usa [`Lc::none()`] para quitarla). - #[builder_fn] - pub fn with_label(mut self, label: Lc) -> Self { - self.label = label; - self - } - - /// Establece el texto emergente del botón (usa [`Lc::none()`] para quitarlo). - #[builder_fn] - pub fn with_title(mut self, title: Lc) -> Self { - self.title = title; - 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 - } -} +mod set; +pub use set::ButtonSet; diff --git a/src/base/component/button/component.rs b/src/base/component/button/component.rs new file mode 100644 index 00000000..aef92e7d --- /dev/null +++ b/src/base/component/button/component.rs @@ -0,0 +1,210 @@ +use crate::prelude::*; + +/// Componente para crear un **botón**. +/// +/// Renderiza un botón con soporte para las variantes: +/// +/// - [`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. +/// +/// Un botón puede usarse dentro o fuera de un formulario. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let save = Button::submit(Lc::n("Save")); +/// let cancel = Button::plain(Lc::n("Cancel")); +/// let clear = Button::reset(Lc::n("Clear")); +/// ``` +/// +/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del +/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar +/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option`: +/// +/// ```rust,ignore +/// #[derive(serde::Deserialize)] +/// struct FormData { +/// #[serde(default)] +/// action: Option, // p. ej., "save" o "delete"; `None` si el botón no tenía `name`. +/// } +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Button { + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. + props: Props, + /// Devuelve el comportamiento del botón al activarse. + kind: button::ButtonKind, + /// Devuelve el estilo visual del botón. + #[getters(copy)] + style: button::ButtonStyle, + /// Devuelve el nombre del botón. + name: AttrName, + /// Devuelve el valor del botón. + value: AttrValue, + /// Devuelve la etiqueta del botón. + label: Lc, + /// Devuelve el texto emergente del botón (atributo `title`). + title: Lc, + /// 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, +} + +#[async_trait] +impl Component for Button { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + use button::ButtonStyle; + + self.alter_prop(PropsOp::prepend_classes(match self.style() { + ButtonStyle::None => "button".to_string(), + ButtonStyle::Solid(intent) => util::join!("button button-", intent.as_str()), + ButtonStyle::Outline(intent) => util::join!("button button-outline-", intent.as_str()), + ButtonStyle::Link => "button button-link".to_string(), + })); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + Ok(html! { + button + type=(self.kind()) + (self.props()) + name=[self.name().as_deref()] + value=[self.value().as_deref()] + title=[self.title().lookup(cx)] + 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: Lc) -> Self { + Self { + kind: button::ButtonKind::Submit, + 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: Lc) -> Self { + Self { + kind: button::ButtonKind::Reset, + 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: Lc) -> Self { + Self { + kind: button::ButtonKind::Plain, + label, + ..Default::default() + } + } + + // **< Button BUILDER >************************************************************************* + + /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. + #[builder_fn] + pub fn with_id(mut self, id: impl Into) -> Self { + self.props.alter_id(id); + self + } + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + #[builder_fn] + pub fn with_prop(mut self, op: PropsOp) -> Self { + self.props.alter_prop(op); + self + } + + /// Establece el comportamiento del botón al activarse. + #[builder_fn] + pub fn with_kind(mut self, kind: button::ButtonKind) -> Self { + self.kind = kind; + self + } + + /// Establece el estilo visual del botón (usa [`button::ButtonStyle::None`] para quitarlo). + #[builder_fn] + pub fn with_style(mut self, style: button::ButtonStyle) -> Self { + self.style = style; + self + } + + /// Establece el nombre del botón (atributo `name`). + /// + /// Cuando el formulario tiene varios botones de envío, el navegador incluye en el envío el par + /// `name=value` sólo del botón que activó el formulario. Permite identificar cuál fue pulsado. + #[builder_fn] + pub fn with_name(mut self, name: impl AsRef) -> Self { + self.name.alter_name(name); + self + } + + /// Establece el valor del botón (atributo `value`). + /// + /// Es el dato que el navegador transmite al servidor junto con el `name` cuando este botón + /// activa el envío. Útil para distinguir entre varios botones de envío en un mismo formulario. + #[builder_fn] + pub fn with_value(mut self, value: impl AsRef) -> Self { + self.value.alter_str(value); + self + } + + /// Establece la etiqueta visible del botón (usa [`Lc::none()`] para quitarla). + #[builder_fn] + pub fn with_label(mut self, label: Lc) -> Self { + self.label = label; + self + } + + /// Establece el texto emergente del botón (usa [`Lc::none()`] para quitarlo). + #[builder_fn] + pub fn with_title(mut self, title: Lc) -> Self { + self.title = title; + self + } + + /// Establece si el botón recibe el foco automáticamente al cargar la página. + #[builder_fn] + pub fn with_autofocus(mut self, autofocus: bool) -> Self { + self.autofocus = autofocus; + self + } + + /// Establece si el botón está deshabilitado. + #[builder_fn] + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} diff --git a/src/base/component/button/props.rs b/src/base/component/button/props.rs new file mode 100644 index 00000000..483e9b86 --- /dev/null +++ b/src/base/component/button/props.rs @@ -0,0 +1,44 @@ +use crate::prelude::*; + +use std::fmt; + +// **< ButtonStyle >******************************************************************************** + +/// Estilo visual de un [`Button`](super::Button). +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum ButtonStyle { + /// Sin clase de estilo (estilo por defecto del tema). + #[default] + None, + /// Botón sólido: genera la clase `button-{color}`. + Solid(Intent), + /// Botón con contorno: genera la clase `button-outline-{color}`. + Outline(Intent), + /// Botón tipo enlace: genera la clase `button-link`. + Link, +} + +// **< ButtonKind >********************************************************************************* + +/// Comportamiento de un [`Button`](super::Button) al activarse. +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum ButtonKind { + /// 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 ButtonKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + ButtonKind::Submit => "submit", + ButtonKind::Reset => "reset", + ButtonKind::Plain => "button", + }) + } +} diff --git a/src/base/component/button/set.rs b/src/base/component/button/set.rs new file mode 100644 index 00000000..a0e5de8c --- /dev/null +++ b/src/base/component/button/set.rs @@ -0,0 +1,73 @@ +use crate::prelude::*; + +/// Componente para mostrar un **conjunto de botones**. +/// +/// Envuelve los botones en un contenedor que cada tema estiliza para separarlos visualmente. Sólo +/// admite componentes [`Button`]. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let actions = button::ButtonSet::new() +/// .with_button(Button::submit(Lc::n("Save"))) +/// .with_button(Button::plain(Lc::n("Cancel"))); +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct ButtonSet { + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. + props: Props, + /// Devuelve los botones del conjunto. + buttons: Children, +} + +#[async_trait] +impl Component for ButtonSet { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + self.alter_prop(PropsOp::prepend_classes("button-set")); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let buttons = self.buttons().render(cx).await; + if buttons.is_empty() { + return Ok(html! {}); + } + Ok(html! { + div (self.props()) { (buttons) } + }) + } +} + +impl ButtonSet { + // **< ButtonSet BUILDER >************************************************************************* + + /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. + #[builder_fn] + pub fn with_id(mut self, id: impl Into) -> Self { + self.props.alter_id(id); + self + } + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + #[builder_fn] + pub fn with_prop(mut self, op: PropsOp) -> Self { + self.props.alter_prop(op); + self + } + + /// Añade un botón al conjunto, o modifica su lista de botones con una operación [`TypedOp`]. + #[builder_fn] + pub fn with_button(mut self, op: impl Into>) -> Self { + self.buttons.alter_child(op.into()); + self + } +} diff --git a/tests/component_button.rs b/tests/component_button.rs index 5918d7c1..27b260f1 100644 --- a/tests/component_button.rs +++ b/tests/component_button.rs @@ -1,5 +1,7 @@ use pagetop::prelude::*; +// **< Button >************************************************************************************* + #[pagetop::test] async fn label_is_rendered_when_set() { let mut button = Button::submit(Lc::n("Save")); @@ -43,3 +45,84 @@ async fn title_attribute_can_be_cleared_with_lc_none() { assert!(!html.contains("title=")); } + +#[pagetop::test] +async fn style_class_reflects_intent_and_style() { + let mut button = + Button::submit(Lc::n("Save")).with_style(button::ButtonStyle::Solid(Intent::Danger)); + let html = button.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("button-danger")); +} + +#[pagetop::test] +async fn outline_style_generates_outline_class() { + let mut button = + Button::submit(Lc::n("Save")).with_style(button::ButtonStyle::Outline(Intent::Primary)); + let html = button.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("button-outline-primary")); +} + +#[pagetop::test] +async fn link_style_generates_link_class_without_intent() { + let mut button = Button::plain(Lc::n("Cancel")).with_style(button::ButtonStyle::Link); + let html = button.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("button-link")); +} + +// **< ButtonSet >********************************************************************************** + +#[pagetop::test] +async fn button_set_is_not_rendered_when_empty() { + let mut set = button::ButtonSet::new(); + let html = set.render(&mut Context::default()).await; + + assert!(html.is_empty()); +} + +#[pagetop::test] +async fn button_set_wraps_buttons_in_button_set_class() { + let mut set = button::ButtonSet::new().with_button(Button::submit(Lc::n("Save"))); + let html = set.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("button-set")); + assert!(html.contains("Save")); +} + +#[pagetop::test] +async fn button_set_renders_buttons_in_insertion_order() { + let mut set = button::ButtonSet::new() + .with_button(Button::submit(Lc::n("First"))) + .with_button(Button::plain(Lc::n("Second"))); + let html = set.render(&mut Context::default()).await.into_string(); + + assert!(html.find("First").unwrap() < html.find("Second").unwrap()); +} + +#[pagetop::test] +async fn button_set_add_many_appends_all_buttons() { + let mut set = button::ButtonSet::new().with_button(TypedOp::AddMany(vec![ + Button::submit(Lc::n("Save")), + Button::reset(Lc::n("Reset")), + Button::plain(Lc::n("Cancel")), + ])); + let html = set.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("Save")); + assert!(html.contains("Reset")); + assert!(html.contains("Cancel")); +} + +#[pagetop::test] +async fn button_set_remove_by_id_drops_matching_button() { + let mut set = button::ButtonSet::new() + .with_button(Button::submit(Lc::n("Save")).with_id("save-button")) + .with_button(Button::plain(Lc::n("Cancel"))) + .with_button(TypedOp::RemoveById("save-button")); + let html = set.render(&mut Context::default()).await.into_string(); + + assert!(!html.contains("Save")); + assert!(html.contains("Cancel")); +}