fix(overlays): no hiding of nested overlays having hideOnEsc configured

This commit is contained in:
Thijs Louisse 2024-10-31 17:04:17 +01:00 committed by Thijs Louisse
parent a5b2c2d977
commit 360641c487
4 changed files with 283 additions and 48 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[overlays] no hiding of nested overlays having `hideOnEsc` configured

View file

@ -1,16 +1,17 @@
import { overlays } from './singleton.js';
import { containFocus } from './utils/contain-focus.js';
import { deepContains } from './utils/deep-contains.js';
import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
import { _adoptStyleUtils } from './utils/adopt-styles.js';
/**
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
* @typedef {import('@lion/ui/types/overlays.js').ViewportConfig} ViewportConfig
* @typedef {import('@popperjs/core').createPopper} Popper
* @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig
* @typedef {import('@popperjs/core').Options} PopperOptions
* @typedef {import('@popperjs/core').Placement} Placement
* @typedef {import('@popperjs/core').createPopper} Popper
* @typedef {{ createPopper: Popper }} PopperModule
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
*/
/**
@ -103,6 +104,8 @@ async function preloadPopper() {
return /** @type {* & Promise<PopperModule>} */ (import('@popperjs/core/dist/esm/popper.js'));
}
const childDialogsClosedInEventLoopWeakmap = new WeakMap();
/**
* OverlayController is the fundament for every single type of overlay. With the right
* configuration, it can be used to build (modal) dialogs, tooltips, dropdowns, popovers,
@ -498,9 +501,6 @@ export class OverlayController extends EventTarget {
'[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled',
);
}
// if (newConfig.popperConfig.modifiers.arrow && !newConfig.contentWrapperNode) {
// throw new Error('You need to provide a .contentWrapperNode when Popper arrow is enabled');
// }
}
/**
@ -1146,10 +1146,39 @@ export class OverlayController extends EventTarget {
ev.preventDefault();
}
/** @private */
__escKeyHandler(/** @type {KeyboardEvent} */ ev) {
return ev.key === 'Escape' && this.hide();
/**
* @param {KeyboardEvent} event
* @returns {void}
*/
__escKeyHandler(event) {
if (event.key !== 'Escape' || childDialogsClosedInEventLoopWeakmap.has(event)) return;
const hasPressedInside =
event.composedPath().includes(this.contentNode) ||
deepContains(this.contentNode, /** @type {HTMLElement|ShadowRoot} */ (event.target));
if (hasPressedInside) {
this.hide();
// We could do event.stopPropagation() here, but we don't want to hide info for
// the outside world about user interactions. Instead, we store the event in a WeakMap
// that will be garbage collected after the event loop.
childDialogsClosedInEventLoopWeakmap.set(event, this);
}
}
/**
* @param {KeyboardEvent} event
* @returns {void}
*/
#outsideEscKeyHandler = event => {
if (event.key !== 'Escape') return;
const hasPressedInside =
event.composedPath().includes(this.contentNode) ||
deepContains(this.contentNode, /** @type {HTMLElement|ShadowRoot} */ (event.target));
if (!hasPressedInside) {
this.hide();
}
};
/**
* @param {{ phase: OverlayPhase }} config
@ -1175,11 +1204,9 @@ export class OverlayController extends EventTarget {
*/
_handleHidesOnOutsideEsc({ phase }) {
if (phase === 'show') {
this.__escKeyHandler = (/** @type {KeyboardEvent} */ ev) =>
ev.key === 'Escape' && this.hide();
document.addEventListener('keyup', this.__escKeyHandler);
document.addEventListener('keyup', this.#outsideEscKeyHandler);
} else if (phase === 'hide') {
document.removeEventListener('keyup', this.__escKeyHandler);
document.removeEventListener('keyup', this.#outsideEscKeyHandler);
}
}

View file

@ -8,9 +8,9 @@ async function sleep(t = 0) {
/**
* @param {HTMLElement} el
* @param {{isAsync?:boolean, releaseElement?: HTMLElement}} config
* @param {{isAsync?:boolean, releaseElement?: HTMLElement}} config releaseElement can be different when the mouse is dragged before release
*/
export async function mimicClick(el, { isAsync, releaseElement } = { isAsync: false }) {
export async function mimicClick(el, { isAsync = false, releaseElement } = {}) {
const releaseEl = releaseElement || el;
el.dispatchEvent(new MouseEvent('mousedown'));
if (isAsync) {

View file

@ -1,28 +1,41 @@
/* eslint-disable no-new */
import {
aTimeout,
defineCE,
expect,
fixture,
html,
unsafeStatic,
fixtureSync,
} from '@open-wc/testing';
import sinon from 'sinon';
import { OverlayController, overlays } from '@lion/ui/overlays.js';
import { mimicClick } from '@lion/ui/overlays-test-helpers.js';
import { keyCodes } from '../src/utils/key-codes.js';
import { simulateTab } from '../src/utils/simulate-tab.js';
import { _adoptStyleUtils } from '../src/utils/adopt-styles.js';
import sinon from 'sinon';
import {
unsafeStatic,
fixtureSync,
defineCE,
aTimeout,
fixture,
expect,
html,
} from '@open-wc/testing';
import { createShadowHost } from '../test-helpers/createShadowHost.js';
import { _adoptStyleUtils } from '../src/utils/adopt-styles.js';
import { simulateTab } from '../src/utils/simulate-tab.js';
import { keyCodes } from '../src/utils/key-codes.js';
/**
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
* @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement
* @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig
*/
const wrappingDialogNodeStyle = 'display: none; z-index: 9999; padding: 0px;';
/**
* A small wrapper function that closely mimics an escape press from a user
* (prevents common mistakes like no bubbling or keydown)
* @param {Element|Document} element
*/
async function mimicEscapePress(element) {
element.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Escape', bubbles: true, composed: true }),
);
await aTimeout(0);
}
/**
* Make sure that all browsers serialize html in a similar way
* (Firefox tends to output empty style attrs)
@ -74,6 +87,50 @@ const withLocalTestConfig = () =>
),
});
/**
* @param {HTMLDivElement} parentContent
* @returns {Promise<{parentOverlay: OverlayController; childOverlay: OverlayController}>}
*/
async function createNestedEscControllers(parentContent) {
const childContent = /** @type {HTMLDivElement} */ (parentContent.querySelector('div[id]'));
// Assert valid fixure
const isValid =
(parentContent.id === 'parent-overlay--hidesOnEsc' ||
parentContent.id === 'parent-overlay--hidesOnOutsideEsc') &&
(childContent.id === 'child-overlay--hidesOnEsc' ||
childContent.id === 'child-overlay--hidesOnOutsideEsc');
if (!isValid) {
throw new Error('Provide a valid fixture');
}
if (parentContent.hasAttribute('data-convert-to-shadow-root')) {
const shadowRootParent = parentContent.attachShadow({ mode: 'open' });
shadowRootParent.appendChild(childContent);
}
const parentHasOutsideOnEsc = parentContent.id === 'parent-overlay--hidesOnOutsideEsc';
const childHasOutsideOnEsc = childContent.id === 'child-overlay--hidesOnOutsideEsc';
const parentConfig = parentHasOutsideOnEsc ? { hidesOnOutsideEsc: true } : { hidesOnEsc: true };
const childConfig = childHasOutsideOnEsc ? { hidesOnOutsideEsc: true } : { hidesOnEsc: true };
const parentOverlay = new OverlayController({
...withGlobalTestConfig(),
contentNode: parentContent,
...parentConfig,
});
const childOverlay = new OverlayController({
...withGlobalTestConfig(),
contentNode: childContent,
...childConfig,
});
await parentOverlay.show();
await childOverlay.show();
return { parentOverlay, childOverlay };
}
afterEach(() => {
overlays.teardown();
});
@ -574,74 +631,221 @@ describe('OverlayController', () => {
});
describe('hidesOnEsc', () => {
it('hides when [escape] is pressed', async () => {
it('hides on [Escape] press', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
hidesOnEsc: true,
});
await ctrl.show();
ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await aTimeout(0);
await mimicEscapePress(ctrl.contentNode);
expect(ctrl.isShown).to.be.false;
});
it("doesn't hide when [escape] is pressed and hidesOnEsc is set to false", async () => {
it('stays shown on [Escape] press with `.hidesOnEsc` set to false', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
hidesOnEsc: false,
});
await ctrl.show();
ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await aTimeout(0);
await mimicEscapePress(ctrl.contentNode);
expect(ctrl.isShown).to.be.true;
});
it('stays shown when [escape] is pressed on outside element', async () => {
it('stays shown on [Escape] press on outside element', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
hidesOnEsc: true,
});
await ctrl.show();
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await mimicEscapePress(document);
expect(ctrl.isShown).to.be.true;
});
it('does not hide when [escape] is pressed with modal <dialog> and "hidesOnEsc" is false', async () => {
it('stays shown on [Escape] press with modal <dialog> and `.hidesOnEsc` is false', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
trapsKeyboardFocus: true,
hidesOnEsc: false,
});
await ctrl.show();
ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await aTimeout(0);
await mimicEscapePress(ctrl.contentNode);
expect(ctrl.isShown).to.be.true;
});
it('parent stays shown on [Escape] press in a nested overlay', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- -->
<div id="parent-overlay--hidesOnEsc">
<div id="child-overlay--hidesOnEsc">we press [Escape] here</div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
await mimicEscapePress(childOverlay.contentNode);
expect(parentOverlay.isShown).to.be.true;
expect(childOverlay.isShown).to.be.false;
});
});
describe('hidesOnOutsideEsc', () => {
it('hides when [escape] is pressed on outside element', async () => {
it('hides on [Escape] press on outside element', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
hidesOnOutsideEsc: true,
});
await ctrl.show();
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await aTimeout(0);
await mimicEscapePress(document);
expect(ctrl.isShown).to.be.false;
});
it('stays shown when [escape] is pressed on inside element', async () => {
it('stays shown on [Escape] press on inside element', async () => {
const ctrl = new OverlayController({
...withGlobalTestConfig(),
hidesOnOutsideEsc: true,
});
await ctrl.show();
ctrl.contentNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await mimicEscapePress(ctrl.contentNode);
expect(ctrl.isShown).to.be.true;
});
it('stays shown on [Escape] press in a nested overlay', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- -->
<div id="parent-overlay--hidesOnOutsideEsc">
<div id="child-overlay--hidesOnOutsideEsc">we press [Escape] here</div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
mimicEscapePress(childOverlay.contentNode);
expect(parentOverlay.isShown).to.be.true;
expect(childOverlay.isShown).to.be.true;
});
});
describe('Nested hidesOnEsc / hidesOnOutsideEsc', () => {
describe('Parent has hidesOnEsc and child has hidesOnOutsideEsc', () => {
it('on [Escape] press in child overlay: parent hides, child stays shown', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- -->
<div id="parent-overlay--hidesOnEsc">
<div id="child-overlay--hidesOnOutsideEsc">we press [Escape] here</div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
await mimicEscapePress(childOverlay.contentNode);
expect(parentOverlay.isShown).to.be.false;
expect(childOverlay.isShown).to.be.true;
});
it('on [Escape] press outside overlays: parent stays shown, child hides', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- we press [Escape] here -->
<div id="parent-overlay--hidesOnEsc">
<div id="child-overlay--hidesOnOutsideEsc"></div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
await mimicEscapePress(document);
expect(parentOverlay.isShown).to.be.true;
expect(childOverlay.isShown).to.be.false;
});
it('on [Escape] press in parent overlay: parent is hidden, child is hidden', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- -->
<div id="parent-overlay--hidesOnEsc">
we press [Escape] here
<div id="child-overlay--hidesOnOutsideEsc"></div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
await mimicEscapePress(parentContent);
expect(parentOverlay.isShown).to.be.false;
expect(childOverlay.isShown).to.be.false;
});
});
describe('Parent has hidesOnOutsideEsc and child has hidesOnEsc', () => {
it('on [Escape] press in child overlay: parent stays shown, child hides', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- -->
<div id="parent-overlay--hidesOnOutsideEsc">
<div id="child-overlay--hidesOnEsc">we press [Escape] here</div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
await mimicEscapePress(childOverlay.contentNode);
expect(parentOverlay.isShown).to.be.true;
expect(childOverlay.isShown).to.be.false;
});
it('on [Escape] press outside overlays: parent hides, child stays shown', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- we press [Escape] here -->
<div id="parent-overlay--hidesOnOutsideEsc">
<div id="child-overlay--hidesOnEsc"></div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
await mimicEscapePress(document);
expect(parentOverlay.isShown).to.be.false;
expect(childOverlay.isShown).to.be.true;
});
it('on [Escape] press in parent overlay: parent hides, child stays shown', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- we press [Escape] here -->
<div id="parent-overlay--hidesOnOutsideEsc">
<div id="child-overlay--hidesOnEsc"></div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
await mimicEscapePress(document);
expect(parentOverlay.isShown).to.be.false;
expect(childOverlay.isShown).to.be.true;
});
});
describe('With shadow dom', () => {
it('on [Escape] press in child overlay in shadow root: parent hides, child stays shown', async () => {
const parentContent = /** @type {HTMLDivElement} */ (
await fixture(
html` <!-- -->
<div id="parent-overlay--hidesOnEsc" data-convert-to-shadow-root>
<div id="child-overlay--hidesOnOutsideEsc">we press [Escape] here</div>
</div>`,
)
);
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
await mimicEscapePress(childOverlay.contentNode);
expect(parentOverlay.isShown).to.be.false;
expect(childOverlay.isShown).to.be.true;
});
});
});
describe('hidesOnOutsideClick', () => {
it('hides on outside click', async () => {
const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
@ -684,9 +888,8 @@ describe('OverlayController', () => {
expect(ctrl.isShown).to.be.true;
// Don't hide on inside mousedown & outside mouseup
ctrl.contentNode.dispatchEvent(new MouseEvent('mousedown'));
await aTimeout(0);
document.body.dispatchEvent(new MouseEvent('mouseup'));
await mimicClick(ctrl.contentNode, { releaseElement: document.body, isAsync: true });
await aTimeout(0);
expect(ctrl.isShown).to.be.true;