feat(engine): position:sticky, a working <select>, and a fixed box that stops scrolling away - #94
Closed
pathscale wants to merge 6 commits into
Closed
feat(engine): position:sticky, a working <select>, and a fixed box that stops scrolling away#94pathscale wants to merge 6 commits into
pathscale wants to merge 6 commits into
Conversation
added 6 commits
September 9, 2026 21:29
`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.
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.
… tree There was no notion of selectedness anywhere in the engine. An <option> was a display:none node carrying a `selected` attribute nobody read, so a select could be measured and pressed but could not say what it offered or what was chosen. Its options reached the accessibility tree with a role and nothing else: no label, no selected state, and no value on the select itself. A QA harness driving worktables.dev's schema designer had nothing to assert against. SpecialElementData::Select(SelectData) holds one selectedness flag per option, seeded at layout construction from the `selected` content attributes and from HTML's "ask for a reset" step, which is why a plain <select> 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 <optgroup> for free.
…bmittable Three ways in, none of which worked. `handle_click` had no arm for a select, so the walk fell through to the no-match tail, which calls `clear_focus()`. Pressing a select actively unfocused the page, and the keyboard handler is gated on focus, so the arrows could not drive a control the user had just pressed. `checkable_activation_target` gets the matching arm, because the two have to agree about where the walk ends. Form submission had a standing TODO exactly where a select's serialization belongs, so a select fell through to the generic tail and submitted its own literal `value` attribute, which a select does not have. Every form containing a picker posted the wrong body. HTMLSelectElement existed only as an instanceof brand. `select.value` read an attribute that is never there and reported the empty string; there was no selectedIndex, no options, and no option.selected. Those go on the shared Element prototype behind a tag guard, the way `value` already branches on text_input_data(), and they write live state then snapshot, which is the rule set_checked's comment lays down.
…en it does There was no keyboard activation for anything but a text input, so a focussed picker could not be driven at all. The arrows never even reached the keyboard handler: the KeyDown default action scrolls the page on ArrowUp, ArrowDown, Home and End and then returns, and `scroll_key_is_claimed_by`, the one way past it, tested only for a text input. A press on a select scrolled the document instead of choosing anything. ArrowUp and ArrowDown step over disabled options and do not wrap; Home and End go to the first and last selectable option. A selection that does not move dispatches nothing, so a `change` listener does not fire on a no-op. The keystroke is the commit, so `input` fires immediately and blitz-script synthesises `change` from it, the way it already does for a checkbox. A select does not wait for blur the way a text control does. The QA case this unblocks: worktables.dev's schema designer picks its column type and index backend by keyboard or not at all, which is why two composite primary key rules there were implemented but untestable.
Still a character count: the options never reach layout, so nothing shapes them and there is no real measurement to be had. What it counts changes. `text_content().trim()` measured a labelled option's element text, which is the one string the control never shows, and `trim()` only strips the ends, so a label whose own words were split across source lines counted the newline and the indentation before the next word. A picker with a `label` attribute came out sized to hidden markup; one formatted across lines came out wider than the same picker written on one line.
Owner
Author
|
Consolidated into #95. Its head contains this entire branch as an ancestor; the sticky, fixed-position and select changes remain preserved there. Release validation continues on that single PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The two features PR #93 deferred, plus the
position: fixedquestion that came with them.#93 merged while this was in flight, so this branches off current
masterrather than continuing #93's branch. Six commits, 45 new tests, 883 passing overall against 838 onmaster.position: stickystylo_taffymappedPosition::Stickyontotaffy::Position::Relativeand 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 a relative box's inset as a displacement of its flow position, and a sticky inset is a threshold rather than an offset, so
position:sticky;top:24pxwas drawn 24px below where it belonged before anything had scrolled. Sticky boxes now reach taffy with no inset and the engine reads the authored values back from the computed style.resolve_sticky_positionsruns after layout and after a scroll, and writes the displacement into the box'sfinal_layoutlocation. Paint, hit testing andabsolute_positionall read that one field, so writing it is what makes them agree; a parallel offset would have to be threaded through each and would disagree the moment one was missed.Both halves of the 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. The scrollport is the nearest scrolling ancestor's padding box, or the viewport when nothing between the box and the root scrolls.
Eleven tests in
tests/blitz-tests/tests/sticky_position.rs, each verified failing first.<select>as a real widget#93 gave
<select>a layout box. Nothing else worked: no state, no events, nochange, no option in the semantic tree. Three of the four priorities are done.SpecialElementData::Select(SelectData)holds selectedness as aVec<bool>indexed by position, becauseselectedIndexis the spec's own handle and a position cannot dangle the way a stored node id can.select.value,selectedIndex,options,option.selectedon the script side, all live-state-then-snapshot so:checkedinvalidation fires; form submission serializes the selected options, skipping disabled ones; clicking a select now focuses it rather than clearing focus, which is what it did before.inputfollowed bychange, and an arrow key claimed by a focused select rather than scrolling the page.Thirty tests across
select_accessibility.rs,select_selection.rs,select_keyboard.rs,select_layout.rsandblitz-script/tests/select_{properties,change}.rs, each verified failing first.Sizing is still a character-count estimate, because options never reach layout and so nothing shapes them. It now counts the resolved option label rather than raw text, so a
labelattribute wins over the element's hidden text and internal whitespace is collapsed.position: fixedAsked as a question, answered by measurement: it resolves against the document, not the viewport, on both counts.
Reproduced on the reported case. A fixed panel with
bottom: 0; height: 816pxon a 7,934px document lays out at y=7118, which is exactly where a chat panel was found "opening off screen", andinset: 0sizes 1000x7934 rather than 1000x700. That half is the containing block: a fixed box is hoisted onto the root element, which takes its height from its content. Closing it needs an initial containing block distinct from the root element, whichopposite_insets_should_size_against_the_viewporthas recorded as an ignored test since the hoist landed. Not fixed here.The other half is fixed here. Paint translates the whole tree by the negated viewport scroll, hoisted fixed layers included, so an overlay authored
top: 0painted at screen y=-800 on a page scrolled 800px down. Its document position now tracks the scroll, in the same shape and next to the sticky adjustment. Boxes under a transformed ancestor are untouched: that ancestor is their containing block.Four tests appended to
tests/blitz-tests/tests/fixed_position.rs, three of them verified failing first (the fourth is the transform control, which already passed).Checks
cargo build --workspace,cargo test --workspace,cargo fmt --checkandcargo clippy --workspace -- -D warningsall green. NoCargo.lock.