fix(dialog): run focusing steps when shown

This commit is contained in:
ldm0
2026-10-11 18:04:09 +08:00
parent 070062ceaf
commit 75991acedb
7 changed files with 247 additions and 13 deletions
+1
View File
@@ -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,
@@ -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<DomHandle> {
self.dom_host
.document_body_handle()
@@ -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;
@@ -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: _,
@@ -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>(
@@ -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<DomHandle> {
fn focusable_area_for_element(runtime: &JsContextHost, handle: DomHandle) -> Option<DomHandle> {
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<DomHandle> {
let mut stack = runtime.dom_host().child_handles(root).collect::<Vec<_>>();
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::<Vec<_>>();
children.reverse();
stack.extend(children);
}
None
}
fn first_focus_delegate(
runtime: &JsContextHost,
root: DomHandle,
require_sequential_focus: bool,
) -> Option<DomHandle> {
if let Some(target) = first_autofocus_delegate(runtime, root) {
return Some(target);
}
let mut stack = runtime.dom_host().child_handles(root).collect::<Vec<_>>();
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<DomHandle> {
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<DomHandle> {
first_focus_delegate(runtime, dialog, true)
}
fn delegated_focus_target(runtime: &JsContextHost, handle: DomHandle) -> Option<DomHandle> {
@@ -292,7 +355,9 @@ fn first_autofocus_candidate(runtime: &JsContextHost) -> Option<DomHandle> {
/// 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 {
@@ -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 = '<input><input id=target autofocus>';
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 = `
<dialog><button autofocus></button></dialog>
<button hidden autofocus></button>
<button tabindex=-1></button>
<button id=target></button>`;
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");