blob: 83573c86f06f838a8d810e01c4d9f95eee256526 [file] [log] [blame]
<!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>