From b163b7726e792d1dfbdcf77fc711ba0ebc61c21a Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 5 Sep 2026 21:19:15 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(util):=20Simplifica?= =?UTF-8?q?=20normalize=5Fascii*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalize_ascii_or_empty()` pasa a llamarse `normalize_ascii()` (la variante indulgente, la más usada); la antigua `normalize_ascii()` pasa a `normalize_ascii_non_blank()`. --- src/base/component/form/props.rs | 4 +-- src/html/props.rs | 53 +++++++++++++------------------- src/locale/request.rs | 7 ++--- src/util.rs | 51 ++++++++++++++++++++++-------- tests/util.rs | 12 ++++---- 5 files changed, 70 insertions(+), 57 deletions(-) diff --git a/src/base/component/form/props.rs b/src/base/component/form/props.rs index 31067f44..5211b33d 100644 --- a/src/base/component/form/props.rs +++ b/src/base/component/form/props.rs @@ -96,7 +96,7 @@ impl Autocomplete { /// El prefijo `section-*` sirve para distinguir entre varios grupos del mismo tipo en una misma /// página (p. ej. una dirección de envío y otra de facturación). pub fn section(name: impl AsRef, field: AutofillField) -> Self { - match util::normalize_ascii(name.as_ref()) { + match util::normalize_ascii_non_blank(name.as_ref()) { Ok(n) if !n.as_ref().contains(' ') => { Self::custom(util::join!("section-", n.as_ref(), " ", field.as_str())) } @@ -196,7 +196,7 @@ impl Autocomplete { let raw = value.as_ref(); // Normaliza la entrada. - let Some(normalized) = util::normalize_ascii_or_empty(raw, "Autocomplete::custom") else { + let Some(normalized) = util::normalize_ascii(raw) else { return Self::On; }; let autocomplete = normalized.as_ref(); diff --git a/src/html/props.rs b/src/html/props.rs index 55a24d35..c001d953 100644 --- a/src/html/props.rs +++ b/src/html/props.rs @@ -539,29 +539,23 @@ impl Props { } } PropsOp::AddClasses(classes) => { - let Some(normalized) = - util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") - else { + let Some(normalized) = util::normalize_ascii(classes.as_ref()) else { return self; }; let pos = self.classes.len(); self.insert_classes(normalized.as_ref().split_ascii_whitespace(), pos); } PropsOp::PrependClasses(classes) => { - let Some(normalized) = - util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") - else { + let Some(normalized) = util::normalize_ascii(classes.as_ref()) else { return self; }; self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); } PropsOp::ReplaceClasses(old, new) => { - let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop") - else { + let Some(old) = util::normalize_ascii(old.as_ref()) else { return self; }; - let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop") - else { + let Some(new) = util::normalize_ascii(new.as_ref()) else { return self; }; let mut pos = self.classes.len(); @@ -578,12 +572,10 @@ impl Props { } } PropsOp::ReplaceAllClasses(old, new) => { - let Some(old) = util::normalize_ascii_or_empty(old.as_ref(), "Props::with_prop") - else { + let Some(old) = util::normalize_ascii(old.as_ref()) else { return self; }; - let Some(new) = util::normalize_ascii_or_empty(new.as_ref(), "Props::with_prop") - else { + let Some(new) = util::normalize_ascii(new.as_ref()) else { return self; }; if !self.has_all_classes(old.as_ref()) { @@ -599,9 +591,7 @@ impl Props { self.insert_classes(new.as_ref().split_ascii_whitespace(), pos); } PropsOp::RemoveClasses(classes) => { - let Some(normalized) = - util::normalize_ascii_or_empty(classes.as_ref(), "Props::with_prop") - else { + let Some(normalized) = util::normalize_ascii(classes.as_ref()) else { return self; }; self.classes.retain(|c| { @@ -621,9 +611,7 @@ impl Props { if name.as_ref() == "id" { self.apply_id(value.as_ref()); } else if name.as_ref() == "class" { - if let Some(normalized) = - util::normalize_ascii_or_empty(value.as_ref(), "Props::with_prop") - { + if let Some(normalized) = util::normalize_ascii(value.as_ref()) { self.classes.clear(); self.insert_classes(normalized.as_ref().split_ascii_whitespace(), 0); } @@ -765,7 +753,7 @@ impl Props { /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente. pub fn has_classes(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { + let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else { return false; }; normalized @@ -776,7 +764,7 @@ impl Props { /// Devuelve `true` si la clase o **todas** las clases indicadas están presentes. pub fn has_all_classes(&self, classes: impl AsRef) -> bool { - let Ok(normalized) = util::normalize_ascii(classes.as_ref()) else { + let Ok(normalized) = util::normalize_ascii_non_blank(classes.as_ref()) else { return false; }; normalized @@ -922,15 +910,17 @@ impl Props { // Añade o sustituye una declaración "propiedad: valor". Si la propiedad ya existe, sustituye // su valor conservando la posición; si no, la añade al final. Ignora la declaración si la - // propiedad o el valor quedan vacíos tras recortar espacios. No aplica - // normalize_ascii_or_empty: ver la documentación de `PropsOp::AddStyle` sobre por qué los - // valores de estilo no se restringen a ASCII. + // propiedad o el valor quedan vacíos tras recortar espacios. No aplica `normalize_ascii`: ver + // la documentación de `PropsOp::AddStyle` sobre por qué los valores de estilo no se restringen + // a ASCII. fn set_style(&mut self, property: &str, value: &str) { - let property = property.trim().to_ascii_lowercase(); - let value = value.trim(); - if property.is_empty() || value.is_empty() { + let Some(property) = util::non_blank(property) else { return; - } + }; + let property = property.to_ascii_lowercase(); + let Some(value) = util::non_blank(value) else { + return; + }; if let Some(pos) = self.styles.iter().position(|(k, _)| k.as_ref() == property) { self.styles[pos].1 = value.to_string().into(); } else { @@ -943,10 +933,9 @@ impl Props { // `style`) y aplica cada declaración con `set_style`. Ignora las declaraciones sin ":". fn parse_styles(&mut self, styles: &str) { for style in Self::split_style_declarations(styles) { - let style = style.trim(); - if style.is_empty() { + let Some(style) = util::non_blank(style) else { continue; - } + }; let Some((property, value)) = style.split_once(':') else { trace::debug!( target = "Props::with_prop", diff --git a/src/locale/request.rs b/src/locale/request.rs index 10aaa5d0..19dc91b3 100644 --- a/src/locale/request.rs +++ b/src/locale/request.rs @@ -1,4 +1,5 @@ use crate::global; +use crate::util; use crate::web::HttpRequest; use super::{LangId, LanguageIdentifier, Locale}; @@ -82,7 +83,7 @@ impl RequestLocale { // En este primer elemento también puede aparecer `;q=...`, así que se // extrae únicamente la etiqueta de idioma: "es-ES;q=0.9" -> "es-ES". - let tag = first.split(';').next()?.trim(); + let tag = util::non_blank(first.split(';').next()?)?; // TODO: Mejorar el soporte de `Accept-Language` en el futuro: // @@ -92,9 +93,7 @@ impl RequestLocale { // - Tener en cuenta rangos de idioma (`es`, `en`, etc.) y variantes // regionales. // - Añadir tests unitarios para distintas combinaciones de cabecera. - if tag.is_empty() { - None - } else if let Locale::Resolved(langid) = Locale::resolve(tag) { + if let Locale::Resolved(langid) = Locale::resolve(tag) { Some(langid) } else { None diff --git a/src/util.rs b/src/util.rs index 0c0ebf22..ed3f9c1e 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,6 +5,7 @@ use crate::trace; use std::borrow::Cow; use std::env; use std::io; +use std::panic::Location; use std::path::{Path, PathBuf}; // **< MACROS INTEGRADAS >************************************************************************** @@ -39,7 +40,7 @@ pub fn build_runtime() -> tokio::runtime::Runtime { tokio::runtime::Runtime::new().expect("Failed to build the Tokio runtime") } -/// Errores posibles al normalizar una cadena ASCII con [`normalize_ascii()`]. +/// Errores al normalizar una cadena ASCII con [`normalize_ascii_non_blank()`]. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NormalizeAsciiError { /// La entrada está vacía (`""`). @@ -69,9 +70,10 @@ pub fn non_blank(s: &str) -> Option<&str> { if s.is_empty() { None } else { Some(s) } } -/// Normaliza una cadena ASCII con uno o varios tokens separados. +/// Normaliza una cadena ASCII con uno o varios tokens, exigiendo contenido no vacío. /// -/// Los *separadores* son caracteres `is_ascii_whitespace()` como `' '`, `'\t'`, `'\n'` o `'\r'`. +/// Los *separadores* de los tokens son caracteres `is_ascii_whitespace()` como `' '`, `'\t'`, +/// `'\n'` o `'\r'`. /// /// Reglas: /// @@ -85,13 +87,20 @@ pub fn non_blank(s: &str) -> Option<&str> { /// Intenta devolver siempre `Cow::Borrowed` para no reservar memoria, y `Cow::Owned` sólo si ha /// tenido que aplicar cambios para normalizar. /// +/// Normalmente se usará [`normalize_ascii()`], que trata una cadena en blanco como resultado +/// válido, en vez de como un error. Esta variante es para detectar también cuándo un resultado es +/// una cadena vacía para rechazarla de inmediato. +/// /// # Ejemplo /// /// ```rust /// # use pagetop::util; -/// assert_eq!(util::normalize_ascii(" Foo\tBAR CLi\r\n").unwrap().as_ref(), "foo bar cli"); +/// assert_eq!( +/// util::normalize_ascii_non_blank(" Foo\tBAR CLi\r\n").unwrap().as_ref(), +/// "foo bar cli" +/// ); /// ``` -pub fn normalize_ascii(input: &str) -> Result, NormalizeAsciiError> { +pub fn normalize_ascii_non_blank(input: &str) -> Result, NormalizeAsciiError> { let bytes = input.as_bytes(); if bytes.is_empty() { return Err(NormalizeAsciiError::IsEmpty); @@ -163,19 +172,35 @@ pub fn normalize_ascii(input: &str) -> Result, NormalizeAsciiError> Ok(Cow::Owned(output)) } -/// Normaliza una cadena ASCII, opcionalmente vacía, con uno o varios tokens separados. +/// Normaliza una cadena ASCII con uno o varios tokens, aceptando cadenas vacías. /// -/// - Devuelve `Some(Cow)` si la entrada es válida ASCII (normalizada a minúsculas). -/// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar. -/// - Devuelve `None` si la entrada contiene bytes no ASCII; y emite un `trace::debug!` con el campo -/// `target`. +/// - Devuelve `Some(Cow)` normalizado (ver [`normalize_ascii_non_blank()`] para las reglas exactas +/// de recorte, colapso de espacios y minúsculas) si la entrada es ASCII válida. +/// - Devuelve `Some(Cow::Borrowed(""))` si la entrada es `""` o queda vacía tras recortar. Una +/// entrada en blanco no es un error, es un resultado trivial. +/// - Devuelve `None` **sólo** si la entrada contiene bytes no ASCII; y emite un `trace::debug!` con +/// la ubicación exacta del llamador (vía `#[track_caller]`) para poder localizar el origen del +/// valor rechazado. +/// +/// # Ejemplo +/// +/// ```rust +/// # use pagetop::util; +/// assert_eq!( +/// util::normalize_ascii(" Foo\tBAR CLi\r\n").unwrap().as_ref(), +/// "foo bar cli" +/// ); +/// assert_eq!(util::normalize_ascii(" ").unwrap().as_ref(), ""); +/// assert_eq!(util::normalize_ascii("ñoño"), None); +/// ``` #[inline] -pub fn normalize_ascii_or_empty<'a>(input: &'a str, target: &'static str) -> Option> { - match normalize_ascii(input) { +#[track_caller] +pub fn normalize_ascii(input: &str) -> Option> { + match normalize_ascii_non_blank(input) { Ok(s) => Some(s), Err(NormalizeAsciiError::NonAscii) => { trace::debug!( - target = %target, + caller = %Location::caller(), input = %input.escape_default(), "Ignoring due to non-ASCII chars" ); diff --git a/tests/util.rs b/tests/util.rs index 0730a2c4..4ed69e01 100644 --- a/tests/util.rs +++ b/tests/util.rs @@ -7,10 +7,10 @@ async fn setup() { Application::new().await; } -// **< Testing normalize_ascii() >****************************************************************** +// **< Testing normalize_ascii_non_blank() >******************************************************** fn assert_err(input: &str, expected: util::NormalizeAsciiError) { - let out = util::normalize_ascii(input); + let out = util::normalize_ascii_non_blank(input); assert_eq!( out, Err(expected), @@ -22,7 +22,7 @@ fn assert_err(input: &str, expected: util::NormalizeAsciiError) { } fn assert_borrowed(input: &str, expected: &str) { - let out = util::normalize_ascii(input).expect("Expected Ok(..)"); + let out = util::normalize_ascii_non_blank(input).expect("Expected Ok(..)"); assert_eq!(out.as_ref(), expected, "Input {:?}", input); assert!( matches!(out, Cow::Borrowed(_)), @@ -33,7 +33,7 @@ fn assert_borrowed(input: &str, expected: &str) { } fn assert_owned(input: &str, expected: &str) { - let out = util::normalize_ascii(input).expect("Expected Ok(..)"); + let out = util::normalize_ascii_non_blank(input).expect("Expected Ok(..)"); assert_eq!(out.as_ref(), expected, "Input {:?}", input); assert!( matches!(out, Cow::Owned(_)), @@ -248,8 +248,8 @@ async fn normalize_is_idempotent() { continue; } - let first = util::normalize_ascii(input).unwrap(); - let second = util::normalize_ascii(first.as_ref()).unwrap(); + let first = util::normalize_ascii_non_blank(input).unwrap(); + let second = util::normalize_ascii_non_blank(first.as_ref()).unwrap(); assert_eq!( first.as_ref(), second.as_ref(), From f2a7507317dfd2127e9cbdced7e7ef9564bc6dec Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 6 Sep 2026 01:46:06 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20(theme):=20A=C3=B1ade=20Breakpo?= =?UTF-8?q?int=20y=20ResponsiveStyles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nuevo `Breakpoint` (`Xs`/`Sm`/`Md`/`Lg`/`Xl`/`Xxl`) y `Theme::breakpoint_min_width()`, que traduce cada variante al ancho mínimo CSS del tema activo (mismo patrón que `Theme::intent_color()`). - `ResponsiveStyles` acumula declaraciones `property: value` agrupadas por punto de corte (opcional: `None` para reglas siempre activas, sin depender del tema) y por clase o clases. Aplica first-write-wins para clases utilitarias repetidas por muchos componentes. `render()` las agrupa en bloques `@media (min-width: ...)` mobile-first, sin saltos de línea. --- src/core/component/context.rs | 27 ++- src/core/theme.rs | 19 +- src/core/theme/breakpoint.rs | 48 ++++ src/core/theme/definition.rs | 38 +++- src/html.rs | 1 + src/html/assets.rs | 1 + src/html/assets/responsive.rs | 215 ++++++++++++++++++ src/response/page.rs | 6 +- tests/html_responsives.rs | 416 ++++++++++++++++++++++++++++++++++ 9 files changed, 760 insertions(+), 11 deletions(-) create mode 100644 src/core/theme/breakpoint.rs create mode 100644 src/html/assets/responsive.rs create mode 100644 tests/html_responsives.rs diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 68d28d9d..c23308e1 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -2,9 +2,9 @@ use crate::auth::CurrentUser; use crate::core::TypeInfo; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; -use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates}; +use crate::core::theme::{Breakpoint, ChildrenInRegions, CoreRegions, CoreTemplates}; use crate::core::theme::{RegionRef, TemplateRef, ThemeRef}; -use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet}; +use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet}; use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::locale::Lc; use crate::locale::{LangId, LanguageIdentifier, RequestLocale}; @@ -38,6 +38,11 @@ pub enum AssetsOp { AddJavaScript(JavaScript), /// Elimina un script por su ruta o identificador. RemoveJavaScript(&'static str), + + /// 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, &'static str, &'static str, &'static str), } /// Errores de acceso a parámetros dinámicos del contexto. @@ -217,6 +222,9 @@ pub trait Contextual: LangId { /// Devuelve los scripts JavaScript de los recursos del contexto. fn javascripts(&self) -> &Assets; + /// Devuelve los estilos *responsive* acumulados en el contexto. + fn responsive_styles(&self) -> &ResponsiveStyles; + /// Devuelve identificador, clases CSS, atributos HTML y valores extra del elemento ``. fn body_props(&self) -> &Props; @@ -313,6 +321,7 @@ pub struct Context { preloads : Assets, // Recursos para precarga. stylesheets : Assets, // Hojas de estilo CSS. javascripts : Assets, // Scripts JavaScript. + responsives : ResponsiveStyles, // Estilos *responsive*. body_props : Props, // Id, clases CSS y atributos del . regions : ChildrenInRegions, // Regiones de componentes para renderizar. params : HashMap<&'static str, (Box, &'static str)>, // Parámetros. @@ -345,6 +354,7 @@ impl Context { preloads : Assets::::new(), stylesheets: Assets::::new(), javascripts: Assets::::new(), + responsives: ResponsiveStyles::new(), body_props : Props::default(), regions : ChildrenInRegions::default(), params : HashMap::default(), @@ -401,6 +411,10 @@ impl Context { // Primero los recursos para precarga para iniciar las descargas inmediatamente. (preloads.render(self)) (stylesheets.render(self)) + // Después los estilos *responsive*, para poder sobrescribir sus clases. + @if !self.responsives.is_empty() { + style { (self.responsives.render(self)) } + } (javascripts.render(self)) }; @@ -604,6 +618,11 @@ impl Contextual for Context { AssetsOp::RemoveJavaScript(path) => { self.javascripts.remove(path); } + // Estilos responsive. + AssetsOp::AddResponsiveStyle(breakpoint, classes, property, value) => { + self.responsives + .add_style(breakpoint, classes, property, value); + } } self } @@ -664,6 +683,10 @@ impl Contextual for Context { &self.javascripts } + fn responsive_styles(&self) -> &ResponsiveStyles { + &self.responsives + } + fn body_props(&self) -> &Props { &self.body_props } diff --git a/src/core/theme.rs b/src/core/theme.rs index 1e8cba33..c6a10048 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -50,8 +50,8 @@ //! devuelva `Some(&Self)`. Basta con un `impl Theme for MyTheme {}` vacío, ya que todos los //! métodos de [`Theme`] tienen implementación por defecto. //! -//! Un tema puede personalizarse en cinco pasos, cada uno necesario sólo si lo que ofrece PageTop -//! por defecto no basta: +//! Un tema puede personalizarse en seis pasos, cada uno necesario sólo si lo que ofrece PageTop +//! por defecto no basta o no aplica: //! //! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegions`] (`Header`, `Aside`, //! `Content`, `Footer`) como regiones de plantilla siempre disponibles, y [`ReservedRegions`] @@ -77,14 +77,22 @@ //! ejemplo, [`CoreTemplates`] o el propio *enum* del tema). `pagetop-bootsier` hace exactamente //! esto para maquetar `Standard` y `Admin` de forma distinta, sin necesitar sus propias //! variantes de plantilla. -//! 4. **Traducir [`Intent`] a la paleta de colores propia del tema** sobrescribiendo +//! 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. +//! 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 //! ejemplo, uno basado en Bootstrap) debe traducir cada variante al nombre que le corresponda en //! su paleta. Los componentes que generan clases CSS a partir de una [`Intent`] (`Button`, //! `Badge`, `Dropdown`, etc.) consultan este método a través de [`Intent::color()`], así que la //! clase resultante ya nace en la paleta del tema activo. -//! 5. **Reexportar, extender o añadir componentes**. Un tema puede reexportar tal cual los +//! 6. **Reexportar, extender o añadir componentes**. Un tema puede reexportar tal cual los //! componentes propios de PageTop que no requieran adaptación, extenderlos con un trait propio //! para añadir métodos exclusivos (guardando su estado en valores extra con //! [`PropsOp::set_extra()`](crate::html::PropsOp::set_extra) para consumirlos en el @@ -137,6 +145,9 @@ mod intent; pub use intent::Intent; +mod breakpoint; +pub use breakpoint::Breakpoint; + mod layout; pub use layout::{CoreRegions, RegionName, RegionRef}; pub use layout::{CoreTemplates, TemplateName, TemplateRef}; diff --git a/src/core/theme/breakpoint.rs b/src/core/theme/breakpoint.rs new file mode 100644 index 00000000..9dcd594e --- /dev/null +++ b/src/core/theme/breakpoint.rs @@ -0,0 +1,48 @@ +use crate::AutoDefault; +use crate::core::component::{Context, Contextual}; + +// **< Breakpoint >********************************************************************************* + +/// Puntos de corte *responsive*, *mobile-first* (aplican "a partir de" el 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()`]). +/// +/// [`Theme::breakpoint_min_width()`]: crate::core::theme::Theme::breakpoint_min_width +#[derive(AutoDefault, Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum Breakpoint { + /// Base *mobile-first*, equivale a "siempre". + #[default] + Xs, + /// 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. + Md, + /// 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. + Xl, + /// A partir del ancho donde un tema suele considerar el escritorio muy ancho. + Xxl, +} + +impl Breakpoint { + // Todas las variantes, en orden mobile-first (de Xs a Xxl). + pub(crate) const ALL: [Breakpoint; 6] = [ + Breakpoint::Xs, + Breakpoint::Sm, + Breakpoint::Md, + Breakpoint::Lg, + Breakpoint::Xl, + Breakpoint::Xxl, + ]; + + /// 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. + /// + /// Atajo de [`Theme::breakpoint_min_width()`](crate::core::theme::Theme::breakpoint_min_width) + /// a través de [`Context::theme()`]. + pub fn min_width(&self, cx: &Context) -> &'static str { + cx.theme().breakpoint_min_width(*self) + } +} diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index b8a83d01..5b3875ec 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -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::{CoreRegions, Intent}; +use crate::core::theme::{Breakpoint, CoreRegions, Intent}; use crate::global; use crate::html::{Markup, html}; use crate::locale::Lc; @@ -65,19 +65,49 @@ pub trait Theme: Extension + Send + Sync { None } + /// Traduce un [`Breakpoint`] al punto de corte *responsive*, *mobile-first*, propio 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`. + /// + /// 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()`]. + /// + /// [`Breakpoint::min_width()`]: crate::core::theme::Breakpoint::min_width + /// [`Context::theme()`]: crate::core::component::Context::theme + #[rustfmt::skip] + fn breakpoint_min_width(&self, bp: Breakpoint) -> &'static str { + if let Some(parent) = self.parent() { + return parent.breakpoint_min_width(bp); + } + match bp { + Breakpoint::Xs => "", + Breakpoint::Sm => "576px", + Breakpoint::Md => "768px", + Breakpoint::Lg => "992px", + Breakpoint::Xl => "1200px", + Breakpoint::Xxl => "1400px", + } + } + /// Traduce una [`Intent`] al nombre de color de la paleta propia del tema. /// /// `Intent` no define ninguna cadena propia. Cada tema decide qué nombre le corresponde a cada /// variante en su paleta (p. ej. un tema basado en Bootstrap traduce `Severe` a `"danger"`). /// Los componentes que generan clases CSS a partir de una `Intent` (`Button`, `Badge`, /// `Dropdown`, etc.) no llaman a este método directamente en su `setup()`, usan mejor - /// [`Intent::color()`](crate::core::theme::Intent::color) como la forma más sencilla de obtener - /// este mismo valor a través de [`Context::theme()`](crate::core::component::Context::theme), - /// para que la clase resultante ya nazca en la paleta del tema activo. + /// [`Intent::color()`] como la forma más sencilla de obtener este mismo valor a través de + /// [`Context::theme()`], para que la clase resultante ya nazca en la paleta del tema activo. /// /// La implementación por defecto devuelve el vocabulario semántico propio de PageTop /// (`"primary"`, `"severe"`, etc.), que actúa como paleta base cuando ningún tema la /// sobrescribe. + /// + /// [`Intent::color()`]: crate::core::theme::Intent::color + /// [`Context::theme()`]: crate::core::component::Context::theme #[rustfmt::skip] fn intent_color(&self, intent: Intent) -> &'static str { if let Some(parent) = self.parent() { diff --git a/src/html.rs b/src/html.rs index aa006aea..25e03603 100644 --- a/src/html.rs +++ b/src/html.rs @@ -17,6 +17,7 @@ mod assets; pub use assets::favicon::Favicon; pub use assets::javascript::JavaScript; pub use assets::preload::Preload; +pub use assets::responsive::ResponsiveStyles; pub use assets::stylesheet::{StyleSheet, TargetMedia}; pub use assets::{Asset, Assets}; diff --git a/src/html/assets.rs b/src/html/assets.rs index b1f78803..82e9ab96 100644 --- a/src/html/assets.rs +++ b/src/html/assets.rs @@ -1,6 +1,7 @@ pub mod favicon; pub mod javascript; pub mod preload; +pub mod responsive; pub mod stylesheet; use crate::core::component::Context; diff --git a/src/html/assets/responsive.rs b/src/html/assets/responsive.rs new file mode 100644 index 00000000..25103fe4 --- /dev/null +++ b/src/html/assets/responsive.rs @@ -0,0 +1,215 @@ +use crate::core::component::Context; +use crate::core::theme::Breakpoint; +use crate::html::{Markup, PreEscaped, html}; +use crate::{AutoDefault, CowStr, util}; + +// Punto de corte (None si no aplica), clase(s) normalizadas y sus declaraciones `propiedad: valor`. +type Entry = (Option, CowStr, Vec<(CowStr, CowStr)>); + +/// Declaraciones de estilo en línea agrupadas por punto de corte ([`Breakpoint`]). +/// +/// También por clase (o clases) que van a renderizarse en bloques `@media` en el `` del +/// documento. +/// +/// Por tanto, cada entrada se identifica por la pareja (punto de corte, clases) y acumula sus +/// propias declaraciones `propiedad: valor`, en el orden en que se añaden. Las clases se guardan en +/// bruto, normalizadas igual que hace [`Props`](crate::html::Props) (ASCII, minúsculas y un único +/// espacio entre tokens): por ejemplo `"foo bar"`, no `.foo.bar`. +/// +/// El punto de corte es opcional, donde `None` declara una regla siempre activa, sin pasar por el +/// tema ni depender de que resuelva algún [`Breakpoint`]. No ordena ni combina las clases de dos +/// llamadas que las declaren en distinto orden (por ejemplo, `"foo bar"` y `"bar foo"` generan dos +/// entradas distintas). La llamada es a través de [`AssetsOp::AddResponsiveStyle`]. +/// +/// [`AssetsOp::AddResponsiveStyle`]: crate::core::component::AssetsOp::AddResponsiveStyle +#[derive(AutoDefault, Clone, Debug)] +pub struct ResponsiveStyles(Vec); + +impl ResponsiveStyles { + /// Crea un conjunto vacío. + pub fn new() -> Self { + Self::default() + } + + /// Añade una declaración de estilo (`property: value`) para las clases indicadas, dentro del + /// punto de corte dado. + /// + /// Si ya existe una declaración para la misma propiedad, en el mismo punto de corte y con las + /// mismas clases, la llamada no hace nada: se conserva el valor ya almacenado, no se sustituye. + /// Pensado para clases utilitarias generadas automáticamente, donde el mismo nombre de clase + /// implica siempre el mismo valor -- declararla de nuevo es entonces una operación de sólo + /// lectura, sin normalizar `value`, en vez de una escritura. + /// + /// Si `classes` contiene caracteres no ASCII, o si `classes`, `property` o `value` quedan + /// vacíos tras recortar espacios, la operación se ignora. + pub fn add_style( + &mut self, + breakpoint: impl Into>, + classes: impl AsRef, + property: impl AsRef, + value: impl AsRef, + ) { + let breakpoint = breakpoint.into(); + + let Some(classes) = util::normalize_ascii(classes.as_ref()) else { + return; + }; + if classes.is_empty() { + return; + } + let classes: CowStr = classes.into_owned().into(); + + let property_norm = property.as_ref().trim().to_ascii_lowercase(); + if property_norm.is_empty() { + return; + } + let property: CowStr = property_norm.into(); + + match self + .0 + .iter_mut() + .find(|(bp, cls, _)| *bp == breakpoint && *cls == classes) + { + Some((_, _, styles)) => { + // Ya declarada: se descarta sin normalizar `value`, el camino habitual (y que debe + // ser barato) cuando muchos componentes comparten la misma clase utilitaria. + if styles.iter().any(|(k, _)| *k == property) { + return; + } + let Some(value) = util::non_blank(value.as_ref()) else { + return; + }; + styles.push((property, value.to_string().into())); + } + None => { + let Some(value) = util::non_blank(value.as_ref()) else { + return; + }; + self.0.push(( + breakpoint, + classes, + vec![(property, value.to_string().into())], + )); + } + } + } + + // **< ResponsiveStyles GETTERS >*************************************************************** + + /// Devuelve el valor de la propiedad indicada para el punto de corte y las clases dados, si + /// existe. + pub fn get_style( + &self, + breakpoint: impl Into>, + classes: impl AsRef, + property: impl AsRef, + ) -> Option { + let styles = self.entry(breakpoint.into(), classes.as_ref())?; + let property = property.as_ref().trim().to_ascii_lowercase(); + styles + .iter() + .find(|(k, _)| k.as_ref() == property) + .map(|(_, v)| v.to_string()) + } + + /// Devuelve todas las declaraciones de estilo del punto de corte y las clases dados, como + /// cadena de texto (separadas por `"; "`), si existen. + pub fn get_styles( + &self, + breakpoint: impl Into>, + classes: impl AsRef, + ) -> Option { + let styles = self.entry(breakpoint.into(), classes.as_ref())?; + if styles.is_empty() { + return None; + } + Some( + styles + .iter() + .map(|(k, v)| util::join!(k.as_ref(), ": ", v.as_ref())) + .collect::>() + .join("; "), + ) + } + + /// Devuelve `true` si no hay ninguna declaración almacenada. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + // **< ResponsiveStyles RENDER >**************************************************************** + + /// Renderiza las declaraciones acumuladas como texto CSS. + /// + /// Emite primero las declaraciones sin punto de corte (`None`), siempre sin envoltorio. Luego + /// recorre los puntos de corte reales en orden *mobile-first* (`Xs` a `Xxl`, omitiendo los que + /// no tengan ninguna declaración) y, para cada uno, agrupa las reglas de todas sus entradas + /// (`.clases { propiedad: valor; ... }`). Si el ancho mínimo resuelto por el tema activo + /// ([`Breakpoint::min_width()`]) es una cadena vacía, las reglas se emiten tal cual, sin punto + /// de corte real; en cualquier otro caso se envuelven en `@media (min-width: ...)`. + /// + /// El resultado no contiene saltos de línea. + pub fn render(&self, cx: &Context) -> Markup { + let mut css = String::new(); + + css.push_str(&self.render_rules(None)); + + for breakpoint in Breakpoint::ALL { + let rules = self.render_rules(Some(breakpoint)); + if rules.is_empty() { + continue; + } + let min_width = breakpoint.min_width(cx); + if min_width.is_empty() { + css.push_str(&rules); + } else { + css.push_str(&util::join!( + "@media (min-width: ", + min_width, + ") { ", + rules, + " }" + )); + } + } + + html! { (PreEscaped(css)) } + } + + // Construye, concatenadas y sin separador, las reglas CSS (`.clases { propiedad: valor; ... }`) + // de todas las entradas del punto de corte indicado. + fn render_rules(&self, breakpoint: Option) -> String { + let mut rules = String::new(); + for (_, classes, styles) in self + .0 + .iter() + .filter(|(bp, _, styles)| *bp == breakpoint && !styles.is_empty()) + { + let selector = classes.replace(' ', "."); + let declarations = styles + .iter() + .map(|(property, value)| util::join!(property.as_ref(), ": ", value.as_ref())) + .collect::>() + .join("; "); + rules.push_str(&util::join!(".", selector, " { ", declarations, " }")); + } + rules + } + + // Normaliza `classes` igual que `add_style()` y busca las declaraciones de la entrada + // correspondiente al punto de corte y las clases dados. + fn entry( + &self, + breakpoint: Option, + classes: &str, + ) -> Option<&Vec<(CowStr, CowStr)>> { + let classes = util::normalize_ascii(classes)?; + if classes.is_empty() { + return None; + } + self.0 + .iter() + .find(|(bp, cls, _)| *bp == breakpoint && cls.as_ref() == classes.as_ref()) + .map(|(_, _, styles)| styles) + } +} diff --git a/src/response/page.rs b/src/response/page.rs index d64c462a..cebfe41a 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -24,7 +24,7 @@ use crate::base::component::layout; use crate::core::component::{AssetsOp, ChildOp, ComponentRender}; use crate::core::component::{Context, ContextError, Contextual}; use crate::core::theme::{CoreRegions, RegionName, RegionRef, TemplateRef, ThemeRef}; -use crate::html::{Assets, Favicon, JavaScript, StyleSheet}; +use crate::html::{Assets, Favicon, JavaScript, ResponsiveStyles, StyleSheet}; use crate::html::{DOCTYPE, Markup, html}; use crate::html::{Props, PropsOp}; use crate::locale::{CharacterDirection, LangId, LanguageIdentifier, Lc}; @@ -348,6 +348,10 @@ impl Contextual for Page { self.context.javascripts() } + fn responsive_styles(&self) -> &ResponsiveStyles { + self.context.responsive_styles() + } + fn body_props(&self) -> &Props { self.context.body_props() } diff --git a/tests/html_responsives.rs b/tests/html_responsives.rs new file mode 100644 index 00000000..561eb45e --- /dev/null +++ b/tests/html_responsives.rs @@ -0,0 +1,416 @@ +use pagetop::prelude::*; + +// **< ResponsiveStyles::add_style >**************************************************************** + +#[pagetop::test] +async fn add_style_basic_adds_declaration() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_multiple_calls_accumulate_in_order() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + r.add_style(Breakpoint::Md, "col", "margin-inline-start", "0"); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%; margin-inline-start: 0".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_keeps_first_value_when_property_already_exists() { + // First-write-wins: a later call for the same (breakpoint, classes, property) is a no-op, + // it does not overwrite the value already stored. + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + r.add_style(Breakpoint::Md, "col", "margin-inline-start", "0"); + r.add_style(Breakpoint::Md, "col", "flex-basis", "33%"); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%; margin-inline-start: 0".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_property_name_is_case_insensitive() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "Flex-Basis", "50%"); + r.add_style(Breakpoint::Md, "col", "FLEX-BASIS", "33%"); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_value_preserves_case() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "font-family", "Arial"); + assert_eq!( + r.get_style(Breakpoint::Md, "col", "font-family"), + Some("Arial".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_trims_whitespace_in_property_and_value() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", " flex-basis ", " 50% "); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_repeated_call_never_looks_at_value_once_property_exists() { + // The already-exists path returns before normalizing `value`, so even a blank value on a + // repeated call has no effect on the declaration already stored. + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + r.add_style(Breakpoint::Md, "col", "flex-basis", " "); + assert_eq!( + r.get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_ignores_empty_property_or_value() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "", "50%"); + r.add_style(Breakpoint::Md, "col", "flex-basis", ""); + r.add_style(Breakpoint::Md, "col", " ", " "); + assert!(r.is_empty()); +} + +#[pagetop::test] +async fn add_style_ignores_empty_or_blank_classes() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "", "flex-basis", "50%"); + r.add_style(Breakpoint::Md, " ", "flex-basis", "50%"); + assert!(r.is_empty()); +} + +#[pagetop::test] +async fn add_style_ignores_non_ascii_classes() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "cañón", "flex-basis", "50%"); + assert!(r.is_empty()); +} + +// **< Class normalization >************************************************************************ + +#[pagetop::test] +async fn add_style_normalizes_classes_case_and_whitespace() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, " Foo BAR ", "color", "red"); + assert_eq!( + r.get_styles(Breakpoint::Md, "foo bar"), + Some("color: red".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_does_not_reorder_classes_tokens() { + // Documented behavior: classes are stored as normalized but NOT sorted, so declaring the + // same classes in a different token order creates a separate entry instead of merging. + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "foo bar", "color", "red"); + r.add_style(Breakpoint::Md, "bar foo", "font-weight", "bold"); + assert_eq!( + r.get_styles(Breakpoint::Md, "foo bar"), + Some("color: red".to_string()) + ); + assert_eq!( + r.get_styles(Breakpoint::Md, "bar foo"), + Some("font-weight: bold".to_string()) + ); +} + +// **< Breakpoint isolation >*********************************************************************** + +#[pagetop::test] +async fn add_style_same_classes_different_breakpoints_do_not_mix() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Sm, "col", "flex-basis", "100%"); + r.add_style(Breakpoint::Lg, "col", "flex-basis", "50%"); + assert_eq!( + r.get_styles(Breakpoint::Sm, "col"), + Some("flex-basis: 100%".to_string()) + ); + assert_eq!( + r.get_styles(Breakpoint::Lg, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +// **< No breakpoint (None) >*********************************************************************** + +#[pagetop::test] +async fn add_style_accepts_none_as_breakpoint() { + let mut r = ResponsiveStyles::new(); + r.add_style(None, "col", "flex-basis", "50%"); + assert_eq!( + r.get_styles(None, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +#[pagetop::test] +async fn add_style_none_and_xs_do_not_mix() { + let mut r = ResponsiveStyles::new(); + r.add_style(None, "col", "flex-basis", "100%"); + r.add_style(Breakpoint::Xs, "col", "flex-basis", "50%"); + assert_eq!( + r.get_styles(None, "col"), + Some("flex-basis: 100%".to_string()) + ); + assert_eq!( + r.get_styles(Breakpoint::Xs, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +// **< is_empty >*********************************************************************************** + +#[pagetop::test] +async fn responsive_styles_is_empty_on_default() { + assert!(ResponsiveStyles::new().is_empty()); +} + +#[pagetop::test] +async fn responsive_styles_is_empty_false_after_add_style() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + assert!(!r.is_empty()); +} + +// **< get_style / get_styles >********************************************************************* + +#[pagetop::test] +async fn get_style_returns_none_for_missing_breakpoint_or_classes() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + assert_eq!(r.get_style(Breakpoint::Lg, "col", "flex-basis"), None); + assert_eq!(r.get_style(Breakpoint::Md, "other", "flex-basis"), None); +} + +#[pagetop::test] +async fn get_style_returns_none_for_missing_property() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + assert_eq!(r.get_style(Breakpoint::Md, "col", "margin"), None); +} + +#[pagetop::test] +async fn get_style_is_case_insensitive_and_trims_input() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + assert_eq!( + r.get_style(Breakpoint::Md, "col", "FLEX-BASIS"), + Some("50%".to_string()) + ); + assert_eq!( + r.get_style(Breakpoint::Md, "col", " flex-basis "), + Some("50%".to_string()) + ); +} + +#[pagetop::test] +async fn get_styles_returns_none_when_nothing_stored() { + assert_eq!( + ResponsiveStyles::new().get_styles(Breakpoint::Md, "col"), + None + ); +} + +#[pagetop::test] +async fn get_styles_matches_classes_after_normalization() { + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "foo bar", "color", "red"); + assert_eq!( + r.get_styles(Breakpoint::Md, " FOO BAR "), + Some("color: red".to_string()) + ); +} + +// **< ResponsiveStyles::render >******************************************************************** + +#[pagetop::test] +async fn render_is_empty_when_nothing_stored() { + let cx = Context::default(); + let r = ResponsiveStyles::new(); + assert_eq!(r.render(&cx).into_string(), ""); +} + +#[pagetop::test] +async fn render_zero_min_width_breakpoint_has_no_media_query() { + // The default theme resolves `Breakpoint::Xs` to `UnitValue::Zero`. + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Xs, "col", "flex-basis", "100%"); + assert_eq!(r.render(&cx).into_string(), ".col { flex-basis: 100% }"); +} + +#[pagetop::test] +async fn render_none_breakpoint_has_no_media_query() { + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(None, "col", "flex-basis", "100%"); + assert_eq!(r.render(&cx).into_string(), ".col { flex-basis: 100% }"); +} + +#[pagetop::test] +async fn render_none_comes_before_every_breakpoint() { + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + r.add_style(None, "row", "display", "flex"); + assert_eq!( + r.render(&cx).into_string(), + ".row { display: flex }@media (min-width: 768px) { .col { flex-basis: 50% } }" + ); +} + +#[pagetop::test] +async fn render_non_zero_breakpoint_wraps_in_media_query() { + // The default theme resolves `Breakpoint::Md` to `768px`. + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + assert_eq!( + r.render(&cx).into_string(), + "@media (min-width: 768px) { .col { flex-basis: 50% } }" + ); +} + +#[pagetop::test] +async fn render_groups_multiple_properties_in_the_same_rule() { + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + r.add_style(Breakpoint::Md, "col", "margin-inline-start", "0"); + assert_eq!( + r.render(&cx).into_string(), + "@media (min-width: 768px) { .col { flex-basis: 50%; margin-inline-start: 0 } }" + ); +} + +#[pagetop::test] +async fn render_concatenates_rules_of_different_selectors_in_the_same_breakpoint() { + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + r.add_style(Breakpoint::Md, "row", "display", "flex"); + assert_eq!( + r.render(&cx).into_string(), + "@media (min-width: 768px) { .col { flex-basis: 50% }.row { display: flex } }" + ); +} + +#[pagetop::test] +async fn render_converts_multiple_classes_into_a_compound_selector() { + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Md, "foo bar", "color", "red"); + assert_eq!( + r.render(&cx).into_string(), + "@media (min-width: 768px) { .foo.bar { color: red } }" + ); +} + +#[pagetop::test] +async fn render_orders_breakpoints_mobile_first_regardless_of_insertion_order() { + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Lg, "col", "flex-basis", "33%"); + r.add_style(Breakpoint::Xs, "col", "flex-basis", "100%"); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + assert_eq!( + r.render(&cx).into_string(), + ".col { flex-basis: 100% }\ + @media (min-width: 768px) { .col { flex-basis: 50% } }\ + @media (min-width: 992px) { .col { flex-basis: 33% } }" + ); +} + +#[pagetop::test] +async fn render_has_no_line_breaks() { + let cx = Context::default(); + let mut r = ResponsiveStyles::new(); + r.add_style(Breakpoint::Xs, "col", "flex-basis", "100%"); + r.add_style(Breakpoint::Md, "col", "flex-basis", "50%"); + r.add_style(Breakpoint::Md, "row", "display", "flex"); + assert!(!r.render(&cx).into_string().contains('\n')); +} + +// **< Context / AssetsOp integration >************************************************************* + +#[pagetop::test] +async fn context_add_responsive_style_feeds_responsives() { + let cx = Context::default().with_assets(AssetsOp::AddResponsiveStyle( + Some(Breakpoint::Md), + "col", + "flex-basis", + "50%", + )); + assert_eq!( + cx.responsive_styles().get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%".to_string()) + ); +} + +#[pagetop::test] +async fn context_add_responsive_style_accumulates_across_calls() { + let cx = Context::default() + .with_assets(AssetsOp::AddResponsiveStyle( + Some(Breakpoint::Md), + "col", + "flex-basis", + "50%", + )) + .with_assets(AssetsOp::AddResponsiveStyle( + Some(Breakpoint::Md), + "col", + "margin-inline-start", + "0", + )); + assert_eq!( + cx.responsive_styles().get_styles(Breakpoint::Md, "col"), + Some("flex-basis: 50%; margin-inline-start: 0".to_string()) + ); +} + +#[pagetop::test] +async fn context_default_has_no_responsive_styles() { + assert!(Context::default().responsive_styles().is_empty()); +} + +// **< Context::render_assets integration >********************************************************* + +#[pagetop::test] +async fn render_assets_includes_style_tag_with_responsive_styles() { + let mut cx = Context::default().with_assets(AssetsOp::AddResponsiveStyle( + Some(Breakpoint::Xs), + "col", + "flex-basis", + "100%", + )); + assert_eq!( + cx.render_assets().into_string(), + "" + ); +} + +#[pagetop::test] +async fn render_assets_omits_style_tag_when_no_responsive_styles() { + let mut cx = Context::default(); + assert_eq!(cx.render_assets().into_string(), ""); +}