fix(dom): complete tabIndex defaults

This commit is contained in:
ldm0
2026-09-23 00:11:52 +08:00
parent 84d9c31185
commit f8bfd0f7bb
5 changed files with 205 additions and 5 deletions
@@ -304,6 +304,7 @@ pub(in crate::native_bridge::document) use structure::set_document_body_for_nati
pub(crate) const XHTML_NS: &str = "http://www.w3.org/1999/xhtml";
pub(crate) const SVG_NS: &str = "http://www.w3.org/2000/svg";
pub(crate) const XLINK_NS: &str = "http://www.w3.org/1999/xlink";
pub(crate) const MATHML_NS: &str = "http://www.w3.org/1998/Math/MathML";
#[derive(Clone, Copy)]
#[repr(u32)]
@@ -73,6 +73,58 @@ fn contenteditable_state_from_attr(value: &str) -> Option<bool> {
}
}
fn html_area_has_associated_image_map(runtime: &JsContextHost, handle: DomHandle) -> bool {
let dom = runtime.dom_host();
let Some(area) = dom.node(handle).and_then(Node::as_element) else {
return false;
};
if area.namespace() != document::XHTML_NS
|| area.local_name() != "area"
|| !area.has_attribute("href")
{
return false;
}
let mut ancestor = dom.parent_node(handle);
let map_name = loop {
let Some(candidate) = ancestor else {
return false;
};
let Some(element) = dom.node(candidate).and_then(Node::as_element) else {
ancestor = dom.parent_node(candidate);
continue;
};
if element.namespace() == document::XHTML_NS && element.local_name() == "map" {
let Some(name) = element.attribute("name").filter(|name| !name.is_empty()) else {
return false;
};
break name.to_owned();
}
ancestor = dom.parent_node(candidate);
};
let mut stack = dom
.child_handles(runtime.document_handle())
.collect::<Vec<_>>();
while let Some(candidate) = stack.pop() {
let Some(element) = dom.node(candidate).and_then(Node::as_element) else {
stack.extend(dom.child_handles(candidate));
continue;
};
if element.namespace() == document::XHTML_NS
&& matches!(element.local_name(), "img" | "object")
&& element
.attribute("usemap")
.and_then(|value| value.strip_prefix('#'))
.is_some_and(|name| name == map_name)
{
return true;
}
stack.extend(dom.child_handles(candidate));
}
false
}
pub(super) fn is_focusable(runtime: &JsContextHost, handle: DomHandle) -> bool {
let Some(element) = runtime.dom_host().node(handle).and_then(Node::as_element) else {
return false;
@@ -85,6 +137,9 @@ pub(super) fn is_focusable(runtime: &JsContextHost, handle: DomHandle) -> bool {
{
return false;
}
if element.namespace() == document::XHTML_NS && element.local_name() == "area" {
return html_area_has_associated_image_map(runtime, handle);
}
matches!(
element.local_name(),
"body" | "input" | "button" | "select" | "textarea" | "a" | "iframe" | "frame"
@@ -6,6 +6,7 @@ use crate::webidl;
use super::super::{
JsContextHost,
document::{MATHML_NS, SVG_NS, XHTML_NS},
node::{
node_is_element, node_runtime_and_handle_from_object_or_detached,
throw_incompatible_getter_receiver, throw_incompatible_setter_receiver,
@@ -13,6 +14,7 @@ use super::super::{
throw_dom_exception,
};
use super::details_dialog::main_summary_child;
use super::reflection::{
DomStringReflection, ElementReflectionInterface, NullToEmptyDomStringReflection,
UnsignedLongReflection, UsvStringReflection, remove_reflected_attribute,
@@ -2588,11 +2590,21 @@ fn default_tab_index_for(runtime: &JsContextHost, handle: DomHandle) -> i32 {
let Some(element) = runtime.dom_host().node(handle).and_then(Node::as_element) else {
return -1;
};
if element.namespace() != "http://www.w3.org/1999/xhtml" {
return -1;
}
match element.local_name() {
"a" | "button" | "input" | "select" | "textarea" => 0,
match (element.namespace(), element.local_name()) {
(
XHTML_NS,
"a" | "area" | "button" | "frame" | "iframe" | "input" | "object" | "select"
| "textarea",
)
| (SVG_NS | MATHML_NS, "a") => 0,
(XHTML_NS, "summary")
if runtime
.dom_host()
.parent_node(handle)
.is_some_and(|parent| main_summary_child(runtime, parent) == Some(handle)) =>
{
0
}
_ => -1,
}
}
@@ -1,5 +1,58 @@
use super::*;
#[test]
fn image_map_area_focusability_supports_autofocus() {
let mut vm = new_storage_test_vm("https://area-autofocus.test/");
let setup = vm
.eval(
r##"
(() => {
const root = document.documentElement ||
document.appendChild(document.createElement('html'));
const body = document.body || root.appendChild(document.createElement('body'));
body.innerHTML = `
<area id="outside" href="#" autofocus>
<map name="unused"><area id="unreferenced" href="#" autofocus></map>
<map name="active">
<area id="no-href" autofocus>
<area id="target" href="#" autofocus>
</map>
<img usemap="#active">`;
const rejected = ['outside', 'unreferenced', 'no-href'].map(id => {
const candidate = document.getElementById(id);
candidate.focus();
return document.activeElement !== candidate;
});
return rejected.every(Boolean) && document.activeElement === body;
})()
"##,
)
.expect("image-map focusability fixture should initialize");
assert_eq!(setup, "true");
vm.with_default_context_scope_and_checkpoint_for_test(|scope, runtime_ptr| {
assert!(
crate::native_bridge::element::post_parse_autofocus_is_pending(unsafe {
&*runtime_ptr
})
);
assert!(crate::native_bridge::element::process_post_parse_autofocus(
scope,
runtime_ptr
));
Ok(())
})
.expect("image-map area autofocus should run");
assert_eq!(
vm.eval("document.activeElement === document.getElementById('target')")
.expect("image-map autofocus result should remain observable"),
"true"
);
}
#[test]
fn focus_prevent_scroll_controls_real_nested_scroll_container_reveal() {
let mut vm = new_storage_test_vm("https://focus-prevent-scroll.test/");
@@ -1045,6 +1045,85 @@ fn htmlelement_standard_accessors_live_on_owner_prototypes() {
assert_eq!(result, "ok");
}
#[test]
fn tab_index_getter_uses_the_spec_default_element_set() {
let mut vm = new_parsed_test_vm(
"https://tab-index-defaults.test/",
"<!doctype html><html><head></head><body></body></html>",
);
let result = vm
.eval(
r#"
(() => {
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
const svg = "http://www.w3.org/2000/svg";
const mathml = "http://www.w3.org/1998/Math/MathML";
const other = "https://tab-index-defaults.test/namespace";
const exercise = ownerDocument => {
for (const name of [
"a", "area", "button", "frame", "iframe", "input", "object", "select", "textarea"
]) {
const element = ownerDocument.createElement(name);
assert(element.tabIndex === 0, `${name} default`);
}
const div = ownerDocument.createElement("div");
assert(div.tabIndex === -1, "ordinary HTML element default");
const svgAnchor = ownerDocument.createElementNS(svg, "a");
const svgGroup = ownerDocument.createElementNS(svg, "g");
const mathAnchor = ownerDocument.createElementNS(mathml, "a");
const mathRow = ownerDocument.createElementNS(mathml, "mrow");
const foreignAnchor = ownerDocument.createElementNS(other, "a");
assert(svgAnchor.tabIndex === 0, "SVG a default");
assert(svgGroup.tabIndex === -1, "other SVG element default");
assert(mathAnchor.tabIndex === 0, "MathML a default");
assert(mathRow.tabIndex === -1, "other MathML element default");
assert(foreignAnchor.tabIndex === undefined, "unrelated namespace has no mixin");
const details = ownerDocument.createElement("details");
const nonSummary = ownerDocument.createElement("span");
const firstSummary = ownerDocument.createElement("summary");
const secondSummary = ownerDocument.createElement("summary");
details.append(nonSummary, firstSummary, secondSummary);
assert(firstSummary.tabIndex === 0, "first summary child default");
assert(secondSummary.tabIndex === -1, "later summary child default");
assert(ownerDocument.createElement("summary").tabIndex === -1,
"orphan summary default");
firstSummary.remove();
assert(secondSummary.tabIndex === 0, "summary default follows tree mutation");
details.append(firstSummary);
assert(secondSummary.tabIndex === 0 && firstSummary.tabIndex === -1,
"first summary is based on current child order");
const wrapper = ownerDocument.createElement("div");
const nestedSummary = ownerDocument.createElement("summary");
wrapper.append(nestedSummary);
details.prepend(wrapper);
assert(nestedSummary.tabIndex === -1, "nested summary is not a details summary");
secondSummary.setAttribute("tabindex", "-7");
assert(secondSummary.tabIndex === -7, "explicit valid tabindex wins");
secondSummary.setAttribute("tabindex", "invalid");
assert(secondSummary.tabIndex === 0, "invalid tabindex uses element default");
div.setAttribute("tabindex", "invalid");
assert(div.tabIndex === -1, "invalid tabindex uses ordinary default");
};
exercise(document);
return "ok";
})()
"#,
)
.expect("tabIndex default getter behavior should evaluate");
assert_eq!(result, "ok");
}
#[test]
fn inner_and_outer_text_setters_build_rendered_fragments_and_merge_text_endpoints() {
let mut vm = new_parsed_test_vm(