Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/blitz-dom/src/accessibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
74 changes: 71 additions & 3 deletions packages/blitz-dom/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,41 @@ pub struct BaseDocument {
/// paints beneath every background between them and disappears.
pub(crate) hoisted_fixed_parents: HashMap<NodeId, NodeId>,

/// 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<NodeId>,

/// 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<f64>,

/// 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<NodeId>,

/// 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<NodeId, taffy::Point<f32>>,

/// Stacking contexts holding a hoisted child that an ancestor clips.
///
/// Collected while flushing styles so that `resolve_hoisted_clips` visits
Expand Down Expand Up @@ -529,6 +564,10 @@ 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(),
id,
tx,
Expand Down Expand Up @@ -2589,7 +2628,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<F: FnMut(DomEvent)>(
&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<F: FnMut(DomEvent)>(
&mut self,
node_id: NodeId,
x: f64,
Expand Down Expand Up @@ -2653,7 +2712,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)
};
Expand Down Expand Up @@ -2743,7 +2802,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;
Expand Down Expand Up @@ -2783,7 +2842,16 @@ 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, 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
}

pub fn scroll_by(
Expand Down
62 changes: 62 additions & 0 deletions packages/blitz-dom/src/events/keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ pub(crate) fn handle_key_or_input_event<F: FnMut(DomEvent)>(
return;
}

// A focussed select owns the arrows, Home and End: they move the
// selection. Checked before the mutable borrow below, because deciding
// where a select's selection goes means reading its options, which are
// other nodes.
//
// There was no keyboard activation for anything but a text input, so a
// picker could be focussed and then not driven at all. A harness has no
// other way in: worktables.dev's schema designer selects its column
// type by keyboard or not at all.
if doc
.get_node(node_id)
.is_some_and(|node| node.data.is_element_with_tag_name(&local_name!("select")))
{
if let KeyboardOrTextInputEvent::KeyPress(key_event) = &event {
handle_select_keypress(doc, node_id, key_event, dispatch_event);
}
return;
}

let node = &mut doc.nodes[node_id];
let Some(element_data) = node.element_data_mut() else {
return;
Expand Down Expand Up @@ -116,6 +135,49 @@ pub(crate) fn handle_key_or_input_event<F: FnMut(DomEvent)>(
}
}

/// Move a focussed select's selection with the arrows, Home and End.
///
/// A select commits immediately, the way a checkbox does and unlike a text
/// control: the keystroke *is* the commit, so `input` fires here and
/// blitz-script synthesises the `change` that follows it. Waiting for blur, as
/// a text field does, would leave a picker that never reports what was chosen.
fn handle_select_keypress<F: FnMut(DomEvent)>(
doc: &mut BaseDocument,
node_id: NodeId,
event: &BlitzKeyEvent,
mut dispatch_event: F,
) {
if !event.state.is_pressed() {
return;
}

let target_index = match &event.key {
Key::ArrowDown => doc.select_index_step(node_id, true),
Key::ArrowUp => doc.select_index_step(node_id, false),
Key::Home => doc.select_index_edge(node_id, false),
Key::End => doc.select_index_edge(node_id, true),
_ => return,
};
let Some(index) = target_index else {
// Already at the end of the list, or nothing selectable. Silently
// doing nothing is right: a browser does not wrap around.
return;
};

if !doc.set_select_selected_index(node_id, index) {
return;
}
// The selectedness is what `:checked` and the accessibility tree read, so
// the restyle has to be asked for or the change is invisible.
doc.snapshot_node(node_id);

let value = doc.select_value(node_id);
dispatch_event(DomEvent::new(
node_id,
DomEventData::Input(BlitzInputEvent { value }),
));
}

impl BaseDocument {
pub(crate) fn apply_generated_text_input_event<F: FnMut(DomEvent)>(
&mut self,
Expand Down
9 changes: 8 additions & 1 deletion packages/blitz-dom/src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,14 @@ fn scroll_key_is_claimed_by(doc: &BaseDocument, node_id: NodeId, key: &Key) -> b
}
doc.get_node(node_id)
.and_then(|node| node.element_data())
.is_some_and(|element| element.text_input_data().is_some())
.is_some_and(|element| {
element.text_input_data().is_some()
// A focussed select moves its selection with the arrows, Home
// and End. Without claiming them here the branch above scrolls
// the page and *returns*, so the keyboard handler never runs
// and a select could not be driven by keyboard at all.
|| element.select_data().is_some()
})
}

/// The scroll a key asks for, in CSS pixels, or `None` if it asks for none.
Expand Down
27 changes: 27 additions & 0 deletions packages/blitz-dom/src/events/pointer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,12 @@ pub(crate) fn checkable_activation_target(doc: &BaseDocument, target: NodeId) ->
return Some(node_id);
}
local_name!("label") => return None,
// A select is not checkable and has an activation behaviour of its
// own, so the walk stops here for the same reason it stops at a
// text input: `handle_click` claims the press, and the two have to
// agree about where the walk ends or a checkbox wrapping a select
// would toggle without ever seeing a click.
local_name!("select") => return None,
_ => {}
}

Expand Down Expand Up @@ -716,6 +722,27 @@ pub(crate) fn handle_click(

break 'matched true;
}
// A press on a select focuses it. Nothing else yet: there is no
// popup to open.
//
// Without an arm of its own the walk fell through to the
// no-match tail below, which calls `clear_focus()`. Clicking a
// select therefore actively *unfocused* the page, and since the
// keyboard handler is gated on focus, the arrows could not
// drive a select the user had just pressed. `handle_pointerdown`
// is no help either: it classifies a select as
// `ClickTarget::SelectableText` and only the `TextInput` arm
// there generates focus events.
local_name!("select") => {
generate_focus_events(
doc,
&mut |doc| {
doc.set_focus_to(node_id);
},
dispatch_event,
);
break 'matched true;
}
// Activating the first <summary> of a <details> element toggles
// the details' `open` attribute (expand/collapse).
local_name!("summary") => {
Expand Down
28 changes: 23 additions & 5 deletions packages/blitz-dom/src/form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,29 @@ fn construct_entry_list(doc: &BaseDocument, form_id: NodeId, submitter_id: NodeI
continue;
};

// TODO: If the field element is a select element,
// then for each option element in the select element's
// list of options whose selectedness is true and that is not disabled,
// create an entry with name and the value of the option element,
// and append it to entry list.
// If the field element is a select element, then for each option
// element in the select element's list of options whose selectedness is
// true and that is not disabled, create an entry with name and the
// value of the option element, and append it to entry list.
//
// Without this a select fell through to the generic tail below and
// submitted its own literal `value` attribute, which a select does not
// have. Every form containing a picker posted the wrong body.
if element.name.local == local_name!("select") {
let options = doc.select_options(control_id);
for (index, option_id) in options.iter().enumerate() {
let is_selected = element
.select_data()
.map(|data| data.is_selected(index))
// Before layout construction has run there is no live
// state, and the content attribute is all there is.
.unwrap_or_else(|| doc.option_is_selected(*option_id));
if is_selected && !doc.option_is_disabled(*option_id) {
create_entry(name, doc.option_value(*option_id).as_str().into());
}
}
continue;
}

// Otherwise, if the field element is an input element whose type attribute is in the Checkbox state or the Radio Button state, then:
if element.name.local == local_name!("input")
Expand Down
26 changes: 26 additions & 0 deletions packages/blitz-dom/src/layout/construct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 13 additions & 8 deletions packages/blitz-dom/src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,15 @@ impl BaseDocument {
/// sheet sees to that, and nothing replaces it. Without a measure of its own it
/// laid out at zero and no site's country picker, currency picker or language
/// picker had a box to press.
///
/// Still a character count rather than shaped text, because the options never
/// reach layout and so are never shaped. What it counts is now the option's
/// *label*, which is the string the control actually shows: a `label`
/// attribute overrides the element's text, and the text is collapsed the way it
/// would be rendered. Counting `text_content().trim()` measured the hidden text
/// of a labelled option, 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 and came out that much too wide.
fn select_metrics_of(
doc: &BaseDocument,
node_id: blitz_traits::node_id::NodeId,
Expand All @@ -348,14 +357,10 @@ fn select_metrics_of(
return None;
}

let widest = crate::traversal::TreeTraverser::new_with_root(doc, node_id)
.filter_map(|descendant_id| doc.nodes.get(descendant_id))
.filter(|descendant| {
descendant
.data
.is_element_with_tag_name(&local_name!("option"))
})
.map(|option| option.text_content().trim().chars().count())
let widest = doc
.select_options(node_id)
.into_iter()
.map(|option_id| doc.option_label(option_id).chars().count())
.max()
.unwrap_or(0);

Expand Down
Loading
Loading