From 86d2b0c0c7ed394873a33098df7738cca574afb7 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 21:00:35 +0700 Subject: [PATCH 01/15] feat(layout): hold a position:sticky box against its scrollport `stylo_taffy` mapped `Position::Sticky` onto `taffy::Position::Relative` and stopped there. Relative is the right layout answer: a sticky box takes its flow position and reserves its space there. Nothing supplied the adjustment on top of it, so every sticky navbar and document sidebar on the fleet scrolled away, on documents 5,000 to 16,700px tall. Worse than nothing, in fact. Taffy applies the inset of a relative box as a displacement of its flow position, and a sticky inset is a threshold rather than an offset, so `position:sticky;top:24px` was drawn 24px below where it belonged before anything had scrolled at all. Sticky boxes now reach taffy with no inset and the engine reads the authored values back from the computed style when it applies the adjustment. `resolve_sticky_positions` runs after layout and after a scroll, and writes the displacement straight into the box's `final_layout` location. Paint, hit testing and `absolute_position` all read that one field, so writing it is what makes them agree; a parallel offset would have to be threaded through each of them and would disagree the moment one was missed. The price is that the pass has to recover the flow position it started from, which `sticky_offsets` records and `resolve_layout` clears along with the locations rounding rewrites. Both halves of the CSS rule are here: the box is held between its own flow position and the far edge of its containing block, so it pins when the page scrolls past it and travels away with its section rather than floating over the next one. Percentage insets resolve against the sticky view rectangle. The scrollport is the nearest scrolling ancestor's padding box, or the viewport when nothing between the box and the root scrolls, which matches where a scroll that reaches the root element is forwarded. The scroll path is hooked as well as the resolve path: a wheel event does not necessarily produce a style and layout pass, and a header that only unstuck on the next restyle is a header that visibly lags the scroll. --- packages/blitz-dom/src/document.rs | 55 +++- packages/blitz-dom/src/resolve.rs | 255 +++++++++++++++- packages/stylo_taffy/src/convert.rs | 44 ++- packages/stylo_taffy/src/wrapper.rs | 9 +- tests/blitz-tests/tests/sticky_position.rs | 332 +++++++++++++++++++++ 5 files changed, 679 insertions(+), 16 deletions(-) create mode 100644 tests/blitz-tests/tests/sticky_position.rs diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 13d26253..3c17e1e2 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -255,6 +255,26 @@ pub struct BaseDocument { /// paints beneath every background between them and disappears. pub(crate) hoisted_fixed_parents: HashMap, + /// Every `position: sticky` node in the document, in tree order. + /// + /// Collected by the same walk that hoists fixed nodes, because both need + /// one pre-order pass over the box tree and a second one would cost the + /// same on every frame of every document, sticky or not. Tree order matters: + /// a sticky box inside another sticky box is adjusted on top of its + /// ancestor's adjustment, so the ancestor has to be settled first. + pub(crate) sticky_nodes: Vec, + + /// For each sticky node, the offset currently baked into its + /// `final_layout().location`. + /// + /// The adjustment is written into the box itself so that paint, hit testing + /// and `absolute_position` cannot disagree about where the box is. That + /// makes the pass non-idempotent unless it can recover the flow position it + /// started from, which is what this records. Cleared by `resolve_layout`, + /// which rewrites every location from taffy and so discards the offsets + /// along with them. + pub(crate) sticky_offsets: HashMap>, + /// Stacking contexts holding a hoisted child that an ancestor clips. /// /// Collected while flushing styles so that `resolve_hoisted_clips` visits @@ -529,6 +549,8 @@ impl BaseDocument { let mut doc = Self { hoisted_fixed_parents: HashMap::new(), + sticky_nodes: Vec::new(), + sticky_offsets: HashMap::new(), hoisted_clip_hosts: Vec::new(), id, tx, @@ -2589,7 +2611,27 @@ impl BaseDocument { /// Scroll a node by given x and y /// Will bubble scrolling up to parent node once it can no longer scroll further /// If we're already at the root node, bubbles scrolling up to the viewport + /// + /// A `position: sticky` box is held against the edge of the scrollport it + /// lives in, so the boxes have to be re-adjusted here rather than only in + /// `resolve`: a wheel event does not necessarily produce a style and layout + /// pass, and a header that only unstuck on the next restyle is a header + /// that visibly lags the scroll. pub fn scroll_node_by_has_changed( + &mut self, + node_id: NodeId, + x: f64, + y: f64, + dispatch_event: F, + ) -> bool { + let has_changed = self.scroll_node_by_inner(node_id, x, y, dispatch_event); + if has_changed { + self.resolve_sticky_positions(); + } + has_changed + } + + fn scroll_node_by_inner( &mut self, node_id: NodeId, x: f64, @@ -2653,7 +2695,7 @@ impl BaseDocument { if bubble_x != 0.0 || bubble_y != 0.0 { let bubbled = if let Some(parent) = parent { - self.scroll_node_by_has_changed(parent, bubble_x, bubble_y, dispatch_event) + self.scroll_node_by_inner(parent, bubble_x, bubble_y, dispatch_event) } else { self.scroll_viewport_by_has_changed(bubble_x, bubble_y) }; @@ -2743,7 +2785,7 @@ impl BaseDocument { if bubble_x != 0.0 || bubble_y != 0.0 { if let Some(parent) = parent { - return self.scroll_node_by_has_changed(parent, bubble_x, bubble_y, dispatch_event) + return self.scroll_node_by_inner(parent, bubble_x, bubble_y, dispatch_event) | has_changed; } else { return self.scroll_viewport_by_has_changed(bubble_x, bubble_y) | has_changed; @@ -2783,7 +2825,14 @@ impl BaseDocument { self.viewport_scroll.y = f64::max(0.0, f64::min(new_scroll.1, content_height - window_height)); - self.viewport_scroll != initial + let has_changed = self.viewport_scroll != initial; + if has_changed { + // The viewport is the scrollport a page-level sticky box is held + // against, so its boxes move with this and not with the next + // relayout. See `resolve_sticky_positions`. + self.resolve_sticky_positions(); + } + has_changed } pub fn scroll_by( diff --git a/packages/blitz-dom/src/resolve.rs b/packages/blitz-dom/src/resolve.rs index afdf500f..a3a97e54 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -290,6 +290,7 @@ impl BaseDocument { } #[cfg(target_arch = "wasm32")] self.resolve_layout(); + self.resolve_sticky_positions(); self.resolve_hoisted_positions(); self.correct_hoisted_fixed_positions(); self.resolve_hoisted_clips(); @@ -311,6 +312,7 @@ impl BaseDocument { } self.flush_styles_to_layout(root_node_id); self.resolve_layout(); + self.resolve_sticky_positions(); self.resolve_hoisted_positions(); self.correct_hoisted_fixed_positions(); self.resolve_hoisted_clips(); @@ -685,11 +687,18 @@ impl BaseDocument { /// /// /// + /// + /// The same walk collects `position: sticky` nodes into + /// [`Self::sticky_nodes`]. Nothing about the two is related, but both need + /// one pre-order pass over the box tree, and a second walk would be paid on + /// every frame of every document whether or not it has a sticky box in it. pub fn hoist_fixed_position_nodes(&mut self) { let root_id = self.root_element().id; let mut hoisted: Vec = Vec::new(); - collect_fixed(self, root_id, false, &mut hoisted); + let mut sticky: Vec = Vec::new(); + collect_fixed(self, root_id, false, &mut hoisted, &mut sticky); + self.sticky_nodes = sticky; // Drop nodes that are no longer fixed, and keep the rest. // @@ -735,6 +744,7 @@ impl BaseDocument { node_id: NodeId, under_transform: bool, out: &mut Vec, + sticky: &mut Vec, ) { let children = doc.nodes[node_id].layout_children.borrow().clone(); let Some(children) = children else { @@ -761,8 +771,10 @@ impl BaseDocument { continue; } - if !under_transform && styles.clone_position() == Position::Fixed { - out.push(child_id); + match styles.clone_position() { + Position::Fixed if !under_transform => out.push(child_id), + Position::Sticky => sticky.push(child_id), + _ => {} } collect_fixed( @@ -770,6 +782,7 @@ impl BaseDocument { child_id, under_transform || establishes_containing_block(&styles), out, + sticky, ); } } @@ -787,6 +800,236 @@ impl BaseDocument { } } + /// Hold every `position: sticky` box against the edge of its scrollport. + /// + /// A sticky box lays out in flow, reserving its space there, and is then + /// displaced so that it stays between its own flow position and the far + /// edge of its containing block. `stylo_taffy` maps `Position::Sticky` onto + /// `taffy::Position::Relative`, which gets the first half right and does + /// nothing at all about the second, so every sticky navbar and document + /// sidebar on the fleet simply scrolled away, on documents 5,000 to + /// 16,700px tall. + /// + /// The displacement is written straight into the box's + /// `final_layout().location` rather than kept beside it. Paint, hit testing + /// and `absolute_position` all read that one field, so writing it is what + /// makes them agree; a parallel offset would have to be threaded through + /// each of them and would disagree the moment one was missed. The cost is + /// that this pass has to be able to recover the flow position it started + /// from, which is what [`Self::sticky_offsets`] records. + /// + /// Runs after layout, which is the only point at which the boxes it reads + /// exist, and again after a scroll, because a wheel event does not + /// necessarily produce a style and layout pass. + /// + /// + pub(crate) fn resolve_sticky_positions(&mut self) { + if self.sticky_nodes.is_empty() && self.sticky_offsets.is_empty() { + return; + } + + // Put every box back at its flow position first. Recomputing from an + // already-displaced box would compound the offset, walking a header + // down the page one scroll at a time, and a node that stopped being + // sticky since the last pass is not in `sticky_nodes` to be corrected + // any other way. + for (&node_id, offset) in self.sticky_offsets.iter() { + let Some(node) = self.nodes.get_mut(node_id) else { + continue; + }; + let location = &mut node.final_layout_mut().location; + location.x -= offset.x; + location.y -= offset.y; + } + self.sticky_offsets.clear(); + + let nodes = std::mem::take(&mut self.sticky_nodes); + for &node_id in nodes.iter() { + let Some(offset) = self.sticky_offset_of(node_id) else { + continue; + }; + if offset.x == 0.0 && offset.y == 0.0 { + continue; + } + let location = &mut self.nodes[node_id].final_layout_mut().location; + location.x += offset.x; + location.y += offset.y; + self.sticky_offsets.insert(node_id, offset); + } + self.sticky_nodes = nodes; + } + + /// The displacement `node_id` needs to stay inside its sticky view + /// rectangle, given the boxes and scroll offsets as they stand. + /// + /// Every coordinate here is a painted document coordinate: + /// `absolute_position` has already applied each ancestor scroll offset, so + /// a box and the scrollport it is measured against are directly comparable. + /// The one scroll offset it does not apply is the viewport's, which paint + /// subtracts from the whole document, so a box that holds still on screen + /// is one whose document coordinate tracks the viewport scroll. + fn sticky_offset_of(&self, node_id: NodeId) -> Option> { + let node = self.nodes.get(node_id)?; + let styles = node.primary_styles()?; + if styles.clone_position() != Position::Sticky { + return None; + } + + let size = node.final_layout().size; + let base = node.absolute_position(0.0, 0.0); + + // The sticky view rectangle: the padding box of the nearest scroll + // container, or the viewport when there is none above it. + let (port_x, port_y, port_width, port_height) = match self.nearest_scrollport(node_id) { + Some(scroller_id) => { + let scroller = &self.nodes[scroller_id]; + let layout = *scroller.final_layout(); + let origin = scroller.absolute_position(0.0, 0.0); + ( + origin.x + layout.border.left, + origin.y + layout.border.top, + layout.size.width - layout.border.left - layout.border.right, + layout.size.height - layout.border.top - layout.border.bottom, + ) + } + None => { + let scale = self.viewport.scale(); + ( + self.viewport_scroll.x as f32, + self.viewport_scroll.y as f32, + self.viewport.window_size.0 as f32 / scale, + self.viewport.window_size.1 as f32 / scale, + ) + } + }; + + let insets = styles.get_position(); + // Percentages resolve against the sticky view rectangle, which is the + // rectangle the inset is measured inside. + let inset = |value: &style::values::computed::Inset, basis: f32| { + use style::values::generics::position::GenericInset as Inset; + match value { + Inset::LengthPercentage(value) => Some( + value + .resolve(style::values::computed::Length::new(basis)) + .px(), + ), + _ => None, + } + }; + + // Where the box may travel: its containing block, which is its layout + // parent's padding box, taken in the same painted coordinates. The + // parent's own scroll offset is subtracted because the region scrolls + // with the content, and the extent is widened to the scrollable content + // so that a sticky box whose parent *is* the scroller stays held for + // the whole scroll rather than only across one scrollport. + let parent = node + .layout_parent + .get() + .and_then(|parent_id| self.nodes.get(parent_id))?; + let (block_x, block_y, block_width, block_height) = { + let layout = *parent.final_layout(); + let origin = parent.absolute_position(0.0, 0.0); + let scroll = *parent.scroll_offset(); + ( + origin.x + layout.border.left - scroll.x as f32, + origin.y + layout.border.top - scroll.y as f32, + (layout.size.width - layout.border.left - layout.border.right) + .max(layout.content_size.width), + (layout.size.height - layout.border.top - layout.border.bottom) + .max(layout.content_size.height), + ) + }; + + let axis = |start: Option, + end: Option, + base: f32, + extent: f32, + port_start: f32, + port_extent: f32, + block_start: f32, + block_extent: f32| { + // `start` (top/left) pushes the box away from the near edge of the + // scrollport; `end` (bottom/right) pulls it back from the far one. + // A box can satisfy both at once when it is smaller than the + // rectangle between them; when it cannot, the near edge wins, so + // that constraint is applied second. + let mut offset = 0.0f32; + if let Some(end) = end { + offset = (port_start + port_extent - end - extent - base).min(0.0); + } + if let Some(start) = start { + let shift = (port_start + start - base).max(0.0); + if shift > 0.0 { + offset = shift; + } + } + + // Stickiness ends where the containing block does: a box pinned for + // ever would escape its own section and float over the next one. + let furthest = (block_start + block_extent - extent - base).max(0.0); + let nearest = (block_start - base).min(0.0); + offset.clamp(nearest, furthest) + }; + + Some(taffy::Point { + x: axis( + inset(&insets.left, port_width), + inset(&insets.right, port_width), + base.x, + size.width, + port_x, + port_width, + block_x, + block_width, + ), + y: axis( + inset(&insets.top, port_height), + inset(&insets.bottom, port_height), + base.y, + size.height, + port_y, + port_height, + block_y, + block_height, + ), + }) + } + + /// The nearest ancestor of `node_id` that scrolls its content, or `None` + /// when nothing between it and the root does and the viewport is the + /// scrollport. + /// + /// The root element is excluded deliberately: per the CSS overflow + /// propagation rules its overflow belongs to the viewport, which is also + /// why `scroll_node_by_has_changed` forwards a scroll that reaches it. + fn nearest_scrollport(&self, node_id: NodeId) -> Option { + use style::computed_values::overflow_x::T as Overflow; + + let root_id = self.try_root_element()?.id; + let mut current = self.nodes.get(node_id)?.layout_parent.get(); + while let Some(id) = current { + if id == root_id { + return None; + } + let ancestor = self.nodes.get(id)?; + if let Some(styles) = ancestor.primary_styles() { + let scrolls = |overflow| { + matches!( + overflow, + Overflow::Scroll | Overflow::Auto | Overflow::Hidden + ) + }; + if scrolls(styles.clone_overflow_x()) || scrolls(styles.clone_overflow_y()) { + return Some(id); + } + } + current = ancestor.layout_parent.get(); + } + None + } + /// Give each held fixed layer the offset that cancels its hoist. /// /// Paint draws a hoisted child at its stacking context root's origin, plus @@ -1058,6 +1301,12 @@ impl BaseDocument { taffy::compute_root_layout(self, root_element_id, available_space); taffy::round_layout(self, root_element_id); + // Rounding rewrites every location from taffy's own output, discarding + // the sticky displacements written into them along with everything + // else. Forgetting them here is what keeps `resolve_sticky_positions` + // able to treat the map as "what is currently baked into a box". + self.sticky_offsets.clear(); + // Table rows and row groups are flattened into a grid of cells and // never reach Taffy, so nothing wrote a layout for them at all. Describe // each from the cells it holds, after rounding: `final_layout` is what diff --git a/packages/stylo_taffy/src/convert.rs b/packages/stylo_taffy/src/convert.rs index a9c5e817..7eba6f4a 100644 --- a/packages/stylo_taffy/src/convert.rs +++ b/packages/stylo_taffy/src/convert.rs @@ -149,6 +149,28 @@ pub fn inset(val: &stylo::InsetVal) -> taffy::LengthPercentageAuto { } } +/// An inset, as the layout engine should see it for a box in `position`. +/// +/// Taffy applies the inset of a `Relative` box as a displacement of its flow +/// position. For `position: sticky` that is wrong twice over: the inset is a +/// threshold rather than an offset, so a box that has not been scrolled past +/// must not move at all, and the offset that does apply is measured from the +/// scrollport rather than from the flow position. `position: sticky; top: 24px` +/// was drawn 24px below where it belonged before anything had scrolled. +/// +/// So sticky boxes reach taffy with no inset, and the engine reads the authored +/// insets from the computed style when it applies the sticky adjustment. +#[inline] +pub fn inset_for_position( + position: stylo::Position, + val: &stylo::InsetVal, +) -> taffy::LengthPercentageAuto { + if matches!(position, stylo::Position::Sticky) { + return taffy::LengthPercentageAuto::AUTO; + } + self::inset(val) +} + #[inline] pub fn is_block(input: stylo::Display) -> bool { matches!(input.outside(), stylo::DisplayOutside::Block) @@ -226,9 +248,16 @@ pub fn position(input: stylo::Position) -> taffy::Position { stylo::Position::Relative => taffy::Position::Relative, stylo::Position::Static => taffy::Position::Relative, - // TODO: support position:fixed and sticky + // TODO: support position:fixed stylo::Position::Absolute => taffy::Position::Absolute, stylo::Position::Fixed => taffy::Position::Absolute, + + // A sticky box lays out in flow and reserves its space there, which is + // exactly what `Relative` does. The offset that holds it against the + // edge of its scrollport is not a layout property at all: it depends on + // a scroll position, so it is applied after layout by the embedder. + // See `inset_for_position` for the half of that which taffy must not + // do on its own. stylo::Position::Sticky => taffy::Position::Relative, } } @@ -698,11 +727,14 @@ pub fn to_taffy_style(style: &stylo::ComputedValues) -> taffy::Style { }, aspect_ratio: self::aspect_ratio(pos.aspect_ratio), - inset: taffy::Rect { - left: self::inset(&pos.left), - right: self::inset(&pos.right), - top: self::inset(&pos.top), - bottom: self::inset(&pos.bottom), + inset: { + let position = style.clone_position(); + taffy::Rect { + left: self::inset_for_position(position, &pos.left), + right: self::inset_for_position(position, &pos.right), + top: self::inset_for_position(position, &pos.top), + bottom: self::inset_for_position(position, &pos.bottom), + } }, margin: taffy::Rect { left: self::margin(&margin.margin_left), diff --git a/packages/stylo_taffy/src/wrapper.rs b/packages/stylo_taffy/src/wrapper.rs index aaebfa37..20940ef2 100644 --- a/packages/stylo_taffy/src/wrapper.rs +++ b/packages/stylo_taffy/src/wrapper.rs @@ -71,11 +71,12 @@ impl> taffy::CoreStyle for TaffyStyloStyle #[inline] fn inset(&self) -> taffy::Rect { let position_styles = self.0.get_position(); + let position = self.0.get_box().position; taffy::Rect { - left: convert::inset(&position_styles.left), - right: convert::inset(&position_styles.right), - top: convert::inset(&position_styles.top), - bottom: convert::inset(&position_styles.bottom), + left: convert::inset_for_position(position, &position_styles.left), + right: convert::inset_for_position(position, &position_styles.right), + top: convert::inset_for_position(position, &position_styles.top), + bottom: convert::inset_for_position(position, &position_styles.bottom), } } diff --git a/tests/blitz-tests/tests/sticky_position.rs b/tests/blitz-tests/tests/sticky_position.rs new file mode 100644 index 00000000..7e0a0105 --- /dev/null +++ b/tests/blitz-tests/tests/sticky_position.rs @@ -0,0 +1,332 @@ +//! `position: sticky` has to pin a box inside the scrollport it lives in. +//! +//! `stylo_taffy` mapped `Position::Sticky` onto `taffy::Position::Relative`, so +//! a sticky box laid out in flow and then scrolled away like any other. Every +//! sticky navbar and document sidebar on the fleet scrolled off the top of +//! documents 5,000 to 16,700px tall. +//! +//! Relative is the right *layout* answer: a sticky box takes its flow position +//! and reserves its space there. What was missing is the adjustment on top of +//! it, which the tests below pin down: nothing before the threshold, a pin +//! after it, and a release when the containing block leaves. + +use blitz_dom::DocumentConfig; +use blitz_html::{HtmlDocument, HtmlProvider}; +use blitz_traits::shell::{ColorScheme, Viewport}; +use std::sync::Arc; + +const VIEWPORT: (u32, u32) = (1000, 700); + +fn document(html: &str) -> HtmlDocument { + let mut doc = HtmlDocument::from_html( + html, + DocumentConfig { + viewport: Some(Viewport::new( + VIEWPORT.0, + VIEWPORT.1, + 1.0, + ColorScheme::Light, + )), + html_parser_provider: Some(Arc::new(HtmlProvider) as _), + ..Default::default() + }, + ); + doc.resolve(0.0); + doc +} + +/// Scroll the viewport down by `amount` CSS pixels. +/// +/// `scroll_viewport_by` takes a wheel delta, which points the other way. +fn scroll_down(doc: &mut HtmlDocument, amount: f64) { + doc.scroll_viewport_by(0.0, -amount); +} + +#[track_caller] +fn origin(doc: &HtmlDocument, id: &str) -> (f32, f32) { + let node_id = doc + .get_element_by_id(id) + .unwrap_or_else(|| panic!("no element with id {id}")); + let position = doc.tree()[node_id].absolute_position(0.0, 0.0); + (position.x, position.y) +} + +/// A sticky box that has not reached its threshold sits exactly where a +/// `position: relative` box with no insets would: at its flow position. +/// +/// The insets are a threshold, not an offset. Taffy applies the inset of a +/// `Relative` box as a displacement, so `position:sticky;top:24px` was drawn +/// 24px below its flow position before anything had scrolled at all. +#[test] +fn an_unreached_threshold_leaves_the_box_in_flow() { + let doc = document( + r#" +
+ +
+ "#, + ); + + assert_eq!( + origin(&doc, "header"), + (0.0, 100.0), + "an inset is a threshold, not a relative displacement" + ); +} + +/// The headline case: a sticky header on a document taller than the viewport. +#[test] +fn a_sticky_header_pins_once_the_page_scrolls_past_it() { + let mut doc = document( + r#" + +
+ "#, + ); + + assert_eq!(origin(&doc, "header"), (0.0, 0.0)); + + scroll_down(&mut doc, 800.0); + doc.resolve(0.0); + + // Document coordinates: paint subtracts the viewport scroll, so a box that + // stays at the top of the screen has to track it. + assert_eq!( + origin(&doc, "header"), + (0.0, 800.0), + "a sticky header must stay at the top of the scrollport" + ); + + scroll_down(&mut doc, 700.0); + doc.resolve(0.0); + assert_eq!(origin(&doc, "header"), (0.0, 1500.0)); +} + +/// A `top` inset is a distance from the top of the scrollport, so the pinned +/// position is the scroll offset plus the inset. +#[test] +fn a_top_inset_offsets_the_pinned_position() { + let mut doc = document( + r#" + +
+ "#, + ); + + scroll_down(&mut doc, 600.0); + doc.resolve(0.0); + + assert_eq!(origin(&doc, "header"), (0.0, 624.0)); +} + +/// Stickiness ends where the containing block does. A box pinned forever would +/// escape its own section and float over the next one. +#[test] +fn the_box_leaves_with_its_containing_block() { + let mut doc = document( + r#" +
+ +
+
+ "#, + ); + + // Inside the section, pinned to the top of the scrollport. + scroll_down(&mut doc, 400.0); + doc.resolve(0.0); + assert_eq!(origin(&doc, "header"), (0.0, 400.0)); + + // The last position at which the box still fits inside its section is + // 1000 - 50 = 950. + scroll_down(&mut doc, 550.0); + doc.resolve(0.0); + assert_eq!(origin(&doc, "header"), (0.0, 950.0)); + + // Past that it travels with the section rather than pinning forever. + scroll_down(&mut doc, 500.0); + doc.resolve(0.0); + assert_eq!( + origin(&doc, "header"), + (0.0, 950.0), + "a sticky box must not outlive its containing block" + ); +} + +/// A `bottom` inset pins against the bottom edge of the scrollport, which means +/// a box further down the document than the scrollport's bottom edge is pulled +/// *up* to sit on it, and released once the page scrolls far enough that its +/// flow position rises above the line. +#[test] +fn a_bottom_inset_pins_against_the_bottom_of_the_scrollport() { + let mut doc = document( + r#" +
+ + "#, + ); + + // The scrollport bottom is at document y=700, so the box's own bottom is + // held there and its top lands at 650 rather than its flow position 5000. + assert_eq!(origin(&doc, "footer"), (0.0, 650.0)); + + scroll_down(&mut doc, 400.0); + doc.resolve(0.0); + assert_eq!(origin(&doc, "footer"), (0.0, 1050.0)); + + // The document is 5050 tall, so 4350 is the last scroll position. There the + // flow position and the pinned position coincide and the box is released. + scroll_down(&mut doc, 3950.0); + doc.resolve(0.0); + assert_eq!(origin(&doc, "footer"), (0.0, 5000.0)); +} + +/// Horizontal stickiness works the same way against a horizontally scrolling +/// container. +#[test] +fn a_left_inset_pins_horizontally() { + let mut doc = document( + r#" +
+
+
+
+
+ "#, + ); + + // Flow position 100 is to the right of the threshold at x=10, so nothing + // moves. + assert_eq!(origin(&doc, "label"), (100.0, 0.0)); + + let scroller = doc.get_element_by_id("scroller").unwrap(); + doc.scroll_node_by(scroller, -250.0, 0.0, |_| {}); + doc.resolve(0.0); + + // The scroller's content has moved 250px left, taking the flow position to + // -150, so the label is held 10px from the scrollport's left edge. + assert_eq!(origin(&doc, "label"), (10.0, 0.0)); +} + +/// A `right` inset is the mirror of `left`: the box is pulled back towards the +/// left edge of the scrollport as content to its right scrolls into view. +#[test] +fn a_right_inset_pins_against_the_right_of_the_scrollport() { + let doc = document( + r#" +
+
+
+
+
+ "#, + ); + + // The scrollport's right edge is at x=400, so the box's own right edge is + // held at 380 and its left edge lands at 320, rather than at its flow + // position of 2000. + assert_eq!(origin(&doc, "label"), (320.0, 0.0)); +} + +/// A sticky box in a nested scroller sticks to *that* scrollport, not to the +/// viewport. The document is not scrolled here at all. +#[test] +fn stickiness_is_relative_to_the_nearest_scrollport() { + let mut doc = document( + r#" +
+
+ +
+
+ "#, + ); + + assert_eq!(origin(&doc, "header"), (0.0, 100.0)); + + let scroller = doc.get_element_by_id("scroller").unwrap(); + doc.scroll_node_by(scroller, 0.0, -500.0, |_| {}); + doc.resolve(0.0); + + // The scroller's box starts at y=100 and is not itself scrolled by the + // page, so its scrollport top stays at 100. + assert_eq!( + origin(&doc, "header"), + (0.0, 100.0), + "a sticky box in a scroller pins to that scroller, not to the viewport" + ); +} + +/// The adjustment has to survive a relayout that does not move anything, and +/// must not accumulate: applying it twice against an already-adjusted box would +/// walk the header down the page one scroll at a time. +#[test] +fn repeated_resolves_do_not_accumulate_the_offset() { + let mut doc = document( + r#" + +
+ "#, + ); + + scroll_down(&mut doc, 900.0); + for _ in 0..5 { + doc.resolve(0.0); + assert_eq!(origin(&doc, "header"), (0.0, 900.0)); + } + + // The non-incremental path rebuilds every box from scratch each pass. + doc.set_incremental_layout(false); + for _ in 0..5 { + doc.resolve(0.0); + assert_eq!(origin(&doc, "header"), (0.0, 900.0)); + } +} + +/// A scroll on its own, with no intervening resolve, still has to move the box: +/// a wheel event does not necessarily produce a full style and layout pass. +#[test] +fn a_scroll_alone_repositions_the_box() { + let mut doc = document( + r#" + +
+ "#, + ); + + scroll_down(&mut doc, 300.0); + assert_eq!(origin(&doc, "header"), (0.0, 300.0)); +} + +/// Hit testing has to follow the box. A pinned header is the thing under the +/// pointer at the top of the screen, not whatever content scrolled beneath it. +#[test] +fn a_pinned_header_takes_the_hit() { + let mut doc = document( + r#" + +
+ "#, + ); + + scroll_down(&mut doc, 800.0); + doc.resolve(0.0); + + let header = doc.get_element_by_id("header").unwrap(); + // Hit tests take page coordinates. Screen point (20, 10) is page point + // (20, 810) once the scroll is added back, which is inside the pinned + // header. + let hit_node = doc + .hit(20.0, 810.0) + .expect("nothing under the pointer") + .node_id; + + let mut ancestor = Some(hit_node); + while let Some(id) = ancestor { + if id == header { + return; + } + ancestor = doc.tree()[id].parent; + } + panic!("expected the pinned header under the pointer, got node {hit_node:?}"); +} From 299e5a7fa85a168a98a08472c2605d90eb487512 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 21:25:46 +0700 Subject: [PATCH 02/15] fix(layout): stop a position:fixed box scrolling away with the page Paint translates the whole tree by the negated viewport scroll, and a hoisted fixed layer sits in that tree like everything else, so a fixed box held its document position and left the screen exactly like flow content. Measured: an overlay authored `top: 0`, on a page scrolled 800px down, painted at screen y=-800. Its document position has to track the scroll for its screen position to hold still, which is the whole of the correction. Applied in the same shape as the sticky adjustment and next to it, because it is the same kind of thing: a displacement that depends on a scroll position rather than on layout, written into the box so that paint, hit testing and client rects cannot disagree about it. One value for the document rather than one per node, since every viewport-anchored box shares the same containing block and therefore the same displacement. Boxes under a transformed ancestor are untouched: that ancestor is their containing block, so they are not viewport-anchored, and the hoist walk already leaves them alone. This does not give a fixed box the containing block CSS asks for. That is still the root element, which takes its height from its content, so `bottom` and `inset` resolve against the document. Reproduced on the reported case: a fixed panel with `bottom: 0; height: 816px` on a 7,934px document lays out at y=7118, which is where a chat panel was found "opening off screen", and `inset: 0` sizes 1000x7934 rather than 1000x700. Closing that needs an initial containing block distinct from the root element, which `opposite_insets_should_size_against_the_viewport` has recorded as an ignored test since the hoist landed. --- packages/blitz-dom/src/document.rs | 23 +++++- packages/blitz-dom/src/resolve.rs | 50 +++++++++++- tests/blitz-tests/tests/fixed_position.rs | 97 +++++++++++++++++++++++ 3 files changed, 165 insertions(+), 5 deletions(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 3c17e1e2..37208a89 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -255,6 +255,21 @@ pub struct BaseDocument { /// paints beneath every background between them and disappears. pub(crate) hoisted_fixed_parents: HashMap, + /// Every `position: fixed` node whose containing block is the viewport, + /// which is every one of them except those under a transformed ancestor. + /// + /// Collected by the walk that hoists them, and used by + /// `resolve_fixed_positions` to hold them still while the page scrolls. + pub(crate) fixed_nodes: Vec, + + /// The viewport scroll currently baked into those nodes' locations. + /// + /// One value for the whole document rather than one per node: the pin is + /// the same displacement for every fixed box, because they all share the + /// viewport as their containing block. Reset by `resolve_layout`, which + /// rewrites the locations it was added to. + pub(crate) fixed_scroll_offset: crate::Point, + /// Every `position: sticky` node in the document, in tree order. /// /// Collected by the same walk that hoists fixed nodes, because both need @@ -549,6 +564,8 @@ impl BaseDocument { let mut doc = Self { hoisted_fixed_parents: HashMap::new(), + fixed_nodes: Vec::new(), + fixed_scroll_offset: crate::Point::ZERO, sticky_nodes: Vec::new(), sticky_offsets: HashMap::new(), hoisted_clip_hosts: Vec::new(), @@ -2828,9 +2845,11 @@ impl BaseDocument { let has_changed = self.viewport_scroll != initial; if has_changed { // The viewport is the scrollport a page-level sticky box is held - // against, so its boxes move with this and not with the next - // relayout. See `resolve_sticky_positions`. + // against, and the containing block a fixed box is pinned to, so + // both move with this and not with the next relayout. See + // `resolve_sticky_positions` and `resolve_fixed_positions`. self.resolve_sticky_positions(); + self.resolve_fixed_positions(); } has_changed } diff --git a/packages/blitz-dom/src/resolve.rs b/packages/blitz-dom/src/resolve.rs index a3a97e54..78ab8b56 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -291,6 +291,7 @@ impl BaseDocument { #[cfg(target_arch = "wasm32")] self.resolve_layout(); self.resolve_sticky_positions(); + self.resolve_fixed_positions(); self.resolve_hoisted_positions(); self.correct_hoisted_fixed_positions(); self.resolve_hoisted_clips(); @@ -313,6 +314,7 @@ impl BaseDocument { self.flush_styles_to_layout(root_node_id); self.resolve_layout(); self.resolve_sticky_positions(); + self.resolve_fixed_positions(); self.resolve_hoisted_positions(); self.correct_hoisted_fixed_positions(); self.resolve_hoisted_clips(); @@ -699,6 +701,7 @@ impl BaseDocument { let mut sticky: Vec = Vec::new(); collect_fixed(self, root_id, false, &mut hoisted, &mut sticky); self.sticky_nodes = sticky; + self.fixed_nodes = hoisted.clone(); // Drop nodes that are no longer fixed, and keep the rest. // @@ -800,6 +803,45 @@ impl BaseDocument { } } + /// Hold every viewport-anchored `position: fixed` box still while the page + /// scrolls under it. + /// + /// Paint translates the whole tree by the negated viewport scroll, and a + /// hoisted fixed layer is in that tree like everything else, so a fixed box + /// held its document position and left the screen exactly like flow + /// content: an overlay authored `top: 0` painted at screen y=-800 on a page + /// scrolled 800px down. Its document position has to track the scroll for + /// its screen position to hold still, which is the whole of the correction. + /// + /// Boxes under a transformed ancestor are left alone: that ancestor is + /// their containing block, so they are not viewport-anchored at all and + /// `hoist_fixed_position_nodes` does not collect them. + /// + /// This does not give a fixed box the containing block CSS asks for. That + /// is still the root element, which takes its height from its content, so + /// `bottom` and `inset` resolve against the document rather than against a + /// viewport-sized initial containing block. Closing that needs an ICB node + /// distinct from the root element, which `fixed_position.rs` records as an + /// ignored test. + pub(crate) fn resolve_fixed_positions(&mut self) { + let scroll = self.viewport_scroll; + if scroll == self.fixed_scroll_offset { + return; + } + let delta_x = (scroll.x - self.fixed_scroll_offset.x) as f32; + let delta_y = (scroll.y - self.fixed_scroll_offset.y) as f32; + + for &node_id in self.fixed_nodes.iter() { + let Some(node) = self.nodes.get_mut(node_id) else { + continue; + }; + let location = &mut node.final_layout_mut().location; + location.x += delta_x; + location.y += delta_y; + } + self.fixed_scroll_offset = scroll; + } + /// Hold every `position: sticky` box against the edge of its scrollport. /// /// A sticky box lays out in flow, reserving its space there, and is then @@ -1302,10 +1344,12 @@ impl BaseDocument { taffy::round_layout(self, root_element_id); // Rounding rewrites every location from taffy's own output, discarding - // the sticky displacements written into them along with everything - // else. Forgetting them here is what keeps `resolve_sticky_positions` - // able to treat the map as "what is currently baked into a box". + // the sticky and fixed displacements written into them along with + // everything else. Forgetting them here is what keeps + // `resolve_sticky_positions` and `resolve_fixed_positions` able to + // treat what they hold as "what is currently baked into a box". self.sticky_offsets.clear(); + self.fixed_scroll_offset = crate::Point::ZERO; // Table rows and row groups are flattened into a grid of cells and // never reach Taffy, so nothing wrote a layout for them at all. Describe diff --git a/tests/blitz-tests/tests/fixed_position.rs b/tests/blitz-tests/tests/fixed_position.rs index 0609537d..e04a4eba 100644 --- a/tests/blitz-tests/tests/fixed_position.rs +++ b/tests/blitz-tests/tests/fixed_position.rs @@ -179,3 +179,100 @@ fn fixed_descendant_of_flow_content_is_viewport_relative() { assert_eq!((rect.x, rect.y), (0.0, 0.0)); assert_eq!((rect.width, rect.height), (1344.0, 900.0)); } + +/// A fixed box must not scroll with the document. +/// +/// Paint translates the whole tree by the negated viewport scroll, hoisted +/// fixed layers included, so a fixed box held its document position and left +/// the screen exactly like flow content: an overlay `top: 0` on a page scrolled +/// 800px down painted at screen y=-800. Its document position has to track the +/// scroll for its screen position to hold still. +#[test] +fn a_fixed_box_holds_its_place_when_the_page_scrolls() { + let mut doc = document( + r#" +
+
+ "#, + ); + + assert_box(&doc, "bar", (0.0, 0.0, 100.0, 50.0)); + + doc.scroll_viewport_by(0.0, -800.0); + doc.resolve(0.0); + + // Paint subtracts the viewport scroll from every box, so a box that stays + // on screen is one whose document coordinate tracks it. + assert_box(&doc, "bar", (0.0, 800.0, 100.0, 50.0)); + + // Which is the same statement in viewport coordinates, where a client rect + // is what a page reads. + let bar = doc.get_element_by_id("bar").unwrap(); + let rect = doc.get_client_bounding_rect(bar).unwrap(); + assert_eq!((rect.x, rect.y), (0.0, 0.0)); +} + +/// The pin has to be recomputed, not accumulated: applying it twice would walk +/// a fixed overlay down the page one scroll at a time. +#[test] +fn scrolling_twice_does_not_accumulate_the_pin() { + let mut doc = document( + r#" +
+
+ "#, + ); + + for _ in 0..4 { + doc.scroll_viewport_by(0.0, -200.0); + doc.resolve(0.0); + } + assert_box(&doc, "bar", (0.0, 800.0, 100.0, 50.0)); + + doc.scroll_viewport_by(0.0, 800.0); + doc.resolve(0.0); + assert_box(&doc, "bar", (0.0, 0.0, 100.0, 50.0)); +} + +/// A scroll on its own, with no resolve after it, still has to move the box. +#[test] +fn a_scroll_alone_pins_a_fixed_box() { + let mut doc = document( + r#" +
+
+ "#, + ); + + doc.scroll_viewport_by(0.0, -300.0); + assert_box(&doc, "bar", (0.0, 300.0, 100.0, 50.0)); +} + +/// A fixed box under a transformed ancestor is positioned against that ancestor +/// rather than the viewport, so it scrolls with the page like any other content. +#[test] +fn a_fixed_box_under_a_transform_is_not_pinned() { + let mut doc = document( + r#" +
+
+
+
+ "#, + ); + + let before = { + let nested = doc.get_element_by_id("nested").unwrap(); + doc.tree()[nested].absolute_position(0.0, 0.0).y + }; + + doc.scroll_viewport_by(0.0, -400.0); + doc.resolve(0.0); + + let nested = doc.get_element_by_id("nested").unwrap(); + assert_eq!( + doc.tree()[nested].absolute_position(0.0, 0.0).y, + before, + "a transformed ancestor is the containing block, so its fixed descendants scroll with it" + ); +} From 6dfa38b5cd1725e1529e534b0760cffca5d5a505 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 20:56:20 +0700 Subject: [PATCH 03/15] feat(dom): give now reports its first option rather than an empty value. Construction is idempotent the way create_checkbox_input is, so a selection survives the next resolve; only the option count is refreshed, which is how a script-added option gets an entry. Positions rather than node ids: selectedIndex is the spec's own handle and a position cannot dangle once the option is removed. The list of options is recomputed from the subtree on every read, which flattens for free. --- packages/blitz-dom/src/accessibility.rs | 27 ++ packages/blitz-dom/src/layout/construct.rs | 26 ++ packages/blitz-dom/src/lib.rs | 3 +- packages/blitz-dom/src/mutator.rs | 44 +++ packages/blitz-dom/src/node/element.rs | 30 +- packages/blitz-dom/src/node/mod.rs | 2 + packages/blitz-dom/src/node/select.rs | 106 ++++++ packages/blitz-dom/src/select.rs | 350 ++++++++++++++++++ .../blitz-tests/tests/select_accessibility.rs | 167 +++++++++ 9 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 packages/blitz-dom/src/node/select.rs create mode 100644 packages/blitz-dom/src/select.rs create mode 100644 tests/blitz-tests/tests/select_accessibility.rs diff --git a/packages/blitz-dom/src/accessibility.rs b/packages/blitz-dom/src/accessibility.rs index cbd5aefc..9b0421c5 100644 --- a/packages/blitz-dom/src/accessibility.rs +++ b/packages/blitz-dom/src/accessibility.rs @@ -136,6 +136,33 @@ impl BaseDocument { }; builder.set_role(role); + + /* + * A select and its options carried their roles and nothing else, so + * the tree said "there is a combo box here" and stopped. What it + * offers, what is chosen, and which option that is were all absent, + * which is exactly the set of questions a QA harness asks of a + * picker before it can drive one. + * + * The options are already in the tree: the traversal walks raw + * children, so `option { display: none }` does not hide them. Only + * the state was missing. + */ + match &*name { + "select" => { + builder.set_value(self.select_label(node.id)); + } + "option" => { + // An explicit label, rather than relying on the text child + // labelling its parent: an option's text is `display: none` + // and a consumer that resolves names through the child text + // runs would be reading a node that never gets laid out. + builder.set_label(self.option_label(node.id)); + builder.set_selected(self.option_is_selected(node.id)); + } + _ => {} + } + builder.set_html_tag(name); } else if node.is_text_node() { builder.set_role(Role::TextRun); diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 101f0d08..1e37f020 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -501,6 +501,14 @@ pub(crate) fn collect_layout_children( } } + // A select has no in-flow content of its own: `option { display: none }` + // in the user-agent sheet sees to that, so returning here costs nothing + // and keeps the options out of the box the control occupies. + if tag_name == "select" { + create_select(doc, container_node_id); + return; + } + #[cfg(feature = "svg")] if matches!(tag_name, "svg") { // Serialised rather than `outer_html`, so that symbols referenced @@ -1118,6 +1126,24 @@ fn create_checkbox_input(doc: &mut BaseDocument, input_element_id: NodeId) { } } +fn create_select(doc: &mut BaseDocument, select_element_id: NodeId) { + // Read before the node is borrowed mutably: the seed comes from the + // options, which are other nodes. + let initial = doc.initial_select_data(select_element_id); + let option_count = initial.len(); + + let node = &mut doc.nodes[select_element_id]; + let element = &mut node.data.downcast_element_mut().unwrap(); + match element.special_data { + // Construction runs again on every resolve. Re-seeding would put the + // control back to its parsed state on the next frame, so a selection + // made by the user or by script would survive exactly until anything + // else on the page changed. Only the option count is refreshed. + SpecialElementData::Select(ref mut data) => data.resize(option_count), + _ => element.special_data = SpecialElementData::Select(initial), + } +} + /// Find and return the "layout_children" (inline boxes) for an inline layout /// without actually constructing the layout. This allows us to defer the expensive /// construction of the Parley layout (which invokes text shaping) to a paralell phase. diff --git a/packages/blitz-dom/src/lib.rs b/packages/blitz-dom/src/lib.rs index 6b83f9c8..70570688 100644 --- a/packages/blitz-dom/src/lib.rs +++ b/packages/blitz-dom/src/lib.rs @@ -53,6 +53,7 @@ mod mutator; pub mod paint_damage; mod query_selector; mod resolve; +mod select; mod selection; #[cfg(feature = "shadow-dom")] mod shadow; @@ -98,7 +99,7 @@ pub use markup5ever::{ namespace_prefix, namespace_url, ns, }; pub use mutator::DocumentMutator; -pub use node::{Attribute, DocumentData, ElementData, Node, NodeData, TextNodeData}; +pub use node::{Attribute, DocumentData, ElementData, Node, NodeData, SelectData, TextNodeData}; pub use paint_damage::PaintDamage; // Re-exported because `PaintDamage` takes and returns `kurbo::Rect` across the // crate boundary. A consumer that pulls kurbo in itself and lands on a diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index 4b6ef2c0..84dc62fd 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -497,6 +497,49 @@ impl DocumentMutator<'_> { || (tag, attr) == tag_and_attr!("iframe", "srcdoc") { self.load_iframe(node_id); + } else if (tag, attr) == tag_and_attr!("option", "selected") { + // `selected` is an HTML boolean attribute: present means selected, + // whatever the value reads. The same trap `checked` fell into, where + // `selected="false"` selected the option. + // + // Selectedness lives on the owning select once that has been + // constructed, and construction is idempotent, so writing the + // attribute alone would land nowhere anything reads. Before + // construction the attribute is the only carrier and seeds the + // state on the next resolve, which is why this is allowed to do + // nothing at all. + self.set_option_selected_state(node_id, true); + } + } + + /// Push an option's selectedness into the owning select's live state, if + /// that state exists yet. + fn set_option_selected_state(&mut self, option_id: NodeId, selected: bool) { + let Some(select_id) = self.doc.option_owner_select(option_id) else { + return; + }; + let Some(index) = self + .doc + .select_options(select_id) + .iter() + .position(|id| *id == option_id) + else { + return; + }; + let changed = if selected { + self.doc.set_select_selected_index(select_id, index) + } else { + self.doc + .get_node_mut(select_id) + .and_then(|node| node.data.downcast_element_mut()) + .and_then(|el| el.select_data_mut()) + .is_some_and(|data| data.set_selected(index, false)) + }; + if changed { + // `option:checked` is matched from this state, so the restyle has + // to be asked for here or the change is invisible to CSS. + self.doc.snapshot_node(option_id); + self.doc.snapshot_node(select_id); } } @@ -1392,6 +1435,7 @@ impl<'doc> DocumentMutator<'doc> { SpecialElementData::TableRoot(_) => {} SpecialElementData::TextInput(_) => {} SpecialElementData::CheckboxInput(_) => {} + SpecialElementData::Select(_) => {} #[cfg(feature = "file-input")] SpecialElementData::FileInput(_) => {} SpecialElementData::None => {} diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index 15075fb8..49061f72 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -32,7 +32,7 @@ use super::stylo_data::StyloData; use super::{Attribute, Attributes}; use crate::Document; use crate::layout::table::TableContext; -use crate::node::{TextBrush, TextInputData, TextLayout}; +use crate::node::{SelectData, TextBrush, TextInputData, TextLayout}; #[cfg(feature = "shadow-dom")] use super::custom_element::CustomElementData; @@ -383,6 +383,7 @@ pub enum SpecialElementType { TableRoot, TextInput, CheckboxInput, + Select, #[cfg(feature = "file-input")] FileInput, #[default] @@ -412,6 +413,8 @@ pub enum SpecialElementData { TextInput(TextInputData), /// Checkbox checked state CheckboxInput(bool), + /// A \ element's selectedness and open state + Select(SelectData), /// Selected files #[cfg(feature = "file-input")] FileInput(FileData), @@ -434,6 +437,7 @@ impl Clone for SpecialElementData { Self::TableRoot(data) => Self::TableRoot(data.clone()), Self::TextInput(data) => Self::TextInput(data.clone()), Self::CheckboxInput(data) => Self::CheckboxInput(*data), + Self::Select(data) => Self::Select(data.clone()), #[cfg(feature = "file-input")] Self::FileInput(data) => Self::FileInput(data.clone()), Self::None => Self::None, @@ -682,6 +686,27 @@ impl ElementData { } } + /// The live state of a `` element. + +/// The selectedness of a `` can +/// have any number of options selected at once, and a single index would make +/// the multiple case unrepresentable rather than merely unsupported. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SelectData { + selected: Vec, + open: bool, +} + +impl SelectData { + /// Seed the state with one entry per option, in the select's list order. + pub fn new(selected: Vec) -> Self { + Self { + selected, + open: false, + } + } + + /// Grow or shrink to `len` options, keeping the selectedness of the options + /// that are still there. + /// + /// Layout construction runs again on every resolve, so this is the only + /// place a script-added `