mirror of
https://github.com/lexmount/moli.git
synced 2026-11-04 16:06:44 +00:00
fix(dialog): run focusing steps when shown
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user