diff --git a/moli-renderer-v8/src/document_runtime.rs b/moli-renderer-v8/src/document_runtime.rs index bc4a4bb028..f9436f7a09 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -765,6 +765,7 @@ pub(super) struct DocumentRuntime { custom_element_reaction_depth: usize, structural_mutation_depth: usize, dom_content_loaded_dispatched: bool, + autofocus_processed: bool, document_incarnation: DocumentRuntimeIncarnationIdentity, document_input_stream_opened: bool, next_document_write_external_script_load_id: u64, diff --git a/moli-renderer-v8/src/document_runtime/dom_facade.rs b/moli-renderer-v8/src/document_runtime/dom_facade.rs index 0ee5db8153..f2d0f13143 100644 --- a/moli-renderer-v8/src/document_runtime/dom_facade.rs +++ b/moli-renderer-v8/src/document_runtime/dom_facade.rs @@ -801,6 +801,14 @@ impl DocumentRuntime { self.dom_host.set_active_element_handle(handle); } + pub(crate) fn autofocus_processed(&self) -> bool { + self.autofocus_processed + } + + pub(crate) fn mark_autofocus_processed(&mut self) { + self.autofocus_processed = true; + } + pub(crate) fn document_focus_fallback_handle(&self) -> Option { self.dom_host .document_body_handle() diff --git a/moli-renderer-v8/src/document_runtime/lifecycle.rs b/moli-renderer-v8/src/document_runtime/lifecycle.rs index 3043ace93e..40286b944f 100644 --- a/moli-renderer-v8/src/document_runtime/lifecycle.rs +++ b/moli-renderer-v8/src/document_runtime/lifecycle.rs @@ -294,6 +294,7 @@ impl DocumentRuntime { self.script_lifecycle.clear_for_document_replacement(); self.post_parse_schedule_invalidated = true; self.dom_content_loaded_dispatched = false; + self.autofocus_processed = false; self.pending_inspector_issues.clear(); self.quirks_mode_issue_reported = false; self.document_write_script_preload_scanner = None; diff --git a/moli-renderer-v8/src/document_runtime/runtime_core.rs b/moli-renderer-v8/src/document_runtime/runtime_core.rs index 749b2485ed..550851c4cd 100644 --- a/moli-renderer-v8/src/document_runtime/runtime_core.rs +++ b/moli-renderer-v8/src/document_runtime/runtime_core.rs @@ -118,6 +118,7 @@ impl DocumentRuntime { custom_element_reaction_depth: 0, structural_mutation_depth: 0, dom_content_loaded_dispatched: false, + autofocus_processed: false, document_incarnation, document_input_stream_opened: false, next_document_write_external_script_load_id: 0, @@ -240,6 +241,7 @@ impl DocumentRuntime { custom_element_reaction_depth: _, structural_mutation_depth: _, dom_content_loaded_dispatched: _, + autofocus_processed: _, document_incarnation: _, document_input_stream_opened: _, next_document_write_external_script_load_id: _, diff --git a/moli-renderer-v8/src/native_bridge/element/details_dialog.rs b/moli-renderer-v8/src/native_bridge/element/details_dialog.rs index fb02926875..92c732f944 100644 --- a/moli-renderer-v8/src/native_bridge/element/details_dialog.rs +++ b/moli-renderer-v8/src/native_bridge/element/details_dialog.rs @@ -13,6 +13,7 @@ use moli_selector::stylo_flat_tree_heading_descendants; use super::super::{ JsContextHost, node::node_runtime_and_handle_from_object_or_detached, throw_dom_exception, }; +use super::focus::run_dialog_focusing_steps; use super::popover::popover_is_open; use super::toggle_event::queue_element_toggle_event; use super::{ @@ -484,6 +485,7 @@ pub(super) fn dialog_show_callback<'s>( return; } dialog_set_open_state_for_handle(scope, runtime_ptr, handle, true, false); + run_dialog_focusing_steps(scope, runtime_ptr, handle); } pub(super) fn dialog_show_modal_callback<'s>( @@ -529,6 +531,7 @@ pub(super) fn dialog_show_modal_callback<'s>( return; } dialog_set_open_state_for_handle(scope, runtime_ptr, handle, true, true); + run_dialog_focusing_steps(scope, runtime_ptr, handle); } pub(super) fn dialog_close_callback<'s>( diff --git a/moli-renderer-v8/src/native_bridge/element/focus.rs b/moli-renderer-v8/src/native_bridge/element/focus.rs index 6fa7244b15..c8b1b7bca6 100644 --- a/moli-renderer-v8/src/native_bridge/element/focus.rs +++ b/moli-renderer-v8/src/native_bridge/element/focus.rs @@ -135,6 +135,7 @@ pub(super) fn is_focusable(runtime: &JsContextHost, handle: DomHandle) -> bool { .node(handle) .is_some_and(Node::is_connected) || is_disabled_form_control(runtime, handle) + || element_focus_is_suppressed(runtime, handle) { return false; } @@ -143,12 +144,38 @@ pub(super) fn is_focusable(runtime: &JsContextHost, handle: DomHandle) -> bool { } matches!( element.local_name(), - "body" | "input" | "button" | "select" | "textarea" | "a" | "iframe" | "frame" + "body" | "input" | "button" | "select" | "textarea" | "a" | "iframe" | "frame" | "dialog" ) || element.has_attribute("tabindex") || contenteditable_editing_host(runtime, handle) == Some(handle) || element_is_scrollable(runtime, handle) } +fn element_focus_is_suppressed(runtime: &JsContextHost, handle: DomHandle) -> bool { + let visibility = style_property_value(runtime, handle, StyleMode::Computed, "visibility"); + if matches!( + visibility.trim().to_ascii_lowercase().as_str(), + "hidden" | "collapse" + ) { + return true; + } + + let mut current = Some(handle); + while let Some(candidate) = current { + if let Some(element) = runtime + .dom_host() + .node(candidate) + .and_then(Node::as_element) + && (element.has_attribute("inert") + || element.has_attribute("hidden") + || computed_display(runtime, candidate) == "none") + { + return true; + } + current = flat_tree_parent(runtime, candidate); + } + false +} + fn overflow_makes_scroll_container(value: &str) -> bool { matches!( value.trim().to_ascii_lowercase().as_str(), @@ -212,11 +239,17 @@ fn shadow_including_contains( false } -fn first_delegates_focus_descendant( - runtime: &JsContextHost, - root: DomHandle, - autofocus_only: bool, -) -> Option { +fn focusable_area_for_element(runtime: &JsContextHost, handle: DomHandle) -> Option { + if is_focusable(runtime, handle) { + return Some(handle); + } + let shadow_root = runtime.dom_host().shadow_root_handle(handle)?; + (runtime.dom_host().shadow_root_delegates_focus(shadow_root) == Some(true)) + .then(|| first_delegates_focus_target(runtime, shadow_root)) + .flatten() +} + +fn first_autofocus_delegate(runtime: &JsContextHost, root: DomHandle) -> Option { let mut stack = runtime.dom_host().child_handles(root).collect::>(); stack.reverse(); while let Some(handle) = stack.pop() { @@ -225,14 +258,41 @@ fn first_delegates_focus_descendant( .node(handle) .and_then(Node::as_element) .is_some_and(|element| element.has_attribute("autofocus")); - if (!autofocus_only || autofocus) && is_focusable(runtime, handle) { + if autofocus && let Some(target) = focusable_area_for_element(runtime, handle) { + return Some(target); + } + + let mut children = runtime.dom_host().child_handles(handle).collect::>(); + children.reverse(); + stack.extend(children); + } + None +} + +fn first_focus_delegate( + runtime: &JsContextHost, + root: DomHandle, + require_sequential_focus: bool, +) -> Option { + if let Some(target) = first_autofocus_delegate(runtime, root) { + return Some(target); + } + + let mut stack = runtime.dom_host().child_handles(root).collect::>(); + stack.reverse(); + while let Some(handle) = stack.pop() { + let is_direct_delegate = if require_sequential_focus { + sequential_tab_index(runtime, handle, None).is_some() + } else { + is_focusable(runtime, handle) + }; + if is_direct_delegate { return Some(handle); } if let Some(shadow_root) = runtime.dom_host().shadow_root_handle(handle) && runtime.dom_host().shadow_root_delegates_focus(shadow_root) == Some(true) - && let Some(target) = - first_delegates_focus_descendant(runtime, shadow_root, autofocus_only) + && let Some(target) = first_delegates_focus_target(runtime, shadow_root) { return Some(target); } @@ -245,8 +305,11 @@ fn first_delegates_focus_descendant( } fn first_delegates_focus_target(runtime: &JsContextHost, root: DomHandle) -> Option { - first_delegates_focus_descendant(runtime, root, true) - .or_else(|| first_delegates_focus_descendant(runtime, root, false)) + first_focus_delegate(runtime, root, false) +} + +fn first_dialog_focus_target(runtime: &JsContextHost, dialog: DomHandle) -> Option { + first_focus_delegate(runtime, dialog, true) } fn delegated_focus_target(runtime: &JsContextHost, handle: DomHandle) -> Option { @@ -292,7 +355,9 @@ fn first_autofocus_candidate(runtime: &JsContextHost) -> Option { /// the candidate again because script can move focus or mutate the Document /// between publication and execution. pub(crate) fn post_parse_autofocus_is_pending(runtime: &JsContextHost) -> bool { - runtime.active_element_handle().is_none() && first_autofocus_candidate(runtime).is_some() + !runtime.autofocus_processed() + && runtime.active_element_handle().is_none() + && first_autofocus_candidate(runtime).is_some() } pub(crate) fn process_post_parse_autofocus( @@ -300,7 +365,7 @@ pub(crate) fn process_post_parse_autofocus( runtime_ptr: *mut JsContextHost, ) -> bool { let runtime = unsafe { &*runtime_ptr }; - if runtime.active_element_handle().is_some() { + if runtime.autofocus_processed() || runtime.active_element_handle().is_some() { return false; } let Some(candidate) = first_autofocus_candidate(runtime) else { @@ -311,9 +376,42 @@ pub(crate) fn process_post_parse_autofocus( } else { update_focus(scope, runtime_ptr, Some(candidate)); } + unsafe { &mut *runtime_ptr }.mark_autofocus_processed(); true } +pub(super) fn run_dialog_focusing_steps( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + dialog: DomHandle, +) { + let target = { + let runtime = unsafe { &*runtime_ptr }; + if element_has_attribute(runtime, dialog, "autofocus") { + dialog + } else { + first_dialog_focus_target(runtime, dialog).unwrap_or(dialog) + } + }; + focus_element(scope, runtime_ptr, target); + + let should_mark_top_document_processed = { + let runtime = unsafe { &*runtime_ptr }; + let owner_document = runtime.dom_host().owner_document_handle(target); + owner_document == Some(runtime.document_handle()) + || owner_document + .and_then(|document| { + runtime.child_browsing_context_host_for_document_handle(document) + }) + .is_some_and(|child| { + runtime.child_window_has_same_origin_with_its_top_level_origin(child) + }) + }; + if should_mark_top_document_processed { + unsafe { &mut *runtime_ptr }.mark_autofocus_processed(); + } +} + fn focused_chain_requires_async_blur(runtime: &JsContextHost, active: DomHandle) -> bool { let mut current = Some(active); while let Some(handle) = current { diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/dialog.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/dialog.rs index b6a4e96d3f..b1d1018199 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/dialog.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/dialog.rs @@ -1,5 +1,126 @@ use super::*; +#[test] +fn dialog_show_focuses_autofocus_descendant_only_once() { + let mut vm = new_storage_test_vm("https://dialog-autofocus-once.test/"); + + let result = vm + .eval( + r#" +(() => { + const root = document.documentElement || document.appendChild(document.createElement('html')); + const host = document.body || root.appendChild(document.createElement('body')); + const dialog = document.createElement('dialog'); + dialog.innerHTML = ''; + host.appendChild(dialog); + dialog.show(); + const focusedOnShow = document.activeElement === dialog.querySelector('#target'); + document.activeElement.blur(); + return JSON.stringify({ focusedOnShow, activeAfterBlur: document.activeElement.localName }); +})() +"#, + ) + .expect("dialog autofocus-once probe should evaluate"); + + assert_eq!(result, r#"{"focusedOnShow":true,"activeAfterBlur":"body"}"#); + 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("processed dialog autofocus should suppress post-parse autofocus"); + assert_eq!( + vm.eval("document.activeElement.localName") + .expect("active element should remain readable"), + "body" + ); +} + +#[test] +fn dialog_show_without_focus_delegate_consumes_document_autofocus() { + let mut vm = new_storage_test_vm("https://dialog-autofocus-consumed.test/"); + + let result = vm + .eval( + r#" +(() => { + const root = document.documentElement || document.appendChild(document.createElement('html')); + const host = document.body || root.appendChild(document.createElement('body')); + const dialog = document.createElement('dialog'); + host.appendChild(dialog); + dialog.show(); + const focusedDialog = document.activeElement === dialog; + dialog.close(); + + const input = document.createElement('input'); + input.id = 'later'; + input.autofocus = true; + host.insertBefore(input, dialog); + return JSON.stringify({ focusedDialog, focusedLater: document.activeElement === input }); +})() +"#, + ) + .expect("dialog autofocus-consumption probe should evaluate"); + + assert_eq!(result, r#"{"focusedDialog":true,"focusedLater":false}"#); + 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("dialog focusing should consume later post-parse autofocus"); + assert_eq!( + vm.eval("document.activeElement === document.querySelector('#later')") + .expect("later autofocus state should remain readable"), + "false" + ); +} + +#[test] +fn dialog_show_skips_hidden_and_nonsequential_descendants() { + let mut vm = new_storage_test_vm("https://dialog-focus-delegate.test/"); + + let result = vm + .eval( + r#" +(() => { + const root = document.documentElement || document.appendChild(document.createElement('html')); + const host = document.body || root.appendChild(document.createElement('body')); + const dialog = document.createElement('dialog'); + dialog.innerHTML = ` + + + + `; + host.appendChild(dialog); + dialog.show(); + const focusedSequential = document.activeElement === dialog.querySelector('#target'); + dialog.querySelector('#target').remove(); + dialog.close(); + dialog.show(); + return JSON.stringify({ + focusedSequential, + focusedDialogFallback: document.activeElement === dialog + }); +})() +"#, + ) + .expect("dialog focus-delegate probe should evaluate"); + + assert_eq!( + result, + r#"{"focusedSequential":true,"focusedDialogFallback":true}"# + ); +} + #[tokio::test] async fn dialog_toggle_events_cancel_opening_and_coalesce_state_changes() { let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");