(base): Añade componente Dropdown

This commit is contained in:
Manuel Cillero 2026-08-14 20:01:15 +02:00
parent e5431bc7d7
commit 946d335664
10 changed files with 584 additions and 0 deletions

View file

@ -141,6 +141,117 @@ input:disabled + label {
color: var(--val-color--text--muted);
}
/*
* Dropdown component
*/
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-button,
.dropdown-toggle {
padding: 0.5rem 0.75rem;
font: inherit;
color: var(--val-color--text);
background-color: transparent;
border: 1px solid var(--val-color--border);
border-radius: 0.375rem;
cursor: pointer;
}
.dropdown-toggle::after {
content: "";
display: inline-block;
width: 0.4em;
height: 0.4em;
margin-left: 0.35em;
border-right: 0.125em solid currentColor;
border-bottom: 0.125em solid currentColor;
transform: rotate(45deg);
transition: transform .15s ease-in-out;
}
.dropdown:has(> .dropdown-menu.show) > .dropdown-toggle::after {
transform: rotate(-135deg);
}
.dropdown-menu {
position: absolute;
z-index: 1000;
top: 100%;
left: 0;
min-width: 12rem;
margin: 0.25rem 0 0;
padding: 0.5rem 0;
list-style: none;
background-color: var(--val-color--bg);
border: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.dropdown-menu.hide {
display: none;
}
.dropdown-item {
display: block;
width: 100%;
padding: 0.375rem 1rem;
text-align: left;
color: var(--val-color--text);
text-decoration: none;
background-color: transparent;
border: none;
font: inherit;
cursor: pointer;
}
.dropdown-item:hover {
background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
}
.dropdown-item.active {
color: var(--val-color--primary);
font-weight: 600;
}
.dropdown-item.disabled {
color: var(--val-color--text--muted);
pointer-events: none;
}
.dropdown-item-text {
display: block;
padding: 0.375rem 1rem;
color: var(--val-color--text--muted);
}
.dropdown-header {
display: block;
padding: 0.5rem 1rem 0.25rem;
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--val-color--text--muted);
}
.dropdown-divider {
height: 0;
margin: 0.5rem 0;
border: none;
border-top: 1px solid var(--val-color--border);
}
/* Utility: visually hidden but still accessible to assistive technology */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/*
* Messages component
*/

3
assets/js/basic.menu.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,26 @@
// Conecta cada `Dropdown` (standalone o embebido en un `Nav`) con `accessible-menu` (ver
// basic.menu.min.js), que añade los roles ARIA, el `aria-expanded` y la navegación por teclado
// sobre el marcado ya generado en el servidor. Selecciona por clase, no por etiqueta, porque el
// disparador es un `<button>` en un `Dropdown` independiente y un `<a>` cuando cuelga de un
// `nav::Item::dropdown()`.
document.querySelectorAll(".dropdown > .dropdown-toggle").forEach(function (toggle) {
var container = toggle.parentElement;
var menu = container.querySelector(":scope > .dropdown-menu");
if (!menu) {
return;
}
new TopLinkDisclosureMenu({
menuElement: menu,
containerElement: container,
controllerElement: toggle,
// `Header`/`Divider`/`Label` no son interactivos: se excluyen de `menuItemSelector`
// (si no, la librería asume que todo `<li>` tiene un enlace y lanza un TypeError al
// intentar añadirle listeners de foco).
menuItemSelector: "li:has(> a.dropdown-item, > button.dropdown-item)",
menuLinkSelector: "a.dropdown-item, button.dropdown-item",
// La navegación con flechas/Home/End es opcional en la librería (`false` por defecto);
// se activa para igualar el comportamiento habitual de un menú desplegable.
optionalKeySupport: true,
});
});

View file

@ -19,6 +19,10 @@ pub mod container;
#[doc(inline)]
pub use container::Container;
pub mod dropdown;
#[doc(inline)]
pub use dropdown::Dropdown;
pub mod form;
#[doc(inline)]
pub use form::Form;

View file

@ -0,0 +1,7 @@
//! Definiciones para crear menús desplegables ([`Dropdown`]) y sus elementos ([`Item`]).
mod component;
pub use component::Dropdown;
mod item;
pub use item::Item;

View file

@ -0,0 +1,147 @@
use crate::prelude::*;
/// Componente para crear un **menú de acciones desplegable**.
///
/// Renderiza un botón (único o desdoblado, ver [`with_button_split()`](Self::with_button_split))
/// que despliega u oculta una lista de elementos [`dropdown::Item`](super::Item).
///
/// Sin título (ver [`with_title()`](Self::with_title)), se muestra únicamente la lista de
/// elementos, sin ningún botón para interactuar, sólo un menú contextual estático.
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
///
/// let dd = dropdown::Dropdown::new()
/// .with_title(Lc::n("Menu"))
/// .with_item(dropdown::Item::link(Lc::n("Home"), "/"))
/// .with_item(dropdown::Item::link_blank(Lc::n("Doc"), "https://docs.rs"))
/// .with_item(dropdown::Item::divider())
/// .with_item(dropdown::Item::header(Lc::n("User session")))
/// .with_item(dropdown::Item::button(Lc::n("Sign out")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Dropdown {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve el título del menú desplegable.
title: Lc,
/// Devuelve si el botón se desdobla (*split*) en botón de acción + *toggle*.
button_split: bool,
/// Devuelve la lista de elementos del menú.
items: Children,
}
#[async_trait]
impl Component for Dropdown {
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("dropdown"));
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Si no hay elementos en el menú, no se prepara.
let items = self.items().render(cx).await;
if items.is_empty() {
return Ok(html! {});
}
let title = self.title().using(cx);
// Sin título: menú contextual estático, sin botón ni comportamiento de apertura/cierre.
if title.is_empty() {
return Ok(html! {
ul class="dropdown-menu" { (items) }
});
}
let toggle_label = Lc::l("dropdown_toggle").using(cx);
Ok(html! {
div (self.props()) {
@if *self.button_split() {
button type="button" class="dropdown-button" { (&title) }
button
type="button"
class="dropdown-toggle"
aria-haspopup="true"
aria-expanded="false"
{
span class="visually-hidden" { (toggle_label) }
}
} @else {
button
type="button"
class="dropdown-toggle"
aria-haspopup="true"
aria-expanded="false"
{
(&title)
}
}
ul class="dropdown-menu" { (items) }
}
})
}
}
impl Dropdown {
// **< Dropdown 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, 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 título del menú desplegable.
#[builder_fn]
pub fn with_title(mut self, title: Lc) -> Self {
self.title = title;
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
}
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una
/// operación [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// dropdown.with_item(dropdown::Item::link("Opción", "/ruta"));
/// dropdown.with_item(ChildOp::AddMany(vec![
/// dropdown::Item::link(...).into(),
/// dropdown::Item::divider().into(),
/// dropdown::Item::link(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

@ -0,0 +1,272 @@
use crate::prelude::*;
// **< ItemKind >***********************************************************************************
/// Tipos de [`dropdown::Item`](super::Item) disponibles en un menú desplegable
/// [`Dropdown`](super::Dropdown).
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault, Clone, Debug)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
Void,
/// Etiqueta sin comportamiento interactivo.
Label(Lc),
/// Elemento de navegación basado en una [`RoutePath`] dinámica resuelta por una [`Route`].
/// Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente deshabilitado.
Link {
label: Lc,
route: Route,
blank: bool,
disabled: bool,
},
/// Acción ejecutable en la propia página, sin navegación asociada. Inicialmente puede estar
/// deshabilitado.
Button { label: Lc, disabled: bool },
/// Título o encabezado que separa grupos de opciones.
Header(Lc),
/// Separador visual entre bloques de elementos.
Divider,
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú desplegable [`Dropdown`](super::Dropdown).
///
/// Cada instancia de [`dropdown::Item`](super::Item) se traduce en un componente visible que
/// puede comportarse como texto, enlace, botón, encabezado o separador, según su [`ItemKind`].
///
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
#[async_trait]
impl Component for Item {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => html! {
li (self.props()) {
span class="dropdown-item-text" {
(label.using(cx))
}
}
},
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route.resolve(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "dropdown-item".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = (*disabled).then_some("true");
let tabindex = disabled.then_some("-1");
html! {
li (self.props()) {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
tabindex=[tabindex]
{
(label.using(cx))
}
}
}
}
ItemKind::Button { label, disabled } => {
let mut classes = "dropdown-item".to_string();
if *disabled {
classes.push_str(" disabled");
}
let aria_disabled = disabled.then_some("true");
let disabled_attr = disabled.then_some("disabled");
html! {
li (self.props()) {
button
class=(classes)
type="button"
aria-disabled=[aria_disabled]
disabled=[disabled_attr]
{
(label.using(cx))
}
}
}
}
ItemKind::Header(label) => html! {
li (self.props()) {
h6 class="dropdown-header" {
(label.using(cx))
}
}
},
ItemKind::Divider => html! {
li (self.props()) { hr class="dropdown-divider" {} }
},
})
}
}
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: Lc) -> Self {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
///
/// La ruta se obtiene invocando [`Route::resolve()`], que devuelve dinámicamente una
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta
/// actual del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
pub fn link(label: Lc, route: impl Into<Route>) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route: route.into(),
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: Lc, route: impl Into<Route>) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route: route.into(),
blank: false,
disabled: true,
},
..Default::default()
}
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: Lc, route: impl Into<Route>) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route: route.into(),
blank: true,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: Lc, route: impl Into<Route>) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route: route.into(),
blank: true,
disabled: true,
},
..Default::default()
}
}
/// Crea un botón de acción local, sin navegación asociada.
pub fn button(label: Lc) -> Self {
Self {
item_kind: ItemKind::Button {
label,
disabled: false,
},
..Default::default()
}
}
/// Crea un botón deshabilitado.
pub fn button_disabled(label: Lc) -> Self {
Self {
item_kind: ItemKind::Button {
label,
disabled: true,
},
..Default::default()
}
}
/// Crea un encabezado para un grupo de elementos dentro del menú.
pub fn header(label: Lc) -> Self {
Self {
item_kind: ItemKind::Header(label),
..Default::default()
}
}
/// Crea un separador visual entre bloques de elementos.
pub fn divider() -> Self {
Self {
item_kind: ItemKind::Divider,
..Default::default()
}
}
// **< Item 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, atributos HTML o valores extra del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
}

View file

@ -24,6 +24,12 @@ impl Theme for Basic {
.with_version(PAGETOP_VERSION)
.with_weight(-99),
))
.alter_assets(AssetsOp::AddJavaScript(
JavaScript::defer("/pagetop/js/basic.menu.min.js").with_version("4.4.0"),
))
.alter_assets(AssetsOp::AddJavaScript(
JavaScript::defer("/pagetop/js/dropdown.init.js").with_version(PAGETOP_VERSION),
))
.alter_child_in(
&CoreRegions::Footer,
ChildOp::AddIfEmpty(PoweredBy::new().into()),

View file

@ -19,6 +19,10 @@ intro_have_fun = Coding is creating
# PoweredBy component.
poweredby_pagetop = Powered by { $pagetop_link }
# Dropdown component.
dropdown_toggle = Toggle dropdown
dropdown_default_title = Dropdown
# Breadcrumb component.
breadcrumb_label = Breadcrumb navigation

View file

@ -19,6 +19,10 @@ intro_have_fun = Programar es crear
# PoweredBy component.
poweredby_pagetop = Funciona con { $pagetop_link }
# Dropdown component.
dropdown_toggle = Alternar menú desplegable
dropdown_default_title = Menú desplegable
# Breadcrumb component.
breadcrumb_label = Ruta de navegación