diff --git a/moli-dom/src/native/element/control_state.rs b/moli-dom/src/native/element/control_state.rs index 2c5429fea6..72f0659372 100644 --- a/moli-dom/src/native/element/control_state.rs +++ b/moli-dom/src/native/element/control_state.rs @@ -173,6 +173,7 @@ pub struct ElementControlState { custom_validation_message: String, popover_open: bool, dialog_modal: bool, + dialog_previously_focused_element: Option, dialog_return_value: String, custom_states: IndexSet, explicit_element_references: Option>, @@ -456,6 +457,10 @@ impl ElementControlState { self.dialog_modal } + pub fn dialog_previously_focused_element(&self) -> Option { + self.dialog_previously_focused_element + } + pub fn dialog_return_value(&self) -> &str { &self.dialog_return_value } @@ -855,6 +860,14 @@ impl ElementControlState { true } + pub fn set_dialog_previously_focused_element(&mut self, element: Option) -> bool { + if self.dialog_previously_focused_element == element { + return false; + } + self.dialog_previously_focused_element = element; + true + } + pub fn set_dialog_return_value(&mut self, value: &str) -> bool { if self.dialog_return_value == value { return false; diff --git a/moli-dom/src/native/element/mod.rs b/moli-dom/src/native/element/mod.rs index 0de3f42eb4..8abc355550 100644 --- a/moli-dom/src/native/element/mod.rs +++ b/moli-dom/src/native/element/mod.rs @@ -1072,6 +1072,10 @@ impl Element { self.control_state().dialog_modal() } + pub fn dialog_previously_focused_element(&self) -> Option { + self.control_state().dialog_previously_focused_element() + } + pub fn dialog_return_value(&self) -> &str { self.control_state().dialog_return_value() } @@ -1098,6 +1102,14 @@ impl Element { self.control_state_mut().set_dialog_modal(modal) } + pub fn set_dialog_previously_focused_element(&mut self, element: Option) -> bool { + if !self.is_html_element("dialog") { + return false; + } + self.control_state_mut() + .set_dialog_previously_focused_element(element) + } + pub fn set_dialog_return_value(&mut self, value: &str) -> bool { if !self.is_html_element("dialog") { return false; diff --git a/moli-dom/src/native/host/mutation/state.rs b/moli-dom/src/native/host/mutation/state.rs index 4aca93b3c5..2b7d5e32ab 100644 --- a/moli-dom/src/native/host/mutation/state.rs +++ b/moli-dom/src/native/host/mutation/state.rs @@ -361,6 +361,26 @@ impl DomHost { did_change } + pub fn set_dialog_previously_focused_element( + &mut self, + handle: DomHandle, + focused: Option, + ) -> bool { + let did_change = { + let Some(element) = self + .node_mut(handle) + .and_then(|node| node.data_mut().as_element_mut()) + else { + return false; + }; + element.set_dialog_previously_focused_element(focused) + }; + if did_change { + self.record_mutation(MutationScope::LocalState); + } + did_change + } + pub fn set_dialog_return_value(&mut self, handle: DomHandle, value: &str) -> bool { let did_change = { let Some(element) = self 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 50cda3e40e..391bd106e2 100644 --- a/moli-renderer-v8/src/native_bridge/element/details_dialog.rs +++ b/moli-renderer-v8/src/native_bridge/element/details_dialog.rs @@ -11,6 +11,7 @@ use crate::{ use super::super::{ JsContextHost, node::node_runtime_and_handle_from_object_or_detached, throw_dom_exception, }; +use super::focus::{remember_dialog_previously_focused_element, restore_dialog_focus_after_close}; use super::toggle_event::queue_element_toggle_event; use super::{ element_has_attribute, html_element_getter_receiver, html_element_setter_receiver, @@ -418,6 +419,7 @@ pub(super) fn dialog_show_callback<'s>( return; } dialog_set_open_state_for_handle(scope, runtime_ptr, handle, true, false); + remember_dialog_previously_focused_element(runtime_ptr, handle); } pub(super) fn dialog_show_modal_callback<'s>( @@ -451,6 +453,7 @@ pub(super) fn dialog_show_modal_callback<'s>( return; } dialog_set_open_state_for_handle(scope, runtime_ptr, handle, true, true); + remember_dialog_previously_focused_element(runtime_ptr, handle); } pub(super) fn dialog_close_callback<'s>( @@ -481,6 +484,7 @@ pub(in crate::native_bridge::element) fn close_dialog_element( return false; } + let was_modal = dialog_is_modal(runtime, handle); set_reflected_boolean_attribute(scope, runtime_ptr, handle, "open", false); let runtime = unsafe { &mut *runtime_ptr }; let _ = runtime.dom_host_mut().set_dialog_modal(handle, false); @@ -489,6 +493,8 @@ pub(in crate::native_bridge::element) fn close_dialog_element( .dom_host_mut() .set_dialog_return_value(handle, return_value); } + restore_dialog_focus_after_close(scope, runtime_ptr, handle, was_modal); + let runtime = unsafe { &mut *runtime_ptr }; queue_dialog_close_event(scope, runtime, handle); true } diff --git a/moli-renderer-v8/src/native_bridge/element/focus.rs b/moli-renderer-v8/src/native_bridge/element/focus.rs index 98dd042831..70cb32d345 100644 --- a/moli-renderer-v8/src/native_bridge/element/focus.rs +++ b/moli-renderer-v8/src/native_bridge/element/focus.rs @@ -259,6 +259,56 @@ pub(crate) fn process_post_parse_autofocus( true } +pub(super) fn remember_dialog_previously_focused_element( + runtime_ptr: *mut JsContextHost, + dialog: DomHandle, +) { + let focused = unsafe { &*runtime_ptr }.active_element_handle(); + let _ = unsafe { &mut *runtime_ptr } + .dom_host_mut() + .set_dialog_previously_focused_element(dialog, focused); +} + +pub(super) fn restore_dialog_focus_after_close( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + dialog: DomHandle, + was_modal: bool, +) { + let (previously_focused, should_restore) = { + let runtime = unsafe { &*runtime_ptr }; + let previously_focused = runtime + .dom_host() + .node(dialog) + .and_then(Node::as_element) + .and_then(|element| element.dialog_previously_focused_element()); + let focus_is_inside_dialog = runtime + .active_element_handle() + .is_some_and(|active| flat_tree_contains(runtime, dialog, active)); + (previously_focused, was_modal || focus_is_inside_dialog) + }; + let _ = unsafe { &mut *runtime_ptr } + .dom_host_mut() + .set_dialog_previously_focused_element(dialog, None); + let Some(previously_focused) = previously_focused.filter(|_| should_restore) else { + return; + }; + if let Err(error) = focus_element_with_options(scope, runtime_ptr, previously_focused, true) { + tracing::warn!(?previously_focused, %error, "failed to restore focus after closing dialog"); + } +} + +fn flat_tree_contains(runtime: &JsContextHost, ancestor: DomHandle, node: DomHandle) -> bool { + let mut current = Some(node); + while let Some(candidate) = current { + if candidate == ancestor { + return true; + } + current = flat_tree_parent(runtime, candidate); + } + false +} + 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 63d2f8aaea..6a007300d7 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 @@ -221,3 +221,102 @@ fn dialog_form_submission_distinguishes_absent_and_empty_submitter_values() { r#"{"absentValue":{"open":false,"returnValue":"previous","valueAttribute":null},"emptyValue":{"open":false,"returnValue":"","valueAttribute":""}}"# ); } + +#[test] +fn dialog_close_restores_saved_focus_without_scrolling() { + let mut vm = new_storage_test_vm("https://dialog-focus-restoration.test/"); + assert_eq!( + vm.eval( + r#" +(() => { + const check = (ok, label) => { if (!ok) throw Error(label); }; + const root = document.documentElement || document.appendChild(document.createElement('html')); + const body = document.body || root.appendChild(document.createElement('body')); + body.style.cssText = 'height:3000px;width:3000px'; + const before = document.createElement('button'); + const outside = document.createElement('button'); + const dialog = document.createElement('dialog'); + const inner = document.createElement('button'); + dialog.append(inner); + body.append(before, outside, dialog); + + for (const method of ['show', 'showModal']) { + before.focus(); + check(document.activeElement === before, method + ' initial focus'); + dialog[method](); + inner.focus(); + check(document.activeElement === inner, method + ' inner focus'); + window.scrollTo(11, 19); + const position = [scrollX, scrollY].join('|'); + before.focus = () => { throw Error('author focus expando must not be called'); }; + dialog.close('result'); + check(document.activeElement === before, method + ' restores saved focus'); + check([scrollX, scrollY].join('|') === position, method + ' prevents scrolling'); + check(dialog.returnValue === 'result', method + ' preserves return value'); + delete before.focus; + } + + before.focus(); + dialog.show(); + inner.focus(); + dialog.show(); + dialog.close(); + check(document.activeElement === before, 'repeated show preserves the original saved element'); + + before.focus(); + dialog.show(); + outside.focus(); + dialog.close(); + check(document.activeElement === outside, 'non-modal close preserves focus moved outside'); + + outside.focus(); + dialog.show(); + inner.focus(); + dialog.close(); + check(document.activeElement === outside, 'reopening stores a fresh focused element'); + + before.focus(); + dialog.showModal(); + inner.focus(); + inner.blur(); + dialog.close(); + check(document.activeElement === before, 'modal close restores even after focus is cleared'); + + const host = document.createElement('div'); + const shadow = host.attachShadow({mode: 'open'}); + const shadowButton = document.createElement('button'); + shadow.append(shadowButton); + dialog.append(host); + before.focus(); + dialog.show(); + shadowButton.focus(); + dialog.close(); + check(document.activeElement === before, 'shadow-including descendant restores saved focus'); + + const slotHost = document.createElement('div'); + const slotShadow = slotHost.attachShadow({mode: 'open'}); + slotShadow.innerHTML = ''; + const slottedButton = document.createElement('button'); + slotHost.append(slottedButton); + body.append(slotHost); + const slotDialog = slotShadow.querySelector('dialog'); + before.focus(); + slotDialog.show(); + slottedButton.focus(); + slotDialog.close(); + check(document.activeElement === before, 'slotted descendant restores saved focus'); + + before.focus(); + dialog.show(); + inner.focus(); + before.remove(); + dialog.close(); + check(document.activeElement !== before, 'removed saved element cannot receive focus'); + return 'passed'; +})() +"# + ) + .unwrap(), + "passed" + ); +}