diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index af53b4f2d7..7ae80768d5 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -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)] diff --git a/moli-renderer-v8/src/native_bridge/element/focus.rs b/moli-renderer-v8/src/native_bridge/element/focus.rs index 4b1d7e4cc8..f68d73ff08 100644 --- a/moli-renderer-v8/src/native_bridge/element/focus.rs +++ b/moli-renderer-v8/src/native_bridge/element/focus.rs @@ -73,6 +73,58 @@ fn contenteditable_state_from_attr(value: &str) -> Option { } } +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::>(); + 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" diff --git a/moli-renderer-v8/src/native_bridge/element/global_attributes.rs b/moli-renderer-v8/src/native_bridge/element/global_attributes.rs index a6cab5c0a6..231f6f0270 100644 --- a/moli-renderer-v8/src/native_bridge/element/global_attributes.rs +++ b/moli-renderer-v8/src/native_bridge/element/global_attributes.rs @@ -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, } } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/focus.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/focus.rs index 5a560865a1..06dbb5db49 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/focus.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/focus.rs @@ -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 = ` + + + + + + + `; + + 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/"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs index 4af5d8d5d9..98b5ddc317 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document.rs @@ -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/", + "", + ); + + 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(