chore: fix elementToFocusAfterHide when shadow dom is used

This commit is contained in:
Thijs Louisse 2022-12-09 13:49:25 +01:00 committed by Thijs Louisse
parent 0bc604d600
commit a96c9692af
2 changed files with 42 additions and 61 deletions

View file

@ -112,58 +112,6 @@ const supportsCSSTypedObject = window.CSS?.number && document.body.attributeStyl
* configuration, it can be used to build (modal) dialogs, tooltips, dropdowns, popovers,
* bottom/top/left/right sheets etc.
*
* ### About contentNode, contentWrapperNode and renderTarget.
*
* #### contentNode
* Node containing actual overlay contents.
* It will not be touched by the OverlayController, it will only set attributes needed
* for accessibility.
*
* #### contentWrapperNode
* The 'positioning' element.
* For local overlays, this node will be provided to Popper and all
* inline positioning styles will be added here. It will also act as the container of an arrow
* element (the arrow needs to be a sibling of contentNode for Popper to work correctly).
* When projecting a contentNode from a shadowRoot, it is essential to have the wrapper in
* shadow dom, so that contentNode can be styled via `::slotted` from the shadow root.
* The Popper arrow can then be styled from that same shadow root as well.
* For global overlays, the contentWrapperNode will be appended to the globalRootNode structure.
*
* #### renderTarget
* Usually the parent node of contentWrapperNode that either exists locally or globally.
* When a responsive scenario is created (in which we switch from global to local or vice versa)
* we need to know where we should reappend contentWrapperNode (or contentNode in case it's projected)
*
* So a regular flow can be summarized as follows:
* 1. Application Developer spawns an OverlayController with a contentNode reference
* 2. OverlayController will create a contentWrapperNode around contentNode (or consumes when provided)
* 3. contentWrapperNode will be appended to the right renderTarget
*
* There are subtle differences depending on the following factors:
* - whether in global/local placement mode
* - whether contentNode projected
* - whether an arrow is provided
*
* This leads to the following possible combinations:
* - [l1]. local + no content projection + no arrow
* - [l2]. local + content projection + no arrow
* - [l3]. local + no content projection + arrow
* - [l4]. local + content projection + arrow
* - [g1]. global
*
* #### html structure for a content projected node
* <div id="contentWrapperNode">
* <slot name="contentNode"></slot>
* <div data-popper-arrow></div>
* </div>
*
* Structure above depicts [l4]
* So in case of [l1] and [l3], the <slot> element would be a regular element
* In case of [l1] and [l2], there would be no arrow.
* Note that a contentWrapperNode should be provided for [l2], [l3] and [l4]
* In case of a global overlay ([g1]), it's enough to provide just the contentNode.
* In case of a local overlay or a responsive overlay switching from placementMode, one should
* always configure as if it were a local overlay.
*/
export class OverlayController extends EventTargetShim {
/**
@ -944,17 +892,21 @@ export class OverlayController extends EventTargetShim {
/** @protected */
_restoreFocus() {
const { activeElement } = /** @type {ShadowRoot} */ (this.contentWrapperNode.getRootNode());
// We only are allowed to move focus if we (still) 'own' it.
// Otherwise we assume the 'outside world' has, purposefully, taken over
if (activeElement instanceof HTMLElement && this.contentWrapperNode.contains(activeElement)) {
// We only are allowed to move focus if we (still) 'own' the active element.
// Otherwise we assume the 'outside world' has purposefully taken over
const { activeElement } = /** @type {ShadowRoot} */ (this.contentNode.getRootNode());
const weStillOwnActiveElement =
activeElement instanceof HTMLElement && this.contentNode.contains(activeElement);
if (!weStillOwnActiveElement) {
return;
}
if (this.elementToFocusAfterHide) {
this.elementToFocusAfterHide.focus();
} else {
activeElement.blur();
}
}
}
async toggle() {
return this.isShown ? this.hide() : this.show();

View file

@ -889,6 +889,35 @@ describe('OverlayController', () => {
expect(document.activeElement).to.equal(input);
});
it('supports elementToFocusAfterHide option when shadowRoot involved involved', async () => {
const input = /** @type {HTMLElement} */ (await fixture('<input />'));
const contentNode = /** @type {HTMLElement} */ (
await fixture('<div><textarea></textarea></div>')
);
const shadowEl = document.createElement('div');
shadowEl.attachShadow({ mode: 'open' });
/** @type {ShadowRoot} */ (shadowEl.shadowRoot).innerHTML = `<slot></slot>`;
shadowEl.appendChild(contentNode);
document.body.appendChild(shadowEl);
const ctrl = new OverlayController({
...withGlobalTestConfig(),
elementToFocusAfterHide: input,
contentNode,
});
await ctrl.show();
const textarea = /** @type {HTMLTextAreaElement} */ (contentNode.querySelector('textarea'));
textarea.focus();
expect(document.activeElement).to.equal(textarea);
await ctrl.hide();
expect(document.activeElement).to.equal(input);
document.body.removeChild(shadowEl);
});
it(`only sets focus when outside world didn't take over already`, async () => {
const input = /** @type {HTMLElement} */ (await fixture('<input />'));
const outsideButton = /** @type {HTMLButtonElement} */ (await fixture('<button></button>'));