✨ (pagetop): Añade PageTopSvg::Tile y ajusta Image
This commit is contained in:
parent
16eec316c4
commit
f8f023b513
5 changed files with 263 additions and 18 deletions
|
|
@ -44,18 +44,11 @@ impl Component for Image {
|
||||||
|
|
||||||
fn setup(&mut self, _cx: &mut Context) {
|
fn setup(&mut self, _cx: &mut Context) {
|
||||||
self.alter_prop(PropsOp::prepend_classes(match self.source() {
|
self.alter_prop(PropsOp::prepend_classes(match self.source() {
|
||||||
image::Source::Logo(_) => "image image-fluid",
|
image::Source::Logo(_) | image::Source::Responsive(_) => "image image-fluid",
|
||||||
image::Source::Responsive(_) => "image image-fluid",
|
|
||||||
image::Source::Thumbnail(_) => "image image-thumbnail",
|
image::Source::Thumbnail(_) => "image image-thumbnail",
|
||||||
image::Source::Plain(_) => "image",
|
image::Source::Plain(_) => "image",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Se asigna un tamaño predefinido para el logotipo que `with_size()` puede sobrescribir.
|
|
||||||
if matches!(self.source(), image::Source::Logo(_)) {
|
|
||||||
self.alter_prop(PropsOp::add_style("width", "1.25em"));
|
|
||||||
self.alter_prop(PropsOp::add_style("height", "1.25em"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// El tamaño se aplica como declaraciones `style` individuales sobre `Props`.
|
// El tamaño se aplica como declaraciones `style` individuales sobre `Props`.
|
||||||
match *self.size() {
|
match *self.size() {
|
||||||
image::Size::Auto => {}
|
image::Size::Auto => {}
|
||||||
|
|
@ -74,6 +67,15 @@ impl Component for Image {
|
||||||
self.alter_prop(PropsOp::add_style("height", v.to_string()));
|
self.alter_prop(PropsOp::add_style("height", v.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// El logotipo es cuadrado. Sin `width`/`height` toma un tamaño predefinido.
|
||||||
|
if matches!(self.source(), image::Source::Logo(_))
|
||||||
|
&& self.props().get_style("width").is_none()
|
||||||
|
&& self.props().get_style("height").is_none()
|
||||||
|
{
|
||||||
|
self.alter_prop(PropsOp::add_style("width", "1.25rem"));
|
||||||
|
self.alter_prop(PropsOp::add_style("height", "1.25rem"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
|
|
@ -81,9 +83,9 @@ impl Component for Image {
|
||||||
image::Source::Logo(svg) => {
|
image::Source::Logo(svg) => {
|
||||||
return Ok(svg.markup_with(cx, self.props(), self.alternative().clone()));
|
return Ok(svg.markup_with(cx, self.props(), self.alternative().clone()));
|
||||||
}
|
}
|
||||||
image::Source::Responsive(source) => Some(source),
|
image::Source::Responsive(source)
|
||||||
image::Source::Thumbnail(source) => Some(source),
|
| image::Source::Thumbnail(source)
|
||||||
image::Source::Plain(source) => Some(source),
|
| image::Source::Plain(source) => Some(source),
|
||||||
};
|
};
|
||||||
Ok(html! {
|
Ok(html! {
|
||||||
img
|
img
|
||||||
|
|
@ -121,7 +123,7 @@ impl Image {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Establece el origen de la imagen, influyendo en su disposición en el contenido.
|
/// Establece el origen de la imagen.
|
||||||
pub fn with_source(mut self, source: image::Source) -> Self {
|
pub fn with_source(mut self, source: image::Source) -> Self {
|
||||||
self.source = source;
|
self.source = source;
|
||||||
self
|
self
|
||||||
|
|
@ -146,8 +148,8 @@ impl From<image::Source> for Image {
|
||||||
|
|
||||||
impl From<image::Source> for Option<Image> {
|
impl From<image::Source> for Option<Image> {
|
||||||
/// Permite pasar un [`image::Source`] directamente donde se espera `impl Into<Option<Image>>`
|
/// Permite pasar un [`image::Source`] directamente donde se espera `impl Into<Option<Image>>`
|
||||||
/// (p. ej. [`Brand::with_image()`](super::super::Brand::with_image)), sin construir la
|
/// (p. ej. [`Brand::with_image()`](crate::base::component::Brand::with_image)), sin construir
|
||||||
/// [`Image`] a mano.
|
/// la [`Image`] a mano.
|
||||||
fn from(source: image::Source) -> Self {
|
fn from(source: image::Source) -> Self {
|
||||||
Some(Image::with(source))
|
Some(Image::with(source))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ use crate::prelude::*;
|
||||||
pub enum Size {
|
pub enum Size {
|
||||||
/// Ajuste automático por defecto.
|
/// Ajuste automático por defecto.
|
||||||
///
|
///
|
||||||
/// La imagen usa su tamaño natural o se ajusta al contenedor donde se publica.
|
/// La imagen usa su tamaño natural o se ajusta al contenedor donde se publica. El logotipo
|
||||||
|
/// ([`Source::Logo`]) toma un tamaño predefinido de `1.25rem` de ancho y alto, salvo que se
|
||||||
|
/// haya fijado alguna de sus dimensiones.
|
||||||
#[default]
|
#[default]
|
||||||
Auto,
|
Auto,
|
||||||
/// Establece explícitamente el **ancho y alto** de la imagen.
|
/// Establece explícitamente el **ancho y alto** de la imagen.
|
||||||
|
|
@ -40,6 +42,10 @@ pub enum Size {
|
||||||
#[derive(AutoDefault, Clone, Debug, PartialEq)]
|
#[derive(AutoDefault, Clone, Debug, PartialEq)]
|
||||||
pub enum Source {
|
pub enum Source {
|
||||||
/// Imagen con el logotipo de PageTop.
|
/// Imagen con el logotipo de PageTop.
|
||||||
|
///
|
||||||
|
/// Se renderiza como un `<svg>` en línea, siempre cuadrado. Sin dimensiones explícitas
|
||||||
|
/// ([`Size::Auto`]) mide `1.25rem` de ancho y alto; si se fija sólo una, la otra se ajusta
|
||||||
|
/// proporcionalmente.
|
||||||
#[default]
|
#[default]
|
||||||
Logo(PageTopSvg),
|
Logo(PageTopSvg),
|
||||||
/// Imagen que se adapta automáticamente a su contenedor.
|
/// Imagen que se adapta automáticamente a su contenedor.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ use crate::core::component::Context;
|
||||||
use crate::html::{Markup, Props, html};
|
use crate::html::{Markup, Props, html};
|
||||||
use crate::locale::Lc;
|
use crate::locale::Lc;
|
||||||
|
|
||||||
|
// Color oscuro del logotipo para `LineDark` y el fondo predeterminado de `Tile`.
|
||||||
|
const DARK: (u8, u8, u8) = (10, 11, 9);
|
||||||
|
// Color claro del logotipo para `LineLight` y línea blanca de `Tile`.
|
||||||
|
const LIGHT: (u8, u8, u8) = (255, 255, 255);
|
||||||
|
|
||||||
/// Representación SVG del **logotipo de PageTop** para incrustar en HTML.
|
/// Representación SVG del **logotipo de PageTop** para incrustar en HTML.
|
||||||
///
|
///
|
||||||
/// # Ejemplo
|
/// # Ejemplo
|
||||||
|
|
@ -23,10 +28,15 @@ use crate::locale::Lc;
|
||||||
/// div class="line_red" {
|
/// div class="line_red" {
|
||||||
/// (PageTopSvg::LineRGB(255, 0, 0).markup())
|
/// (PageTopSvg::LineRGB(255, 0, 0).markup())
|
||||||
/// }
|
/// }
|
||||||
|
/// div class="tile" {
|
||||||
|
/// (PageTopSvg::Tile.markup())
|
||||||
|
/// }
|
||||||
|
/// div class="tile_blue" {
|
||||||
|
/// (PageTopSvg::TileRGB(0, 82, 204).markup())
|
||||||
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// };
|
/// };
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum PageTopSvg {
|
pub enum PageTopSvg {
|
||||||
/// Versión por defecto con el logotipo a color.
|
/// Versión por defecto con el logotipo a color.
|
||||||
|
|
@ -38,6 +48,12 @@ pub enum PageTopSvg {
|
||||||
LineLight,
|
LineLight,
|
||||||
/// Versión monocroma configurable por RGB.
|
/// Versión monocroma configurable por RGB.
|
||||||
LineRGB(u8, u8, u8),
|
LineRGB(u8, u8, u8),
|
||||||
|
/// Versión icono de aplicación. Muestra un recuadro de fondo oscuro y esquinas redondeadas
|
||||||
|
/// (salvo la inferior derecha), con el logotipo ampliado y recortado en línea clara.
|
||||||
|
Tile,
|
||||||
|
/// Igual que [`Tile`](Self::Tile), aunque el color del recuadro de fondo se configura por RGB.
|
||||||
|
/// El logotipo se muestra con línea clara u oscura según su contraste con el fondo.
|
||||||
|
TileRGB(u8, u8, u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PageTopSvg {
|
impl PageTopSvg {
|
||||||
|
|
@ -94,9 +110,11 @@ impl PageTopSvg {
|
||||||
fn path_fills(&self) -> Markup {
|
fn path_fills(&self) -> Markup {
|
||||||
match self {
|
match self {
|
||||||
Self::Color => self.logo_color(),
|
Self::Color => self.logo_color(),
|
||||||
Self::LineDark => self.logo_line(10, 11, 9),
|
Self::LineDark => self.logo_line(DARK.0, DARK.1, DARK.2),
|
||||||
Self::LineLight => self.logo_line(255, 255, 255),
|
Self::LineLight => self.logo_line(LIGHT.0, LIGHT.1, LIGHT.2),
|
||||||
Self::LineRGB(r, g, b) => self.logo_line(*r, *g, *b),
|
Self::LineRGB(r, g, b) => self.logo_line(*r, *g, *b),
|
||||||
|
Self::Tile => self.logo_tile(DARK.0, DARK.1, DARK.2),
|
||||||
|
Self::TileRGB(r, g, b) => self.logo_tile(*r, *g, *b),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,4 +143,37 @@ impl PageTopSvg {
|
||||||
path fill=(logo_rgb) d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {}
|
path fill=(logo_rgb) d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn logo_tile(&self, r: u8, g: u8, b: u8) -> Markup {
|
||||||
|
// Luminancia relativa (WCAG 2.x), a partir de los canales sRGB.
|
||||||
|
let luminance = |(r, g, b): (u8, u8, u8)| {
|
||||||
|
let linear = |c: u8| {
|
||||||
|
let c = f32::from(c) / 255.0;
|
||||||
|
if c <= 0.03928 {
|
||||||
|
c / 12.92
|
||||||
|
} else {
|
||||||
|
((c + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
|
||||||
|
};
|
||||||
|
// Elige la línea, oscura o clara, con más contraste sobre el fondo. El contraste entre dos
|
||||||
|
// luminancias es `(L_mayor + 0.05) / (L_menor + 0.05)`.
|
||||||
|
let bg = luminance((r, g, b)) + 0.05;
|
||||||
|
let (lr, lg, lb) = if bg / (luminance(DARK) + 0.05) > (luminance(LIGHT) + 0.05) / bg {
|
||||||
|
DARK
|
||||||
|
} else {
|
||||||
|
LIGHT
|
||||||
|
};
|
||||||
|
|
||||||
|
// El logotipo se amplía y desplaza para recortar todo salvo la parte superior izquierda.
|
||||||
|
let fill = format!("rgb({r},{g},{b})");
|
||||||
|
html! {
|
||||||
|
rect width="1614" height="1614" rx="200" fill=(fill) {}
|
||||||
|
rect x="1414" y="1414" width="200" height="200" fill=(fill) {}
|
||||||
|
g transform="translate(161.4 161.4) scale(1.375)" {
|
||||||
|
(self.logo_line(lr, lg, lb))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
tests/component_image.rs
Normal file
133
tests/component_image.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
async fn render(mut image: Image) -> String {
|
||||||
|
image.render(&mut Context::default()).await.into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logo() -> Image {
|
||||||
|
Image::with(image::Source::logo(PageTopSvg::Color))
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Logo size >**********************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn logo_without_size_gets_the_default_dimensions() {
|
||||||
|
let html = render(logo()).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"style="width: 1.25rem; height: 1.25rem""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn logo_with_only_width_does_not_get_a_default_height() {
|
||||||
|
let html = render(logo().with_size(image::Size::Width(UnitValue::Px(320)))).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"style="width: 320px""#));
|
||||||
|
assert!(!html.contains("height"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn logo_with_only_height_does_not_get_a_default_width() {
|
||||||
|
let html = render(logo().with_size(image::Size::Height(UnitValue::Px(40)))).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"style="height: 40px""#));
|
||||||
|
assert!(!html.contains("width:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn logo_with_a_percentage_keeps_a_single_dimension() {
|
||||||
|
// A percentage height would refer to the container height, not to the width.
|
||||||
|
let html = render(logo().with_size(image::Size::Width(UnitValue::RelPct(33.0)))).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"style="width: 33%""#));
|
||||||
|
assert!(!html.contains("height"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn logo_with_both_dimensions_uses_them() {
|
||||||
|
let both = render(logo().with_size(image::Size::Both(UnitValue::Px(36)))).await;
|
||||||
|
let different = render(logo().with_size(image::Size::Dimensions(
|
||||||
|
UnitValue::Px(10),
|
||||||
|
UnitValue::Px(20),
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(both.contains(r#"style="width: 36px; height: 36px""#));
|
||||||
|
// Both dimensions are set explicitly, so the default is not applied.
|
||||||
|
assert!(different.contains(r#"style="width: 10px; height: 20px""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn logo_keeps_a_custom_style_instead_of_the_default_dimensions() {
|
||||||
|
let width = render(logo().with_prop(PropsOp::add_style("width", "3em"))).await;
|
||||||
|
let both = render(
|
||||||
|
logo()
|
||||||
|
.with_prop(PropsOp::add_style("width", "3em"))
|
||||||
|
.with_prop(PropsOp::add_style("height", "4em")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(width.contains(r#"style="width: 3em""#));
|
||||||
|
assert!(!width.contains("height"));
|
||||||
|
assert!(both.contains(r#"style="width: 3em; height: 4em""#));
|
||||||
|
assert!(!both.contains("1.25rem"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn logo_with_a_size_without_value_gets_the_default_dimensions() {
|
||||||
|
// `UnitValue::None` writes no declaration, so no dimension is actually set.
|
||||||
|
for size in [
|
||||||
|
image::Size::Width(UnitValue::None),
|
||||||
|
image::Size::Height(UnitValue::None),
|
||||||
|
image::Size::Both(UnitValue::None),
|
||||||
|
image::Size::Dimensions(UnitValue::None, UnitValue::None),
|
||||||
|
] {
|
||||||
|
let html = render(logo().with_size(size)).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"style="width: 1.25rem; height: 1.25rem""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Other sources >******************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn responsive_image_without_size_has_no_style() {
|
||||||
|
let html = render(Image::with(image::Source::responsive("/photo.jpg"))).await;
|
||||||
|
|
||||||
|
assert!(html.starts_with(r#"<img src="/photo.jpg""#));
|
||||||
|
assert!(!html.contains("style="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn responsive_image_with_only_width_does_not_get_a_height() {
|
||||||
|
let html = render(
|
||||||
|
Image::with(image::Source::responsive("/photo.jpg"))
|
||||||
|
.with_size(image::Size::Width(UnitValue::Px(320))),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"style="width: 320px""#));
|
||||||
|
assert!(!html.contains("height"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn responsive_image_with_a_size_without_value_has_no_style() {
|
||||||
|
let html = render(
|
||||||
|
Image::with(image::Source::responsive("/photo.jpg"))
|
||||||
|
.with_size(image::Size::Width(UnitValue::None)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(!html.contains("style="));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Alternative text >***************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn logo_alternative_is_an_accessible_label_or_hides_the_svg() {
|
||||||
|
let labeled = render(logo().with_alternative(Lc::n("PageTop"))).await;
|
||||||
|
let decorative = render(logo()).await;
|
||||||
|
|
||||||
|
assert!(labeled.contains(r#"role="img" aria-label="PageTop""#));
|
||||||
|
assert!(decorative.contains(r#"aria-hidden="true""#));
|
||||||
|
assert!(!decorative.contains("aria-label"));
|
||||||
|
}
|
||||||
53
tests/html_logo.rs
Normal file
53
tests/html_logo.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
// The background is made of `rect`s; only the logo paths use the line color.
|
||||||
|
fn line_color(bg: (u8, u8, u8)) -> &'static str {
|
||||||
|
let svg = PageTopSvg::TileRGB(bg.0, bg.1, bg.2).markup().into_string();
|
||||||
|
let dark = svg.contains("<path fill=\"rgb(10,11,9)\"");
|
||||||
|
let white = svg.contains("<path fill=\"rgb(255,255,255)\"");
|
||||||
|
assert_ne!(dark, white, "the line must use exactly one color");
|
||||||
|
if dark { "dark" } else { "white" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< PageTopSvg::TileRGB >************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn tile_rgb_uses_a_white_line_on_dark_backgrounds() {
|
||||||
|
assert_eq!(line_color((0, 82, 204)), "white");
|
||||||
|
assert_eq!(line_color((0, 0, 0)), "white");
|
||||||
|
assert_eq!(line_color((220, 20, 60)), "white");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn tile_rgb_uses_a_dark_line_on_light_backgrounds() {
|
||||||
|
assert_eq!(line_color((255, 184, 75)), "dark");
|
||||||
|
assert_eq!(line_color((0, 255, 0)), "dark");
|
||||||
|
assert_eq!(line_color((255, 255, 255)), "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn tile_rgb_switches_line_color_around_the_contrast_threshold() {
|
||||||
|
// Relative luminance ~0.181 and ~0.188 respectively; with the default dark and light lines the
|
||||||
|
// contrast with both is equal at ~0.186.
|
||||||
|
assert_eq!(line_color((118, 118, 118)), "white");
|
||||||
|
assert_eq!(line_color((120, 120, 120)), "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn tile_keeps_a_white_line_on_its_default_dark_background() {
|
||||||
|
let svg = PageTopSvg::Tile.markup().into_string();
|
||||||
|
|
||||||
|
assert!(svg.contains("<path fill=\"rgb(255,255,255)\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn tile_keeps_a_square_bottom_right_corner() {
|
||||||
|
for svg in [
|
||||||
|
PageTopSvg::Tile.markup().into_string(),
|
||||||
|
PageTopSvg::TileRGB(0, 82, 204).markup().into_string(),
|
||||||
|
] {
|
||||||
|
// Rounded background plus a square of the same color covering the bottom-right corner.
|
||||||
|
assert!(svg.contains("rx=\"200\""));
|
||||||
|
assert!(svg.contains("<rect x=\"1414\" y=\"1414\" width=\"200\" height=\"200\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue