🎨 (bootsier): Mejora menús desplegables Dropdown

This commit is contained in:
Manuel Cillero 2025-10-25 09:02:58 +02:00
parent d21e1a2168
commit 3841d1d3f3
8 changed files with 654 additions and 72 deletions

View file

@ -1,13 +1,134 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
// **< AutoClose >**********************************************************************************
/// Estrategia para el cierre automático de un menú [`Dropdown`].
///
/// Define cuándo se cierra el menú desplegado según la interacción del usuario.
#[derive(AutoDefault)]
pub enum AutoClose {
/// Comportamiento por defecto, se cierra con clics dentro y fuera del menú, o pulsando `Esc`.
#[default]
Default,
/// Sólo se cierra con clics dentro del menú.
ClickableInside,
/// Sólo se cierra con clics fuera del menú.
ClickableOutside,
/// Cierre manual, no se cierra con clics; sólo al pulsar nuevamente el botón del menú
/// (*toggle*), o pulsando `Esc`.
ManualClose,
}
// **< Direction >**********************************************************************************
/// Dirección de despliegue de un menú [`Dropdown`].
///
/// Controla desde qué posición se muestra el menú respecto al botón.
#[derive(AutoDefault)]
pub enum Direction {
/// Comportamiento por defecto (despliega el menú hacia abajo desde la posición inicial,
/// respetando LTR/RTL).
#[default]
Default,
/// Centra horizontalmente el menú respecto al botón.
Centered,
/// Despliega el menú hacia arriba.
Dropup,
/// Despliega el menú hacia arriba y centrado.
DropupCentered,
/// Despliega el menú desde el lateral final, respetando LTR/RTL.
Dropend,
/// Despliega el menú desde el lateral inicial, respetando LTR/RTL.
Dropstart,
}
// **< MenuAlign >**********************************************************************************
/// Alineación horizontal del menú desplegable [`Dropdown`].
///
/// Permite alinear el menú al inicio o al final del botón (respetando LTR/RTL) y añadirle una
/// alineación diferente a partir de un punto de ruptura ([`BreakPoint`]).
#[derive(AutoDefault)]
pub enum MenuAlign {
/// Alineación al inicio (comportamiento por defecto).
#[default]
Start,
/// Alineación al inicio a partir del punto de ruptura indicado.
StartAt(BreakPoint),
/// Alineación al inicio por defecto, y al final a partir de un punto de ruptura válido.
StartAndEnd(BreakPoint),
/// Alineación al final.
End,
/// Alineación al final a partir del punto de ruptura indicado.
EndAt(BreakPoint),
/// Alineación al final por defecto, y al inicio a partir de un punto de ruptura válido.
EndAndStart(BreakPoint),
}
// **< MenuPosition >*******************************************************************************
/// Posición relativa del menú desplegable [`Dropdown`].
///
/// Permite indicar un desplazamiento (*offset*) manual o referenciar al elemento padre para el
/// cálculo de la posición.
#[derive(AutoDefault)]
pub enum MenuPosition {
/// Posicionamiento automático por defecto.
#[default]
Default,
/// Desplazamiento manual en píxeles `(x, y)` aplicado al menú. Se admiten valores negativos.
Offset(i8, i8),
/// Posiciona el menú tomando como referencia el botón padre. Especialmente útil cuando
/// [`button_split()`](crate::theme::Dropdown::button_split) es `true`.
Parent,
}
// **< Dropdown >**********************************************************************************
/// Componente para crear un **menú desplegable**.
///
/// Renderiza un botón (único o desdoblado, ver [`with_button_split()`](Self::with_button_split))
/// para mostrar un menú desplegable de elementos [`dropdown::Item`], que se muestra/oculta según la
/// interacción del usuario. Admite variaciones de tamaño/color del botón, también dirección de
/// apertura, alineación o política de cierre.
///
/// Sin título para el botón (ver [`with_button_title()`](Self::with_button_title)) se muestra
/// únicamente la lista de elementos sin ningún botón para interactuar.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let dd = Dropdown::new()
/// .with_button_title(L10n::n("Menu"))
/// .with_button_color(ButtonColor::Background(Color::Secondary))
/// .with_auto_close(dropdown::AutoClose::ClickableInside)
/// .with_direction(dropdown::Direction::Dropend)
/// .add_item(dropdown::Item::link(L10n::n("Home"), |_| "/"))
/// .add_item(dropdown::Item::link_blank(L10n::n("External"), |_| "https://www.google.es"))
/// .add_item(dropdown::Item::divider())
/// .add_item(dropdown::Item::header(L10n::n("User session")))
/// .add_item(dropdown::Item::button(L10n::n("Sign out")));
/// ```
#[rustfmt::skip]
#[derive(AutoDefault)]
pub struct Dropdown {
id : AttrId,
classes: AttrClasses,
items : Children,
id : AttrId,
classes : AttrClasses,
button_title : L10n,
button_size : ButtonSize,
button_color : ButtonColor,
button_split : bool,
button_grouped: bool,
auto_close : dropdown::AutoClose,
direction : dropdown::Direction,
menu_align : dropdown::MenuAlign,
menu_position : dropdown::MenuPosition,
items : Children,
}
impl Component for Dropdown {
@ -19,42 +140,134 @@ impl Component for Dropdown {
self.id.get()
}
#[rustfmt::skip]
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(ClassesOp::Prepend, "dropdown");
let g = self.button_grouped();
self.alter_classes(ClassesOp::Prepend, [
if g { "btn-group" } else { "" },
match self.direction() {
Direction::Default if g => "",
Direction::Default => "dropdown",
Direction::Centered => "dropdown-center",
Direction::Dropup => "dropup",
Direction::DropupCentered => "dropup-center",
Direction::Dropend => "dropend",
Direction::Dropstart => "dropstart",
}
].join(" "));
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
// Si no hay elementos en el menú, no se prepara.
let items = self.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
}
// Título opcional para el menú desplegable.
let button_title = self.button_title().using(cx);
PrepareMarkup::With(html! {
div id=[self.id()] class=[self.classes().get()] {
button
type="button"
class="btn btn-secondary dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
{
("Dropdown button")
}
ul class="dropdown-menu" {
li {
a class="dropdown-item" href="#" {
("Action")
@if !button_title.is_empty() {
@let mut btn_classes = AttrClasses::new([
"btn",
&self.button_size().to_string(),
&self.button_color().to_string(),
].join(" "));
@let (offset, reference) = match self.menu_position() {
MenuPosition::Default => (None, None),
MenuPosition::Offset(x, y) => (Some(format!("{x},{y}")), None),
MenuPosition::Parent => (None, Some("parent")),
};
@let auto_close = match self.auto_close {
AutoClose::Default => None,
AutoClose::ClickableInside => Some("inside"),
AutoClose::ClickableOutside => Some("outside"),
AutoClose::ManualClose => Some("false"),
};
@let menu_classes = AttrClasses::new("dropdown-menu")
.with_value(ClassesOp::Add, match self.menu_align() {
MenuAlign::Start => String::new(),
MenuAlign::StartAt(bp) => bp.try_class("dropdown-menu")
.map_or(String::new(), |class| join!(class, "-start")),
MenuAlign::StartAndEnd(bp) => bp.try_class("dropdown-menu")
.map_or(
"dropdown-menu-start".into(),
|class| join!("dropdown-menu-start ", class, "-end")
),
MenuAlign::End => "dropdown-menu-end".into(),
MenuAlign::EndAt(bp) => bp.try_class("dropdown-menu")
.map_or(String::new(), |class| join!(class, "-end")),
MenuAlign::EndAndStart(bp) => bp.try_class("dropdown-menu")
.map_or(
"dropdown-menu-end".into(),
|class| join!("dropdown-menu-end ", class, "-start")
),
});
// Renderizado en modo split (dos botones) o simple (un botón).
@if self.button_split() {
// Botón principal (acción/etiqueta).
@let btn = html! {
button
type="button"
class=[btn_classes.get()]
{
(button_title)
}
};
// Botón *toggle* que abre/cierra el menú asociado.
@let btn_toggle = html! {
button
type="button"
class=[btn_classes.alter_value(
ClassesOp::Add, "dropdown-toggle dropdown-toggle-split"
).get()]
data-bs-toggle="dropdown"
data-bs-offset=[offset]
data-bs-reference=[reference]
data-bs-auto-close=[auto_close]
aria-expanded="false"
{
span class="visually-hidden" {
(L10n::t("dropdown_toggle", &LOCALES_BOOTSIER).using(cx))
}
}
};
// Orden según dirección (en `dropstart` el *toggle* se sitúa antes).
@match self.direction() {
Direction::Dropstart => {
(btn_toggle)
ul class=[menu_classes.get()] { (items) }
(btn)
}
_ => {
(btn)
(btn_toggle)
ul class=[menu_classes.get()] { (items) }
}
}
}
li {
a class="dropdown-item" href="#" {
("Another action")
}
}
li {
a class="dropdown-item" href="#" {
("Something else here")
} @else {
// Botón único con funcionalidad de *toggle*.
button
type="button"
class=[btn_classes.alter_value(
ClassesOp::Add, "dropdown-toggle"
).get()]
data-bs-toggle="dropdown"
data-bs-offset=[offset]
data-bs-reference=[reference]
data-bs-auto-close=[auto_close]
aria-expanded="false"
{
(button_title)
}
ul class=[menu_classes.get()] { (items) }
}
} @else {
// Sin botón: sólo el listado como menú contextual.
ul class="dropdown-menu" { (items) }
}
}
})
@ -64,23 +277,91 @@ impl Component for Dropdown {
impl Dropdown {
// **< Dropdown BUILDER >***********************************************************************
/// Establece el identificador único (`id`) del menú desplegable.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self
}
/// Modifica la lista de clases CSS aplicadas al menú desplegable.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self
}
/// Establece el título del botón.
#[builder_fn]
pub fn with_button_title(mut self, title: L10n) -> Self {
self.button_title = title;
self
}
/// Ajusta el tamaño del botón.
#[builder_fn]
pub fn with_button_size(mut self, size: ButtonSize) -> Self {
self.button_size = size;
self
}
/// Define el color/estilo del botón.
#[builder_fn]
pub fn with_button_color(mut self, color: ButtonColor) -> Self {
self.button_color = color;
self
}
/// Activa/desactiva el modo *split* (botón de acción + *toggle*).
#[builder_fn]
pub fn with_button_split(mut self, split: bool) -> Self {
self.button_split = split;
self
}
/// Indica si el botón del menú está integrado en un grupo de botones.
#[builder_fn]
pub fn with_button_grouped(mut self, grouped: bool) -> Self {
self.button_grouped = grouped;
self
}
/// Establece la política de cierre automático del menú desplegable.
#[builder_fn]
pub fn with_auto_close(mut self, auto_close: dropdown::AutoClose) -> Self {
self.auto_close = auto_close;
self
}
/// Establece la dirección de despliegue del menú.
#[builder_fn]
pub fn with_direction(mut self, direction: dropdown::Direction) -> Self {
self.direction = direction;
self
}
/// Configura la alineación horizontal (con posible comportamiento *responsive* adicional).
#[builder_fn]
pub fn with_menu_align(mut self, align: dropdown::MenuAlign) -> Self {
self.menu_align = align;
self
}
/// Configura la posición del menú.
#[builder_fn]
pub fn with_menu_position(mut self, position: dropdown::MenuPosition) -> Self {
self.menu_position = position;
self
}
/// Añade un nuevo elemento hijo al menú.
#[inline]
pub fn add_item(mut self, item: dropdown::Item) -> Self {
self.items.add(Child::with(item));
self
}
/// Modifica la lista de elementos (`children`) aplicando una operación [`TypedOp`].
#[builder_fn]
pub fn with_items(mut self, op: TypedOp<dropdown::Item>) -> Self {
self.items.alter_typed(op);
@ -89,10 +370,57 @@ impl Dropdown {
// **< Dropdown GETTERS >***********************************************************************
/// Devuelve las clases CSS asociadas al menú desplegable.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el título del botón.
pub fn button_title(&self) -> &L10n {
&self.button_title
}
/// Devuelve el tamaño configurado del botón.
pub fn button_size(&self) -> &ButtonSize {
&self.button_size
}
/// Devuelve el color/estilo configurado del botón.
pub fn button_color(&self) -> &ButtonColor {
&self.button_color
}
/// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*).
pub fn button_split(&self) -> bool {
self.button_split
}
/// Devuelve si el botón del menú está integrado en un grupo de botones.
pub fn button_grouped(&self) -> bool {
self.button_grouped
}
/// Devuelve la política de cierre automático del menú desplegado.
pub fn auto_close(&self) -> &dropdown::AutoClose {
&self.auto_close
}
/// Devuelve la dirección de despliegue configurada.
pub fn direction(&self) -> &dropdown::Direction {
&self.direction
}
/// Devuelve la configuración de alineación horizontal del menú desplegable.
pub fn menu_align(&self) -> &dropdown::MenuAlign {
&self.menu_align
}
/// Devuelve la posición configurada para el menú desplegable.
pub fn menu_position(&self) -> &dropdown::MenuPosition {
&self.menu_position
}
/// Devuelve la lista de elementos (`children`) del menú.
pub fn items(&self) -> &Children {
&self.items
}