diff --git a/packages/blitz-dom/assets/default.css b/packages/blitz-dom/assets/default.css index 01918fea8..a8ba17589 100644 --- a/packages/blitz-dom/assets/default.css +++ b/packages/blitz-dom/assets/default.css @@ -79,8 +79,7 @@ textarea { background-color: white; } -input:focus, -textarea:focus { +:focus-visible { outline: 2px solid #4D90FE; } diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 3f5d79184..b9d9515d2 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -180,6 +180,16 @@ impl Document for Rc> { } } +/// How focus was moved to an element. Determines whether the element +/// matches `:focus-visible` (keyboard and script focus show a focus ring; +/// pointer focus only does so for keyboard-input elements). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusSource { + Keyboard, + Pointer, + Script, +} + pub enum DocumentEvent { ResourceLoad(ResourceLoadResponse), /// A navigation originating from within an iframe's sub-document @@ -1617,7 +1627,7 @@ impl BaseDocument { pub fn focus_next_node(&mut self) -> Option { let focussed_node_id = self.get_focussed_node_id()?; let id = self.next_node(&self.nodes[focussed_node_id], |node| node.is_focussable())?; - self.set_focus_to(id); + self.set_focus_to_with_source(id, FocusSource::Keyboard); Some(id) } @@ -1625,7 +1635,7 @@ impl BaseDocument { pub fn focus_prev_node(&mut self) -> Option { let focussed_node_id = self.get_focussed_node_id()?; let id = self.prev_node(&self.nodes[focussed_node_id], |node| node.is_focussable())?; - self.set_focus_to(id); + self.set_focus_to_with_source(id, FocusSource::Keyboard); Some(id) } @@ -1644,10 +1654,35 @@ impl BaseDocument { self.mousedown_node_id = node_id.and_then(|id| self.nearest_non_anonymous_ancestor(id)); } pub fn set_focus_to(&mut self, focus_node_id: NodeId) -> bool { + self.set_focus_to_with_source(focus_node_id, FocusSource::Script) + } + + pub fn set_focus_to_with_source(&mut self, focus_node_id: NodeId, source: FocusSource) -> bool { let Some(focus_node_id) = self.nearest_non_anonymous_ancestor(focus_node_id) else { return false; }; + let focus_visible = match source { + FocusSource::Keyboard | FocusSource::Script => true, + // Pointer-initiated focus only shows a focus ring on elements which + // support keyboard input (per the :focus-visible heuristics) + FocusSource::Pointer => self.nodes[focus_node_id] + .element_data() + .is_some_and(|el| el.text_input_data().is_some()), + }; if Some(focus_node_id) == self.focus_node_id { + // Update the focus ring visibility to reflect the latest focus source + let has_ring = self.nodes[focus_node_id] + .element_state() + .contains(ElementState::FOCUSRING); + if has_ring != focus_visible { + self.snapshot_node_and(focus_node_id, ElementState::FOCUSRING, |node| { + if let Some(data) = node.element_data_mut() { + data.element_state + .set(ElementState::FOCUSRING, focus_visible); + } + node.mark_ancestors_dirty(); + }); + } return false; } @@ -1667,7 +1702,7 @@ impl BaseDocument { self.snapshot_node_and( focus_node_id, ElementState::FOCUS | ElementState::FOCUSRING, - |node| node.focus(shell_provider), + |node| node.focus(shell_provider, focus_visible), ); self.focus_node_id = Some(focus_node_id); diff --git a/packages/blitz-dom/src/events/mod.rs b/packages/blitz-dom/src/events/mod.rs index d73c45e2b..da239c030 100644 --- a/packages/blitz-dom/src/events/mod.rs +++ b/packages/blitz-dom/src/events/mod.rs @@ -13,7 +13,7 @@ use keyboard::{KeyboardOrTextInputEvent, handle_key_or_input_event}; pub(crate) use pointer::DragMode; use pointer::{handle_click, handle_pointerdown, handle_pointermove, handle_pointerup}; -use crate::{BaseDocument, events::pointer::handle_wheel}; +use crate::{BaseDocument, document::FocusSource, events::pointer::handle_wheel}; fn adjust_coords_for_subdocument( coords: &mut PointerCoords, @@ -133,7 +133,7 @@ pub(crate) fn handle_dom_event( generate_focus_events( doc, &mut |doc| { - doc.set_focus_to(target_node_id); + doc.set_focus_to_with_source(target_node_id, FocusSource::Pointer); }, &mut dispatch_event, ); @@ -163,7 +163,7 @@ pub(crate) fn handle_dom_event( generate_focus_events( doc, &mut |doc| { - doc.set_focus_to(target_node_id); + doc.set_focus_to_with_source(target_node_id, FocusSource::Pointer); }, &mut dispatch_event, ); diff --git a/packages/blitz-dom/src/events/pointer.rs b/packages/blitz-dom/src/events/pointer.rs index 4eca26658..708042bf5 100644 --- a/packages/blitz-dom/src/events/pointer.rs +++ b/packages/blitz-dom/src/events/pointer.rs @@ -18,6 +18,7 @@ use taffy::AbsoluteAxis; use crate::{ BaseDocument, + document::FocusSource, node::{ScrollbarRef, SpecialElementData}, scrolling::{FlingState, ScrollAnimationState}, }; @@ -544,7 +545,7 @@ pub(crate) fn handle_pointerdown( generate_focus_events( doc, &mut |doc| { - doc.set_focus_to(hit.node_id); + doc.set_focus_to_with_source(hit.node_id, FocusSource::Pointer); }, dispatch_event, ); @@ -658,7 +659,7 @@ pub(crate) fn handle_click( generate_focus_events( doc, &mut |doc| { - doc.set_focus_to(node_id); + doc.set_focus_to_with_source(node_id, FocusSource::Pointer); }, dispatch_event, ); @@ -686,7 +687,7 @@ pub(crate) fn handle_click( generate_focus_events( doc, &mut |doc| { - doc.set_focus_to(node_id); + doc.set_focus_to_with_source(node_id, FocusSource::Pointer); }, dispatch_event, ); @@ -712,7 +713,7 @@ pub(crate) fn handle_click( generate_focus_events( doc, &mut |doc| { - doc.set_focus_to(node_id); + doc.set_focus_to_with_source(node_id, FocusSource::Pointer); }, dispatch_event, ); diff --git a/packages/blitz-dom/src/lib.rs b/packages/blitz-dom/src/lib.rs index 4999cab85..70b8a1a6b 100644 --- a/packages/blitz-dom/src/lib.rs +++ b/packages/blitz-dom/src/lib.rs @@ -81,7 +81,7 @@ pub use crate::node::Widget; pub use blitz_traits::node_id::NodeId; pub use config::{DocumentConfig, StyleThreading}; -pub use document::{BaseDocument, DocGuard, DocGuardMut, Document, PlainDocument}; +pub use document::{BaseDocument, DocGuard, DocGuardMut, Document, FocusSource, PlainDocument}; pub use markup5ever::{ LocalName, Namespace, NamespaceStaticSet, Prefix, PrefixStaticSet, QualName, local_name, namespace_prefix, namespace_url, ns, diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index d559f45f5..f40a42de3 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -665,10 +665,11 @@ impl Node { .is_some_and(|data| data.element_state.contains(ElementState::HOVER)) } - pub fn focus(&mut self, shell_provider: Arc) { + pub fn focus(&mut self, shell_provider: Arc, focus_visible: bool) { if let Some(data) = self.element_data_mut() { + data.element_state.insert(ElementState::FOCUS); data.element_state - .insert(ElementState::FOCUS | ElementState::FOCUSRING); + .set(ElementState::FOCUSRING, focus_visible); } self.mark_ancestors_dirty(); diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index 6a6e3414a..94092c2b6 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -423,7 +423,9 @@ impl selectors::Element for BlitzNode<'_> { NonTSPseudoClass::Enabled => self.element_state().contains(ElementState::ENABLED), NonTSPseudoClass::Focus => self.element_state().contains(ElementState::FOCUS), NonTSPseudoClass::FocusWithin => false, - NonTSPseudoClass::FocusVisible => false, + NonTSPseudoClass::FocusVisible => { + self.element_state().contains(ElementState::FOCUSRING) + } NonTSPseudoClass::Fullscreen => false, NonTSPseudoClass::Hover => self.element_state().contains(ElementState::HOVER), NonTSPseudoClass::Indeterminate => false,