popover-focus.html (13750B)
1 <!DOCTYPE html> 2 <meta charset="utf-8" /> 3 <title>Popover focus behaviors</title> 4 <link rel="author" href="mailto:masonf@chromium.org"> 5 <link rel=help href="https://open-ui.org/components/popover.research.explainer"> 6 <meta name="timeout" content="long"> 7 <script src="/resources/testharness.js"></script> 8 <script src="/resources/testharnessreport.js"></script> 9 <script src="/resources/testdriver.js"></script> 10 <script src="/resources/testdriver-actions.js"></script> 11 <script src="/resources/testdriver-vendor.js"></script> 12 <script src="resources/popover-utils.js"></script> 13 14 <div popover data-test='default behavior - popover is not focused' data-no-focus> 15 <p>This is a popover</p> 16 <button tabindex="0">first button</button> 17 </div> 18 19 <div popover data-test='autofocus popover' autofocus tabindex=-1 class=should-be-focused> 20 <p>This is a popover</p> 21 </div> 22 23 <div popover data-test='autofocus empty popover' autofocus tabindex=-1 class=should-be-focused></div> 24 25 <div popover data-test='autofocus popover with button' autofocus tabindex=-1 class=should-be-focused> 26 <p>This is a popover</p> 27 <button tabindex="0">button</button> 28 </div> 29 30 <div popover data-test='autofocus child'> 31 <p>This is a popover</p> 32 <button autofocus class=should-be-focused tabindex="0">autofocus button</button> 33 </div> 34 35 <div popover data-test='autofocus on tabindex=0 element'> 36 <p autofocus tabindex=0 class=should-be-focused>This is a popover with autofocus on a tabindex=0 element</p> 37 <button tabindex="0">button</button> 38 </div> 39 40 <div popover data-test='autofocus multiple children'> 41 <p>This is a popover</p> 42 <button autofocus class=should-be-focused tabindex="0">autofocus button</button> 43 <button autofocus tabindex="0">second autofocus button</button> 44 </div> 45 46 <div popover autofocus tabindex=-1 data-test='autofocus popover and multiple autofocus children' class=should-be-focused> 47 <p>This is a popover</p> 48 <button autofocus tabindex="0">autofocus button</button> 49 <button autofocus tabindex="0">second autofocus button</button> 50 </div> 51 52 <dialog popover=auto data-test='Opening dialogs as popovers should use dialog initial focus algorithm.'> 53 <button class=should-be-focused tabindex="0">button</button> 54 </dialog> 55 56 <dialog popover=auto autofocus class=should-be-focused data-test='Opening dialogs as popovers which have autofocus should focus the dialog.'> 57 <button tabindex="0">button</button> 58 </dialog> 59 60 <style> 61 [popover] { 62 border: 2px solid black; 63 top:150px; 64 left:150px; 65 } 66 :focus-within { border: 5px dashed red; } 67 :focus { border: 5px solid lime; } 68 </style> 69 70 <script> 71 function addInvoker(t, popover) { 72 const button = document.createElement('button'); 73 button.innerText = 'Click me'; 74 const popoverId = 'popover-id'; 75 assert_equals(document.querySelectorAll('#' + popoverId).length, 0); 76 document.body.appendChild(button); 77 t.add_cleanup(function() { 78 popover.removeAttribute('id'); 79 button.remove(); 80 }); 81 popover.id = popoverId; 82 button.setAttribute('tabindex', '0'); 83 button.setAttribute('popovertarget', popoverId); 84 return button; 85 } 86 function addPriorFocus(t) { 87 const priorFocus = document.createElement('button'); 88 priorFocus.setAttribute("tabindex", "0"); 89 priorFocus.id = 'priorFocus'; 90 document.body.appendChild(priorFocus); 91 t.add_cleanup(() => priorFocus.remove()); 92 return priorFocus; 93 } 94 function activateAndVerify(popover) { 95 const testName = popover.getAttribute('data-test'); 96 promise_test(async t => { 97 const priorFocus = addPriorFocus(t); 98 let expectedFocusedElement = popover.matches('.should-be-focused') ? popover : popover.querySelector('.should-be-focused'); 99 const changesFocus = !popover.hasAttribute('data-no-focus'); 100 if (!changesFocus) { 101 expectedFocusedElement = priorFocus; 102 } 103 assert_true(!!expectedFocusedElement); 104 assert_false(popover.matches(':popover-open')); 105 106 // Directly show and hide the popover: 107 priorFocus.focus(); 108 assert_equals(document.activeElement, priorFocus); 109 popover.showPopover(); 110 assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popover.showPopover()`); 111 popover.hidePopover(); 112 assert_equals(document.activeElement, priorFocus, 'prior element should get focus on hide, or if focus didn\'t shift on show, focus should stay where it was'); 113 assert_false(isElementVisible(popover)); 114 115 // Manual popover does not restore focus 116 popover.popover = 'manual'; 117 priorFocus.focus(); 118 assert_equals(document.activeElement, priorFocus); 119 popover.showPopover(); 120 assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popover.showPopover()`); 121 popover.hidePopover(); 122 if (!popover.hasAttribute('data-no-focus')) { 123 assert_not_equals(document.activeElement, priorFocus, 'prior element should *not* get focus when the popover is manual'); 124 } 125 assert_false(isElementVisible(popover)); 126 popover.popover = 'auto'; 127 128 // Hit Escape: 129 priorFocus.focus(); 130 assert_equals(document.activeElement, priorFocus); 131 popover.showPopover(); 132 assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popover.showPopover()`); 133 await sendEscape(); 134 assert_equals(document.activeElement, priorFocus, 'prior element should get focus after Escape'); 135 assert_false(isElementVisible(popover)); 136 137 // Move focus into the popover, then hit Escape: 138 let containedButton = popover.querySelector('button'); 139 if (containedButton) { 140 priorFocus.focus(); 141 assert_equals(document.activeElement, priorFocus); 142 popover.showPopover(); 143 containedButton.focus(); 144 assert_equals(document.activeElement, containedButton); 145 await sendEscape(); 146 assert_equals(document.activeElement, priorFocus, 'prior element should get focus after Escape'); 147 assert_false(isElementVisible(popover)); 148 } 149 150 // Change the popover type: 151 priorFocus.focus(); 152 popover.showPopover(); 153 assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popover.showPopover()`); 154 assert_equals(popover.popover, 'auto', 'All popovers in this test should start as popover=auto'); 155 popover.popover = 'manual'; 156 assert_false(popover.matches(':popover-open'), 'Changing the popover type should hide the popover'); 157 assert_equals(document.activeElement, priorFocus, 'prior element should get focus when the type is changed'); 158 assert_false(isElementVisible(popover)); 159 popover.popover = 'auto'; 160 161 // Remove from the document: 162 priorFocus.focus(); 163 popover.showPopover(); 164 assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popover.showPopover()`); 165 popover.remove(); 166 assert_false(isElementVisible(popover), 'Removing the popover should hide it immediately'); 167 if (!popover.hasAttribute('data-no-focus')) { 168 assert_not_equals(document.activeElement, priorFocus, 'prior element should *not* get focus when the popover is removed from the document'); 169 } 170 document.body.appendChild(popover); 171 172 // Show a modal dialog: 173 priorFocus.focus(); 174 popover.showPopover(); 175 assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by popover.showPopover()`); 176 const dialog = document.body.appendChild(document.createElement('dialog')); 177 dialog.showModal(); 178 assert_false(popover.matches(':popover-open'), 'Opening a modal dialog should hide the popover'); 179 assert_not_equals(document.activeElement, priorFocus, 'prior element should *not* get focus when a modal dialog is shown'); 180 assert_false(isElementVisible(popover)); 181 dialog.close(); 182 dialog.remove(); 183 184 // Use an activating element: 185 const button = addInvoker(t, popover); 186 priorFocus.focus(); 187 button.click(); 188 assert_true(popover.matches(':popover-open')); 189 assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by button.click()`); 190 191 // Make sure Escape works in the invoker case: 192 await sendEscape(); 193 assert_equals(document.activeElement, priorFocus, 'prior element should get focus after Escape (via invoker)'); 194 assert_false(isElementVisible(popover)); 195 196 // Make sure we can directly focus the (already open) popover: 197 priorFocus.focus(); 198 button.click(); 199 assert_true(popover.matches(':popover-open')); 200 assert_equals(document.activeElement, expectedFocusedElement, `${testName} activated by button.click()`); 201 popover.focus(); 202 assert_equals(document.activeElement, popover.hasAttribute('tabindex') || popover.tagName === 'DIALOG' ? popover : expectedFocusedElement, `${testName} directly focus with popover.focus()`); 203 button.click(); // Button is set to toggle the popover 204 assert_false(popover.matches(':popover-open')); 205 assert_equals(document.activeElement, priorFocus, 'prior element should get focus on button-toggled hide'); 206 assert_false(isElementVisible(popover)); 207 }, "Popover focus test: " + testName); 208 209 promise_test(async t => { 210 const priorFocus = addPriorFocus(t); 211 assert_false(popover.matches(':popover-open'), 'popover should start out hidden'); 212 let button = addInvoker(t, popover); 213 assert_equals(button.getAttribute('popovertarget'), popover.id, 'This test assumes the button uses `popovertarget`.'); 214 assert_not_equals(button, priorFocus, 'Stranger things have happened'); 215 assert_false(popover.contains(button), 'Start with a non-contained button'); 216 priorFocus.focus(); 217 assert_equals(document.activeElement, priorFocus); 218 popover.showPopover(); 219 assert_true(popover.matches(':popover-open')); 220 await clickOn(button); // This will *not* light dismiss, but will "toggle" the popover. 221 assert_false(popover.matches(':popover-open')); 222 assert_equals(document.activeElement, button, 'focus should move to the button when clicked, and should stay there when the popover closes'); 223 assert_false(isElementVisible(popover)); 224 225 // Same thing, but the button is contained within the popover 226 button.setAttribute('popovertarget', popover.id); 227 button.setAttribute('popovertargetaction', 'hide'); 228 popover.appendChild(button); 229 t.add_cleanup(() => button.remove()); 230 priorFocus.focus(); 231 popover.showPopover(); 232 assert_true(popover.matches(':popover-open')); 233 const changesFocus = !popover.hasAttribute('data-no-focus'); 234 if (changesFocus) { 235 assert_not_equals(document.activeElement, priorFocus, 'focus should shift for this element'); 236 } 237 await clickOn(button); 238 assert_false(popover.matches(':popover-open'), 'clicking button should hide the popover'); 239 assert_equals(document.activeElement, priorFocus, 'Contained button should return focus to the previously focused element'); 240 assert_false(isElementVisible(popover)); 241 242 // Same thing, but the button is unrelated (no popovertarget) 243 button = document.createElement('button'); 244 button.setAttribute("tabindex", "0"); 245 document.body.appendChild(button); 246 priorFocus.focus(); 247 popover.showPopover(); 248 assert_true(popover.matches(':popover-open')); 249 await clickOn(button); // This will light dismiss the popover, focus the prior focus, then focus this button. 250 assert_false(popover.matches(':popover-open'), 'clicking button should hide the popover (via light dismiss)'); 251 assert_equals(document.activeElement, button, 'Focus should go to unrelated button on light dismiss'); 252 assert_false(isElementVisible(popover)); 253 }, "Popover button click focus test: " + testName); 254 255 promise_test(async t => { 256 if (popover.hasAttribute('data-no-focus')) { 257 // This test only applies if the popover changes focus 258 return; 259 } 260 const priorFocus = addPriorFocus(t); 261 assert_false(popover.matches(':popover-open'), 'popover should start out hidden'); 262 263 // Move the prior focus out of the document 264 priorFocus.focus(); 265 popover.showPopover(); 266 assert_true(popover.matches(':popover-open')); 267 const newFocus = document.activeElement; 268 assert_not_equals(newFocus, priorFocus, 'focus should shift for this element'); 269 priorFocus.remove(); 270 assert_equals(document.activeElement, newFocus, 'focus should not change when prior focus is removed'); 271 popover.hidePopover(); 272 assert_not_equals(document.activeElement, priorFocus, 'focused element has been removed'); 273 assert_false(isElementVisible(popover)); 274 document.body.appendChild(priorFocus); // Put it back 275 276 // Move the prior focus inside the (already open) popover 277 priorFocus.focus(); 278 popover.showPopover(); 279 assert_true(popover.matches(':popover-open')); 280 assert_false(popover.contains(priorFocus), 'Start with a non-contained prior focus'); 281 popover.appendChild(priorFocus); // Move inside the popover 282 assert_true(popover.contains(priorFocus)); 283 assert_true(popover.matches(':popover-open'), 'popover should stay open'); 284 popover.hidePopover(); 285 assert_false(isElementVisible(popover)); 286 assert_not_equals(document.activeElement, priorFocus, 'focused element is display:none inside the popover'); 287 document.body.appendChild(priorFocus); // Put it back 288 }, "Popover corner cases test: " + testName); 289 } 290 291 document.querySelectorAll('body > [popover]').forEach(popover => activateAndVerify(popover)); 292 </script>