(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

@ -142,7 +142,7 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_child(
Container::new()
.with_flex(
Flex::row()
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
@ -262,7 +262,7 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_child(
Container::new()
.with_flex(
Flex::row()
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
@ -442,7 +442,7 @@ fn form_lists() -> Form {
.with_child(
Container::new()
.with_flex(
Flex::row()
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)

View file

@ -40,21 +40,21 @@ fn direction_block() -> Block {
let mut block = Block::new().with_title(Lc::t("flex_block_title_direction", &LOC));
let direction_variants: [(&str, Flex, &str); 4] = [
("flex_title_direction_row", Flex::row(), "Flex::row()"),
("flex_title_direction_row", Flex::new(), "Flex::new()"),
(
"flex_title_direction_row_reverse",
Flex::row().with_direction(flex::Direction::RowReverse),
"Flex::row().with_direction(Direction::RowReverse)",
Flex::new().with_direction(flex::Direction::RowReverse),
"Flex::new().with_direction(Direction::RowReverse)",
),
(
"flex_title_direction_column",
Flex::column(),
"Flex::column()",
Flex::new().with_direction(flex::Direction::Column),
"Flex::new().with_direction(Direction::Column)",
),
(
"flex_title_direction_column_reverse",
Flex::column().with_direction(flex::Direction::ColumnReverse),
"Flex::column().with_direction(Direction::ColumnReverse)",
Flex::new().with_direction(flex::Direction::ColumnReverse),
"Flex::new().with_direction(Direction::ColumnReverse)",
),
];
for (title_key, flex, code) in direction_variants {
@ -77,32 +77,32 @@ fn justify_block() -> Block {
(
"flex_title_justify_start",
flex::ContentJustify::Start,
"Flex::row().with_justify(ContentJustify::Start)",
"Flex::new().with_justify(ContentJustify::Start)",
),
(
"flex_title_justify_center",
flex::ContentJustify::Center,
"Flex::row().with_justify(ContentJustify::Center)",
"Flex::new().with_justify(ContentJustify::Center)",
),
(
"flex_title_justify_end",
flex::ContentJustify::End,
"Flex::row().with_justify(ContentJustify::End)",
"Flex::new().with_justify(ContentJustify::End)",
),
(
"flex_title_justify_between",
flex::ContentJustify::SpaceBetween,
"Flex::row().with_justify(ContentJustify::SpaceBetween)",
"Flex::new().with_justify(ContentJustify::SpaceBetween)",
),
(
"flex_title_justify_around",
flex::ContentJustify::SpaceAround,
"Flex::row().with_justify(ContentJustify::SpaceAround)",
"Flex::new().with_justify(ContentJustify::SpaceAround)",
),
(
"flex_title_justify_evenly",
flex::ContentJustify::SpaceEvenly,
"Flex::row().with_justify(ContentJustify::SpaceEvenly)",
"Flex::new().with_justify(ContentJustify::SpaceEvenly)",
),
];
for (title_key, justify, code) in justify_variants {
@ -110,7 +110,7 @@ fn justify_block() -> Block {
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_row(
Flex::row()
Flex::new()
.with_justify(justify)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
@ -129,22 +129,22 @@ fn align_block() -> Block {
(
"flex_title_align_start",
flex::Align::Start,
"Flex::row().with_align(Align::Start)",
"Flex::new().with_align(Align::Start)",
),
(
"flex_title_align_center",
flex::Align::Center,
"Flex::row().with_align(Align::Center)",
"Flex::new().with_align(Align::Center)",
),
(
"flex_title_align_end",
flex::Align::End,
"Flex::row().with_align(Align::End)",
"Flex::new().with_align(Align::End)",
),
(
"flex_title_align_stretch",
flex::Align::Stretch,
"Flex::row().with_align(Align::Stretch)",
"Flex::new().with_align(Align::Stretch)",
),
];
for (title_key, align, code) in align_variants {
@ -152,7 +152,7 @@ fn align_block() -> Block {
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_row(
Flex::row()
Flex::new()
.with_align(align)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
@ -164,11 +164,11 @@ fn align_block() -> Block {
block
.with_child(caption(
Lc::t("flex_title_align_baseline", &LOC),
Lc::n("Flex::row().with_align(Align::Baseline)"),
Lc::n("Flex::new().with_align(Align::Baseline)"),
))
.with_child(
demo_row(
Flex::row()
Flex::new()
.with_align(flex::Align::Baseline)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
@ -211,7 +211,7 @@ fn align_self_block() -> Block {
.with_child(caption(Lc::t(title_key, &LOC), Lc::n(code)))
.with_child(
demo_row(
Flex::row()
Flex::new()
.with_align(flex::Align::Start)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
@ -229,7 +229,7 @@ fn align_self_block() -> Block {
))
.with_child(
demo_row(
Flex::row()
Flex::new()
.with_align(flex::Align::Start)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
@ -251,42 +251,42 @@ fn align_content_block() -> Block {
(
"flex_title_align_content_start",
flex::AlignContent::Start,
"Flex::row().with_wrap(Behavior::Wrap).with_align_content(AlignContent::Start)",
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(AlignContent::Start)",
),
(
"flex_title_align_content_end",
flex::AlignContent::End,
"Flex::row().with_wrap(Behavior::Wrap).with_align_content(AlignContent::End)",
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(AlignContent::End)",
),
(
"flex_title_align_content_center",
flex::AlignContent::Center,
"Flex::row().with_wrap(Behavior::Wrap).with_align_content(AlignContent::Center)",
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(AlignContent::Center)",
),
(
"flex_title_align_content_between",
flex::AlignContent::SpaceBetween,
"Flex::row().with_wrap(Behavior::Wrap).with_align_content(AlignContent::SpaceBetween)",
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(AlignContent::SpaceBetween)",
),
(
"flex_title_align_content_around",
flex::AlignContent::SpaceAround,
"Flex::row().with_wrap(Behavior::Wrap).with_align_content(AlignContent::SpaceAround)",
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(AlignContent::SpaceAround)",
),
(
"flex_title_align_content_evenly",
flex::AlignContent::SpaceEvenly,
"Flex::row().with_wrap(Behavior::Wrap).with_align_content(AlignContent::SpaceEvenly)",
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(AlignContent::SpaceEvenly)",
),
(
"flex_title_align_content_stretch",
flex::AlignContent::Stretch,
"Flex::row().with_wrap(Behavior::Wrap).with_align_content(AlignContent::Stretch)",
"Flex::new().with_wrap(Behavior::Wrap).with_align_content(AlignContent::Stretch)",
),
];
for (title_key, align_content, code) in align_content_variants {
let mut row = demo_row(
Flex::row()
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_align_content(align_content)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
@ -314,7 +314,7 @@ fn grow_shrink_block() -> Block {
Lc::n("FlexItem::new().with_grow(flex::ItemGrow::Is1)"),
))
.with_child(
demo_row(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
demo_row(Flex::new().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::t("flex_box_fixed", &LOC)))
.with_child(
demo_box(Lc::t("flex_box_grows", &LOC)).with_prop(PropsOp::flex_item(
@ -328,7 +328,7 @@ fn grow_shrink_block() -> Block {
Lc::n("FlexItem::new().with_shrink(flex::ItemShrink::Is0)"),
))
.with_child(
demo_row(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
demo_row(Flex::new().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
.with_prop(PropsOp::add_style("max-width", "31rem"))
.with_child(
demo_box(flex_item("1")).with_prop(PropsOp::add_style("width", "10.5rem")),
@ -355,14 +355,16 @@ fn other_block() -> Block {
Lc::n("FlexItem::push_end()"),
))
.with_child(
demo_row(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
demo_row(Flex::new().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::t("flex_box_start_1", &LOC)))
.with_child(demo_box(Lc::t("flex_box_start_2", &LOC)))
.with_child(demo_box(Lc::t("flex_box_end", &LOC)).with_prop(FlexItem::push_end())),
.with_child(
demo_box(Lc::t("flex_box_end", &LOC)).with_prop(FlexItem::push_end().into()),
),
);
let mut wrap_row = demo_row(
Flex::row()
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_align_content(flex::AlignContent::SpaceBetween)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
@ -379,7 +381,7 @@ fn other_block() -> Block {
.with_child(caption(
Lc::t("flex_title_wrap", &LOC),
Lc::n(concat!(
"Flex::row()",
"Flex::new()",
".with_wrap(Behavior::Wrap)",
".with_align_content(AlignContent::SpaceBetween)",
)),
@ -390,7 +392,7 @@ fn other_block() -> Block {
Lc::n("FlexItem::new().with_order(ItemOrder::First) / .with_order(ItemOrder::Last)"),
))
.with_child(
demo_row(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
demo_row(Flex::new().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::n("A")).with_prop(PropsOp::flex_item(
FlexItem::new().with_order(flex::ItemOrder::Last),
)))
@ -403,20 +405,20 @@ fn other_block() -> Block {
)
.with_child(caption(
Lc::t("flex_title_gap_none", &LOC),
Lc::n("Flex::row() (Gap::None por defecto)"),
Lc::n("Flex::new() (Gap::None por defecto)"),
))
.with_child(
demo_row(Flex::row())
demo_row(Flex::new())
.with_child(demo_box(flex_item("1")))
.with_child(demo_box(flex_item("2")))
.with_child(demo_box(flex_item("3"))),
)
.with_child(caption(
Lc::t("flex_title_gap_some", &LOC),
Lc::n("Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(1.5)))"),
Lc::n("Flex::new().with_gap(flex::Gap::Both(UnitValue::RelRem(1.5)))"),
))
.with_child(
demo_row(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(1.5))))
demo_row(Flex::new().with_gap(flex::Gap::Both(UnitValue::RelRem(1.5))))
.with_child(demo_box(flex_item("1")))
.with_child(demo_box(flex_item("2")))
.with_child(demo_box(flex_item("3"))),
@ -426,7 +428,7 @@ fn other_block() -> Block {
Lc::n("FlexItem::new().with_size(flex::ItemSize::Percent33)"),
))
.with_child(
demo_row(Flex::row())
demo_row(Flex::new())
.with_child(demo_box(Lc::n("1/3")).with_prop(PropsOp::flex_item(
FlexItem::new().with_size(flex::ItemSize::Percent33),
)))
@ -446,7 +448,7 @@ fn other_block() -> Block {
)),
))
.with_child(
demo_row(Flex::row()).with_child(
demo_row(Flex::new()).with_child(
demo_box(Lc::t("flex_box_half_centered", &LOC)).with_prop(PropsOp::flex_item(
FlexItem::new()
.with_size(flex::ItemSize::Percent50)
@ -460,14 +462,16 @@ fn other_block() -> Block {
))
.with_child(
demo_row(
Flex::row()
Flex::new()
.with_align(flex::Align::Center)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(Lc::t("flex_box_file", &LOC)))
.with_child(demo_box(Lc::t("flex_box_edit", &LOC)))
.with_child(demo_box(Lc::t("flex_box_view", &LOC)))
.with_child(demo_box(Lc::t("flex_box_profile", &LOC)).with_prop(FlexItem::push_end()))
.with_child(
demo_box(Lc::t("flex_box_profile", &LOC)).with_prop(FlexItem::push_end().into()),
)
.with_child(demo_box(Lc::t("flex_box_logout", &LOC))),
)
}

View file

@ -0,0 +1,261 @@
use pagetop::prelude::*;
include_locales!(LOC from "examples/locale");
struct IntroResponsive;
#[async_trait]
impl Extension for IntroResponsive {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_bootsier::Bootsier]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_responsive))
}
}
async fn intro_responsive(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_assets(AssetsOp::AddStyleSheet(demo_styles()))
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("responsive_slogan", &LOC))
.with_button(None::<(Lc, Route)>)
.with_child(Html::with(|cx| {
html! {
p class="intro-text-lead" {
(Lc::t("responsive_note", &LOC).using(cx))
}
}
}))
.with_child(activation_block())
.with_child(direction_block())
.with_child(grid_block())
.with_child(justify_align_block())
.with_child(order_block())
.with_child(gap_grow_block()),
)
.render()
.await
}
fn activation_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_activation", &LOC))
.with_child(caption(
Lc::t("responsive_title_activation", &LOC),
Lc::n("Flex::at(Breakpoint::Md)"),
))
.with_child(
demo_row(Flex::at(Breakpoint::Md).with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::t("responsive_box_nav_home", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_nav_products", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_nav_about", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_nav_contact", &LOC))),
)
}
fn direction_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_direction", &LOC))
.with_child(caption(
Lc::t("responsive_title_direction", &LOC),
Lc::n(concat!(
"Flex::new()",
".with_direction(Direction::Column)",
".with_direction_at(Breakpoint::Md, Direction::RowReverse)",
)),
))
.with_child(
demo_row(
Flex::new()
.with_direction(flex::Direction::Column)
.with_direction_at(Breakpoint::Md, flex::Direction::RowReverse)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(sized_box(
Lc::t("responsive_box_image", &LOC),
"2.5rem 1rem",
))
.with_child(demo_box(Lc::t("responsive_box_text", &LOC))),
)
}
fn grid_block() -> Block {
let mut row = demo_row(Flex::new().with_wrap(flex::Behavior::Wrap));
for n in 1..=6 {
row = row.with_child(
Container::new()
.with_prop(PropsOp::add_style("padding", "0.25rem"))
.with_prop(PropsOp::flex_item(
FlexItem::new()
.with_size(flex::ItemSize::Percent100)
.with_size_at(Breakpoint::Sm, flex::ItemSize::Percent50)
.with_size_at(Breakpoint::Md, flex::ItemSize::Percent33),
))
.with_child(demo_box(card_label(n))),
);
}
Block::new()
.with_title(Lc::t("responsive_block_title_grid", &LOC))
.with_child(caption(
Lc::t("responsive_title_grid", &LOC),
Lc::n(concat!(
"FlexItem::new()",
".with_size(ItemSize::Percent100)",
".with_size_at(Breakpoint::Sm, ItemSize::Percent50)",
".with_size_at(Breakpoint::Md, ItemSize::Percent33)",
)),
))
.with_child(row)
}
fn justify_align_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_justify_align", &LOC))
.with_child(caption(
Lc::t("responsive_title_justify_align", &LOC),
Lc::n(concat!(
"Flex::new()",
".with_justify(ContentJustify::Center)",
".with_justify_at(Breakpoint::Md, ContentJustify::SpaceBetween)",
".with_align(Align::Center)",
)),
))
.with_child(
demo_row(
Flex::new()
.with_justify(flex::ContentJustify::Center)
.with_justify_at(Breakpoint::Md, flex::ContentJustify::SpaceBetween)
.with_align(flex::Align::Center)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
)
.with_child(demo_box(Lc::t("responsive_box_logo", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_menu", &LOC))),
)
}
fn order_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_order", &LOC))
.with_child(caption(
Lc::t("responsive_title_order", &LOC),
Lc::n(concat!(
"Flex::at(Breakpoint::Lg)",
" + FlexItem::new().with_order_at(Breakpoint::Lg, ItemOrder::First)",
)),
))
.with_child(
demo_row(Flex::at(Breakpoint::Lg).with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(demo_box(Lc::t("responsive_box_content", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_sidebar", &LOC)).with_prop(
PropsOp::flex_item(
FlexItem::new().with_order_at(Breakpoint::Lg, flex::ItemOrder::First),
),
)),
)
}
fn gap_grow_block() -> Block {
Block::new()
.with_title(Lc::t("responsive_block_title_gap_grow", &LOC))
.with_child(caption(
Lc::t("responsive_title_gap_grow", &LOC),
Lc::n(concat!(
"Flex::new()",
".with_gap(Gap::Both(RelRem(0.5)))",
".with_gap_at(Breakpoint::Md, Gap::Both(RelRem(1.5)))",
" + FlexItem::new().with_grow_at(Breakpoint::Md, ItemGrow::Is1)",
)),
))
.with_child(
demo_row(
Flex::new()
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5)))
.with_gap_at(Breakpoint::Md, flex::Gap::Both(UnitValue::RelRem(1.5))),
)
.with_child(demo_box(Lc::t("responsive_box_file", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_edit", &LOC)))
.with_child(demo_box(Lc::t("responsive_box_search", &LOC)).with_prop(
PropsOp::flex_item(
FlexItem::new().with_grow_at(Breakpoint::Md, flex::ItemGrow::Is1),
),
)),
)
}
// **< HELPERS >************************************************************************************
// Aspecto fijo de las cajas y filas de muestra.
fn demo_styles() -> StyleSheet {
StyleSheet::inline("intro-responsive", |_| {
util::indoc!(
r#"
.flex-demo-box {
background-color: #0d6efd;
color: #fff;
min-width: 3rem;
width: auto;
max-width: none;
margin: 0;
border-radius: 0.375rem;
text-align: center;
}
.flex-demo-row {
background-color: #f1f3f5;
width: 100%;
max-width: none;
margin: 0 0 1.5rem;
padding: 0.75rem;
}
code {
overflow-wrap: anywhere;
}
"#
)
.to_string()
})
}
// Caja con fondo azul y relleno vertical configurable, para mostrar diferencias de altura.
fn sized_box(label: Lc, padding: &'static str) -> Container {
Container::new()
.with_prop(PropsOp::add_classes("flex-demo-box"))
.with_prop(PropsOp::add_style("padding", padding))
.with_child(Html::with(move |cx| html! { (label.using(cx)) }))
}
// Caja con el relleno vertical estandar del resto de ejemplos.
fn demo_box(label: Lc) -> Container {
sized_box(label, "0.5rem 1rem")
}
// Etiqueta "Tarjeta N" para las cajas de la rejilla responsive.
fn card_label(n: usize) -> Lc {
Lc::t("responsive_box_card", &LOC).with_arg("n", n.to_string())
}
// Fila de demostracion con fondo gris para visualizar los limites del propio contenedor flex.
fn demo_row(flex: Flex) -> Container {
Container::new()
.with_prop(PropsOp::add_classes("flex-demo-row"))
.with_flex(flex)
}
// Titulo y fragmento de codigo que introducen cada demostracion.
fn caption(title: Lc, code: Lc) -> Html {
Html::with(move |cx| {
html! {
h3 { (title.using(cx)) }
p { code { (code.using(cx)) } }
}
})
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroResponsive).await.run().await
}

View file

@ -0,0 +1,36 @@
responsive_slogan = Responsive behavior with Flex
responsive_note = Resize your browser window to see how the boxes below react. The breakpoints used are the usual Bootstrap ones: sm from 576px, md from 768px, lg from 992px.
responsive_block_title_activation = Flex activation at a breakpoint
responsive_block_title_direction = Direction change
responsive_block_title_grid = Column grid
responsive_block_title_justify_align = Justify and align
responsive_block_title_order = Visual order
responsive_block_title_gap_grow = Spacing and growth
responsive_title_activation = Stacked menu on mobile, in a row from md
responsive_title_direction = Image and text stacked on mobile; in a row, image on the right, from md
responsive_title_grid = From one column on mobile to three on desktop
responsive_title_justify_align = Centered header on mobile, spread out from md
responsive_title_order = Sidebar after the content on mobile, before it from lg
responsive_title_gap_grow = Growing spacing and a search box that only grows from md
responsive_box_nav_home = Home
responsive_box_nav_products = Products
responsive_box_nav_about = About
responsive_box_nav_contact = Contact
responsive_box_image = Image
responsive_box_text = Text
responsive_box_card = Card { $n }
responsive_box_logo = Logo
responsive_box_menu = Menu
responsive_box_content = Content
responsive_box_sidebar = Sidebar
responsive_box_file = File
responsive_box_edit = Edit
responsive_box_search = Search

View file

@ -0,0 +1,36 @@
responsive_slogan = Comportamiento responsive con Flex
responsive_note = Cambia el ancho de la ventana del navegador para ver cómo reaccionan las siguientes cajas. Los puntos de corte usados son los habituales de Bootstrap: sm a partir de 576px, md a partir de 768px, lg a partir de 992px.
responsive_block_title_activation = Activación de Flex por punto de corte
responsive_block_title_direction = Cambio de dirección
responsive_block_title_grid = Rejilla de columnas
responsive_block_title_justify_align = Justificación y alineación
responsive_block_title_order = Orden visual
responsive_block_title_gap_grow = Espaciado y crecimiento
responsive_title_activation = Menú apilado en móvil, en fila a partir de md
responsive_title_direction = Imagen y texto apilados en móvil; en fila, con la imagen a la derecha, a partir de md
responsive_title_grid = De una columna en móvil a tres en escritorio
responsive_title_justify_align = Cabecera centrada en móvil, distribuida a partir de md
responsive_title_order = Barra lateral después del contenido en móvil, antes a partir de lg
responsive_title_gap_grow = Espaciado creciente y buscador que sólo crece a partir de md
responsive_box_nav_home = Inicio
responsive_box_nav_products = Productos
responsive_box_nav_about = Nosotros
responsive_box_nav_contact = Contacto
responsive_box_image = Imagen
responsive_box_text = Texto
responsive_box_card = Tarjeta { $n }
responsive_box_logo = Logotipo
responsive_box_menu = Menú
responsive_box_content = Contenido
responsive_box_sidebar = Barra lateral
responsive_box_file = Archivo
responsive_box_edit = Editar
responsive_box_search = Buscador

View file

@ -83,7 +83,7 @@ impl Extension for SuperMenu {
.with_item(bs::navbar::Item::nav(
bs::Nav::new()
// Empuja este menú (y lo que le siga) al extremo final de la barra.
.with_prop(FlexItem::push_end())
.with_prop(FlexItem::push_end().into())
.with_item(bs::nav::Item::link(
Lc::t("menus_item_sign_up", &LOC),
"/auth/sign-up",

View file

@ -363,7 +363,7 @@ fn edit_actions(
let mut container = Container::new()
.with_flex(
Flex::row()
Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_align(flex::Align::Center)
.with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),

View file

@ -49,7 +49,7 @@ pub struct Container {
kind: Kind,
/// Devuelve el posicionamiento Flexbox como contenedor, si tiene alguno.
#[getters(copy)]
flex: Option<Flex>,
flex: Flex,
/// Devuelve la lista de componentes (`children`) del contenedor.
children: Children,
}
@ -64,25 +64,20 @@ impl Component for Container {
self.props.get_id()
}
fn setup(&mut self, _cx: &mut Context) {
if let Some(flex) = self.flex() {
flex.apply_to(&mut self.props);
}
}
#[rustfmt::skip]
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let output = self.children().render(cx).await;
if output.is_empty() {
return Ok(html! {});
}
let container_props = self.props().unpack_with_flex(cx, self.flex());
Ok(match self.kind() {
Kind::Default => html! { div (self.props().unpack(cx)) { (output) } },
Kind::Main => html! { main (self.props().unpack(cx)) { (output) } },
Kind::Header => html! { header (self.props().unpack(cx)) { (output) } },
Kind::Footer => html! { footer (self.props().unpack(cx)) { (output) } },
Kind::Section => html! { section (self.props().unpack(cx)) { (output) } },
Kind::Article => html! { article (self.props().unpack(cx)) { (output) } },
Kind::Default => html! { div (container_props) { (output) } },
Kind::Main => html! { main (container_props) { (output) } },
Kind::Header => html! { header (container_props) { (output) } },
Kind::Footer => html! { footer (container_props) { (output) } },
Kind::Section => html! { section (container_props) { (output) } },
Kind::Article => html! { article (container_props) { (output) } },
})
}
}
@ -145,7 +140,7 @@ impl Container {
/// Establece el posicionamiento Flexbox como contenedor (usa `None` para quitarlo).
pub fn with_flex(mut self, flex: impl Into<Option<Flex>>) -> Self {
self.flex = flex.into();
self.flex = self.flex.merge(flex);
self
}

View file

@ -83,7 +83,7 @@ pub struct Navbar {
layout: navbar::Layout,
/// Devuelve el posicionamiento Flexbox como contenedor, si tiene alguno.
#[getters(copy)]
flex: Option<Flex>,
flex: Flex,
/// Devuelve la lista de contenidos.
items: Children,
}
@ -106,21 +106,6 @@ impl Component for Navbar {
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Botón de despliegue para el contenido colapsable de la barra.
fn button(cx: &mut Context, id_content: &str) -> Markup {
html! {
button
type="button"
class="navbar-toggle"
aria-expanded="false"
aria-controls=(id_content)
aria-label=[Lc::l("navbar_toggle").lookup(cx)]
{
span class="navbar-toggle-icon" {}
}
}
}
// Si no hay contenidos, no tiene sentido mostrar una barra vacía.
let items = self.items().render(cx).await;
if items.is_empty() {
@ -131,44 +116,53 @@ impl Component for Navbar {
let id = self.id().unwrap();
let id_content = util::join!(id, "-content");
// Posicionamiento Flexbox opcional (no del `<nav>`, cuya estructura la fija `layout()`).
let mut content_props = Props::default();
if let Some(flex) = self.flex() {
flex.apply_to(&mut content_props);
// Botón de despliegue para el contenido colapsable de la barra.
let button = html! {
button
type="button"
class="navbar-toggle"
aria-expanded="false"
aria-controls=(&id_content)
aria-label=[Lc::l("navbar_toggle").lookup(cx)]
{
span class="navbar-toggle-icon" {}
}
content_props.alter_prop(PropsOp::prepend_classes("navbar-content"));
};
// Posicionamiento Flexbox opcional (no del `<nav>`, cuya estructura la fija `layout()`).
let content_props = Props::classes("navbar-content").with_id(id_content);
Ok(html! {
nav (self.props().unpack(cx)) {
@match self.layout() {
// Barra más sencilla: sólo contenido, siempre visible.
navbar::Layout::Simple => {
div (content_props.unpack(cx)) { (items) }
div (content_props.unpack_with_flex(cx, self.flex())) { (items) }
},
// Barra sencilla que se puede contraer/expandir.
navbar::Layout::SimpleToggle => {
(button(cx, &id_content))
div id=(&id_content) (content_props.unpack(cx)) { (items) }
(button)
div (content_props.unpack_with_flex(cx, self.flex())) { (items) }
},
// Barra con marca, siempre visible, sin botón.
navbar::Layout::SimpleBrandLeft(brand) => {
(brand.render(cx).await)
div (content_props.unpack(cx)) { (items) }
div (content_props.unpack_with_flex(cx, self.flex())) { (items) }
},
// Barra con marca y botón, en ese orden.
navbar::Layout::BrandLeft(brand) => {
(brand.render(cx).await)
(button(cx, &id_content))
div id=(&id_content) (content_props.unpack(cx)) { (items) }
(button)
div (content_props.unpack_with_flex(cx, self.flex())) { (items) }
},
// Barra con botón y marca, en ese orden.
navbar::Layout::BrandRight(brand) => {
(button(cx, &id_content))
div id=(&id_content) (content_props.unpack(cx)) { (items) }
(button)
div (content_props.unpack_with_flex(cx, self.flex())) { (items) }
(brand.render(cx).await)
},
}
@ -231,7 +225,7 @@ impl Navbar {
/// No afecta a la posición de la marca ni del botón de despliegue, que quedan fijados con
/// [`with_layout()`](Self::with_layout).
pub fn with_flex(mut self, flex: impl Into<Option<Flex>>) -> Self {
self.flex = flex.into();
self.flex = self.flex.merge(flex);
self
}

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" },
}
}

View file

@ -142,13 +142,14 @@ impl ResponsiveStyles {
/// 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
/// recorre los puntos de corte reales en orden *mobile-first* (`Xs` a `Xxxl`, 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
/// (`.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: ...)`.
/// de corte real; en cualquier otro caso se envuelven en `@media(min-width:...)`.
///
/// El resultado no contiene saltos de línea.
/// El resultado no contiene espacios ni saltos de línea salvo los que pueda llevar el propio
/// valor de una declaración (p. ej. `font-family: "Segoe UI", sans-serif`).
pub fn render(&self, cx: &Context) -> Markup {
let mut css = String::new();
@ -164,11 +165,11 @@ impl ResponsiveStyles {
css.push_str(&rules);
} else {
css.push_str(&util::join!(
"@media (min-width: ",
"@media(min-width:",
min_width,
") { ",
"){",
rules,
" }"
"}"
));
}
}
@ -176,8 +177,8 @@ impl ResponsiveStyles {
html! { (PreEscaped(css)) }
}
// Construye, concatenadas y sin separador, las reglas CSS (`.clases { propiedad: valor; ... }`)
// de todas las entradas del punto de corte indicado.
// 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<Breakpoint>) -> String {
let mut rules = String::new();
for (_, classes, styles) in self
@ -188,10 +189,10 @@ impl ResponsiveStyles {
let selector = classes.replace(' ', ".");
let declarations = styles
.iter()
.map(|(property, value)| util::join!(property.as_ref(), ": ", value.as_ref()))
.map(|(property, value)| util::join!(property.as_ref(), ":", value.as_ref()))
.collect::<Vec<_>>()
.join("; ");
rules.push_str(&util::join!(".", selector, " { ", declarations, " }"));
.join(";");
rules.push_str(&util::join!(".", selector, "{", declarations, "}"));
}
rules
}

View file

@ -5,24 +5,30 @@
//! ofrecen su propio `with_flex()`, como [`Container`] o [`Navbar`].
//!
//! [`FlexItem`] configura, en cambio, un único elemento en relación con el contenedor flex de su
//! padre (crecimiento, reducción, alineación individual, orden, ancho y desplazamiento). Al poder
//! acabar aplicándose sobre cualquier componente (no sólo los que ofrecen `with_flex()`), no tiene
//! un builder propio: se aplica con [`PropsOp::flex_item()`] sobre el `with_prop()` que ya expone
//! cualquier componente.
//! padre (crecimiento, reducción, alineación individual, orden, ancho y desplazamiento). No tiene
//! un builder propio ya que puede acabar aplicándose sobre cualquier componente (no sólo los que
//! ofrecen `with_flex()`). Por eso se aplica con [`PropsOp::flex_item()`] sobre el `with_prop()`
//! que normalmente ya expone cualquier componente.
//!
//! # Un entorno autosuficiente
//! # Un entorno nativo autosuficiente
//!
//! Toda la configuración de `Flex`/`FlexItem` se resuelve con estilos en línea (`style="..."`),
//! nunca como clases CSS (consulta el propio [`Flex`] para ver el porqué). Los estilos en línea
//! tienen la especificidad más alta que existe en CSS, salvo `!important`, así que ningún framework
//! CSS de terceros, ni el CSS de la propia aplicación, puede sobrescribirlo por accidente. Funciona
//! igual conviva con quien conviva en la misma página, sin necesidad de coordinar nombres de clase
//! ni orden alguno en la carga de hojas de estilo.
//! Toda la configuración de `Flex`/`FlexItem` se resuelve generando clases CSS dinámicamente y de
//! manera independiente a cualquier tema o framework CSS. El nombre interno de cada clase se deriva
//! de la propiedad y el valor que representa, así que dos elementos con la misma configuración
//! comparten la misma regla en vez de duplicarla. Las declaraciones correspondientes se registran
//! vía [`AssetsOp::AddResponsiveStyle`] y se renderizan como reglas en el `<head>` del documento.
//! Funciona igual conviva con quien conviva en la misma página, sin necesidad de coordinar nombres
//! de clase ni orden alguno en la carga de hojas de estilo.
//!
//! [Flexbox]: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Flexible_box_layout
//! [`AssetsOp::AddResponsiveStyle`]: crate::core::component::AssetsOp::AddResponsiveStyle
//! [`PropsOp::flex_item()`]: crate::html::props::PropsOp::flex_item
//! [`Container`]: crate::base::component::Container
//! [`Navbar`]: crate::base::component::Navbar
//! [`PropsOp::flex_item()`]: crate::html::props::PropsOp::flex_item
use crate::CowStr;
use crate::core::component::{AssetsOp, Context, Contextual};
use crate::core::theme::BreakpointEntry;
mod props_container;
pub use props_container::{Align, AlignContent, Behavior, ContentJustify, Direction, Gap};
@ -35,3 +41,83 @@ pub use container::Flex;
mod item;
pub use item::FlexItem;
// **< Flex / FlexItem PRIVATE >********************************************************************
// Sustituye, en un valor CSS ya resuelto, los únicos caracteres (`.`, `%`) que no podrían usarse
// como fragmento de un nombre de clase. Así, `"1.5rem"` sería `"1_5rem"` y `"33.3333%"` quedaría
// como `"33_3333pct"`.
fn value_to_token(value: &str) -> String {
value.replace('.', "_").replace('%', "pct")
}
// Añade un estilo (`property: value`) al punto de corte indicado, y la clase a `classes`, separada
// con un espacio de las que ya hubiera. Recibe un `BreakpointEntry` ya resuelto (ver
// `Breakpoint::resolved()`) y extrae aquí el `Breakpoint` que `AddResponsiveStyle` necesita, que
// puede ser nulo si aplica siempre.
//
// La clase se copia al acumulador y se mueve al `AssetsOp`, sin clonarla. Recibirla como `CowStr`
// permite además que las clases fijas, las que no dependen de ningún punto de corte, lleguen como
// `&'static str` sin asignar memoria.
fn styles(
cx: &mut Context,
classes: &mut String,
entry: Option<BreakpointEntry>,
class: CowStr,
property: &'static str,
value: CowStr,
) {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(&class);
cx.alter_assets(AssetsOp::AddResponsiveStyle(
entry.map(|e| e.breakpoint),
class,
property.into(),
value,
));
}
// Nombre de clase según el punto de corte: `prefix` ya incluye el guion bajo final antes del valor
// (p. ej. `"_flex-direction_"`), y `entry` añade su sufijo si aplica (`"_flex-direction_row_md_"`),
// ya resuelto para el tema activo (ver `Breakpoint::resolved()`).
macro_rules! responsive_class {
($prefix:expr, $token:expr, $entry:expr) => {
match $entry {
None => util::join!($prefix, $token, "_"),
Some(entry) => util::join!($prefix, $token, "_", entry.name, "_"),
}
};
}
use responsive_class;
// Recorre las entradas para una propiedad `Responsive<T>` cuyo valor CSS es un único `T::value()`,
// generando y registrando (vía `styles()`) una clase por punto de corte con valor.
//
// La forma con el marcador final `val` es para propiedades cuyo valor puede contener `.`/`%` (como
// `ItemSize` o `ItemOffset` en `FlexItem`) y necesitan pasar por `value_to_token()`.
macro_rules! apply {
($cx:expr, $classes:expr, $field:expr, $prefix:literal, $property:literal) => {
for (bp, value) in $field.by_breakpoint() {
let value = value.value();
if !value.is_empty() {
let entry = bp.resolved($cx);
let class = responsive_class!($prefix, value, entry);
styles($cx, $classes, entry, class.into(), $property, value);
}
}
};
($cx:expr, $classes:expr, $field:expr, $prefix:literal, $property:literal, val) => {
for (bp, value) in $field.by_breakpoint() {
let value = value.value();
if !value.is_empty() {
let entry = bp.resolved($cx);
let class = responsive_class!($prefix, value_to_token(&value), entry);
styles($cx, $classes, entry, class.into(), $property, value);
}
}
};
}
use apply;

View file

@ -1,23 +1,39 @@
use crate::html::flex::props_container::{
Align, AlignContent, Behavior, ContentJustify, Direction, Gap,
};
use crate::html::props::{Props, PropsOp};
use crate::{AutoDefault, Getters, builder_impl};
use crate::core::component::Context;
use crate::core::theme::{Breakpoint, Responsive};
use crate::html::flex::{Align, AlignContent, Behavior, ContentJustify, Direction, Gap};
use crate::{AutoDefault, Getters, builder_impl, util};
// **< DisplayFlex >********************************************************************************
// Modo de activación del posicionamiento Flexbox de un contenedor `Flex`. Detalle interno de
// implementación: la API pública sólo expone los constructores `Flex::new()`, `Flex::at()`,
// `Flex::inline()` e `Flex::inline_at()`, nunca esta variante directamente.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
enum DisplayFlex {
#[default]
Always,
AlwaysInline,
At(Breakpoint),
InlineAt(Breakpoint),
}
// **< Flex >***************************************************************************************
/// Configuración para el posicionamiento Flexbox en un contenedor.
///
/// Se resuelve con estilos en línea (`display`, `flex-direction`, `flex-wrap`, `justify-content`,
/// `align-items`, `align-content`, `gap`), nunca como clases CSS. Son propiedades estándar que no
/// requieren interpretación por parte de los temas, siempre funcionan igual, sin una sola
/// línea de CSS ni de código específico.
/// Se resuelve como clases CSS generadas dinámicamente (`display`, `flex-direction`, `flex-wrap`,
/// `justify-content`, `align-items`, `align-content`, `gap`), registradas vía
/// [`AssetsOp::AddResponsiveStyle`] en [`ResponsiveStyles`] y renderizadas como reglas en el
/// `<head>` del documento. Son propiedades nativas que no requieren interpretación por parte de los
/// temas, siempre funcionan igual, sin una sola línea de CSS ni de código específico.
///
/// Esto tiene además una consecuencia práctica; los estilos en línea tienen la especificidad más
/// alta que existe en CSS, salvo `!important`. Ningún *framework* CSS de terceros, ni el CSS de la
/// propia aplicación, puede sobrescribir por accidente lo que `Flex` aplica. Es un mecanismo
/// autosuficiente que funciona igual conviva con quien conviva en la misma página, sin coordinar
/// nombres de clase ni orden de carga de hojas de estilo con nadie.
/// El nombre de cada clase se deriva de la propiedad y el valor que representa (por ejemplo
/// `_flex-direction_row_`), así que dos contenedores con la misma configuración comparten la misma
/// regla generada en vez de duplicarla, y el nombre generado no coincide por accidente con clases
/// de terceros.
///
/// [`AssetsOp::AddResponsiveStyle`]: crate::core::component::AssetsOp::AddResponsiveStyle
/// [`ResponsiveStyles`]: crate::html::ResponsiveStyles
///
/// # Ejemplo
///
@ -26,7 +42,7 @@ use crate::{AutoDefault, Getters, builder_impl};
///
/// let actions = Container::new()
/// .with_flex(
/// Flex::row()
/// Flex::new()
/// .with_justify(flex::ContentJustify::End)
/// .with_align(flex::Align::Center)
/// .with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))),
@ -36,38 +52,66 @@ use crate::{AutoDefault, Getters, builder_impl};
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq, Getters)]
pub struct Flex {
/// Devuelve la dirección del eje principal.
// Determina si esta configuración debe aplicarse (y con qué variante de `display`) o si
// `Flex` no está en absoluto configurado. `None` es el estado real de ausencia: lo que tiene
// un contenedor que nunca ha llamado a `with_flex()`. Sin getter público; `new()`, `at()`,
// `inline()` e `inline_at()` son la única forma de activarlo.
#[getters(skip)]
display: Option<DisplayFlex>,
/// Devuelve la dirección del eje principal por punto de corte.
#[getters(copy)]
direction: Direction,
/// Devuelve el comportamiento cuando los elementos no caben en una sola línea.
direction: Responsive<Direction>,
/// Devuelve el comportamiento cuando los elementos no caben en una sola línea, por punto de
/// corte.
#[getters(copy)]
wrap: Behavior,
/// Devuelve la alineación de los elementos en el eje principal.
wrap: Responsive<Behavior>,
/// Devuelve la alineación de los elementos en el eje principal, por punto de corte.
#[getters(copy)]
justify: ContentJustify,
/// Devuelve la alineación de los elementos en el eje transversal.
justify: Responsive<ContentJustify>,
/// Devuelve la alineación de los elementos en el eje transversal, por punto de corte.
#[getters(copy)]
align: Align,
/// Devuelve la alineación de las líneas cuando hay más de una.
align: Responsive<Align>,
/// Devuelve la alineación de las líneas cuando hay más de una, por punto de corte.
#[getters(copy)]
align_content: AlignContent,
/// Devuelve el espaciado entre elementos.
align_content: Responsive<AlignContent>,
/// Devuelve el espaciado entre elementos, por punto de corte.
#[getters(copy)]
gap: Gap,
gap: Responsive<Gap>,
}
#[builder_impl]
impl Flex {
/// Crea una configuración Flex para disponer los elementos en fila (comportamiento por
/// defecto).
pub fn row() -> Self {
Self::default()
/// Define una configuración Flex con `display: flex`, sin punto de corte: se aplica siempre.
pub fn new() -> Self {
Self {
display: Some(DisplayFlex::Always),
..Default::default()
}
}
/// Crea una configuración Flex para disponer los elementos en columna.
pub fn column() -> Self {
/// Define una configuración Flex con `display: flex` que se aplica a partir del punto de corte
/// indicado.
pub fn at(bp: Breakpoint) -> Self {
Self {
direction: Direction::Column,
display: Some(DisplayFlex::At(bp)),
..Default::default()
}
}
/// Define una configuración Flex con `display: inline-flex`, sin punto de corte: se aplica
/// siempre.
pub fn inline() -> Self {
Self {
display: Some(DisplayFlex::AlwaysInline),
..Default::default()
}
}
/// Define una configuración Flex con `display: inline-flex` que se aplica a partir del punto de
/// corte indicado.
pub fn inline_at(bp: Breakpoint) -> Self {
Self {
display: Some(DisplayFlex::InlineAt(bp)),
..Default::default()
}
}
@ -75,43 +119,106 @@ impl Flex {
// **< Flex BUILDER >***************************************************************************
/// Establece la dirección del eje principal.
pub fn with_direction(mut self, direction: Direction) -> Self {
self.direction = direction;
pub fn with_direction(mut self, dir: Direction) -> Self {
self.direction = self.direction.set(dir);
self
}
/// Establece la dirección del eje principal a partir del punto de corte indicado.
pub fn with_direction_at(mut self, bp: Breakpoint, dir: Direction) -> Self {
self.direction = self.direction.set_at(bp, dir);
self
}
/// Establece el comportamiento cuando los elementos no caben en una sola línea.
pub fn with_wrap(mut self, wrap: Behavior) -> Self {
self.wrap = wrap;
self.wrap = self.wrap.set(wrap);
self
}
/// Establece el comportamiento cuando los elementos no caben en una sola línea, a partir del
/// punto de corte indicado.
pub fn with_wrap_at(mut self, bp: Breakpoint, wrap: Behavior) -> Self {
self.wrap = self.wrap.set_at(bp, wrap);
self
}
/// Establece la alineación de los elementos en el eje principal.
pub fn with_justify(mut self, justify: ContentJustify) -> Self {
self.justify = justify;
self.justify = self.justify.set(justify);
self
}
/// Establece la alineación de los elementos en el eje principal, a partir del punto de corte
/// indicado.
pub fn with_justify_at(mut self, bp: Breakpoint, justify: ContentJustify) -> Self {
self.justify = self.justify.set_at(bp, justify);
self
}
/// Establece la alineación de los elementos en el eje transversal.
pub fn with_align(mut self, align: Align) -> Self {
self.align = align;
self.align = self.align.set(align);
self
}
/// Establece la alineación de los elementos en el eje transversal, a partir del punto de corte
/// indicado.
pub fn with_align_at(mut self, bp: Breakpoint, align: Align) -> Self {
self.align = self.align.set_at(bp, align);
self
}
/// Establece la alineación de las líneas cuando hay más de una (ver [`AlignContent`]).
pub fn with_align_content(mut self, align_content: AlignContent) -> Self {
self.align_content = align_content;
self.align_content = self.align_content.set(align_content);
self
}
/// Establece la alineación de las líneas cuando hay más de una (ver [`AlignContent`]), a partir
/// del punto de corte indicado.
pub fn with_align_content_at(mut self, bp: Breakpoint, align_content: AlignContent) -> Self {
self.align_content = self.align_content.set_at(bp, align_content);
self
}
/// Establece el espaciado entre elementos.
pub fn with_gap(mut self, gap: Gap) -> Self {
self.gap = gap;
self.gap = self.gap.set(gap);
self
}
/// Establece el espaciado entre elementos, a partir del punto de corte indicado.
pub fn with_gap_at(mut self, bp: Breakpoint, gap: Gap) -> Self {
self.gap = self.gap.set_at(bp, gap);
self
}
}
impl Flex {
/// Combina esta configuración con otra `Flex`, campo a campo, o la resetea a los valores por
/// defecto si se pasa `None`.
///
/// Cada campo de `flex` que tenga un valor sustituye al correspondiente de `self`; los que
/// estén a `None` dejan intacto el valor ya presente en `self`. Así, sucesivas llamadas pueden
/// ir completando o sobrescribiendo campos concretos sin necesidad de repetir los ya
/// establecidos. Es el método recomendado para que un contenedor propio adopte `Flex` de forma
/// incremental (ver [`Container::with_flex()`](crate::base::component::Container::with_flex)
/// como referencia de uso).
pub fn merge(mut self, flex: impl Into<Option<Flex>>) -> Self {
let Some(flex) = flex.into() else {
return Flex::default();
};
self.display = flex.display.or(self.display);
self.direction = self.direction.merge(flex.direction);
self.wrap = self.wrap.merge(flex.wrap);
self.justify = self.justify.merge(flex.justify);
self.align = self.align.merge(flex.align);
self.align_content = self.align_content.merge(flex.align_content);
self.gap = self.gap.merge(flex.gap);
self
}
/// Aplica esta configuración a un [`Props`] como declaraciones de estilo en línea.
///
/// Es el método recomendado para que un componente adopte `Flex`: concentra en un único sitio
@ -119,19 +226,53 @@ impl Flex {
/// use. Precedente: [`Container`](crate::base::component::Container) lo aplica sobre su
/// propio `Props`; [`Navbar`](crate::base::component::Navbar), sobre el `Props` de su área de
/// contenido.
pub fn apply_to(self, props: &mut Props) {
props.alter_prop(PropsOp::add_style("display", "flex"));
for (property, value) in [
("flex-direction", self.direction.value()),
("flex-wrap", self.wrap.value()),
("justify-content", self.justify.value()),
("align-items", self.align.value()),
("align-content", self.align_content.value()),
] {
props.alter_prop(PropsOp::add_style(property, value));
///
/// Las clases generadas se añaden a `classes`, separadas con un espacio de las que ya hubiera,
/// para poder compartir un único acumulador con [`FlexItem::apply()`](super::FlexItem::apply)
/// sin cadenas intermedias.
#[rustfmt::skip]
pub(crate) fn apply(self, cx: &mut Context, classes: &mut String) {
// Sin `display` no hay contenedor Flex: el resto de facetas (`flex-direction`, `gap`...)
// no tienen ningún efecto en CSS sin `display: flex`/`inline-flex`, así que ni se generan.
let Some(display) = self.display else {
return;
};
use super::{apply, responsive_class, styles, value_to_token};
let (prefix, value) = match display {
DisplayFlex::Always
| DisplayFlex::At(_) => ("_flex_", "flex"),
DisplayFlex::AlwaysInline
| DisplayFlex::InlineAt(_) => ("_inline-flex_", "inline-flex"),
};
let entry = match display {
DisplayFlex::At(bp) | DisplayFlex::InlineAt(bp) => bp.resolved(cx),
_ => None,
};
let class = match entry {
None => prefix.into(),
Some(entry) => util::join!(prefix, entry.name, "_").into(),
};
styles(cx, classes, entry, class, "display", value.into());
apply!(cx, classes, self.direction, "_flex-direction_", "flex-direction");
apply!(cx, classes, self.wrap, "_flex-wrap_", "flex-wrap");
apply!(cx, classes, self.justify, "_flex-justify_", "justify-content");
apply!(cx, classes, self.align, "_flex-align-items_", "align-items");
apply!(cx, classes, self.align_content, "_flex-align-content_", "align-content");
for (bp, gap) in self.gap.by_breakpoint() {
let entry = bp.resolved(cx);
for (property, value) in gap.styles().into_iter().flatten() {
// El prefijo de `gap` no es literal (depende de la propiedad), así que se compone
// aquí en un único `join!` en vez de pasar por `responsive_class!`.
let token = value_to_token(&value);
let class = match entry {
None => util::join!("_flex-", property, "_", token, "_"),
Some(e) => util::join!("_flex-", property, "_", token, "_", e.name, "_"),
};
styles(cx, classes, entry, class.into(), property, value);
}
for (property, value) in self.gap.styles() {
props.alter_prop(PropsOp::add_style(property, value));
}
}
}

View file

@ -1,17 +1,15 @@
use crate::html::flex::props_item::{
ItemAlign, ItemGrow, ItemOffset, ItemOrder, ItemShrink, ItemSize,
};
use crate::html::props::{Props, PropsOp};
use crate::{AutoDefault, Getters, builder_impl};
// **< FlexItem >***********************************************************************************
use crate::core::component::Context;
use crate::core::theme::{Breakpoint, Responsive};
use crate::html::PropsOp;
use crate::html::flex::{ItemAlign, ItemGrow, ItemOffset, ItemOrder, ItemShrink, ItemSize};
use crate::{AutoDefault, Getters, builder_impl, util};
/// Configuración de un elemento como ítem de un contenedor Flexbox.
///
/// A diferencia de [`Flex`](crate::html::flex::Flex), que configura el comportamiento Flexbox
/// global de un contenedor y sus hijos como grupo, `FlexItem` configura un único elemento en
/// relación con el contenedor flex padre: crecimiento ([`ItemGrow`]), reducción ([`ItemShrink`]),
/// alineación individual ([`ItemAlign`]), orden visual ([`ItemOrder`]), ancho ([`ItemSize`]) y
/// alineación individual ([`ItemAlign`]), orden visual ([`ItemOrder`]), tamaño ([`ItemSize`]) y
/// desplazamiento ([`ItemOffset`]).
///
/// No tiene un builder dedicado en ningún componente. De hecho, no tendría sentido porque cualquier
@ -20,7 +18,8 @@ use crate::{AutoDefault, Getters, builder_impl};
/// exponer cualquier componente.
///
/// Con [`ItemSize`] y [`ItemOffset`] se pueden modelar rejillas de columnas fijas sobre Flexbox,
/// combinando un ancho en fracción del contenedor con un desplazamiento lateral cuando se necesite.
/// combinando un tamaño en fracción del contenedor con un desplazamiento lateral cuando se
/// necesite.
///
/// # Ejemplo
///
@ -28,7 +27,7 @@ use crate::{AutoDefault, Getters, builder_impl};
/// use pagetop::prelude::*;
///
/// // Crece para ocupar el espacio sobrante, partiendo de ancho cero.
/// let title = Button::plain(Lc::n("Panel")).with_prop(PropsOp::flex_item(
/// let panel = Button::plain(Lc::n("Panel")).with_prop(PropsOp::flex_item(
/// FlexItem::new()
/// .with_grow(flex::ItemGrow::Is1)
/// .with_size(flex::ItemSize::Custom(UnitValue::Zero)),
@ -43,24 +42,24 @@ use crate::{AutoDefault, Getters, builder_impl};
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq, Getters)]
pub struct FlexItem {
/// Devuelve el factor de crecimiento.
/// Devuelve el factor de crecimiento, por punto de corte.
#[getters(copy)]
grow: ItemGrow,
/// Devuelve el factor de reducción.
grow: Responsive<ItemGrow>,
/// Devuelve el factor de reducción, por punto de corte.
#[getters(copy)]
shrink: ItemShrink,
/// Devuelve la alineación individual en el eje transversal.
shrink: Responsive<ItemShrink>,
/// Devuelve la alineación individual en el eje transversal, por punto de corte.
#[getters(copy)]
align_self: ItemAlign,
/// Devuelve la posición en el orden visual.
align_self: Responsive<ItemAlign>,
/// Devuelve la posición en el orden visual, por punto de corte.
#[getters(copy)]
order: ItemOrder,
/// Devuelve el ancho como fracción del contenedor.
order: Responsive<ItemOrder>,
/// Devuelve el tamaño como fracción del contenedor, por punto de corte.
#[getters(copy)]
size: ItemSize,
/// Devuelve el desplazamiento respecto al inicio del contenedor.
size: Responsive<ItemSize>,
/// Devuelve el desplazamiento respecto al inicio del contenedor, por punto de corte.
#[getters(copy)]
offset: ItemOffset,
offset: Responsive<ItemOffset>,
}
#[builder_impl]
@ -70,80 +69,19 @@ impl FlexItem {
Self::default()
}
// **< FlexItem BUILDER >***********************************************************************
/// Establece el factor de crecimiento.
pub fn with_grow(mut self, grow: ItemGrow) -> Self {
self.grow = grow;
self
}
/// Establece el factor de reducción.
pub fn with_shrink(mut self, shrink: ItemShrink) -> Self {
self.shrink = shrink;
self
}
/// Establece la alineación individual en el eje transversal.
pub fn with_align_self(mut self, align_self: ItemAlign) -> Self {
self.align_self = align_self;
self
}
/// Establece la posición en el orden visual.
pub fn with_order(mut self, order: ItemOrder) -> Self {
self.order = order;
self
}
/// Establece el ancho como una fracción del contenedor (`flex-basis`). No fuerza
/// [`ItemShrink::Is0`](super::ItemShrink::Is0) por sí solo (consulta la documentación de
/// [`ItemSize`] antes de combinarlo con [`with_shrink()`](Self::with_shrink) porque con un
/// tamaño en porcentaje, forzar `ItemShrink::Is0` sólo es seguro si el contenedor no tiene
/// [`Gap`](super::Gap)).
pub fn with_size(mut self, size: ItemSize) -> Self {
self.size = size;
self
}
/// Establece el desplazamiento respecto al inicio del contenedor (`margin-inline-start`). No
/// tiene relación con [`push_end()`](Self::push_end) aunque aplican la misma propiedad CSS para
/// casos de uso distintos.
pub fn with_offset(mut self, offset: ItemOffset) -> Self {
self.offset = offset;
self
}
}
impl FlexItem {
// Aplica esta configuración a un Props como declaraciones de estilo en línea.
pub(crate) fn apply_to(self, props: &mut Props) {
for (property, value) in [
("flex-grow", self.grow.value()),
("flex-shrink", self.shrink.value()),
("align-self", self.align_self.value()),
("order", self.order.value()),
("flex-basis", self.size.value()),
("margin-inline-start", self.offset.value()),
] {
props.alter_prop(PropsOp::add_style(property, value));
}
}
/// Separa un elemento (y los que le sigan en el mismo eje principal) del resto, empujándolo
/// hacia el extremo final de un contenedor flex.
/// Crea una configuración de ítem que empuja el elemento, y los que le sigan, hacia el extremo
/// final de un contenedor flex en fila.
///
/// Se resuelve siempre como margen inicial automático (`margin-inline-start: auto`) en línea,
/// igual que el resto de facetas de `FlexItem`. Es el mecanismo estándar de Flexbox para, por
/// ejemplo, separar dos menús dentro de una misma [`Navbar`](crate::base::component::Navbar)
/// -- uno pegado al inicio, el siguiente empujado al final -- sin que el contenedor necesite
/// conocer ninguna distinción entre sus elementos.
/// Aplica `margin-inline-start: auto`, un margen automático que absorbe todo el espacio libre
/// que quede antes del elemento en el eje de escritura. Con la dirección por defecto
/// ([`Direction::Row`](super::Direction::Row)) ese eje es el principal, de ahí el efecto de
/// empuje. En un contenedor en columna, en cambio, ese eje es el transversal: el margen ya no
/// empuja nada, sólo desplaza ese elemento hacia el final de la línea (a la derecha si se
/// escribe de izquierda a derecha).
///
/// No forma parte de los campos de `FlexItem` (no se combina con `grow`/`shrink`/`align_self`/
/// `order`/`size`/`offset` en una misma llamada): es una función asociada independiente porque
/// resuelve un caso de uso completo por sí sola, con una sola línea, y vive aquí -- en vez de
/// como función suelta del módulo `flex` -- para dejar claro que es una operación de **ítem**,
/// no de contenedor.
/// Es el mecanismo estándar de Flexbox para, por ejemplo, separar dos menús dentro de un mismo
/// [`Navbar`](crate::base::component::Navbar) (uno pegado al inicio, el siguiente empujado al
/// final) sin que el contenedor necesite conocer ninguna distinción entre sus elementos.
///
/// # Ejemplo
///
@ -151,12 +89,139 @@ impl FlexItem {
/// use pagetop::prelude::*;
///
/// let user_menu = Nav::new()
/// .with_prop(FlexItem::push_end())
/// .with_prop(FlexItem::push_end().into())
/// .with_item(nav::Item::link(Lc::n("Profile"), "/profile"))
/// .with_item(nav::Item::link(Lc::n("Sign out"), "/sign-out"));
/// ```
pub fn push_end() -> PropsOp {
PropsOp::add_style("margin-inline-start", "auto")
pub fn push_end() -> Self {
Self::new().with_offset(ItemOffset::Auto)
}
// **< FlexItem BUILDER >***********************************************************************
/// Establece el factor de crecimiento.
pub fn with_grow(mut self, grow: ItemGrow) -> Self {
self.grow = self.grow.set(grow);
self
}
/// Establece el factor de crecimiento, a partir del punto de corte indicado.
pub fn with_grow_at(mut self, bp: Breakpoint, grow: ItemGrow) -> Self {
self.grow = self.grow.set_at(bp, grow);
self
}
/// Establece el factor de reducción.
pub fn with_shrink(mut self, shrink: ItemShrink) -> Self {
self.shrink = self.shrink.set(shrink);
self
}
/// Establece el factor de reducción, a partir del punto de corte indicado.
pub fn with_shrink_at(mut self, bp: Breakpoint, shrink: ItemShrink) -> Self {
self.shrink = self.shrink.set_at(bp, shrink);
self
}
/// Establece la alineación individual en el eje transversal.
pub fn with_align_self(mut self, align_self: ItemAlign) -> Self {
self.align_self = self.align_self.set(align_self);
self
}
/// Establece la alineación individual en el eje transversal, a partir del punto de corte
/// indicado.
pub fn with_align_self_at(mut self, bp: Breakpoint, align_self: ItemAlign) -> Self {
self.align_self = self.align_self.set_at(bp, align_self);
self
}
/// Establece la posición en el orden visual.
pub fn with_order(mut self, order: ItemOrder) -> Self {
self.order = self.order.set(order);
self
}
/// Establece la posición en el orden visual, a partir del punto de corte indicado.
pub fn with_order_at(mut self, bp: Breakpoint, order: ItemOrder) -> Self {
self.order = self.order.set_at(bp, order);
self
}
/// Establece el tamaño como una fracción del contenedor (`flex-basis`). No fuerza
/// [`ItemShrink::Is0`](super::ItemShrink::Is0) por sí solo (consulta la documentación de
/// [`ItemSize`] antes de combinarlo con [`with_shrink()`](Self::with_shrink) porque con un
/// tamaño en porcentaje, forzar `ItemShrink::Is0` sólo es seguro si el contenedor no tiene
/// [`Gap`](super::Gap)).
pub fn with_size(mut self, size: ItemSize) -> Self {
self.size = self.size.set(size);
self
}
/// Establece el tamaño como una fracción del contenedor (`flex-basis`), a partir del punto de
/// corte indicado.
pub fn with_size_at(mut self, bp: Breakpoint, size: ItemSize) -> Self {
self.size = self.size.set_at(bp, size);
self
}
/// Establece el desplazamiento respecto al inicio del contenedor (`margin-inline-start`).
/// [`push_end()`](Self::push_end) fija este mismo campo a [`ItemOffset::Auto`]; combinar los
/// dos deja el que se aplique en último lugar.
pub fn with_offset(mut self, offset: ItemOffset) -> Self {
self.offset = self.offset.set(offset);
self
}
/// Establece el desplazamiento respecto al inicio del contenedor (`margin-inline-start`), a
/// partir del punto de corte indicado.
pub fn with_offset_at(mut self, bp: Breakpoint, offset: ItemOffset) -> Self {
self.offset = self.offset.set_at(bp, offset);
self
}
}
impl FlexItem {
/// Combina esta configuración con otra `FlexItem`, campo a campo.
///
/// La fusión llega al nivel de cada punto de corte; donde `item` tenga un valor establecido,
/// sustituye al de `self` y donde no lo tenga, se conserva el que ya hubiera. Por eso un `item`
/// que sólo establezca `with_size_at(Breakpoint::Lg, ...)` no borra el `with_size()` base que
/// `self` ya tuviera, sólo sustituye la entrada de ese punto de corte.
///
/// Es el método que usa [`Props::with_prop()`](crate::html::props::Props::with_prop) para que
/// sucesivas [`PropsOp::FlexItem`](crate::html::props::PropsOp::FlexItem) sobre el mismo
/// componente vayan completando campos concretos sin repetir los ya establecidos, en vez de
/// partir de cero en cada llamada.
pub fn merge(mut self, item: FlexItem) -> Self {
self.grow = self.grow.merge(item.grow);
self.shrink = self.shrink.merge(item.shrink);
self.align_self = self.align_self.merge(item.align_self);
self.order = self.order.merge(item.order);
self.size = self.size.merge(item.size);
self.offset = self.offset.merge(item.offset);
self
}
/// Aplica esta configuración como clases de utilidad responsive en el [`Context`], igual que
/// [`Flex::apply()`](super::Flex::apply): cada faceta con valor añade una declaración de
/// estilo (por punto de corte, si se ha establecido alguno) y su propia clase. Un campo sin
/// ningún valor establecido, o con un valor cuya variante es la "por defecto" del propio enum
/// (p. ej. `ItemGrow::Default`), no añade nada.
///
/// Las clases generadas se añaden a `classes`, separadas con un espacio de las que ya hubiera,
/// para poder compartir un único acumulador con [`Flex::apply()`](super::Flex::apply) sin
/// cadenas intermedias.
#[rustfmt::skip]
pub(crate) fn apply(self, cx: &mut Context, classes: &mut String) {
use super::{apply, responsive_class, styles, value_to_token};
apply!(cx, classes, self.grow, "_flex-item-grow_", "flex-grow");
apply!(cx, classes, self.shrink, "_flex-item-shrink_", "flex-shrink");
apply!(cx, classes, self.align_self, "_flex-item-align_", "align-self");
apply!(cx, classes, self.order, "_flex-item-order_", "order");
apply!(cx, classes, self.size, "_flex-item-basis_", "flex-basis", val);
apply!(cx, classes, self.offset, "_flex-item-offset_", "margin-inline-start", val);
}
}

View file

@ -207,28 +207,21 @@ pub enum Gap {
}
impl Gap {
// Declaraciones de estilo (propiedad, valor) para este espaciado; vacío si no hay ninguna
// medible (`UnitValue::None`/`UnitValue::Auto` no producen ningún estilo).
pub(super) fn styles(self) -> Vec<(&'static str, CowStr)> {
// Declaraciones de estilo (propiedad, valor) para este espaciado; cada hueco a `None` si no hay
// ninguna medible (`UnitValue::None`/`UnitValue::Auto` no producen ningún estilo).
pub(super) fn styles(self) -> [Option<(&'static str, CowStr)>; 2] {
match self {
Self::None => Vec::new(),
Self::Both(value) => {
if value.is_measurable() {
vec![("gap", value.into())]
} else {
Vec::new()
}
}
Self::Distinct { row, column } => {
let mut styles = Vec::new();
if row.is_measurable() {
styles.push(("row-gap", row.into()));
}
if column.is_measurable() {
styles.push(("column-gap", column.into()));
}
styles
Self::None => [None, None],
Self::Both(value) => [Self::style("gap", value), None],
Self::Distinct { row, column } => [
Self::style("row-gap", row),
Self::style("column-gap", column),
],
}
}
// Declaración (propiedad, valor) para un valor medible, o `None` si no lo es.
fn style(property: &'static str, value: UnitValue) -> Option<(&'static str, CowStr)> {
value.is_measurable().then(|| (property, value.into()))
}
}

View file

@ -74,6 +74,10 @@ pub enum ItemOffset {
/// Por defecto, sin desplazamiento (`margin-inline-start: 0` no explícito).
#[default]
None,
/// Empuja el ítem, y los que le sigan, hacia el extremo final de un contenedor en fila
/// (`margin-inline-start: auto`). Ver [`FlexItem::push_end()`](super::FlexItem::push_end), que
/// detalla el comportamiento cuando el contenedor está en columna.
Auto,
/// Se desplaza el 10% del ancho del contenedor (`margin-inline-start: 10%`).
Percent10,
/// Se desplaza el 20% del ancho del contenedor (`margin-inline-start: 20%`).
@ -106,6 +110,7 @@ impl ItemOffset {
pub(super) fn value(self) -> CowStr {
match self {
Self::None => "".into(),
Self::Auto => "auto".into(),
Self::Percent10 => "10%".into(),
Self::Percent20 => "20%".into(),
Self::Percent25 => "25%".into(),
@ -217,10 +222,14 @@ impl ItemShrink {
// **< ItemSize >***********************************************************************************
/// Ancho en [`FlexItem`](super::FlexItem) para un ítem como fracción del contenedor.
/// Tamaño en [`FlexItem`](super::FlexItem) para un ítem como fracción del contenedor.
///
/// Dimensiona el eje principal (`flex-basis`). Con la dirección por defecto
/// ([`Direction::Row`](super::Direction::Row)) fija el ancho, y en un contenedor en columna fija el
/// alto. El resto de esta documentación describe el caso en fila, que es el habitual.
///
/// Permite maquetar rejillas de columnas fijas. Un ítem con [`ItemSize::Percent33`] ocupa un tercio
/// del ancho del contenedor con independencia de su contenido.
/// del contenedor con independencia de su contenido.
///
/// # Cómo combinarlo con `Gap`
///
@ -253,31 +262,31 @@ pub enum ItemSize {
/// Por defecto, el tamaño se calcula según el contenido (`flex-basis: auto` no explícito).
#[default]
Default,
/// Ocupa el 10% del ancho del contenedor (`flex-basis: 10%`).
/// Ocupa el 10% del contenedor (`flex-basis: 10%`).
Percent10,
/// Ocupa el 20% del ancho del contenedor (`flex-basis: 20%`).
/// Ocupa el 20% del contenedor (`flex-basis: 20%`).
Percent20,
/// Ocupa el 25% del ancho del contenedor (`flex-basis: 25%`).
/// Ocupa el 25% del contenedor (`flex-basis: 25%`).
Percent25,
/// Ocupa un tercio del ancho del contenedor (`flex-basis: 33.3333%`).
/// Ocupa un tercio del contenedor (`flex-basis: 33.3333%`).
Percent33,
/// Ocupa el 40% del ancho del contenedor (`flex-basis: 40%`).
/// Ocupa el 40% del contenedor (`flex-basis: 40%`).
Percent40,
/// Ocupa la mitad del ancho del contenedor (`flex-basis: 50%`).
/// Ocupa la mitad del contenedor (`flex-basis: 50%`).
Percent50,
/// Ocupa el 60% del ancho del contenedor (`flex-basis: 60%`).
/// Ocupa el 60% del contenedor (`flex-basis: 60%`).
Percent60,
/// Ocupa dos tercios del ancho del contenedor (`flex-basis: 66.6667%`).
/// Ocupa dos tercios del contenedor (`flex-basis: 66.6667%`).
Percent66,
/// Ocupa el 75% del ancho del contenedor (`flex-basis: 75%`).
/// Ocupa el 75% del contenedor (`flex-basis: 75%`).
Percent75,
/// Ocupa el 80% del ancho del contenedor (`flex-basis: 80%`).
/// Ocupa el 80% del contenedor (`flex-basis: 80%`).
Percent80,
/// Ocupa el 90% del ancho del contenedor (`flex-basis: 90%`).
/// Ocupa el 90% del contenedor (`flex-basis: 90%`).
Percent90,
/// Ocupa el 100% del ancho del contenedor (`flex-basis: 100%`).
/// Ocupa el 100% del contenedor (`flex-basis: 100%`).
Percent100,
/// Cualquier otro valor, incluidas unidades absolutas (p. ej. un ancho fijo en píxeles).
/// Cualquier otro valor, incluidas unidades absolutas (p. ej. un tamaño fijo en píxeles).
Custom(UnitValue),
}

View file

@ -1,5 +1,6 @@
use crate::core::TypeInfo;
use crate::core::component::Context;
use crate::html::flex::{Flex, FlexItem};
use crate::html::maud::{Escaper, RenderAttrs};
use crate::html::props::{PropsError, PropsExtra, PropsOp};
use crate::{AutoDefault, CowStr, builder_impl, trace, util};
@ -188,6 +189,7 @@ pub struct Props {
styles: Vec<(CowStr, CowStr)>,
attrs: Vec<(CowStr, CowStr)>,
extras: HashMap<&'static str, PropsExtra>,
flex_item: FlexItem,
}
#[builder_impl]
@ -335,7 +337,7 @@ impl Props {
self.extras.remove(key);
}
PropsOp::FlexItem(placement) => {
placement.apply_to(self);
self.flex_item = self.flex_item.merge(placement);
}
}
self
@ -578,9 +580,11 @@ impl Props {
/// [`FlexItem`]: crate::html::flex::FlexItem
/// [`unpack_with_flex()`]: Self::unpack_with_flex
pub fn unpack<'a>(&'a self, cx: &mut Context) -> impl RenderAttrs + 'a {
let mut classes = String::new();
self.flex_item.apply(cx, &mut classes);
PropsUnpack {
props: self,
classes: self.flex_item.apply(cx),
classes,
}
}
@ -597,9 +601,12 @@ impl Props {
/// [`FlexItem`]: crate::html::flex::FlexItem
/// [`PropsOp::FlexItem`]: crate::html::props::PropsOp::FlexItem
pub fn unpack_with_flex<'a>(&'a self, cx: &mut Context, flex: Flex) -> impl RenderAttrs + 'a {
let mut classes = String::new();
flex.apply(cx, &mut classes);
self.flex_item.apply(cx, &mut classes);
PropsUnpack {
props: self,
classes: util::join_pair!(flex.apply(cx), " ", self.flex_item.apply(cx)),
classes,
}
}

View file

@ -143,3 +143,75 @@ async fn gap_none_adds_no_gap_style() {
assert!(!html.contains("gap"));
assert!(!assets.contains("gap"));
}
#[pagetop::test]
async fn inline_flex_uses_its_own_display_value() {
let mut cx = Context::default();
let mut container = Container::new()
.with_flex(Flex::inline())
.with_child(Lc::n("x"));
let html = container.render(&mut cx).await.into_string();
let assets = cx.render_assets().into_string();
assert!(html.contains(r#"class="_inline-flex_""#));
assert!(assets.contains("_inline-flex_{display:inline-flex}"));
}
#[pagetop::test]
async fn inline_flex_at_a_breakpoint_adds_its_suffix() {
let mut cx = Context::default();
let mut container = Container::new()
.with_flex(Flex::inline_at(Breakpoint::Lg))
.with_child(Lc::n("x"));
let html = container.render(&mut cx).await.into_string();
let assets = cx.render_assets().into_string();
assert!(html.contains(r#"class="_inline-flex_lg_""#));
assert!(assets.contains("@media(min-width:992px){._inline-flex_lg_{display:inline-flex}}"));
}
#[pagetop::test]
async fn flex_and_flex_item_classes_keep_their_order() {
let mut cx = Context::default();
let mut container = Container::new()
.with_prop(PropsOp::add_classes("own"))
.with_flex(Flex::new().with_direction(flex::Direction::Column))
.with_prop(PropsOp::flex_item(
FlexItem::new().with_grow(flex::ItemGrow::Is1),
))
.with_child(Lc::n("x"));
let html = container.render(&mut cx).await.into_string();
// The component's own classes come first, then those of `Flex`, then those of `FlexItem`,
// which share a single accumulator in `Props::unpack_with_flex()`.
assert!(html.contains(r#"class="own _flex_ _flex-direction_column_ _flex-item-grow_1_""#));
}
#[pagetop::test]
async fn flex_without_display_adds_no_class_of_its_own() {
let mut cx = Context::default();
let mut container = Container::new()
.with_prop(PropsOp::add_classes("own"))
.with_flex(Flex::default())
.with_child(Lc::n("x"));
let html = container.render(&mut cx).await.into_string();
assert!(html.contains(r#"class="own""#));
assert!(cx.render_assets().into_string().is_empty());
}
#[pagetop::test]
async fn breakpoints_add_their_suffix_and_wrap_rules_in_media_queries() {
let mut cx = Context::default();
let mut container = Container::new()
.with_flex(
Flex::at(Breakpoint::Md).with_direction_at(Breakpoint::Lg, flex::Direction::Column),
)
.with_child(Lc::n("x"));
let html = container.render(&mut cx).await.into_string();
let assets = cx.render_assets().into_string();
assert!(html.contains(r#"class="_flex_md_ _flex-direction_column_lg_""#));
assert!(assets.contains("@media(min-width:768px){._flex_md_{display:flex}}"));
assert!(assets.contains("@media(min-width:992px){._flex-direction_column_lg_"));
}

View file

@ -143,6 +143,39 @@ async fn combines_several_facets_in_one_call() {
assert!(assets.contains("_flex-item-offset_10pct_{margin-inline-start:10%}"));
}
#[pagetop::test]
async fn size_at_a_breakpoint_combines_token_and_suffix() {
let mut cx = Context::default();
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_size_at(Breakpoint::Md, flex::ItemSize::Percent33),
));
let html = html! { span (props.unpack(&mut cx)) {} }.into_string();
let assets = cx.render_assets().into_string();
// Both the sanitized value (`33.3333%` -> `33_3333pct`) and the breakpoint suffix are part of
// the same class name, and the rule is wrapped in its media query.
assert!(html.contains(r#"class="_flex-item-basis_33_3333pct_md_""#));
assert!(assets.contains(
"@media(min-width:768px){._flex-item-basis_33_3333pct_md_{flex-basis:33.3333%}}"
));
}
#[pagetop::test]
async fn base_and_breakpoint_values_generate_one_class_each() {
let mut cx = Context::default();
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new()
.with_grow(flex::ItemGrow::Default)
.with_size(flex::ItemSize::Percent50)
.with_size_at(Breakpoint::Lg, flex::ItemSize::Percent25),
));
let html = html! { span (props.unpack(&mut cx)) {} }.into_string();
// `ItemGrow::Default` resolves to an empty CSS value, so it adds no class at all.
assert!(html.contains(r#"class="_flex-item-basis_50pct_ _flex-item-basis_25pct_lg_""#));
assert!(!html.contains("_flex-item-grow_"));
}
#[pagetop::test]
async fn from_flex_item_for_props_op() {
let mut cx = Context::default();