Skip to content

Commit

Permalink
Return focus to previously-focused element on popup hide
Browse files Browse the repository at this point in the history
Akin to <dialog>'s behavior, the desired behavior is that focus
is returned to the previously-focused element when a popup is
hidden:

  openui/open-ui#327

Bug: 1307772
Change-Id: Ia6ae1981612a0c0150b8b5f51b485a00a9a90de9
  • Loading branch information
mfreed7 authored and chromium-wpt-export-bot committed May 19, 2022
1 parent 9370bce commit b4a448b
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 44 deletions.
155 changes: 133 additions & 22 deletions html/semantics/popups/popup-focus.tentative.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
<title>Popup focus behaviors</title>
<link rel="author" href="mailto:[email protected]">
<link rel=help href="https://open-ui.org/components/popup.research.explainer">
<meta name="timeout" content="long">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-actions.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script src="resources/popup-utils.js"></script>

<div popup=popup data-test='default behavior - popup is not focused' data-no-focus>
<p>This is a popup</p>
Expand Down Expand Up @@ -98,47 +103,153 @@
</style>

<script>
function activateAndVerify(popup) {
const testName = popup.getAttribute('data-test');
function addInvoker(t, popup) {
const button = document.createElement('button');
button.innerText = 'Click me';
const popupId = 'popup-id';
assert_equals(document.querySelectorAll('#' + popupId).length, 0);
document.body.appendChild(button);
t.add_cleanup(function() {
popup.removeAttribute('id');
button.remove();
});
popup.id = popupId;
button.setAttribute('togglepopup', popupId);
return button;
}
function addPriorFocus(t) {
const priorFocus = document.createElement('button');
priorFocus.id = 'prior-focus';
document.body.appendChild(priorFocus);
let expectedFocusedElement = popup.matches('.should-be-focused') ? popup : popup.querySelector('.should-be-focused');
if (popup.hasAttribute('data-no-focus')) {
expectedFocusedElement = priorFocus;
}
t.add_cleanup(() => priorFocus.remove());
return priorFocus;
}
function activateAndVerify(popup) {
const testName = popup.getAttribute('data-test');
test(t => {
t.add_cleanup(() => priorFocus.remove());
const priorFocus = addPriorFocus(t);
let expectedFocusedElement = popup.matches('.should-be-focused') ? popup : popup.querySelector('.should-be-focused');
if (popup.hasAttribute('data-no-focus')) {
expectedFocusedElement = priorFocus;
}
assert_true(!!expectedFocusedElement);
assert_false(popup.matches(':popup-open'));
priorFocus.focus();
assert_equals(document.activeElement,priorFocus);

// Directly show the popup:
// Directly show and hide the popup:
priorFocus.focus();
assert_equals(document.activeElement, priorFocus);
popup.showPopup();
assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popup.showPopup()`);
popup.hidePopup();
assert_equals(document.activeElement, priorFocus, 'prior element should get focus on hide');

// Change the popup type:
priorFocus.focus();
popup.showPopup();
assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popup.showPopup()`);
assert_equals(popup.popup, 'popup', 'All popups in this test should start as popup=popup');
popup.popup = 'hint';
assert_false(popup.matches(':popup-open'), 'Changing the popup type should hide the popup');
assert_equals(document.activeElement, priorFocus, 'prior element should get focus when the type is changed');
popup.popup = 'popup';

// Remove from the document:
priorFocus.focus();
popup.showPopup();
assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popup.showPopup()`);
popup.remove();
assert_false(popup.matches(':popup-open'), 'Removing the popup should hide it');
if (!popup.hasAttribute('data-no-focus')) {
assert_not_equals(document.activeElement, priorFocus, 'prior element should *not* get focus when the popup is removed from the document');
}
document.body.appendChild(popup);

// Show a modal dialog:
priorFocus.focus();
popup.showPopup();
assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popup.showPopup()`);
const dialog = document.body.appendChild(document.createElement('dialog'));
dialog.showModal();
assert_false(popup.matches(':popup-open'), 'Opening a modal dialog should hide the popup');
assert_not_equals(document.activeElement, priorFocus, 'prior element should *not* get focus when a modal dialog is shown');
dialog.close();
dialog.remove();

// Use an activating element:
const button = document.createElement('button');
const popupId = 'popup-id';
assert_equals(document.querySelectorAll('#' + popupId).length,0);
document.body.appendChild(button);
t.add_cleanup(function() {
popup.removeAttribute('id');
button.remove();
});
popup.id = popupId;
button.setAttribute('togglepopup', popupId);
const button = addInvoker(t, popup);
priorFocus.focus();
button.click();
assert_true(popup.matches(':popup-open'));
assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by button.click()`);

// Make sure we can directly focus the (already open) popup:
popup.focus();
assert_equals(document.activeElement, popup.hasAttribute('tabindex') ? popup : expectedFocusedElement, `${testName} directly focus with popup.focus()`);
popup.hidePopup();
button.click(); // Button is set to toggle the popup
assert_false(popup.matches(':popup-open'));
assert_equals(document.activeElement, priorFocus, 'prior element should get focus on button-toggled hide');
}, "Popup focus test: " + testName);

promise_test(async t => {
const priorFocus = addPriorFocus(t);
assert_false(popup.matches(':popup-open'), 'popup should start out hidden');
const button = addInvoker(t, popup);
assert_equals(button.getAttribute('togglepopup'), popup.id, 'This test assumes the button uses `togglepopup`.');
assert_false(popup.contains(button), 'Start with a non-contained button');
priorFocus.focus();
assert_equals(document.activeElement, priorFocus);
await clickOn(button);
assert_true(popup.matches(':popup-open'));
await clickOn(button);
assert_false(popup.matches(':popup-open'));
assert_equals(document.activeElement, button, 'Button should get focused');

// Same thing, but the button is contained within the popup
button.removeAttribute('togglepopup');
button.setAttribute('hidepopup', popup.id);
popup.appendChild(button);
priorFocus.focus();
popup.showPopup();
assert_true(popup.matches(':popup-open'));
if (!popup.hasAttribute('data-no-focus')) {
assert_not_equals(document.activeElement, priorFocus, 'focus should shift for this element');
}
await clickOn(button);
assert_false(popup.matches(':popup-open'), 'clicking button should hide the popup');
assert_equals(document.activeElement, priorFocus, 'Contained button should return focus to the previously focused element');
}, "Popup button click focus test: " + testName);

promise_test(async t => {
if (popup.hasAttribute('data-no-focus')) {
// This test only applies if the popup changes focus
return;
}
const priorFocus = addPriorFocus(t);
assert_false(popup.matches(':popup-open'), 'popup should start out hidden');

// Move the prior focus out of the document
priorFocus.focus();
popup.showPopup();
assert_true(popup.matches(':popup-open'));
const newFocus = document.activeElement;
assert_not_equals(newFocus, priorFocus, 'focus should shift for this element');
priorFocus.remove();
assert_equals(document.activeElement, newFocus, 'focus should not change when prior focus is removed');
popup.hidePopup();
assert_not_equals(document.activeElement, priorFocus, 'focused element has been removed');
document.body.appendChild(priorFocus); // Put it back

// Move the prior focus inside the (already open) popup
priorFocus.focus();
popup.showPopup();
assert_true(popup.matches(':popup-open'));
assert_false(popup.contains(priorFocus), 'Start with a non-contained prior focus');
popup.appendChild(priorFocus); // Move inside the popup
assert_true(popup.contains(priorFocus));
assert_true(popup.matches(':popup-open'), 'popup should stay open');
popup.hidePopup();
assert_not_equals(document.activeElement, priorFocus, 'focused element is display:none inside the popup');
document.body.appendChild(priorFocus); // Put it back
}, "Popup corner cases test: " + testName);
}

document.querySelectorAll('body > [popup]').forEach(popup => activateAndVerify(popup));
Expand Down
27 changes: 5 additions & 22 deletions html/semantics/popups/popup-light-dismiss.tentative.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-actions.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script src="resources/popup-utils.js"></script>

<button id=b1 togglepopup='p1'>Popup 1</button>
<button id=p1anchor>Popup1 anchor (no action)</button>
Expand All @@ -30,24 +31,6 @@
}
</style>
<script>
function spinEventLoop() {
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
async function clickOn(element) {
const actions = new test_driver.Actions();
await spinEventLoop();
await actions.pointerMove(0, 0, {origin: element})
.pointerDown({button: actions.ButtonType.LEFT})
.pointerUp({button: actions.ButtonType.LEFT})
.send();
await spinEventLoop();
}
async function sendTab() {
await spinEventLoop();
await new test_driver.send_keys(document.body,'\uE004'); // Tab
await spinEventLoop();
}

const popup1 = document.querySelector('#p1');
const button1 = document.querySelector('#b1');
const popup1anchor = document.querySelector('#p1anchor');
Expand Down Expand Up @@ -81,7 +64,7 @@
promise_test(async () => {
assert_false(popup1.matches(':popup-open'));
popup1.showPopup();
await spinEventLoop();
await waitForRender();
p1HideCount = popup1HideCount;
await clickOn(inside1);
assert_true(popup1.matches(':popup-open'));
Expand All @@ -102,7 +85,7 @@
promise_test(async () => {
popup1.showPopup();
popup2.showPopup();
await spinEventLoop();
await waitForRender();
p1HideCount = popup1HideCount;
let p2HideCount = popup2HideCount;
await clickOn(inside2);
Expand All @@ -118,7 +101,7 @@
promise_test(async () => {
popup1.showPopup();
popup2.showPopup();
await spinEventLoop();
await waitForRender();
p1HideCount = popup1HideCount;
p2HideCount = popup2HideCount;
await clickOn(inside1);
Expand All @@ -132,7 +115,7 @@
promise_test(async () => {
popup1.showPopup();
assert_true(popup1.matches(':popup-open'));
await spinEventLoop();
await waitForRender();
p1HideCount = popup1HideCount;
await clickOn(popup1anchor);
assert_true(popup1.matches(':popup-open'),'popup1 not open');
Expand Down
17 changes: 17 additions & 0 deletions html/semantics/popups/resources/popup-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
function waitForRender() {
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
async function clickOn(element) {
const actions = new test_driver.Actions();
await waitForRender();
await actions.pointerMove(0, 0, {origin: element})
.pointerDown({button: actions.ButtonType.LEFT})
.pointerUp({button: actions.ButtonType.LEFT})
.send();
await waitForRender();
}
async function sendTab() {
await waitForRender();
await new test_driver.send_keys(document.body,'\uE004'); // Tab
await waitForRender();
}

0 comments on commit b4a448b

Please sign in to comment.