From 3c67d9d04ca3c1ec1a0a9485f890d507f5fd9f34 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 26 Aug 2026 06:55:37 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20(props):=20A=C3=B1ade=20la=20op?= =?UTF-8?q?eraci=C3=B3n=20`PropsOp::translate`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permite a un tema traducir el vocabulario de atributos de un componente al suyo propio (p. ej. `data-dialog-*` -> `data-bs-*`) sin conocer su valor, respetando el que ya tuviera el destino si existía. --- src/html/props.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/html/props.rs b/src/html/props.rs index 281a586d..b80349f0 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -142,6 +142,13 @@ pub enum PropsOp { /// Elimina el atributo indicado. Usar `"id"` elimina el identificador; usar `"class"` vacía la /// lista de clases; y usar `"style"` vacía la lista de estilos. Remove(CowStr), + /// Si el primer atributo (origen) existe, lo renombra al segundo (destino), conservando su + /// valor; si el destino ya tiene su propio valor, se respeta sin sobrescribir y sólo se elimina + /// el origen. Si el origen no existe, la operación no tiene efecto. + /// + /// Sólo actúa sobre atributos genéricos, por lo que los nombres `"id"`, `"class"` y `"style"` + /// no se reconocen como origen ni destino. + Translate(CowStr, CowStr), /// Almacena un valor extra tipado asociado a la clave indicada. Si ya existe uno con esa clave, /// lo reemplaza. SetExtra(&'static str, PropsExtra), @@ -241,6 +248,26 @@ impl PropsOp { Self::Remove(name.into()) } + /// Crea la variante [`Translate`](Self::Translate) para renombrar `from` a `to`. + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let props = Props::new("data-dialog-toggle", "modal") + /// .with_prop(PropsOp::translate("data-dialog-toggle", "data-bs-toggle")); + /// assert_eq!(props.get_prop("data-bs-toggle"), Some("modal".to_string())); + /// assert_eq!(props.get_prop("data-dialog-toggle"), None); + /// + /// // Si el destino tiene su propio valor no se sobrescribe, sólo se elimina el origen. + /// let props = Props::new("data-bs-toggle", "collapse") + /// .with_prop(PropsOp::set("data-dialog-toggle", "modal")) + /// .with_prop(PropsOp::translate("data-dialog-toggle", "data-bs-toggle")); + /// assert_eq!(props.get_prop("data-bs-toggle"), Some("collapse".to_string())); + /// assert_eq!(props.get_prop("data-dialog-toggle"), None); + /// ``` + pub fn translate(from: impl Into, to: impl Into) -> Self { + Self::Translate(from.into(), to.into()) + } + /// Crea la variante [`SetExtra`](Self::SetExtra) con la clave y el valor indicados. /// /// ```rust @@ -582,6 +609,14 @@ impl Props { self.attrs.retain(|(k, _)| k != &name); } } + PropsOp::Translate(from, to) => { + if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &from) { + let (_, value) = self.attrs.remove(pos); + if !self.attrs.iter().any(|(k, _)| k == &to) { + self.attrs.push((to, value)); + } + } + } PropsOp::SetExtra(key, extra) => { self.extras.insert(key, extra); } From 76c5cb9b765551cb8cc0880c758bec1d121057ff Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 26 Aug 2026 21:56:10 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20(base):=20A=C3=B1ade=20componen?= =?UTF-8?q?te=20Dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sustituye `PropsOp::Translate` por `Rename` para que los temas traduzcan atributos `data-dialog-*` al vocabulario propio (p. ej. `data-bs-*` en Bootsier). --- assets/css/basic.css | 62 ++++++++++ assets/js/basic.dialog.init.js | 45 +++++++ examples/form-controls.rs | 33 ++++++ examples/locale/en-US/form-controls.ftl | 4 + examples/locale/es-ES/form-controls.ftl | 4 + src/base/component.rs | 3 + src/base/component/dialog.rs | 149 ++++++++++++++++++++++++ src/base/theme/basic.rs | 3 + src/html/props.rs | 53 +++++---- src/locale/en-US/base.ftl | 24 ++-- src/locale/es-ES/base.ftl | 24 ++-- 11 files changed, 362 insertions(+), 42 deletions(-) create mode 100644 assets/js/basic.dialog.init.js create mode 100644 src/base/component/dialog.rs diff --git a/assets/css/basic.css b/assets/css/basic.css index 1a3f5c18..ded9e3ef 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -283,6 +283,68 @@ input:disabled + label { color: var(--val-color--text--muted); } +/* + * Dialog component + */ + +.dialog { + padding: 0; + width: 100%; + max-width: 32rem; + color: var(--val-color--text); + background-color: var(--val-color--bg); + border: none; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} +.dialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); +} + +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); +} + +.dialog-title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.dialog-close { + width: 1.5rem; + height: 1.5rem; + padding: 0; + line-height: 1; + color: var(--val-color--text--muted); + background-color: transparent; + border: none; + cursor: pointer; +} +.dialog-close::before { + content: "\2715"; +} +.dialog-close:hover { + color: var(--val-color--text); +} + +.dialog-body { + padding: 1.25rem; +} + +.dialog-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 1rem 1.25rem; + border-top: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); +} + /* * Dropdown component */ diff --git a/assets/js/basic.dialog.init.js b/assets/js/basic.dialog.init.js new file mode 100644 index 00000000..e40780b9 --- /dev/null +++ b/assets/js/basic.dialog.init.js @@ -0,0 +1,45 @@ +// Abre y cierra los componentes `Dialog` de Basic (elemento `` nativo) interpretando el +// vocabulario de atributos `data-dialog-*`, común a todos los temas: Bootsier traduce estos mismos +// nombres a los suyos propios (`data-bs-*`) en `setup()` para que el JS de Bootstrap los reconozca, +// así el marcado de un disparador o de un botón de cierre es idéntico en los dos temas. Sin +// dependencias: ni htmx, ni Bootstrap, ni ninguna otra librería. + +// Abre el diálogo referenciado por `data-dialog-target` al pulsar un elemento con +// `data-dialog-toggle="modal"`. +document.addEventListener("click", function (event) { + var toggle = event.target.closest('[data-dialog-toggle="modal"]'); + if (!toggle) { + return; + } + var target = toggle.getAttribute("data-dialog-target"); + var dialog = target && document.querySelector(target); + if (!(dialog instanceof HTMLDialogElement)) { + return; + } + event.preventDefault(); + dialog.showModal(); +}); + +// Cierra el `` ascendente más cercano al pulsar un elemento con +// `data-dialog-dismiss="modal"`. +document.addEventListener("click", function (event) { + var dismiss = event.target.closest('[data-dialog-dismiss="modal"]'); + if (!dismiss) { + return; + } + var dialog = dismiss.closest("dialog"); + if (dialog) { + event.preventDefault(); + dialog.close(); + } +}); + +// Cierra el diálogo al pulsar sobre el fondo (`::backdrop`): un clic ahí llega con el propio +// `` como `event.target`, porque el fondo no forma parte de su caja de contenido. +document.querySelectorAll("dialog.dialog").forEach(function (dialog) { + dialog.addEventListener("click", function (event) { + if (event.target === dialog) { + dialog.close(); + } + }); +}); diff --git a/examples/form-controls.rs b/examples/form-controls.rs index e40b7af0..6b064165 100644 --- a/examples/form-controls.rs +++ b/examples/form-controls.rs @@ -285,6 +285,39 @@ async fn form_controls(request: HttpRequest) -> Result { }, ) .with_child(form_lists()), + ) + // Bloque 4: diálogo modal. + .with_child( + Block::new() + .with_title(Lc::t("block_dialog", &LOC)) + .with_child( + Dialog::new() + .with_id("delete-confirm") + .with_title(Lc::t("dialog_delete_title", &LOC)) + .with_child(Html::with(|cx| { + html! { + p { (Lc::t("dialog_delete_body", &LOC).using(cx)) } + } + })) + .with_footer( + Button::plain(Lc::t("btn_cancel", &LOC)) + .with_prop(PropsOp::set("data-dialog-dismiss", "modal")) + .with_style(button::ButtonStyle::Outline( + Intent::Secondary, + )), + ) + .with_footer( + Button::plain(Lc::t("btn_ok", &LOC)) + .with_prop(PropsOp::set("data-dialog-dismiss", "modal")) + .with_style(button::ButtonStyle::Solid(Intent::Primary)), + ), + ) + .with_child( + Button::plain(Lc::t("btn_delete", &LOC)) + .with_prop(PropsOp::set("data-dialog-toggle", "modal")) + .with_prop(PropsOp::set("data-dialog-target", "#delete-confirm")) + .with_style(button::ButtonStyle::Solid(Intent::Danger)), + ), ), ) .render() diff --git a/examples/locale/en-US/form-controls.ftl b/examples/locale/en-US/form-controls.ftl index 05e345c6..472cb35c 100644 --- a/examples/locale/en-US/form-controls.ftl +++ b/examples/locale/en-US/form-controls.ftl @@ -4,6 +4,7 @@ block_selections = Checkboxes, switches and radio buttons block_text = Text fields, multiline and range block_lists = Selection lists block_lists_floating = Select lists and floating labels +block_dialog = Modal dialog fieldset_text = Text fields label_name = Full name @@ -77,3 +78,6 @@ btn_reset = Reset btn_cancel = Cancel btn_ok = Ok btn_delete = Delete record + +dialog_delete_title = Delete record +dialog_delete_body = Are you sure you want to delete this record? This action cannot be undone. diff --git a/examples/locale/es-ES/form-controls.ftl b/examples/locale/es-ES/form-controls.ftl index 57ce3318..6d46f122 100644 --- a/examples/locale/es-ES/form-controls.ftl +++ b/examples/locale/es-ES/form-controls.ftl @@ -4,6 +4,7 @@ block_selections = Casillas, interruptores y botones de opción block_text = Campos de texto, multilínea y rango block_lists = Listas de selección block_lists_floating = Listas de selección y etiquetas flotantes +block_dialog = Diálogo modal fieldset_text = Campos de texto label_name = Nombre completo @@ -77,3 +78,6 @@ btn_reset = Restablecer btn_cancel = Cancelar btn_ok = Aceptar btn_delete = Eliminar registro + +dialog_delete_title = Eliminar registro +dialog_delete_body = ¿Seguro que quieres eliminar este registro? Esta acción no se puede deshacer. diff --git a/src/base/component.rs b/src/base/component.rs index fbabe173..6523cfca 100644 --- a/src/base/component.rs +++ b/src/base/component.rs @@ -23,6 +23,9 @@ pub mod container; #[doc(inline)] pub use container::Container; +mod dialog; +pub use dialog::Dialog; + pub mod dropdown; #[doc(inline)] pub use dropdown::Dropdown; diff --git a/src/base/component/dialog.rs b/src/base/component/dialog.rs new file mode 100644 index 00000000..58ccd5eb --- /dev/null +++ b/src/base/component/dialog.rs @@ -0,0 +1,149 @@ +use crate::prelude::*; + +/// Componente para crear un **diálogo modal**. +/// +/// Renderiza un cuadro de diálogo con cabecera (título opcional más el botón de cierre), cuerpo y +/// un pie opcional para botones de acción (`Ok`, `Cancelar`, u otros botones personalizados). +/// +/// Sin cuerpo ni pie, el componente **no se renderiza**. +/// +/// Cualquier [`Button`] que deba abrir o cerrar el diálogo debe usar el siguiente vocabulario de +/// atributos `data-dialog-*`: +/// +/// - `data-dialog-toggle="modal"` más `data-dialog-target="#id"` en el `Button` que abre el +/// diálogo. +/// - `data-dialog-dismiss="modal"` en cualquier `Button` que deba cerrarlo (el botón de cierre de +/// la cabecera ya lo lleva). +/// +/// Sólo en los componentes `Button` **podrán renombrarse automáticamente estos atributos al +/// vocabulario propio de cada tema**; cualquier otro elemento deberá usar directamente el nombre +/// final que el tema en cuestión espera, perdiendo así la portabilidad entre temas que sí tiene un +/// `Button`. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// +/// let dialog = Dialog::new() +/// .with_id("confirm-delete") +/// .with_title(Lc::n("Delete record")) +/// .with_child( +/// Html::with(|_| html! { p { "Are you sure you want to delete this record?" } }) +/// ) +/// .with_footer( +/// Button::plain(Lc::n("Cancel")).with_prop(PropsOp::set("data-dialog-dismiss", "modal")), +/// ) +/// .with_footer(Button::submit(Lc::n("Delete"))); +/// +/// let opener = Button::plain(Lc::n("Delete")) +/// .with_prop(PropsOp::set("data-dialog-toggle", "modal")) +/// .with_prop(PropsOp::set("data-dialog-target", "#confirm-delete")); +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct Dialog { + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. + props: Props, + /// Devuelve el título del diálogo. + title: Lc, + /// Devuelve la lista de componentes (`children`) del cuerpo del diálogo. + body: Children, + /// Devuelve la lista de componentes (`children`) del pie del diálogo. + footer: Children, +} + +#[async_trait] +impl Component for Dialog { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, cx: &Context) { + // Asegura que el diálogo tiene un identificador único con el que abrirlo. + self.alter_prop(PropsOp::ensure_id(cx.build_id::(1))); + self.alter_prop(PropsOp::prepend_classes("dialog")); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let body = self.body().render(cx).await; + let footer = self.footer().render(cx).await; + if body.is_empty() && footer.is_empty() { + return Ok(html! {}); + } + + let title = self.title().using(cx); + // `setup()` garantiza que habrá un `id` antes de renderizar. Sin título no hay elemento + // que etiquete el diálogo, así que `aria-labelledby` se omite en vez de apuntar a un `id` + // inexistente. + let id_label = (!title.is_empty()).then(|| util::join!(self.id().unwrap(), "-label")); + + Ok(html! { + dialog (self.props()) aria-labelledby=[id_label.as_deref()] { + div class="dialog-header" { + @if let Some(id_label) = &id_label { + h2 id=(id_label) class="dialog-title" { (title) } + } + button + type="button" + class="dialog-close" + data-dialog-dismiss="modal" + aria-label=[Lc::l("dialog_close").lookup(cx)] + {} + } + div class="dialog-body" { (body) } + @if !footer.is_empty() { + div class="dialog-footer" { (footer) } + } + } + }) + } +} + +impl Dialog { + // **< Dialog 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 título del diálogo. + #[builder_fn] + pub fn with_title(mut self, title: Lc) -> Self { + self.title = title; + self + } + + /// Añade un nuevo componente al cuerpo del diálogo o modifica la lista de componentes + /// (`children`) del cuerpo con una operación [`ChildOp`]. + #[builder_fn] + pub fn with_child(mut self, op: impl Into) -> Self { + self.body.alter_child(op.into()); + self + } + + /// Añade un nuevo componente al pie del diálogo (normalmente un [`Button`]) o modifica la + /// lista de componentes (`children`) del pie con una operación [`ChildOp`]. + /// + /// El pie ya se maqueta en fila y alineado a la derecha por su propia clase CSS + /// (`dialog-footer`); por lo que no requiere un [`ButtonSet`](super::ButtonSet) para alinear + /// los botones, aunque puede usarse si se desea. + #[builder_fn] + pub fn with_footer(mut self, op: impl Into) -> Self { + self.footer.alter_child(op.into()); + self + } +} diff --git a/src/base/theme/basic.rs b/src/base/theme/basic.rs index 9171b49e..e0af9a3a 100644 --- a/src/base/theme/basic.rs +++ b/src/base/theme/basic.rs @@ -30,6 +30,9 @@ impl Theme for Basic { .alter_assets(AssetsOp::AddJavaScript( JavaScript::defer("/pagetop/js/dropdown.init.js").with_version(PAGETOP_VERSION), )) + .alter_assets(AssetsOp::AddJavaScript( + JavaScript::defer("/pagetop/js/basic.dialog.min.js").with_version(PAGETOP_VERSION), + )) .alter_child_in( &CoreRegions::Footer, ChildOp::AddIfEmpty(PoweredBy::new().into()), diff --git a/src/html/props.rs b/src/html/props.rs index b80349f0..9989fecb 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -76,8 +76,13 @@ pub enum PropsError { /// /// Las variantes [`Set`](Self::Set) y [`Remove`](Self::Remove) son operaciones de propósito /// general. `Set` añade o reemplaza cualquier atributo HTML por nombre y valor, y `Remove` lo -/// elimina. Los atributos `id`, `class` y `style` tienen semántica especial documentada en cada -/// variante. +/// elimina. Los atributos `id`, `class` y `style` tienen semántica especial documentada en estas +/// variantes. +/// +/// [`Rename`](Self::Rename) cambia el nombre de un atributo genérico conservando su valor; no +/// reconoce `"id"`, `"class"` ni `"style"` como origen ni destino. Está pensada para que un tema +/// traduzca a su propio vocabulario los atributos que un componente ya expone, sin que éste tenga +/// que conocer ningún tema en concreto. /// /// Las variantes `*Extra` permiten añadir valores tipados usando una clave. Están pensadas para /// ampliar el comportamiento de componentes ya existentes. Como no es posible añadir campos a la @@ -139,16 +144,16 @@ pub enum PropsOp { /// cadena con `;` (`content: "a;b"`) se interpretan correctamente. En cualquier caso, se /// recomienda usar [`PropsOp::add_style()`](Self::add_style) para declarar estilos. Set(CowStr, CowStr), - /// Elimina el atributo indicado. Usar `"id"` elimina el identificador; usar `"class"` vacía la - /// lista de clases; y usar `"style"` vacía la lista de estilos. - Remove(CowStr), /// Si el primer atributo (origen) existe, lo renombra al segundo (destino), conservando su /// valor; si el destino ya tiene su propio valor, se respeta sin sobrescribir y sólo se elimina /// el origen. Si el origen no existe, la operación no tiene efecto. /// /// Sólo actúa sobre atributos genéricos, por lo que los nombres `"id"`, `"class"` y `"style"` /// no se reconocen como origen ni destino. - Translate(CowStr, CowStr), + Rename(CowStr, CowStr), + /// Elimina el atributo indicado. Usar `"id"` elimina el identificador; usar `"class"` vacía la + /// lista de clases; y usar `"style"` vacía la lista de estilos. + Remove(CowStr), /// Almacena un valor extra tipado asociado a la clave indicada. Si ya existe uno con esa clave, /// lo reemplaza. SetExtra(&'static str, PropsExtra), @@ -243,29 +248,29 @@ impl PropsOp { Self::Set(name.into(), value.into()) } - /// Crea la variante [`Remove`](Self::Remove) para el atributo indicado. - pub fn remove(name: impl Into) -> Self { - Self::Remove(name.into()) - } - - /// Crea la variante [`Translate`](Self::Translate) para renombrar `from` a `to`. + /// Crea la variante [`Rename`](Self::Rename) para renombrar `from` a `to`. /// /// ```rust /// # use pagetop::prelude::*; /// let props = Props::new("data-dialog-toggle", "modal") - /// .with_prop(PropsOp::translate("data-dialog-toggle", "data-bs-toggle")); + /// .with_prop(PropsOp::rename("data-dialog-toggle", "data-bs-toggle")); /// assert_eq!(props.get_prop("data-bs-toggle"), Some("modal".to_string())); /// assert_eq!(props.get_prop("data-dialog-toggle"), None); /// /// // Si el destino tiene su propio valor no se sobrescribe, sólo se elimina el origen. /// let props = Props::new("data-bs-toggle", "collapse") /// .with_prop(PropsOp::set("data-dialog-toggle", "modal")) - /// .with_prop(PropsOp::translate("data-dialog-toggle", "data-bs-toggle")); + /// .with_prop(PropsOp::rename("data-dialog-toggle", "data-bs-toggle")); /// assert_eq!(props.get_prop("data-bs-toggle"), Some("collapse".to_string())); /// assert_eq!(props.get_prop("data-dialog-toggle"), None); /// ``` - pub fn translate(from: impl Into, to: impl Into) -> Self { - Self::Translate(from.into(), to.into()) + pub fn rename(from: impl Into, to: impl Into) -> Self { + Self::Rename(from.into(), to.into()) + } + + /// Crea la variante [`Remove`](Self::Remove) para el atributo indicado. + pub fn remove(name: impl Into) -> Self { + Self::Remove(name.into()) } /// Crea la variante [`SetExtra`](Self::SetExtra) con la clave y el valor indicados. @@ -598,6 +603,14 @@ impl Props { self.attrs.push((name, value)); } } + PropsOp::Rename(from, to) => { + if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &from) { + let (_, value) = self.attrs.remove(pos); + if !self.attrs.iter().any(|(k, _)| k == &to) { + self.attrs.push((to, value)); + } + } + } PropsOp::Remove(name) => { if name.as_ref() == "id" { self.id = None; @@ -609,14 +622,6 @@ impl Props { self.attrs.retain(|(k, _)| k != &name); } } - PropsOp::Translate(from, to) => { - if let Some(pos) = self.attrs.iter().position(|(k, _)| k == &from) { - let (_, value) = self.attrs.remove(pos); - if !self.attrs.iter().any(|(k, _)| k == &to) { - self.attrs.push((to, value)); - } - } - } PropsOp::SetExtra(key, extra) => { self.extras.insert(key, extra); } diff --git a/src/locale/en-US/base.ftl b/src/locale/en-US/base.ftl index dc1aebd6..8255e2f2 100644 --- a/src/locale/en-US/base.ftl +++ b/src/locale/en-US/base.ftl @@ -1,3 +1,13 @@ +# Breadcrumb component. +breadcrumb_label = Breadcrumb navigation + +# Dialog component. +dialog_close = Close dialog + +# Dropdown component. +dropdown_toggle = Toggle dropdown +dropdown_default_title = Dropdown + # Form components. field_required = This field is required @@ -16,15 +26,8 @@ intro_text2 = PageTop brings back the essence of the classic web, renders on the intro_code = Code 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 +# Navbar component. +navbar_toggle = Toggle navigation # Pager component. pager_aria_label = Page navigation @@ -36,5 +39,8 @@ pager_goto_label = Jump to page pager_goto_button = Go pager_summary = Showing { $first }-{ $last } of { $total } +# PoweredBy component. +poweredby_pagetop = Powered by { $pagetop_link } + # Table component. table_empty = No data to display diff --git a/src/locale/es-ES/base.ftl b/src/locale/es-ES/base.ftl index e579658b..906c77fd 100644 --- a/src/locale/es-ES/base.ftl +++ b/src/locale/es-ES/base.ftl @@ -1,3 +1,13 @@ +# Breadcrumb component. +breadcrumb_label = Ruta de navegación + +# Dialog component. +dialog_close = Cerrar diálogo + +# Dropdown component. +dropdown_toggle = Alternar menú desplegable +dropdown_default_title = Menú desplegable + # Form components. field_required = Este campo es obligatorio @@ -16,15 +26,8 @@ intro_text2 = PageTop reivindica la esencia de la web clásica, renderiza en el intro_code = Código 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 +# Navbar component. +navbar_toggle = Alternar navegación # Pager component. pager_aria_label = Navegación de páginas @@ -36,5 +39,8 @@ pager_goto_label = Saltar a la página pager_goto_button = Ir pager_summary = Mostrando { $first }-{ $last } de { $total } +# PoweredBy component. +poweredby_pagetop = Funciona con { $pagetop_link } + # Table component. table_empty = No hay datos que mostrar From af53cd4b528dda660d6b174576d477ed2feb2696 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Wed, 26 Aug 2026 23:58:33 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(dropdown):=20Integra?= =?UTF-8?q?=20Dropdown=20con=20accessible-menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renombra `dropdown.init.js` a `basic.dropdown.init.js` y añade cierre cruzado entre menús abiertos y cierre al clicar fuera. Corrige el icono de apertura/cierre y el estado cerrado por defecto de dropdown-menu. Migra `with_item` de `ChildOp` a `TypedOp`. --- assets/css/basic.css | 16 +++--- assets/js/basic.dropdown.init.js | 66 ++++++++++++++++++++++++ assets/js/dropdown.init.js | 26 ---------- src/base/component/dropdown.rs | 2 +- src/base/component/dropdown/component.rs | 29 ++++++----- src/base/theme/basic.rs | 2 +- 6 files changed, 91 insertions(+), 50 deletions(-) create mode 100644 assets/js/basic.dropdown.init.js delete mode 100644 assets/js/dropdown.init.js diff --git a/assets/css/basic.css b/assets/css/basic.css index ded9e3ef..2c69d35c 100644 --- a/assets/css/basic.css +++ b/assets/css/basic.css @@ -365,18 +365,14 @@ input:disabled + label { cursor: pointer; } .dropdown-toggle::after { - content: ""; + content: "\25BC"; 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); + font-size: 0.8em; transition: transform .15s ease-in-out; } .dropdown:has(> .dropdown-menu.show) > .dropdown-toggle::after { - transform: rotate(-135deg); + transform: rotate(180deg); } .dropdown-menu { @@ -392,10 +388,12 @@ input:disabled + label { 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 { + /* Closed by default */ display: none; } +.dropdown-menu.show { + display: block; +} .dropdown-item { display: block; diff --git a/assets/js/basic.dropdown.init.js b/assets/js/basic.dropdown.init.js new file mode 100644 index 00000000..2f593cea --- /dev/null +++ b/assets/js/basic.dropdown.init.js @@ -0,0 +1,66 @@ +// 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 `