Compare commits

...

2 Commits

Author SHA1 Message Date
Martin Robinson
038a919e95 script: Add support for ShadowRoot.delegatesFocus and start implementing the focusing steps
This change has two main interdependent parts:

1. It starts to align Servo's focus code with what is written in the
   HTML specification. This is going to be a gradual change, so there
   are still many parts that do not match the specification yet. Still,
   this adds the major pieces.
2. It adds initial support for the `ShadowRoot.delegatesFocus` property
   which controls how focusing a shadow DOM root can delegate focus to
   one of its children.

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
2026-03-31 19:31:11 +02:00
Martin Robinson
566420fbeb script: Remove the special inner editor click focus workaround
This workaround was only necessary when click focusing didn't look up
the ancestry chain for an applicable focusable area. Now that this
happens (via the code directly after), the workaround is no longer necessary.

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
2026-03-31 16:04:23 +02:00
6 changed files with 265 additions and 61 deletions

View File

@@ -885,18 +885,22 @@ impl DocumentEventHandler {
self.down_button_count.set(down_button_count + 1);
// For a node within a text input UA shadow DOM,
// delegate the focus target into its shadow host.
// TODO: This focus delegation should be done
// with shadow DOM delegateFocus attribute.
let target_el = element.find_click_focusable_area();
let document = self.window.Document();
document.begin_focus_transaction();
// Try to focus `el`. If it's not focusable, focus the document instead.
//
// The specification says to run the focusing steps on `el` here, but we want a
// special behavior implemented by `Element::find_click_focusable_area` which climbs
// the tree finding the first ancestor with an associated focusable area.
document.request_focus(None, FocusInitiator::Click, can_gc);
document.request_focus(target_el.as_deref(), FocusInitiator::Click, can_gc);
if let Some(click_focusable_area) = element.find_click_focusable_area() {
document.request_focus(
Some(&*click_focusable_area),
FocusInitiator::Click,
can_gc,
);
}
// Step 7. Let result = dispatch event at target
let result = dom_event.dispatch(node.upcast(), false, can_gc);

View File

@@ -69,6 +69,7 @@ use xml5ever::serialize::TraversalScope::{
};
use crate::conversions::Convert;
use crate::dom::FocusInitiator;
use crate::dom::activation::Activatable;
use crate::dom::attr::{Attr, AttrHelpersForLayout, is_relevant_attribute};
use crate::dom::bindings::cell::{DomRefCell, Ref, RefMut};
@@ -79,6 +80,7 @@ use crate::dom::bindings::codegen::Bindings::ElementBinding::{
};
use crate::dom::bindings::codegen::Bindings::FunctionBinding::Function;
use crate::dom::bindings::codegen::Bindings::HTMLElementBinding::HTMLElementMethods;
use crate::dom::bindings::codegen::Bindings::HTMLOrSVGElementBinding::FocusOptions;
use crate::dom::bindings::codegen::Bindings::HTMLTemplateElementBinding::HTMLTemplateElementMethods;
use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods;
use crate::dom::bindings::codegen::Bindings::ShadowRootBinding::{
@@ -177,6 +179,7 @@ use crate::dom::svg::svgsvgelement::{LayoutSVGSVGElementHelpers, SVGSVGElement};
use crate::dom::text::Text;
use crate::dom::trustedtypes::trustedhtml::TrustedHTML;
use crate::dom::trustedtypes::trustedtypepolicyfactory::TrustedTypePolicyFactory;
use crate::dom::types::HTMLDialogElement;
use crate::dom::validation::Validatable;
use crate::dom::validitystate::ValidationFlags;
use crate::dom::virtualmethods::{VirtualMethods, vtable_for};
@@ -1849,10 +1852,18 @@ impl Element {
return Default::default();
}
// An element with a shadow root that delegates focus should never itself be a focusable area.
if self
.shadow_root()
.is_some_and(|shadow_root| shadow_root.DelegatesFocus())
{
return Default::default();
}
// > Elements that meet all the following criteria:
// > the element's tabindex value is non-null, or the element is determined by the user agent to be focusable;
// > the element is either not a shadow host, or has a shadow root whose delegates focus is false;
// TODO: Handle this.
// Note: Checked above
// > the element is not actually disabled;
// Note: Checked above
// > the element is not inert;
@@ -1957,37 +1968,211 @@ impl Element {
}
/// Returns the focusable appropriate DOM anchor for the focuable area when this element is
/// clicked on.
/// clicked on according to <https://www.w3.org/TR/pointerevents4/#handle-native-mouse-down>.
///
/// This returns the shadow host if this is a text control inner editor. This is a workaround
/// for the focus delegation of shadow DOM and should be used only to delegate focusable inner
/// editor of [HTMLInputElement] and [HTMLTextAreaElement].
///
/// TODO: This should eventually handle `delegatesFocus` in shadow DOM.
/// Note that this is doing more than the specification which says to only take into account
/// the node from the hit test. This isn't exactly how browsers work though, as they seem
/// to look for the first inclusive ancestor node that has a focusable area associated with it.
pub(crate) fn find_click_focusable_area(&self) -> Option<DomRoot<Element>> {
if self.is_click_focusable() {
return Some(DomRoot::from_ref(self));
}
Some(
self.node
.inclusive_ancestors(ShadowIncluding::Yes)
.find_map(|node| {
DomRoot::downcast::<Element>(node)
.iter()
.filter_map(|element| element.get_the_focusable_area())
.find(|(_, focusable_area_kind)| {
focusable_area_kind.contains(FocusableAreaKind::Click)
})
})?
.0,
)
}
if self.upcast::<Node>().implemented_pseudo_element() ==
Some(PseudoElement::ServoTextControlInnerEditor)
/// <https://html.spec.whatwg.org/multipage/#get-the-focusable-area>
///
/// There seems to be hole in the specification here. It describes how to get the focusable area
/// for a focus target that isn't a focuable area, but is ambiguous about how to do this for a
/// focus target that *is* a focusable area. The obvious thing is to just return the focus
/// target, but it's still odd that this isn't mentioned in the specification.
fn get_the_focusable_area(&self) -> Option<(DomRoot<Element>, FocusableAreaKind)> {
let focusable_area_kind = self.focusable_area_kind();
if !focusable_area_kind.is_empty() {
return Some((DomRoot::from_ref(self), focusable_area_kind));
}
self.get_the_focusable_area_if_not_a_focusable_area()
}
/// <https://html.spec.whatwg.org/multipage/#get-the-focusable-area>
///
/// In addition to returning the DOM anchor of the focusable area for this [`Element`], this
/// method also returns the [`FocusableAreaKind`] for efficiency reasons. Note that `None`
/// is returned if this [`Element`] does not have a focusable area or if its focusable area
/// is the `Document`'s viewport.
///
/// TODO: It might be better to distinguish these two cases in the future.
fn get_the_focusable_area_if_not_a_focusable_area(
&self,
) -> Option<(DomRoot<Element>, FocusableAreaKind)> {
// > To get the focusable area for a focus target that is either an element that is not a
// > focusable area, or is a navigable, given an optional string focus trigger (default
// > "other"), run the first matching set of steps from the following list:
//
// > ↪ If focus target is an area element with one or more shapes that are focusable areas
// > Return the shape corresponding to the first img element in tree order that uses the image
// > map to which the area element belongs.
// TODO: Implement this.
// > ↪ If focus target is an element with one or more scrollable regions that are focusable areas
// > Return the element's first scrollable region, according to a pre-order, depth-first
// > traversal of the flat tree. [CSSSCOPING]
// TODO: Implement this.
// > ↪ If focus target is the document element of its Document
// > Return the Document's viewport.
// TODO: Implement this.
// > ↪ If focus target is a navigable
// > Return the navigable's active document.
// TODO: Implement this.
// > ↪ If focus target is a navigable container with a non-null content navigable
// > Return the navigable container's content navigable's active document.
// TODO: Implement this.
// > ↪ If focus target is a shadow host whose shadow root's delegates focus is true
// > 1. Let focusedElement be the currently focused area of a top-level traversable's DOM
// > anchor.
// > 2. If focus target is a shadow-including inclusive ancestor of focusedElement, then
// > return focusedElement.
// > 3. Return the focus delegate for focus target given focus trigger.
if self
.shadow_root()
.is_some_and(|shadow_root| shadow_root.DelegatesFocus())
{
// The containing shadow host might not be a focusable area if it is disabled.
let containing_shadow_host = self
.containing_shadow_root()
.map(|root| root.Host())
.expect("Text control inner shadow DOM should always have a shadow host.");
if !containing_shadow_host.is_click_focusable() {
return None;
if let Some(focused_element) = self.owner_document().get_focused_element() {
if self
.upcast::<Node>()
.is_shadow_including_inclusive_ancestor_of(focused_element.upcast())
{
let focusable_area_kind = focused_element.focusable_area_kind();
return Some((focused_element, focusable_area_kind));
}
}
return Some(containing_shadow_host);
return self.focus_delegate();
}
self.node
.inclusive_ancestors(ShadowIncluding::Yes)
.find_map(|node| {
DomRoot::downcast::<Element>(node).filter(|element| element.is_click_focusable())
})
None
}
/// <https://html.spec.whatwg.org/multipage/#focus-delegate>
///
/// In addition to returning the focus delegate for this [`Element`], this method also returns
/// the [`FocusableAreaKind`] for efficiency reasons.
fn focus_delegate(&self) -> Option<(DomRoot<Element>, FocusableAreaKind)> {
// > 1. If focusTarget is a shadow host and its shadow root's delegates focus is false, then
// > return null.
let shadow_root = self.shadow_root();
if shadow_root
.as_ref()
.is_some_and(|shadow_root| !shadow_root.DelegatesFocus())
{
return None;
}
// > 2. Let whereToLook be focusTarget.
let mut where_to_look = self.upcast::<Node>();
// > 3. If whereToLook is a shadow host, then set whereToLook to whereToLook's shadow root.
if let Some(shadow_root) = shadow_root.as_ref() {
where_to_look = shadow_root.upcast();
}
// > 4. Let autofocusDelegate be the autofocus delegate for whereToLook given focusTrigger.
// TODO: Implement this.
// > 5. If autofocusDelegate is not null, then return autofocusDelegate.
// TODO: Implement this.
// > 6. For each descendant of whereToLook's descendants, in tree order:
let is_dialog_element = self.is::<HTMLDialogElement>();
for descendant in where_to_look.traverse_preorder(ShadowIncluding::No).skip(1) {
// > 6.1. Let focusableArea be null.
// Handled via early return.
let Some(descendant) = descendant.downcast::<Element>() else {
continue;
};
// > 6.2. If focusTarget is a dialog element and descendant is sequentially focusable, then
// > set focusableArea to descendant.
let focusable_area_kind = descendant.focusable_area_kind();
if is_dialog_element && focusable_area_kind.contains(FocusableAreaKind::Sequential) {
return Some((DomRoot::from_ref(descendant), focusable_area_kind));
}
// > 6.3. Otherwise, if focusTarget is not a dialog and descendant is a focusable area, set
// > focusableArea to descendant.
if !focusable_area_kind.is_empty() {
return Some((DomRoot::from_ref(descendant), focusable_area_kind));
}
// > 6.4. Otherwise, set focusableArea to the result of getting the focusable area for
// descendant given focusTrigger.
if let Some(focusable_area) =
descendant.get_the_focusable_area_if_not_a_focusable_area()
{
// > 6.5. If focusableArea is not null, then return focusableArea.
return Some(focusable_area);
}
}
// > 7. Return null.
None
}
/// <https://html.spec.whatwg.org/multipage/#focusing-steps>
///
/// This is an initial implementation of the "focusing steps" from the HTML specification. Note
/// that this is currently in a state of transition from Servo's old internal focus APIs to ones
/// that match the specification. That is why the arguments to this method do not match the
/// specification yet.
pub(crate) fn run_the_focusing_steps(
&self,
focus_initiator: FocusInitiator,
focus_options: FocusOptions,
can_gc: CanGc,
) {
// > 1. If new focus target is not a focusable area, then set new focus target to the result
// > of getting the focusable area for new focus target, given focus trigger if it was
// > passed.
let element = self.get_the_focusable_area().map(|(element, _)| element);
// > 2. If new focus target is null, then:
// > 2.1 If no fallback target was specified, then return.
// > 2.2 Otherwise, set new focus target to the fallback target.
// TODO: Handle the fallback.
// > 3. If new focus target is a navigable container with non-null content navigable, then
// > set new focus target to the content navigable's active document.
// > 4. If new focus target is a focusable area and its DOM anchor is inert, then return.
// > 5. If new focus target is the currently focused area of a top-level traversable, then
// > return.
// > 6. Let old chain be the current focus chain of the top-level traversable in which new
// > focus target finds itself.
// > 6.1. Let new chain be the focus chain of new focus target.
// > 6.2. Run the focus update steps with old chain, new chain, and new focus target
// > respectively.
//
// TODO: Handle all of these steps by converting the focus transaction code to follow
// the HTML focus specification.
let document = self.owner_document();
document.request_focus_with_options(
element.as_deref(),
focus_initiator,
focus_options,
can_gc,
);
}
pub(crate) fn is_actually_disabled(&self) -> bool {

View File

@@ -455,17 +455,21 @@ impl HTMLElementMethods<crate::DomTypeHolder> for HTMLElement {
/// <https://html.spec.whatwg.org/multipage/#dom-focus>
fn Focus(&self, options: &FocusOptions, can_gc: CanGc) {
// TODO: Mark the element as locked for focus and run the focusing steps.
// <https://html.spec.whatwg.org/multipage/#focusing-steps>
let document = self.owner_document();
document.request_focus_with_options(
Some(self.upcast()),
FocusInitiator::Script,
FocusOptions {
preventScroll: options.preventScroll,
},
can_gc,
);
// 1. If the allow focus steps given this's node document return false, then return.
// TODO: Implement this.
// 2. Run the focusing steps for this.
self.upcast::<Element>()
.run_the_focusing_steps(FocusInitiator::Script, *options, can_gc);
// > 3. If options["focusVisible"] is true, or does not exist but in an
// > implementation-defined way the user agent determines it would be best to do so,
// > then indicate focus. TODO: Implement this.
// > 4. If options["preventScroll"] is false, then scroll a target into view given this,
// > "auto", "center", and "center".
// TODO: This is currently handled as part of the focusing steps, but should eventually be
// handled here.
}
/// <https://html.spec.whatwg.org/multipage/#dom-blur>

View File

@@ -12,11 +12,12 @@ use script_bindings::inheritance::Castable;
use script_bindings::root::DomRoot;
use script_bindings::script_runtime::CanGc;
use crate::dom::bindings::codegen::Bindings::HTMLOrSVGElementBinding::FocusOptions;
use crate::dom::document::FocusInitiator;
use crate::dom::node::{Node, NodeTraits, ShadowIncluding};
use crate::dom::types::{
HTMLAnchorElement, HTMLButtonElement, HTMLElement, HTMLFieldSetElement, HTMLInputElement,
HTMLLabelElement, HTMLLegendElement, HTMLOptionElement,
Element, HTMLAnchorElement, HTMLButtonElement, HTMLElement, HTMLFieldSetElement,
HTMLInputElement, HTMLLabelElement, HTMLLegendElement, HTMLOptionElement,
};
/// This is an implementation of <https://html.spec.whatwg.org/multipage/#concept-command>. Note
@@ -183,12 +184,15 @@ impl InteractiveElementCommand {
option_element.SetSelected(true, can_gc)
},
// > The Action of the command is to run the following steps:
// > 1. Run the focusing steps for the element.
// > 2. Fire a click event at the element.
// > 1. Run the focusing steps for the element.
// > 2. Fire a click event at the element.
InteractiveElementCommand::HTMLElement(html_element) => {
html_element.owner_document().request_focus(
Some(html_element.upcast()),
let element: &Element = html_element.upcast();
element.run_the_focusing_steps(
FocusInitiator::Script,
FocusOptions {
preventScroll: true,
},
can_gc,
);
html_element

View File

@@ -139,16 +139,23 @@ impl SVGElementMethods<crate::DomTypeHolder> for SVGElement {
}
/// <https://html.spec.whatwg.org/multipage/#dom-focus>
fn Focus(&self, options: &FocusOptions) {
let document = self.element.owner_document();
document.request_focus_with_options(
Some(&self.element),
FocusInitiator::Script,
FocusOptions {
preventScroll: options.preventScroll,
},
CanGc::note(),
);
fn Focus(&self, options: &FocusOptions, can_gc: CanGc) {
// 1. If the allow focus steps given this's node document return false, then return.
// TODO: Implement this.
// 2. Run the focusing steps for this.
self.upcast::<Element>()
.run_the_focusing_steps(FocusInitiator::Script, *options, can_gc);
// > 3. If options["focusVisible"] is true, or does not exist but in an
// > implementation-defined way the user agent determines it would be best to do so,
// > then indicate focus. TODO: Implement this.
// TODO: Implement this.
// > 4. If options["preventScroll"] is false, then scroll a target into view given this,
// > "auto", "center", and "center".
// TODO: This is currently handled as part of the focusing steps, but should eventually be
// handled here.
}
/// <https://html.spec.whatwg.org/multipage/#dom-tabindex>

View File

@@ -772,7 +772,7 @@ DOMInterfaces = {
},
'SVGElement': {
'canGc': ['SetAutofocus', 'SetTabIndex']
'canGc': ['Focus', 'SetAutofocus', 'SetTabIndex']
},
#FIXME(jdm): This should be 'register': False, but then we don't generate enum types
@@ -1017,7 +1017,7 @@ Dictionaries = {
},
'FocusOptions': {
'derives': ['Clone', 'MallocSizeOf']
'derives': ['Clone', 'Copy', 'MallocSizeOf']
},
'FontFaceDescriptors': {