✨ (pagetop): Añade puntos de corte a Flex/FlexItem

- `Breakpoint` gana variante `Xxxl` y métodos `name()`/`min_width()`/
  `resolved()`, para consultarse contra el tema activo.
- `Theme::breakpoint_entry()` sustituye a `breakpoint_min_width()`:
  devuelve un `BreakpointEntry` con nombre y ancho mínimo.
- Nuevo `Responsive<T>`, valor en cascada mobile-first por punto de
  corte.
- Ejemplo `examples/intro-responsive.rs` con los patrones de uso.
This commit is contained in:
Manuel Cillero 2026-09-12 11:13:19 +02:00
parent 8577ca8a59
commit 0b8f3f3000
22 changed files with 1257 additions and 359 deletions

View file

@ -9,7 +9,7 @@ use crate::html::{Markup, Props, PropsOp, RoutePath, html};
use crate::locale::Lc;
use crate::locale::{LangId, LanguageIdentifier, RequestLocale};
use crate::web::HttpRequest;
use crate::{builder_impl, util};
use crate::{CowStr, builder_impl, util};
use parking_lot::Mutex;
use thiserror::Error;
@ -42,7 +42,7 @@ pub enum AssetsOp {
/// Añade una declaración de estilo responsive (`property: value`) para las clases indicadas,
/// dentro del punto de corte dado (`None` para una regla siempre activa). Ver
/// [`ResponsiveStyles::add_style()`].
AddResponsiveStyle(Option<Breakpoint>, &'static str, &'static str, &'static str),
AddResponsiveStyle(Option<Breakpoint>, CowStr, CowStr, CowStr),
}
/// Errores de acceso a parámetros dinámicos del contexto.

View file

@ -14,11 +14,11 @@
//! PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre,
//! identificado por [`Theme::parent()`]. Un tema hijo hereda automáticamente todos los métodos del
//! padre y puede sobrescribirlos selectivamente. Esta herencia determina qué implementación de sus
//! métodos se usa cuando el tema hijo no los sobrescribe (ya sea el renderizado del `<body>` o del
//! `<head>`, la definición de los recursos necesarios, la traducción de puntos de corte y colores
//! por intención vía [`Theme::breakpoint_min_width()`] y [`Theme::intent_color()`], la captura de
//! componentes para alterar su comportamiento usando [`Theme::setup_component()`] y
//! [`Theme::render_component()`], las páginas de error, etc.).
//! métodos se usa cuando el tema hijo no los sobrescribe, ya sea el renderizado del `<body>` o del
//! `<head>`, la definición de los recursos necesarios, la traducción de puntos de corte con
//! [`Theme::breakpoint_entry()`] y colores según intención vía [`Theme::intent_color()`], la
//! captura de componentes para alterar su comportamiento usando [`Theme::setup_component()`] y
//! [`Theme::render_component()`], las páginas de error, etc.
//!
//! Un tema hijo puede ser a su vez padre de otro, basta declararlo cada vez en [`Theme::parent()`].
//! Como `parent()` se resuelve en tiempo de ejecución, PageTop no puede descartar en compilación
@ -78,13 +78,14 @@
//! variantes de plantilla. Para ajustarlo sin rehacer su marcado (añadir una clase, un
//! atributo, etc.), se usa [`Theme::setup_component()`] en su lugar.
//! 4. **Definir los anchos mínimos *mobile-first* para los puntos de corte** sobrescribiendo
//! [`Theme::breakpoint_min_width()`]. Por defecto, [`Breakpoint`] resuelve el ancho mínimo de
//! cada variante (`Sm`, `Md`, etc.) como una cadena CSS ya formateada (p. ej. `"768px"`) que
//! cada tema puede adaptar. Cuando se genera CSS *responsive* a partir de un [`Breakpoint`], se
//! consulta el punto de corte a través de [`Breakpoint::min_width()`], listo para interpolar en
//! un `@media (min-width: ...)` sin ningún cálculo adicional. Un tema sin diseño *responsive*
//! puede traducir todas las variantes a `""` porque al ser *mobile-first*, un punto de corte sin
//! ancho real se aplicará siempre.
//! [`Theme::breakpoint_entry()`]. [`Breakpoint`] no define ningún ancho propio; la
//! implementación por defecto de este método resuelve el ancho mínimo de cada variante (`Sm`,
//! `Md`, etc.) como una cadena CSS ya formateada (p. ej. `"768px"`), que cada tema puede
//! sobrescribir. Cuando se genera CSS *responsive* a partir de un [`Breakpoint`], se consulta el
//! punto de corte a través de [`Breakpoint::min_width()`], listo para interpolar en un
//! `@media (min-width: ...)` sin ningún cálculo adicional. Un tema sin diseño *responsive* puede
//! traducir todas las variantes a `""` porque al ser *mobile-first*, un punto de corte sin ancho
//! real se aplicará siempre.
//! 5. **Traducir [`Intent`] a la paleta de colores propia del tema** sobrescribiendo
//! [`Theme::intent_color()`]. Por defecto, este método devuelve el vocabulario semántico de
//! [`Intent`] (`"primary"`, `"severe"`, etc.); un tema con su propio catálogo de colores (por
@ -155,7 +156,7 @@ mod intent;
pub use intent::Intent;
mod breakpoint;
pub use breakpoint::Breakpoint;
pub use breakpoint::{Breakpoint, BreakpointEntry, Responsive};
mod layout;
pub use layout::{CoreRegions, RegionName, RegionRef};

View file

@ -3,46 +3,182 @@ use crate::core::component::{Context, Contextual};
// **< Breakpoint >*********************************************************************************
/// Puntos de corte *responsive*, *mobile-first* (aplican "a partir de" el ancho indicado).
/// Puntos de corte *responsive*, *mobile-first* (se aplican a partir del ancho indicado).
///
/// No define ningún valor en píxeles por sí mismo; cada tema decide a qué ancho corresponde cada
/// variante en su sistema de diseño (ver [`Theme::breakpoint_min_width()`]).
/// `Breakpoint` no define ningún valor en píxeles por sí mismo; cada tema decide qué nombre y a qué
/// ancho mínimo corresponde cada variante en su especificación (ver [`Theme::breakpoint_entry()`]).
///
/// [`Theme::breakpoint_min_width()`]: crate::core::theme::Theme::breakpoint_min_width
#[derive(AutoDefault, Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
/// [`Theme::breakpoint_entry()`]: crate::core::theme::Theme::breakpoint_entry
#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)]
pub enum Breakpoint {
/// Base *mobile-first*, equivale a "siempre".
/// Base *mobile-first*, equivale a "siempre aplica".
#[default]
Xs,
/// A partir del ancho donde un tema suele pasar de móvil a tableta.
/// Aplica a partir del ancho donde un tema suele pasar de móvil a tableta.
Sm,
/// A partir del ancho donde un tema suele pasar a un escritorio pequeño.
/// Aplica a partir del ancho donde un tema suele pasar a un escritorio pequeño.
Md,
/// A partir del ancho donde un tema suele pasar a un escritorio normal.
/// Aplica a partir del ancho donde un tema suele pasar a un escritorio normal.
Lg,
/// A partir del ancho donde un tema suele considerar el escritorio ancho.
/// Aplica a partir del ancho donde un tema suele considerar el escritorio ancho.
Xl,
/// A partir del ancho donde un tema suele considerar el escritorio muy ancho.
/// Aplica a partir del ancho donde un tema suele considerar el escritorio muy ancho.
Xxl,
/// Aplica a partir del ancho donde un tema suele considerar el escritorio extra ancho.
Xxxl,
}
impl Breakpoint {
// Todas las variantes, en orden mobile-first (de Xs a Xxl).
pub(crate) const ALL: [Breakpoint; 6] = [
// Todas las variantes, en orden mobile-first (de Xs a Xxxl).
pub(crate) const ALL: [Breakpoint; 7] = [
Breakpoint::Xs,
Breakpoint::Sm,
Breakpoint::Md,
Breakpoint::Lg,
Breakpoint::Xl,
Breakpoint::Xxl,
Breakpoint::Xxxl,
];
/// Ancho mínimo resuelto a través del tema activo del contexto actual, como valor CSS ya
/// formateado (p. ej. `"768px"`), o `""` si la variante se aplica siempre, sin ancho real.
/// Nombre del punto de corte resuelto a través del tema activo del contexto actual.
///
/// Atajo de [`Theme::breakpoint_min_width()`](crate::core::theme::Theme::breakpoint_min_width)
/// a través de [`Context::theme()`].
/// Depende de [`Context`] y puede cambiar entre temas. Es un atajo de acceso al campo `name` de
/// [`BreakpointEntry`] devuelto por [`Theme::breakpoint_entry()`] en el tema activo del
/// contexto ([`Context::theme()`]).
///
/// [`Theme::breakpoint_entry()`]: crate::core::theme::Theme::breakpoint_entry
#[inline]
pub fn name(&self, cx: &Context) -> &'static str {
cx.theme().breakpoint_entry(*self).name
}
/// Ancho mínimo resuelto para el punto de corte a través del tema activo del contexto actual,
/// como valor CSS ya formateado (p. ej. `"768px"`); o devuelve `""` si la variante se aplica
/// siempre, sin un ancho real asociado.
///
/// Normalmente se usará este método, aunque realmente es un atajo de acceso al campo
/// `min_width` de [`BreakpointEntry`] que devuelve [`Theme::breakpoint_entry()`] en el tema
/// activo del contexto ([`Context::theme()`]).
///
/// [`Theme::breakpoint_entry()`]: crate::core::theme::Theme::breakpoint_entry
#[inline]
pub fn min_width(&self, cx: &Context) -> &'static str {
cx.theme().breakpoint_min_width(*self)
cx.theme().breakpoint_entry(*self).min_width
}
/// Resuelve la variante en el tema activo del contexto ([`Context::theme()`]). Devuelve `None`
/// si el valor de `min_width` está vacío por lo que no representa ningún ancho mínimo real para
/// este tema. Devuelve el [`BreakpointEntry`] completo en caso contrario.
///
/// Permite decidir si una variante debe tratarse como incondicional (sin envolver en `@media`
/// y sin sufijo de punto de corte en nombres de clase), o como un punto de corte real. Se
/// devuelve el `BreakpointEntry` completo para poder obtener el [`name`](BreakpointEntry::name)
/// y el [`min_width`](BreakpointEntry::min_width) para el tema activo, sin tener que volver a
/// consultar el tema.
#[inline]
pub fn resolved(self, cx: &Context) -> Option<BreakpointEntry> {
let entry = cx.theme().breakpoint_entry(self);
if entry.min_width.is_empty() {
None
} else {
Some(entry)
}
}
// **< Breakpoint HELPERS >*********************************************************************
// Posición de esta variante en `Breakpoint::ALL`, para indexar `Responsive::values`. Válido
// porque el orden de declaración del enum coincide con `ALL` (de `Xs` a `Xxxl`).
fn index(self) -> usize {
self as usize
}
}
// **< BreakpointEntry >****************************************************************************
/// Punto de corte [`Breakpoint`] con su nombre y ancho mínimo *responsive* en un tema.
///
/// Ver [`Theme::breakpoint_entry()`](crate::core::theme::Theme::breakpoint_entry) para entender
/// cómo definir los puntos de corte en la implementación de un tema dado.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BreakpointEntry {
/// Variante de la que procede esta entrada. Coincide siempre con el [`Breakpoint`] pasado a
/// [`Theme::breakpoint_entry()`](crate::core::theme::Theme::breakpoint_entry), incluso si el
/// tema delega en su padre. Se incluye para que el valor siga siendo identificable aunque se
/// conozca de partida.
pub breakpoint: Breakpoint,
/// Nombre del punto de corte según el tema (p. ej. `"md"` o `"tablet"` podrían ser nombres para
/// `Breakpoint::Md` en dos temas diferentes).
pub name: &'static str,
/// Ancho mínimo *responsive*, como valor CSS ya formateado, por ejemplo `"768px"`. Se usará una
/// cadena vacía `""` si la variante se aplica siempre (para cualquier ancho).
pub min_width: &'static str,
}
// **< Responsive >*********************************************************************************
/// Encapsula valores para cada punto de corte, aplicados en cascada *mobile-first*.
///
/// Guarda un valor opcional para cada variante de [`Breakpoint`]. No decide por sí mismo cómo se
/// interpreta cada punto de corte. Con [`by_breakpoint()`] se pueden devolver los valores
/// establecidos para cada variante, sin resolver ningún ancho ni consultar el tema activo. Traducir
/// eso a CSS, incluida la decisión de envolver en `@media` según [`Breakpoint::min_width()`], es
/// responsabilidad de quien consuma [`by_breakpoint()`], normalmente para acabar registrado en
/// [`ResponsiveStyles`].
///
/// Uso típico: los campos de [`Flex`]/[`FlexItem`] para el posicionamiento Flexbox de componentes.
///
/// [`by_breakpoint()`]: Self::by_breakpoint
/// [`ResponsiveStyles`]: crate::html::ResponsiveStyles
/// [`Flex`]: crate::html::Flex
/// [`FlexItem`]: crate::html::FlexItem
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct Responsive<T> {
values: [Option<T>; 7],
}
impl<T: Copy> Responsive<T> {
// **< Responsive BUILDER >*********************************************************************
/// Establece el valor base (sin punto de corte, activo siempre).
pub fn set(mut self, value: T) -> Self {
self.values[0] = Some(value);
self
}
/// Establece el valor a partir del punto de corte indicado.
pub fn set_at(mut self, bp: Breakpoint, value: T) -> Self {
self.values[bp.index()] = Some(value);
self
}
/// Combina con otro `Responsive<T>`, punto de corte a punto de corte. Donde `other` tenga un
/// valor, sustituye al de `self`; donde no, se conserva el de `self`.
pub fn merge(mut self, other: Self) -> Self {
for (slot, value) in self.values.iter_mut().zip(other.values) {
if value.is_some() {
*slot = value;
}
}
self
}
// **< Responsive GETTERS >*********************************************************************
/// Devuelve el valor establecido para el punto de corte exacto indicado, si existe.
pub fn get_at(&self, bp: Breakpoint) -> Option<T> {
self.values[bp.index()]
}
// **< Responsive HELPERS >*********************************************************************
/// Recorre los valores establecidos, en orden, como pares `(punto de corte, valor)`. Decidir si
/// su ancho mínimo resuelto la hace incondicional, el caso por defecto, es responsabilidad de
/// quien consuma este iterador, vía [`Breakpoint::resolved()`].
pub fn by_breakpoint(&self) -> impl Iterator<Item = (Breakpoint, T)> + '_ {
Breakpoint::ALL
.iter()
.zip(self.values.iter())
.filter_map(|(bp, value)| value.map(|value| (*bp, value)))
}
}

View file

@ -3,7 +3,7 @@ use crate::base::component::{Html, Intro, IntroOpening, layout};
use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender};
use crate::core::component::{Context, Contextual};
use crate::core::extension::Extension;
use crate::core::theme::{Breakpoint, CoreRegions, Intent};
use crate::core::theme::{Breakpoint, BreakpointEntry, CoreRegions, Intent};
use crate::global;
use crate::html::{Markup, html};
use crate::locale::Lc;
@ -65,31 +65,59 @@ pub trait Theme: Extension + Send + Sync {
None
}
/// Traduce un [`Breakpoint`] al punto de corte *responsive*, *mobile-first*, propio del tema.
/// Traduce un [`Breakpoint`] a su [`BreakpointEntry`] correspondiente, donde se asocia a cada
/// variante su nombre y ancho mínimo *responsive*, *mobile-first*, propios del tema.
///
/// `Breakpoint` no define ningún valor propio en píxeles. Será cada tema el que decida a qué
/// ancho corresponde cada variante como valor CSS ya formateado (p. ej. `"768px"`), listo para
/// aplicar en un `@media (min-width: ...)` sin ningún cálculo adicional. La cadena vacía (`""`)
/// indica que la variante no representa ningún ancho mínimo y se aplica siempre; es el caso de
/// `Xs`.
/// `Breakpoint` no define ningún nombre ni ancho mínimo propios. Será cada tema el que decida
/// cómo se llama cada variante y a qué ancho corresponde (p. ej. `"768px"`) para aplicar en un
/// `@media (min-width: ...)` sin ningún cálculo adicional. La cadena vacía (`""`) en
/// `min_width` indica que la variante no representa ningún ancho mínimo y se aplica siempre.
///
/// Normalmente, para resolver un ancho *responsive* no se llamará a este método directamente,
/// sino que se usará [`Breakpoint::min_width()`] a través de [`Context::theme()`].
/// **Temas sin puntos de corte.** Devolver `""` como ancho para una variante no la deshabilita,
/// de hecho la regla generada para ese punto de corte se sigue renderizando, pero sin incluirla
/// en un `@media` (ver [`ResponsiveStyles::render()`]). Por tanto, pasa a aplicarse siempre,
/// exactamente igual que si nunca se hubiera pedido ningún punto de corte. Un tema sin diseño
/// *responsive* puede traducir así todas las variantes a `""`; no por eso se vuelve un tema
/// "desktop-first", sino que cada punto de corte pedido pasa a aplicarse siempre, sin ninguna
/// condición de ancho.
///
/// **Temas con menos puntos de corte que variantes.** Dos variantes consecutivas que devuelvan
/// el mismo ancho no vacío se funden en la práctica: ambas generan un `@media (min-width: ...)`
/// idéntico, así que no hay forma de distinguir en CSS "a partir de `Md`" de "a partir de `Lg`"
/// si las dos resuelven, por ejemplo, a `"992px"`. Es la forma correcta de implementar menos
/// puntos de corte reales que las siete variantes de `Breakpoint`: repetir el mismo ancho en
/// las variantes consecutivas que no se quieran distinguir. Por ejemplo, un tema con tres
/// franjas reales (`Xs`, `Md`-`Lg` y `Xl`-`Xxl`-`Xxxl`) devolvería `""` para `Xs`, el mismo
/// ancho para `Md` y `Lg`, y otro ancho mayor, también repetido, para `Xl`, `Xxl` y `Xxxl`.
/// Para que el resultado siga siendo coherente, los anchos deben mantenerse no decrecientes en
/// el orden *mobile-first* (`Xs` a `Xxxl`); repetir un ancho en variantes no consecutivas, o no
/// ordenarlos de menor a mayor, produce puntos de corte confusos o contradictorios, aunque nada
/// en tiempo de compilación ni de ejecución lo impida.
///
/// Normalmente, para resolver el nombre o el ancho *responsive* de un punto de corte no se
/// llamará a este método directamente, sino que se usarán [`Breakpoint::name()`] y
/// [`Breakpoint::min_width()`], respectivamente, a través de [`Context::theme()`].
///
/// [`Breakpoint::name()`]: crate::core::theme::Breakpoint::name
/// [`Breakpoint::min_width()`]: crate::core::theme::Breakpoint::min_width
/// [`Context::theme()`]: crate::core::component::Context::theme
/// [`ResponsiveStyles::render()`]: crate::html::ResponsiveStyles::render
#[rustfmt::skip]
fn breakpoint_min_width(&self, bp: Breakpoint) -> &'static str {
fn breakpoint_entry(&self, bp: Breakpoint) -> BreakpointEntry {
if let Some(parent) = self.parent() {
return parent.breakpoint_min_width(bp);
return parent.breakpoint_entry(bp);
}
use Breakpoint::*;
match bp {
Breakpoint::Xs => "",
Breakpoint::Sm => "576px",
Breakpoint::Md => "768px",
Breakpoint::Lg => "992px",
Breakpoint::Xl => "1200px",
Breakpoint::Xxl => "1400px",
Xs => BreakpointEntry { breakpoint: Xs, name: "xs", min_width: "" },
Sm => BreakpointEntry { breakpoint: Sm, name: "sm", min_width: "576px" },
Md => BreakpointEntry { breakpoint: Md, name: "md", min_width: "768px" },
Lg => BreakpointEntry { breakpoint: Lg, name: "lg", min_width: "992px" },
Xl => BreakpointEntry { breakpoint: Xl, name: "xl", min_width: "1200px" },
Xxl => BreakpointEntry { breakpoint: Xxl, name: "xxl", min_width: "1400px" },
Xxxl => BreakpointEntry { breakpoint: Xxxl, name: "xxxl", min_width: "1920px" },
}
}