fix(dialog): restore saved focus when closing

This commit is contained in:
ldm0
2026-10-04 02:26:30 +08:00
parent 82540f8d5c
commit c7f92adeab
6 changed files with 200 additions and 0 deletions
@@ -173,6 +173,7 @@ pub struct ElementControlState {
custom_validation_message: String,
popover_open: bool,
dialog_modal: bool,
dialog_previously_focused_element: Option<NativeNodeId>,
dialog_return_value: String,
custom_states: IndexSet<String>,
explicit_element_references: Option<Box<ExplicitElementReferenceState>>,
@@ -456,6 +457,10 @@ impl ElementControlState {
self.dialog_modal
}
pub fn dialog_previously_focused_element(&self) -> Option<NativeNodeId> {
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<NativeNodeId>) -> 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;
+12
View File
@@ -1072,6 +1072,10 @@ impl Element {
self.control_state().dialog_modal()
}
pub fn dialog_previously_focused_element(&self) -> Option<NativeNodeId> {
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<NativeNodeId>) -> 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;
@@ -361,6 +361,26 @@ impl DomHost {
did_change
}
pub fn set_dialog_previously_focused_element(
&mut self,
handle: DomHandle,
focused: Option<DomHandle>,
) -> 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
@@ -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
}
@@ -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 {
@@ -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 = '<dialog><slot></slot></dialog>';
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"
);
}