From a96c9692aff0e0b942b7347e8588af7f379533eb Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Fri, 9 Dec 2022 13:49:25 +0100 Subject: [PATCH] chore: fix elementToFocusAfterHide when shadow dom is used --- .../overlays/src/OverlayController.js | 74 ++++--------------- .../overlays/test/OverlayController.test.js | 29 ++++++++ 2 files changed, 42 insertions(+), 61 deletions(-) diff --git a/packages/ui/components/overlays/src/OverlayController.js b/packages/ui/components/overlays/src/OverlayController.js index f7d4e2476..c55c49cb3 100644 --- a/packages/ui/components/overlays/src/OverlayController.js +++ b/packages/ui/components/overlays/src/OverlayController.js @@ -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 - *
- * - *
- *
- * - * Structure above depicts [l4] - * So in case of [l1] and [l3], the 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,15 +892,19 @@ 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)) { - if (this.elementToFocusAfterHide) { - this.elementToFocusAfterHide.focus(); - } else { - activeElement.blur(); - } + // 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(); } } diff --git a/packages/ui/components/overlays/test/OverlayController.test.js b/packages/ui/components/overlays/test/OverlayController.test.js index 78bf22da4..2307bb155 100644 --- a/packages/ui/components/overlays/test/OverlayController.test.js +++ b/packages/ui/components/overlays/test/OverlayController.test.js @@ -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('')); + const contentNode = /** @type {HTMLElement} */ ( + await fixture('
') + ); + + const shadowEl = document.createElement('div'); + shadowEl.attachShadow({ mode: 'open' }); + /** @type {ShadowRoot} */ (shadowEl.shadowRoot).innerHTML = ``; + 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('')); const outsideButton = /** @type {HTMLButtonElement} */ (await fixture(''));