| <!DOCTYPE html> |
| <meta charset="utf-8" /> |
| <title>Popover light dismiss behavior</title> |
| <meta name="timeout" content="long"> |
| <link rel="author" href="mailto:masonf@chromium.org"> |
| <link rel=help href="https://open-ui.org/components/popup.research.explainer"> |
| <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/popover-utils.js"></script> |
| |
| <button id=b1t popovertoggletarget='p1'>Popover 1</button> |
| <button id=b1s popovershowtarget='p1'>Popover 1</button> |
| <button id=p1anchor>Popover1 anchor (no action)</button> |
| <span id=outside>Outside all popovers</span> |
| <div popover id=p1 anchor=p1anchor> |
| <span id=inside1>Inside popover 1</span> |
| <button id=b2 popovershowtarget='p2'>Popover 2</button> |
| <span id=inside1after>Inside popover 1 after button</span> |
| </div> |
| <div popover id=p2 anchor=b2> |
| <span id=inside2>Inside popover 2</span> |
| </div> |
| <button id=after_p1>Next control after popover1</button> |
| <style> |
| #p1 {top: 50px;} |
| #p2 {top: 120px;} |
| [popover] {bottom:auto;} |
| [popover]::backdrop { |
| /* This should *not* affect anything: */ |
| pointer-events: auto; |
| } |
| </style> |
| <script> |
| const popover1 = document.querySelector('#p1'); |
| const button1toggle = document.querySelector('#b1t'); |
| const button1show = document.querySelector('#b1s'); |
| const popover1anchor = document.querySelector('#p1anchor'); |
| const inside1After = document.querySelector('#inside1after'); |
| const button2 = document.querySelector('#b2'); |
| const popover2 = document.querySelector('#p2'); |
| const outside = document.querySelector('#outside'); |
| const inside1 = document.querySelector('#inside1'); |
| const inside2 = document.querySelector('#inside2'); |
| const afterp1 = document.querySelector('#after_p1'); |
| |
| let popover1HideCount = 0; |
| popover1.addEventListener('beforetoggle',(e) => { |
| if (e.newState !== "closed") |
| return; |
| ++popover1HideCount; |
| e.preventDefault(); // 'beforetoggle' should not be cancellable. |
| }); |
| let popover2HideCount = 0; |
| popover2.addEventListener('beforetoggle',(e) => { |
| if (e.newState !== "closed") |
| return; |
| ++popover2HideCount; |
| e.preventDefault(); // 'beforetoggle' should not be cancellable. |
| }); |
| promise_test(async () => { |
| assert_false(popover1.matches(':open')); |
| popover1.showPopover(); |
| assert_true(popover1.matches(':open')); |
| let p1HideCount = popover1HideCount; |
| await clickOn(outside); |
| assert_false(popover1.matches(':open')); |
| assert_equals(popover1HideCount,p1HideCount+1); |
| },'Clicking outside a popover will dismiss the popover'); |
| |
| promise_test(async (t) => { |
| const controller = new AbortController(); |
| t.add_cleanup(() => controller.abort()); |
| function addListener(eventName) { |
| document.addEventListener(eventName,(e) => e.preventDefault(),{signal:controller.signal,capture: true}); |
| } |
| addListener('pointerdown'); |
| addListener('pointerup'); |
| addListener('mousedown'); |
| addListener('mouseup'); |
| assert_false(popover1.matches(':open')); |
| popover1.showPopover(); |
| assert_true(popover1.matches(':open')); |
| let p1HideCount = popover1HideCount; |
| await clickOn(outside); |
| assert_false(popover1.matches(':open'),'preventDefault should not prevent light dismiss'); |
| assert_equals(popover1HideCount,p1HideCount+1); |
| },'Canceling pointer events should not keep clicks from light dismissing popovers'); |
| |
| promise_test(async () => { |
| assert_false(popover1.matches(':open')); |
| popover1.showPopover(); |
| await waitForRender(); |
| p1HideCount = popover1HideCount; |
| await clickOn(inside1); |
| assert_true(popover1.matches(':open')); |
| assert_equals(popover1HideCount,p1HideCount); |
| popover1.hidePopover(); |
| },'Clicking inside a popover does not close that popover'); |
| |
| promise_test(async () => { |
| assert_false(popover1.matches(':open')); |
| popover1.showPopover(); |
| await waitForRender(); |
| assert_true(popover1.matches(':open')); |
| const actions = new test_driver.Actions(); |
| await actions.pointerMove(0, 0, {origin: outside}) |
| .pointerDown({button: actions.ButtonType.LEFT}) |
| .send(); |
| await waitForRender(); |
| assert_true(popover1.matches(':open'),'pointerdown (outside the popover) should not hide the popover'); |
| await actions.pointerUp({button: actions.ButtonType.LEFT}) |
| .send(); |
| await waitForRender(); |
| assert_false(popover1.matches(':open'),'pointerup (outside the popover) should trigger light dismiss'); |
| },'Popovers close on pointerup, not pointerdown'); |
| |
| promise_test(async () => { |
| assert_false(popover1.matches(':open')); |
| popover1.showPopover(); |
| assert_true(popover1.matches(':open')); |
| async function testOne(eventName) { |
| document.body.dispatchEvent(new PointerEvent(eventName)); |
| document.body.dispatchEvent(new MouseEvent(eventName)); |
| document.body.dispatchEvent(new ProgressEvent(eventName)); |
| await waitForRender(); |
| assert_true(popover1.matches(':open'),`A synthetic "${eventName}" event should not hide the popover`); |
| } |
| await testOne('pointerup'); |
| await testOne('pointerdown'); |
| await testOne('mouseup'); |
| await testOne('mousedown'); |
| popover1.hidePopover(); |
| },'Synthetic events can\'t close popovers'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); |
| await clickOn(inside1After); |
| assert_true(popover1.matches(':open')); |
| await sendTab(); |
| assert_equals(document.activeElement,afterp1,'Focus should move to a button outside the popover'); |
| assert_true(popover1.matches(':open')); |
| popover1.hidePopover(); |
| },'Moving focus outside the popover should not dismiss the popover'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); |
| popover2.showPopover(); |
| await waitForRender(); |
| p1HideCount = popover1HideCount; |
| let p2HideCount = popover2HideCount; |
| await clickOn(inside2); |
| assert_true(popover1.matches(':open'),'popover1 should be open'); |
| assert_true(popover2.matches(':open'),'popover2 should be open'); |
| assert_equals(popover1HideCount,p1HideCount,'popover1'); |
| assert_equals(popover2HideCount,p2HideCount,'popover2'); |
| popover1.hidePopover(); |
| assert_false(popover1.matches(':open')); |
| assert_false(popover2.matches(':open')); |
| },'Clicking inside a child popover shouldn\'t close either popover'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); |
| popover2.showPopover(); |
| await waitForRender(); |
| p1HideCount = popover1HideCount; |
| p2HideCount = popover2HideCount; |
| await clickOn(inside1); |
| assert_true(popover1.matches(':open')); |
| assert_equals(popover1HideCount,p1HideCount); |
| assert_false(popover2.matches(':open')); |
| assert_equals(popover2HideCount,p2HideCount+1); |
| popover1.hidePopover(); |
| },'Clicking inside a parent popover should close child popover'); |
| |
| promise_test(async () => { |
| await clickOn(button1show); |
| assert_true(popover1.matches(':open')); |
| await waitForRender(); |
| p1HideCount = popover1HideCount; |
| await clickOn(button1show); |
| assert_true(popover1.matches(':open'),'popover1 should stay open'); |
| assert_equals(popover1HideCount,p1HideCount,'popover1 should not get hidden and reshown'); |
| popover1.hidePopover(); // Cleanup |
| assert_false(popover1.matches(':open')); |
| },'Clicking on invoking element, after using it for activation, shouldn\'t close its popover'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); |
| assert_true(popover1.matches(':open')); |
| assert_false(popover2.matches(':open')); |
| await clickOn(button2); |
| assert_true(popover2.matches(':open'),'button2 should activate popover2'); |
| p2HideCount = popover2HideCount; |
| await clickOn(button2); |
| assert_true(popover2.matches(':open'),'popover2 should stay open'); |
| assert_equals(popover2HideCount,p2HideCount,'popover2 should not get hidden and reshown'); |
| popover1.hidePopover(); // Cleanup |
| assert_false(popover1.matches(':open')); |
| assert_false(popover2.matches(':open')); |
| },'Clicking on invoking element, after using it for activation, shouldn\'t close its popover (nested case)'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); |
| popover2.showPopover(); |
| assert_true(popover1.matches(':open')); |
| assert_true(popover2.matches(':open')); |
| p2HideCount = popover2HideCount; |
| await clickOn(button2); |
| assert_true(popover2.matches(':open'),'popover2 should stay open'); |
| assert_equals(popover2HideCount,p2HideCount,'popover2 should not get hidden and reshown'); |
| popover1.hidePopover(); // Cleanup |
| assert_false(popover1.matches(':open')); |
| assert_false(popover2.matches(':open')); |
| },'Clicking on invoking element, after using it for activation, shouldn\'t close its popover (nested case, not used for invocation)'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); // Directly show the popover |
| assert_true(popover1.matches(':open')); |
| await waitForRender(); |
| p1HideCount = popover1HideCount; |
| await clickOn(button1show); |
| assert_true(popover1.matches(':open'),'popover1 should stay open'); |
| assert_equals(popover1HideCount,p1HideCount,'popover1 should not get hidden and reshown'); |
| popover1.hidePopover(); // Cleanup |
| assert_false(popover1.matches(':open')); |
| },'Clicking on invoking element, even if it wasn\'t used for activation, shouldn\'t close its popover'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); // Directly show the popover |
| assert_true(popover1.matches(':open')); |
| await waitForRender(); |
| p1HideCount = popover1HideCount; |
| await clickOn(button1toggle); |
| assert_false(popover1.matches(':open'),'popover1 should be hidden by popovertoggletarget'); |
| assert_equals(popover1HideCount,p1HideCount+1,'popover1 should get hidden only once by popovertoggletarget'); |
| },'Clicking on popovertoggletarget element, even if it wasn\'t used for activation, should hide it exactly once'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); |
| assert_true(popover1.matches(':open')); |
| await waitForRender(); |
| await clickOn(popover1anchor); |
| assert_false(popover1.matches(':open'),'popover1 should close'); |
| },'Clicking on anchor element (that isn\'t an invoking element) shouldn\'t prevent its popover from being closed'); |
| |
| promise_test(async () => { |
| popover1.showPopover(); |
| popover2.showPopover(); // Popover1 is an ancestral element for popover2. |
| assert_true(popover1.matches(':open')); |
| assert_true(popover2.matches(':open')); |
| const drag_actions = new test_driver.Actions(); |
| // Drag *from* popover2 *to* popover1 (its ancestor). |
| await drag_actions.pointerMove(0,0,{origin: popover2}) |
| .pointerDown({button: drag_actions.ButtonType.LEFT}) |
| .pointerMove(0,0,{origin: popover1}) |
| .pointerUp({button: drag_actions.ButtonType.LEFT}) |
| .send(); |
| assert_true(popover1.matches(':open'),'popover1 should be open'); |
| assert_true(popover2.matches(':open'),'popover1 should be open'); |
| popover1.hidePopover(); |
| assert_false(popover2.matches(':open')); |
| },'Dragging from an open popover outside an open popover should leave the popover open'); |
| </script> |
| |
| <button id=b3 popovertoggletarget=p3>Popover 3 - button 3 |
| <div popover id=p4>Inside popover 4</div> |
| </button> |
| <div popover id=p3>Inside popover 3</div> |
| <div popover id=p5>Inside popover 5 |
| <button popovertoggletarget=p3>Popover 3 - button 4 - unused</button> |
| </div> |
| <style> |
| #p3 {top:100px;} |
| #p4 {top:200px;} |
| #p5 {top:200px;} |
| </style> |
| <script> |
| const popover3 = document.querySelector('#p3'); |
| const popover4 = document.querySelector('#p4'); |
| const popover5 = document.querySelector('#p5'); |
| const button3 = document.querySelector('#b3'); |
| promise_test(async () => { |
| await clickOn(button3); |
| assert_true(popover3.matches(':open'),'invoking element should open popover'); |
| popover4.showPopover(); |
| assert_true(popover4.matches(':open')); |
| assert_false(popover3.matches(':open'),'popover3 is unrelated to popover4'); |
| popover4.hidePopover(); // Cleanup |
| assert_false(popover4.matches(':open')); |
| },'A popover inside an invoking element doesn\'t participate in that invoker\'s ancestor chain'); |
| |
| promise_test(async () => { |
| popover5.showPopover(); |
| assert_true(popover5.matches(':open')); |
| assert_false(popover3.matches(':open')); |
| popover3.showPopover(); |
| assert_true(popover3.matches(':open')); |
| assert_true(popover5.matches(':open')); |
| popover5.hidePopover(); |
| assert_false(popover3.matches(':open')); |
| assert_false(popover5.matches(':open')); |
| },'An invoking element that was not used to invoke the popover can still be part of the ancestor chain'); |
| </script> |
| |
| <div popover id=p6>Inside popover 6 |
| <div style="height:2000px;background:lightgreen"></div> |
| Bottom of popover6 |
| </div> |
| <button popovertoggletarget=p6>Popover 6</button> |
| <style> |
| #p6 { |
| width: 300px; |
| height: 300px; |
| overflow-y: scroll; |
| } |
| </style> |
| <script> |
| const popover6 = document.querySelector('#p6'); |
| promise_test(async () => { |
| popover6.showPopover(); |
| assert_equals(popover6.scrollTop,0,'popover6 should start non-scrolled'); |
| await new test_driver.Actions() |
| .scroll(0, 0, 0, 50, {origin: popover6}) |
| .send(); |
| assert_true(popover6.matches(':open'),'popover6 should stay open'); |
| assert_equals(popover6.scrollTop,50,'popover6 should be scrolled'); |
| popover6.hidePopover(); |
| },'Scrolling within a popover should not close the popover'); |
| </script> |
| |
| <my-element id="myElement"> |
| <template shadowroot="open"> |
| <button id=b7 onclick='showPopover7()'>Popover7</button> |
| <div popover id=p7 anchor=b7 style="top: 100px;"> |
| <p>Popover content.</p> |
| <input id="inside7" type="text" placeholder="some text"> |
| </div> |
| </template> |
| </my-element> |
| <script> |
| const button7 = document.querySelector('#myElement').shadowRoot.querySelector('#b7'); |
| const popover7 = document.querySelector('#myElement').shadowRoot.querySelector('#p7'); |
| const inside7 = document.querySelector('#myElement').shadowRoot.querySelector('#inside7'); |
| function showPopover7() { |
| popover7.showPopover(); |
| } |
| promise_test(async () => { |
| button7.click(); |
| assert_true(popover7.matches(':open'),'invoking element should open popover'); |
| inside7.click(); |
| assert_true(popover7.matches(':open')); |
| popover7.hidePopover(); |
| },'Clicking inside a shadow DOM popover does not close that popover'); |
| |
| promise_test(async () => { |
| button7.click(); |
| inside7.click(); |
| assert_true(popover7.matches(':open')); |
| await clickOn(outside); |
| assert_false(popover7.matches(':open')); |
| },'Clicking outside a shadow DOM popover should close that popover'); |
| </script> |
| |
| <div popover id=p8 anchor=p8anchor> |
| <button>Button</button> |
| <span id=inside8after>Inside popover 8 after button</span> |
| </div> |
| <button id=p8anchor>Popover8 anchor (no action)</button> |
| <script> |
| promise_test(async () => { |
| const popover8 = document.querySelector('#p8'); |
| const inside8After = document.querySelector('#inside8after'); |
| const popover8Anchor = document.querySelector('#p8anchor'); |
| assert_false(popover8.matches(':open')); |
| popover8.showPopover(); |
| await clickOn(inside8After); |
| assert_true(popover8.matches(':open')); |
| await sendTab(); |
| assert_equals(document.activeElement,popover8Anchor,'Focus should move to the anchor element'); |
| assert_true(popover8.matches(':open'),'popover should stay open'); |
| popover8.hidePopover(); |
| },'Moving focus back to the anchor element should not dismiss the popover'); |
| </script> |
| |
| <!-- Convoluted ancestor relationship --> |
| <div popover id=convoluted_p1>Popover 1 |
| <div id=convoluted_anchor>Anchor |
| <button popovertoggletarget=convoluted_p2>Open Popover 2</button> |
| <div popover id=convoluted_p4><p>Popover 4</p></div> |
| </div> |
| </div> |
| <div popover id=convoluted_p2>Popover 2 |
| <button popovertoggletarget=convoluted_p3>Open Popover 3</button> |
| </div> |
| <div popover id=convoluted_p3 anchor=convoluted_anchor>Popover 3 |
| <button popovertoggletarget=convoluted_p4>Open Popover 4</button> |
| </div> |
| <button onclick="convoluted_p1.showPopover()">Open convoluted popover</button> |
| <style> |
| #convoluted_p1 {top:50px;} |
| #convoluted_p2 {top:150px;} |
| #convoluted_p3 {top:250px;} |
| #convoluted_p4 {top:350px;} |
| </style> |
| <script> |
| const convPopover1 = document.querySelector('#convoluted_p1'); |
| const convPopover2 = document.querySelector('#convoluted_p2'); |
| const convPopover3 = document.querySelector('#convoluted_p3'); |
| const convPopover4 = document.querySelector('#convoluted_p4'); |
| promise_test(async () => { |
| convPopover1.showPopover(); // Programmatically open p1 |
| assert_true(convPopover1.matches(':open')); |
| convPopover1.querySelector('button').click(); // Click to invoke p2 |
| assert_true(convPopover1.matches(':open')); |
| assert_true(convPopover2.matches(':open')); |
| convPopover2.querySelector('button').click(); // Click to invoke p3 |
| assert_true(convPopover1.matches(':open')); |
| assert_true(convPopover2.matches(':open')); |
| assert_true(convPopover3.matches(':open')); |
| convPopover3.querySelector('button').click(); // Click to invoke p4 |
| assert_true(convPopover1.matches(':open')); |
| assert_true(convPopover2.matches(':open')); |
| assert_true(convPopover3.matches(':open')); |
| assert_true(convPopover4.matches(':open')); |
| convPopover4.firstElementChild.click(); // Click within p4 |
| assert_true(convPopover1.matches(':open')); |
| assert_true(convPopover2.matches(':open')); |
| assert_true(convPopover3.matches(':open')); |
| assert_true(convPopover4.matches(':open')); |
| convPopover1.hidePopover(); |
| assert_false(convPopover1.matches(':open')); |
| assert_false(convPopover2.matches(':open')); |
| assert_false(convPopover3.matches(':open')); |
| assert_false(convPopover4.matches(':open')); |
| },'Ensure circular/convoluted ancestral relationships are functional'); |
| |
| promise_test(async () => { |
| convPopover1.showPopover(); // Programmatically open p1 |
| convPopover1.querySelector('button').click(); // Click to invoke p2 |
| assert_true(convPopover1.matches(':open')); |
| assert_true(convPopover2.matches(':open')); |
| assert_false(convPopover3.matches(':open')); |
| assert_false(convPopover4.matches(':open')); |
| convPopover4.showPopover(); // Programmatically open p4 |
| assert_true(convPopover1.matches(':open'),'popover1 stays open because it is a DOM ancestor of popover4'); |
| assert_false(convPopover2.matches(':open'),'popover2 closes because it isn\'t connected to popover4 via active invokers'); |
| assert_true(convPopover4.matches(':open')); |
| convPopover4.firstElementChild.click(); // Click within p4 |
| assert_true(convPopover1.matches(':open'),'nothing changes'); |
| assert_false(convPopover2.matches(':open')); |
| assert_true(convPopover4.matches(':open')); |
| convPopover1.hidePopover(); |
| assert_false(convPopover1.matches(':open')); |
| assert_false(convPopover2.matches(':open')); |
| assert_false(convPopover3.matches(':open')); |
| assert_false(convPopover4.matches(':open')); |
| },'Ensure circular/convoluted ancestral relationships are functional, with a direct showPopover()'); |
| </script> |
| |
| <div popover id=p13>Popover 1 |
| <div popover id=p14>Popover 2 |
| <div popover id=p15>Popover 3</div> |
| </div> |
| </div> |
| <style> |
| #p13 {top: 100px;} |
| #p14 {top: 200px;} |
| #p15 {top: 300px;} |
| </style> |
| <script> |
| promise_test(async () => { |
| const p13 = document.querySelector('#p13'); |
| const p14 = document.querySelector('#p14'); |
| const p15 = document.querySelector('#p15'); |
| p13.showPopover(); |
| p14.showPopover(); |
| p15.showPopover(); |
| p15.addEventListener('beforetoggle', (e) => { |
| if (e.newState !== "closed") |
| return; |
| p14.hidePopover(); |
| },{once:true}); |
| assert_true(p13.matches(':open') && p14.matches(':open') && p15.matches(':open'),'all three should be open'); |
| p14.hidePopover(); |
| assert_true(p13.matches(':open'),'p13 should still be open'); |
| assert_false(p14.matches(':open')); |
| assert_false(p15.matches(':open')); |
| },'Hide the target popover during "hide all popovers until"'); |
| </script> |