diff --git a/packages/a11y-base/src/focus-restoration-controller.js b/packages/a11y-base/src/focus-restoration-controller.js index bc509f1138..5dc787632f 100644 --- a/packages/a11y-base/src/focus-restoration-controller.js +++ b/packages/a11y-base/src/focus-restoration-controller.js @@ -23,20 +23,22 @@ export class FocusRestorationController { /** * Restores focus to the target node that was saved previously with `saveFocus()`. */ - restoreFocus() { + restoreFocus(options) { const focusNode = this.focusNode; if (!focusNode) { return; } + const preventScroll = options ? options.preventScroll : false; + if (getDeepActiveElement() === document.body) { // In Firefox and Safari, focusing the node synchronously // doesn't work as expected when the overlay is closing on outside click. // These browsers force focus to move to the body element and retain it // there until the next event loop iteration. - setTimeout(() => focusNode.focus()); + setTimeout(() => focusNode.focus({ preventScroll })); } else { - focusNode.focus(); + focusNode.focus({ preventScroll }); } this.focusNode = null; diff --git a/packages/a11y-base/test/focus-restoration-controller.test.js b/packages/a11y-base/test/focus-restoration-controller.test.js index 44c380aa15..39d41d7fd6 100644 --- a/packages/a11y-base/test/focus-restoration-controller.test.js +++ b/packages/a11y-base/test/focus-restoration-controller.test.js @@ -1,5 +1,6 @@ import { expect } from '@esm-bundle/chai'; import { aTimeout, fixtureSync, outsideClick } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; import { FocusRestorationController } from '../src/focus-restoration-controller.js'; import { getDeepActiveElement } from '../src/focus-utils.js'; @@ -58,4 +59,44 @@ describe('focus-restoration-controller', () => { controller.restoreFocus(); expect(getDeepActiveElement()).to.equal(button2); }); + + it('should not prevent scroll when restoring focus synchronously by default', () => { + button1.focus(); + const spy = sinon.spy(button2, 'focus'); + controller.saveFocus(button2); + controller.restoreFocus(); + expect(spy).to.be.calledOnce; + expect(spy.firstCall.args[0]).to.eql({ preventScroll: false }); + }); + + it('should prevent scroll when restoring focus synchronously with preventScroll', () => { + button1.focus(); + const spy = sinon.spy(button2, 'focus'); + controller.saveFocus(button2); + controller.restoreFocus({ preventScroll: true }); + expect(spy).to.be.calledOnce; + expect(spy.firstCall.args[0]).to.eql({ preventScroll: true }); + }); + + it('should not prevent scroll when restoring focus asynchronously by default', async () => { + button1.focus(); + const spy = sinon.spy(button2, 'focus'); + controller.saveFocus(button2); + outsideClick(); + controller.restoreFocus(); + await aTimeout(0); + expect(spy).to.be.calledOnce; + expect(spy.firstCall.args[0]).to.eql({ preventScroll: false }); + }); + + it('should prevent scroll when restoring focus asynchronously with preventScroll', async () => { + button1.focus(); + const spy = sinon.spy(button2, 'focus'); + controller.saveFocus(button2); + outsideClick(); + controller.restoreFocus({ preventScroll: true }); + await aTimeout(0); + expect(spy).to.be.calledOnce; + expect(spy.firstCall.args[0]).to.eql({ preventScroll: true }); + }); }); diff --git a/packages/overlay/src/vaadin-overlay-focus-mixin.js b/packages/overlay/src/vaadin-overlay-focus-mixin.js index d0c62d3e12..dd60f949b6 100644 --- a/packages/overlay/src/vaadin-overlay-focus-mixin.js +++ b/packages/overlay/src/vaadin-overlay-focus-mixin.js @@ -6,7 +6,7 @@ import { AriaModalController } from '@vaadin/a11y-base/src/aria-modal-controller.js'; import { FocusRestorationController } from '@vaadin/a11y-base/src/focus-restoration-controller.js'; import { FocusTrapController } from '@vaadin/a11y-base/src/focus-trap-controller.js'; -import { getDeepActiveElement } from '@vaadin/a11y-base/src/focus-utils.js'; +import { getDeepActiveElement, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js'; import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; /** @@ -76,7 +76,8 @@ export const OverlayFocusMixin = (superClass) => } if (this.restoreFocusOnClose && this._shouldRestoreFocus()) { - this.__focusRestorationController.restoreFocus(); + const preventScroll = !isKeyboardActive(); + this.__focusRestorationController.restoreFocus({ preventScroll }); } } diff --git a/packages/overlay/test/restore-focus.common.js b/packages/overlay/test/restore-focus.common.js index a0b6a40b11..71d6f49c88 100644 --- a/packages/overlay/test/restore-focus.common.js +++ b/packages/overlay/test/restore-focus.common.js @@ -1,5 +1,6 @@ import { expect } from '@esm-bundle/chai'; -import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import { escKeyDown, fixtureSync, mousedown, nextRender } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; import { getDeepActiveElement } from '@vaadin/a11y-base/src/focus-utils.js'; @@ -142,6 +143,32 @@ describe('restore focus', () => { expect(getDeepActiveElement()).to.equal(focusInput); }); }); + + describe('prevent scroll', () => { + it('should prevent scroll when restoring focus on close after mousedown', async () => { + focusable.focus(); + overlay.opened = true; + await nextRender(); + const spy = sinon.spy(focusable, 'focus'); + mousedown(document.body); + overlay.opened = false; + await nextRender(); + expect(spy).to.be.calledOnce; + expect(spy.firstCall.args[0]).to.eql({ preventScroll: true }); + }); + + it('should not prevent scroll when restoring focus on close after keydown', async () => { + focusable.focus(); + overlay.opened = true; + await nextRender(); + const spy = sinon.spy(focusable, 'focus'); + escKeyDown(document.body); + overlay.opened = false; + await nextRender(); + expect(spy).to.be.calledOnce; + expect(spy.firstCall.args[0]).to.eql({ preventScroll: false }); + }); + }); }); }); });