mirror of
https://github.com/lexmount/moli.git
synced 2026-10-06 08:00:59 +00:00
fix(dialog): restore saved focus when closing
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user