Skip to content
Open
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
35 changes: 35 additions & 0 deletions packages/blitz-dom/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,16 @@ impl BaseDocument {
if self.focus_node_id == Some(node_id) {
let shell_provider = self.shell_provider.clone();
self.nodes[node_id].blur(shell_provider);
// Nothing on the chain contains the focus anymore. Only the surviving
// ancestors need a snapshot for restyling: nodes in the removed subtree had
// theirs dropped already and may be freed entirely.
for ancestor in self.node_ancestors(node_id) {
if self.nodes[ancestor].flags.is_in_document() {
self.snapshot_node_and(ancestor, |node| node.remove_focus_within());
} else {
self.nodes[ancestor].remove_focus_within();
}
}
self.focus_node_id = None;
}
if self.mousedown_node_id == Some(node_id) {
Expand Down Expand Up @@ -1496,7 +1506,11 @@ impl BaseDocument {
if let Some(id) = self.focus_node_id {
let shell_provider = self.shell_provider.clone();
self.snapshot_node_and(id, |node| node.blur(shell_provider));
for &ancestor in self.node_ancestors(id).iter() {
self.snapshot_node_and(ancestor, |node| node.remove_focus_within());
}
self.focus_node_id = None;
self.shell_provider.request_redraw();
}
}

Expand Down Expand Up @@ -1524,8 +1538,29 @@ impl BaseDocument {
// Focus the new node
self.snapshot_node_and(focus_node_id, |node| node.focus(shell_provider));

// Update :focus-within on the ancestor chains (shared prefix stays). The
// specification walks the flat tree, which is the DOM tree as long as there is
// no shadow DOM implementation to flatten.
let old_node_path = self.maybe_node_ancestors(self.focus_node_id);
let new_node_path = self.maybe_node_ancestors(Some(focus_node_id));
let same_count = old_node_path
.iter()
.zip(&new_node_path)
.take_while(|(o, n)| o == n)
.count();
for &id in old_node_path.iter().skip(same_count) {
self.snapshot_node_and(id, |node| node.remove_focus_within());
}
for &id in new_node_path.iter().skip(same_count) {
self.snapshot_node_and(id, |node| node.add_focus_within());
}

self.focus_node_id = Some(focus_node_id);

// The focus is styled, so moving it changes what is on screen. Nothing else asks
// for the frame: a keyboard focus change touches no layout and no content.
self.shell_provider.request_redraw();

true
}

Expand Down
36 changes: 36 additions & 0 deletions packages/blitz-dom/src/mutator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,13 +597,26 @@ impl DocumentMutator<'_> {
) {
let new_parent_is_in_document = self.doc.nodes[parent_id].flags.is_in_document();
self.mutations_occurred |= new_parent_is_in_document && !child_ids.is_empty();

// If one of the moved subtrees contains the focused node, the ancestors it is
// detached from stop matching :focus-within. Record them while the parent links
// are still intact; the state is transferred after the children are reattached.
let focus_chain = self.doc.maybe_node_ancestors(self.doc.focus_node_id);
let mut focus_within_old_chain: Option<Vec<NodeId>> = None;

// Detach the children from their old parents *before* inserting them into
// the new parent (matching DOM `insertBefore` semantics). If a child is
// being moved within the same parent then detaching it after insertion
// would remove both the old and the newly-inserted entries from the
// parent's child list, and anchor indices would be computed against a
// child list that still contains the moved nodes.
for child_id in child_ids.iter().copied() {
if focus_within_old_chain.is_none() {
if let Some(pos) = focus_chain.iter().position(|&id| id == child_id) {
focus_within_old_chain = Some(focus_chain[..pos].to_vec());
}
}

let child = &mut self.doc.nodes[child_id];
let child_was_in_doc = child.flags.is_in_document();
self.mutations_occurred |= child_was_in_doc;
Expand Down Expand Up @@ -659,6 +672,29 @@ impl DocumentMutator<'_> {
}
}

// Transfer :focus-within from the old ancestor chain to the new one (the
// shared prefix stays). If the subtree left the document, the focus was
// cleared with it and only the old chain needs the state removed.
if let Some(old_chain) = focus_within_old_chain {
let new_chain = match self.doc.focus_node_id {
Some(_) => self.doc.node_ancestors(parent_id),
None => Vec::new(),
};
let same_count = old_chain
.iter()
.zip(&new_chain)
.take_while(|(o, n)| o == n)
.count();
for &id in old_chain.iter().skip(same_count) {
self.doc
.snapshot_node_and(id, |node| node.remove_focus_within());
}
for &id in new_chain.iter().skip(same_count) {
self.doc
.snapshot_node_and(id, |node| node.add_focus_within());
}
}

self.maybe_record_node(parent_id);
}

Expand Down
17 changes: 17 additions & 0 deletions packages/blitz-dom/src/node/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,23 @@ impl Node {
.is_some_and(|data| data.element_state.contains(ElementState::HOVER))
}

/// Mark this node as containing focus (`:focus-within`). Set on the
/// focused element and all of its DOM ancestors.
pub fn add_focus_within(&mut self) {
if let Some(data) = self.element_data_mut() {
data.element_state.insert(ElementState::FOCUS_WITHIN);
}
self.set_restyle_hint(RestyleHint::restyle_subtree());
}

/// Mark this node as no longer containing focus (`:focus-within`).
pub fn remove_focus_within(&mut self) {
if let Some(data) = self.element_data_mut() {
data.element_state.remove(ElementState::FOCUS_WITHIN);
}
self.set_restyle_hint(RestyleHint::restyle_subtree());
}

pub fn focus(&mut self, shell_provider: Arc<dyn ShellProvider>) {
if let Some(data) = self.element_data_mut() {
data.element_state
Expand Down
4 changes: 3 additions & 1 deletion packages/blitz-dom/src/stylo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,9 @@ impl selectors::Element for BlitzNode<'_> {
NonTSPseudoClass::Disabled => self.element_state().contains(ElementState::DISABLED),
NonTSPseudoClass::Enabled => self.element_state().contains(ElementState::ENABLED),
NonTSPseudoClass::Focus => self.element_state().contains(ElementState::FOCUS),
NonTSPseudoClass::FocusWithin => false,
NonTSPseudoClass::FocusWithin => {
self.element_state().contains(ElementState::FOCUS_WITHIN)
}
NonTSPseudoClass::FocusVisible => false,
NonTSPseudoClass::Fullscreen => false,
NonTSPseudoClass::Hover => self.element_state().contains(ElementState::HOVER),
Expand Down
38 changes: 24 additions & 14 deletions packages/blitz-dom/src/traversal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,28 @@ impl BaseDocument {
.unwrap_or_default()
}

/// A node and its ancestors in the DOM tree, root first.
///
/// Unlike the layout chain, this includes `display: contents` elements (which have no box
/// and so no layout parent) and is valid before the first layout pass, since it does not
/// depend on box-tree construction.
pub fn node_ancestors(&self, node_id: NodeId) -> Vec<NodeId> {
let mut ancestors = Vec::with_capacity(16);
let mut maybe_id = Some(node_id);
while let Some(id) = maybe_id {
ancestors.push(id);
maybe_id = self.get_node(id).and_then(|node| node.parent);
}
ancestors.reverse();
ancestors
}

pub fn maybe_node_ancestors(&self, node_id: Option<NodeId>) -> Vec<NodeId> {
node_id
.map(|id| self.node_ancestors(id))
.unwrap_or_default()
}

/// Compare the document order of two nodes.
/// Returns Ordering::Less if node_a comes before node_b in document order.
/// Returns Ordering::Greater if node_a comes after node_b.
Expand All @@ -333,8 +355,8 @@ impl BaseDocument {
}

// Build ancestor chains from root to node (inclusive)
let chain_a = self.ancestor_chain_from_root(node_a);
let chain_b = self.ancestor_chain_from_root(node_b);
let chain_a = self.node_ancestors(node_a);
let chain_b = self.node_ancestors(node_b);

// Find where the chains diverge
let mut common_depth = 0;
Expand Down Expand Up @@ -380,18 +402,6 @@ impl BaseDocument {
Ordering::Equal
}

/// Build ancestor chain from root to node (inclusive), ordered [root, ..., node].
fn ancestor_chain_from_root(&self, node_id: NodeId) -> Vec<NodeId> {
let mut ancestors = Vec::with_capacity(16);
let mut current = Some(node_id);
while let Some(id) = current {
ancestors.push(id);
current = self.nodes[id].parent;
}
ancestors.reverse();
ancestors
}

/// Collect all inline root nodes between start_node and end_node in document order.
/// Both start and end are assumed to be inline roots.
/// Returns the nodes in document order (from first to last).
Expand Down
120 changes: 120 additions & 0 deletions tests/blitz-tests/tests/focus_redraw.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Moving the focus changes what is painted, because the focus is styled
//! (`:focus`). A keyboard focus change touches neither layout nor content, so
//! unless the focus change itself asks for a frame, nothing does, and the
//! previous focus styling stays on screen until an unrelated event brings a
//! redraw along.

use blitz_dom::{Document, DocumentConfig, NodeId};
use blitz_html::{HtmlDocument, HtmlProvider};
use blitz_traits::{
events::{BlitzKeyEvent, KeyState, UiEvent},
shell::{ColorScheme, ShellProvider, Viewport},
};
use keyboard_types::{Code, Key, Location, Modifiers};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};

#[derive(Default)]
struct RecordingShell {
redraws: AtomicUsize,
}
impl ShellProvider for RecordingShell {
fn request_redraw(&self) {
self.redraws.fetch_add(1, Ordering::Relaxed);
}
}
impl RecordingShell {
fn take_redraws(&self) -> usize {
self.redraws.swap(0, Ordering::Relaxed)
}
}

const HTML: &str = r#"
<html><body>
<input id="first" type="text">
<input id="second" type="text">
</body></html>
"#;

fn make_doc() -> (HtmlDocument, Arc<RecordingShell>) {
let shell = Arc::new(RecordingShell::default());
let mut doc = HtmlDocument::from_html(
HTML,
DocumentConfig {
viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
html_parser_provider: Some(Arc::new(HtmlProvider) as _),
shell_provider: Some(shell.clone() as _),
..Default::default()
},
);
doc.resolve(0.0);
(doc, shell)
}

fn node_id(doc: &HtmlDocument, selector: &str) -> NodeId {
doc.query_selector(selector).unwrap().expect(selector)
}

fn tab_key_event() -> BlitzKeyEvent {
BlitzKeyEvent {
key: Key::Tab,
code: Code::Tab,
modifiers: Modifiers::empty(),
location: Location::Standard,
is_auto_repeating: false,
is_composing: false,
state: KeyState::Pressed,
text: None,
}
}

#[test]
fn focusing_a_node_requests_a_redraw() {
let (mut doc, shell) = make_doc();
let first = node_id(&doc, "#first");
shell.take_redraws();

assert!(doc.set_focus_to(first));
assert_eq!(shell.take_redraws(), 1);
}

#[test]
fn refocusing_the_same_node_requests_nothing() {
let (mut doc, shell) = make_doc();
let first = node_id(&doc, "#first");
doc.set_focus_to(first);
shell.take_redraws();

assert!(!doc.set_focus_to(first));
assert_eq!(shell.take_redraws(), 0);
}

#[test]
fn blurring_requests_a_redraw() {
let (mut doc, shell) = make_doc();
let first = node_id(&doc, "#first");
doc.set_focus_to(first);
shell.take_redraws();

doc.clear_focus();
assert_eq!(shell.take_redraws(), 1);

// Nothing was focused, so nothing changed on screen.
doc.clear_focus();
assert_eq!(shell.take_redraws(), 0);
}

#[test]
fn tabbing_to_the_next_node_requests_a_redraw() {
let (mut doc, shell) = make_doc();
let first = node_id(&doc, "#first");
doc.set_focus_to(first);
shell.take_redraws();

doc.handle_ui_event(UiEvent::KeyDown(tab_key_event()));

assert_eq!(doc.get_focussed_node_id(), Some(node_id(&doc, "#second")));
assert_eq!(shell.take_redraws(), 1);
}
Loading
Loading