(pagetop): Añade Flex/FlexItem para usar Flexbox

Container y Navbar lo adoptan vía `with_flex()`/`PropsOp::flex_item()`.
Y se elimina `ButtonSet` porque su funcionalidad queda cubierta por este
mecanismo más general. Incluye el ejemplo `examples/intro-flex.rs` con
los patrones de uso.
This commit is contained in:
Manuel Cillero 2026-09-04 01:04:53 +02:00
parent 4e4fdf7b10
commit 17e16652e4
26 changed files with 2023 additions and 193 deletions

View file

@ -142,58 +142,3 @@ async fn disabled_anchor_omits_href_and_sets_aria_disabled() {
assert!(html.contains(r#"aria-disabled="true""#));
assert!(html.contains(r#"tabindex="-1""#));
}
// **< ButtonSet >**********************************************************************************
#[pagetop::test]
async fn button_set_is_not_rendered_when_empty() {
let mut set = button::ButtonSet::new();
let html = set.render(&mut Context::default()).await;
assert!(html.is_empty());
}
#[pagetop::test]
async fn button_set_wraps_buttons_in_button_set_class() {
let mut set = button::ButtonSet::new().with_button(Button::submit(Lc::n("Save")));
let html = set.render(&mut Context::default()).await.into_string();
assert!(html.contains("button-set"));
assert!(html.contains("Save"));
}
#[pagetop::test]
async fn button_set_renders_buttons_in_insertion_order() {
let mut set = button::ButtonSet::new()
.with_button(Button::submit(Lc::n("First")))
.with_button(Button::plain(Lc::n("Second")));
let html = set.render(&mut Context::default()).await.into_string();
assert!(html.find("First").unwrap() < html.find("Second").unwrap());
}
#[pagetop::test]
async fn button_set_add_many_appends_all_buttons() {
let mut set = button::ButtonSet::new().with_button(TypedOp::AddMany(vec![
Button::submit(Lc::n("Save")),
Button::reset(Lc::n("Reset")),
Button::plain(Lc::n("Cancel")),
]));
let html = set.render(&mut Context::default()).await.into_string();
assert!(html.contains("Save"));
assert!(html.contains("Reset"));
assert!(html.contains("Cancel"));
}
#[pagetop::test]
async fn button_set_remove_by_id_drops_matching_button() {
let mut set = button::ButtonSet::new()
.with_button(Button::submit(Lc::n("Save")).with_id("save-button"))
.with_button(Button::plain(Lc::n("Cancel")))
.with_button(TypedOp::RemoveById("save-button"));
let html = set.render(&mut Context::default()).await.into_string();
assert!(!html.contains("Save"));
assert!(html.contains("Cancel"));
}

View file

@ -0,0 +1,140 @@
use pagetop::prelude::*;
// **< Container >**********************************************************************************
#[pagetop::test]
async fn is_not_rendered_when_empty() {
let mut container = Container::new();
let html = container.render(&mut Context::default()).await;
assert!(html.is_empty());
}
#[pagetop::test]
async fn default_kind_renders_a_div_element() {
let mut container = Container::new().with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(html.starts_with("<div"));
assert!(html.ends_with("</div>"));
}
#[pagetop::test]
async fn main_kind_renders_a_main_element() {
let mut container = Container::main().with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(html.starts_with("<main"));
assert!(html.ends_with("</main>"));
}
// **< Container + Flex >***************************************************************************
#[pagetop::test]
async fn without_flex_no_style_attribute_is_added() {
let mut container = Container::new().with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(!html.contains("style="));
}
#[pagetop::test]
async fn default_flex_adds_only_display_flex() {
let mut container = Container::new()
.with_flex(Flex::row())
.with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains(r#"style="display: flex""#));
}
#[pagetop::test]
async fn column_direction_adds_flex_direction_style() {
let mut container = Container::new()
.with_flex(Flex::column())
.with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains("display: flex"));
assert!(html.contains("flex-direction: column"));
}
#[pagetop::test]
async fn wrap_justify_and_align_add_their_matching_styles() {
let mut container = Container::new()
.with_flex(
Flex::row()
.with_wrap(flex::Behavior::Wrap)
.with_justify(flex::ContentJustify::Center)
.with_align(flex::Align::Center)
.with_align_content(flex::AlignContent::SpaceBetween),
)
.with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains("flex-wrap: wrap"));
assert!(html.contains("justify-content: center"));
assert!(html.contains("align-items: center"));
assert!(html.contains("align-content: space-between"));
}
#[pagetop::test]
async fn gap_both_adds_a_single_gap_style() {
let mut container = Container::new()
.with_flex(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
.with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains("gap: 0.5rem"));
}
#[pagetop::test]
async fn gap_distinct_adds_row_and_column_gap_styles() {
let mut container = Container::new()
.with_flex(Flex::row().with_gap(flex::Gap::Distinct {
row: UnitValue::Px(4),
column: UnitValue::Px(8),
}))
.with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains("row-gap: 4px"));
assert!(html.contains("column-gap: 8px"));
}
#[pagetop::test]
async fn gap_none_adds_no_gap_style() {
let mut container = Container::new()
.with_flex(Flex::row())
.with_child(Lc::n("x"));
let html = container
.render(&mut Context::default())
.await
.into_string();
assert!(!html.contains("gap:"));
}

68
tests/component_navbar.rs Normal file
View file

@ -0,0 +1,68 @@
use pagetop::prelude::*;
fn one_link_nav() -> Nav {
Nav::new().with_item(nav::Item::link(Lc::n("Home"), "/"))
}
// **< Navbar >*************************************************************************************
#[pagetop::test]
async fn is_not_rendered_when_empty() {
let mut navbar = Navbar::simple();
let html = navbar.render(&mut Context::default()).await;
assert!(html.is_empty());
}
#[pagetop::test]
async fn nav_root_class_is_unaffected_by_content_flex() {
let mut navbar = Navbar::simple()
.with_flex(Flex::row().with_justify(flex::ContentJustify::End))
.with_item(navbar::Item::nav(one_link_nav()));
let html = navbar.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"class="navbar""#));
}
// **< Navbar + Flex (content area) >***************************************************************
#[pagetop::test]
async fn without_flex_content_area_has_no_style_attribute() {
let mut navbar = Navbar::simple().with_item(navbar::Item::nav(one_link_nav()));
let html = navbar.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"class="navbar-content""#));
assert!(!html.contains("style="));
}
#[pagetop::test]
async fn flex_adds_its_styles_to_the_content_area() {
let mut navbar = Navbar::simple()
.with_flex(Flex::row().with_justify(flex::ContentJustify::End))
.with_item(navbar::Item::nav(one_link_nav()));
let html = navbar.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"class="navbar-content""#));
assert!(html.contains("display: flex"));
assert!(html.contains("justify-content: flex-end"));
}
#[pagetop::test]
async fn flex_gap_adds_a_style_to_the_content_area() {
let mut navbar = Navbar::simple()
.with_flex(Flex::row().with_gap(flex::Gap::Both(UnitValue::RelRem(0.5))))
.with_item(navbar::Item::nav(one_link_nav()));
let html = navbar.render(&mut Context::default()).await.into_string();
assert!(html.contains("gap: 0.5rem"));
}
// **< Navbar + FlexItem::push_end >****************************************************************
#[pagetop::test]
async fn push_end_adds_an_automatic_start_margin() {
let mut nav = one_link_nav().with_prop(FlexItem::push_end());
let html = nav.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"style="margin-inline-start: auto""#));
}

View file

@ -0,0 +1,119 @@
use pagetop::prelude::*;
#[pagetop::test]
async fn default_flex_item_adds_nothing() {
let props = Props::default().with_prop(PropsOp::flex_item(FlexItem::new()));
assert_eq!(props.get_classes(), None);
assert_eq!(props.get_styles(), None);
}
#[pagetop::test]
async fn grow_adds_flex_grow_style() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_grow(flex::ItemGrow::Is1),
));
assert_eq!(props.get_styles(), Some("flex-grow: 1".to_string()));
}
#[pagetop::test]
async fn shrink_adds_flex_shrink_style() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_shrink(flex::ItemShrink::Is0),
));
assert_eq!(props.get_styles(), Some("flex-shrink: 0".to_string()));
}
#[pagetop::test]
async fn align_self_adds_matching_style() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_align_self(flex::ItemAlign::Center),
));
assert_eq!(props.get_styles(), Some("align-self: center".to_string()));
}
#[pagetop::test]
async fn order_adds_matching_style() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_order(flex::ItemOrder::First),
));
assert_eq!(props.get_styles(), Some("order: -129".to_string()));
}
#[pagetop::test]
async fn size_percent_adds_flex_basis_style() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_size(flex::ItemSize::Percent33),
));
assert_eq!(props.get_styles(), Some("flex-basis: 33.3333%".to_string()));
}
#[pagetop::test]
async fn size_custom_adds_flex_basis_style() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_size(flex::ItemSize::Custom(UnitValue::Zero)),
));
assert_eq!(props.get_classes(), None);
assert_eq!(props.get_styles(), Some("flex-basis: 0".to_string()));
}
#[pagetop::test]
async fn offset_percent_adds_margin_inline_start_style() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_offset(flex::ItemOffset::Percent33),
));
assert_eq!(
props.get_styles(),
Some("margin-inline-start: 33.3333%".to_string())
);
}
#[pagetop::test]
async fn offset_custom_adds_margin_inline_start_style() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new().with_offset(flex::ItemOffset::Custom(UnitValue::Px(16))),
));
assert_eq!(
props.get_styles(),
Some("margin-inline-start: 16px".to_string())
);
}
#[pagetop::test]
async fn combines_several_facets_in_one_call() {
let props = Props::default().with_prop(PropsOp::flex_item(
FlexItem::new()
.with_grow(flex::ItemGrow::Is1)
.with_shrink(flex::ItemShrink::Is0)
.with_align_self(flex::ItemAlign::Start)
.with_order(flex::ItemOrder::Is2)
.with_size(flex::ItemSize::Custom(UnitValue::Zero))
.with_offset(flex::ItemOffset::Percent10),
));
assert_eq!(
props.get_styles(),
Some(
"flex-grow: 1; flex-shrink: 0; align-self: flex-start; order: 2; flex-basis: 0; \
margin-inline-start: 10%"
.to_string()
)
);
assert_eq!(props.get_classes(), None);
}
#[pagetop::test]
async fn from_flex_item_for_props_op() {
let item = FlexItem::new().with_grow(flex::ItemGrow::Is1);
let props = Props::default().with_prop(item.into());
assert_eq!(props.get_styles(), Some("flex-grow: 1".to_string()));
}