Skip to content

feat(engine): position:sticky, a working <select>, and a fixed box that stops scrolling away - #94

Closed
pathscale wants to merge 6 commits into
masterfrom
feat/engine-sticky-and-select
Closed

feat(engine): position:sticky, a working <select>, and a fixed box that stops scrolling away#94
pathscale wants to merge 6 commits into
masterfrom
feat/engine-sticky-and-select

Conversation

@pathscale

Copy link
Copy Markdown
Owner

The two features PR #93 deferred, plus the position: fixed question that came with them.

#93 merged while this was in flight, so this branches off current master rather than continuing #93's branch. Six commits, 45 new tests, 883 passing overall against 838 on master.

position: sticky

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 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:24px was 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_positions runs after layout and after a scroll, and writes the displacement 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 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, no change, no option in the semantic tree. Three of the four priorities are done.

  1. Options reach the semantic tree with their accessible names, each option's selected state, and the select's current value. SpecialElementData::Select(SelectData) holds selectedness as a Vec<bool> indexed by position, because selectedIndex is the spec's own handle and a position cannot dangle the way a stored node id can.
  2. A selection can be made and observed. select.value, selectedIndex, options, option.selected on the script side, all live-state-then-snapshot so :checked invalidation 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.
  3. Keyboard selection works. ArrowUp/Down stepping over disabled options, Home/End, input followed by change, and an arrow key claimed by a focused select rather than scrolling the page.
  4. A visible popup is NOT included. See below.

Thirty tests across select_accessibility.rs, select_selection.rs, select_keyboard.rs, select_layout.rs and blitz-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 label attribute wins over the element's hidden text and internal whitespace is collapsed.

position: fixed

Asked 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: 816px on a 7,934px document lays out at y=7118, which is exactly where a chat panel was found "opening off screen", and inset: 0 sizes 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, which opposite_insets_should_size_against_the_viewport has 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: 0 painted 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 --check and cargo clippy --workspace -- -D warnings all green. No Cargo.lock.

meh 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.
@pathscale

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant