diff --git a/packages/popover/src/vaadin-popover.js b/packages/popover/src/vaadin-popover.js
index 301c08e62a..70da333f49 100644
--- a/packages/popover/src/vaadin-popover.js
+++ b/packages/popover/src/vaadin-popover.js
@@ -127,6 +127,13 @@ class Popover extends PopoverPositionMixin(
type: Boolean,
value: false,
},
+
+ /** @private */
+ __shouldRestoreFocus: {
+ type: Boolean,
+ value: false,
+ sync: true,
+ },
};
}
@@ -165,9 +172,11 @@ class Popover extends PopoverPositionMixin(
@focusin="${this.__onOverlayFocusIn}"
@focusout="${this.__onOverlayFocusOut}"
@opened-changed="${this.__onOpenedChanged}"
+ .restoreFocusOnClose="${this.__shouldRestoreFocus}"
.restoreFocusNode="${this.target}"
@vaadin-overlay-escape-press="${this.__onEscapePress}"
@vaadin-overlay-outside-click="${this.__onOutsideClick}"
+ @vaadin-overlay-closed="${this.__onOverlayClosed}"
>
`;
}
@@ -257,6 +266,9 @@ class Popover extends PopoverPositionMixin(
/** @private */
__onTargetClick() {
if (this.__hasTrigger('click')) {
+ if (!this.opened) {
+ this.__shouldRestoreFocus = true;
+ }
this.opened = !this.opened;
}
}
@@ -268,6 +280,11 @@ class Popover extends PopoverPositionMixin(
event.stopPropagation();
this.opened = false;
}
+
+ // Prevent restoring focus after target blur on Tab key
+ if (event.key === 'Tab' && this.__shouldRestoreFocus) {
+ this.__shouldRestoreFocus = false;
+ }
}
/** @private */
@@ -282,7 +299,11 @@ class Popover extends PopoverPositionMixin(
return;
}
- this.opened = true;
+ // Prevent overlay re-opening when restoring focus on close.
+ if (!this.__shouldRestoreFocus) {
+ this.__shouldRestoreFocus = true;
+ this.opened = true;
+ }
}
}
@@ -299,7 +320,11 @@ class Popover extends PopoverPositionMixin(
__onTargetMouseEnter() {
this.__hoverInside = true;
- if (this.__hasTrigger('hover')) {
+ if (this.__hasTrigger('hover') && !this.opened) {
+ // Prevent closing due to `pointer-events: none` set on body.
+ if (this.modal) {
+ this.target.style.pointerEvents = 'auto';
+ }
this.opened = true;
}
}
@@ -316,6 +341,12 @@ class Popover extends PopoverPositionMixin(
/** @private */
__onOverlayFocusIn() {
this.__focusInside = true;
+
+ // When using Tab to move focus, restoring focus is reset. However, if pressing Tab
+ // causes focus to be moved inside the overlay, we should restore focus on close.
+ if (this.__hasTrigger('focus') || this.__hasTrigger('click')) {
+ this.__shouldRestoreFocus = true;
+ }
}
/** @private */
@@ -372,6 +403,22 @@ class Popover extends PopoverPositionMixin(
this.opened = event.detail.value;
}
+ /** @private */
+ __onOverlayClosed() {
+ // Reset restoring focus state after a timeout to make sure focus was restored
+ // and then allow re-opening overlay on re-focusing target with focus trigger.
+ if (this.__shouldRestoreFocus) {
+ setTimeout(() => {
+ this.__shouldRestoreFocus = false;
+ });
+ }
+
+ // Restore pointer-events set when opening on hover.
+ if (this.modal && this.target.style.pointerEvents) {
+ this.target.style.pointerEvents = '';
+ }
+ }
+
/**
* Close the popover if `noCloseOnEsc` isn't set to true.
* @private
diff --git a/packages/popover/test/a11y.test.js b/packages/popover/test/a11y.test.js
new file mode 100644
index 0000000000..7bfc9c4252
--- /dev/null
+++ b/packages/popover/test/a11y.test.js
@@ -0,0 +1,210 @@
+import { expect } from '@esm-bundle/chai';
+import { esc, fixtureSync, focusout, nextRender, nextUpdate, outsideClick, tab } from '@vaadin/testing-helpers';
+import sinon from 'sinon';
+import './not-animated-styles.js';
+import '../vaadin-popover.js';
+import { mouseenter, mouseleave } from './helpers.js';
+
+describe('a11y', () => {
+ let popover, target, overlay;
+
+ beforeEach(async () => {
+ popover = fixtureSync('');
+ target = fixtureSync('');
+ popover.target = target;
+ popover.renderer = (root) => {
+ if (!root.firstChild) {
+ root.appendChild(document.createElement('input'));
+ }
+ };
+ await nextRender();
+ overlay = popover.shadowRoot.querySelector('vaadin-popover-overlay');
+ });
+
+ describe('focus restoration', () => {
+ describe('focus trigger', () => {
+ beforeEach(async () => {
+ popover.trigger = ['focus'];
+ await nextUpdate(popover);
+
+ target.focus();
+ await nextRender();
+ });
+
+ it('should restore focus on Esc with trigger set to focus', async () => {
+ const focusSpy = sinon.spy(target, 'focus');
+ overlay.$.overlay.focus();
+ esc(overlay.$.overlay);
+ await nextRender();
+
+ expect(focusSpy).to.be.calledOnce;
+ });
+
+ it('should not restore focus on Tab with trigger set to focus', async () => {
+ const focusSpy = sinon.spy(target, 'focus');
+ overlay.$.overlay.focus();
+ tab(target);
+ focusout(target);
+ await nextRender();
+
+ expect(focusSpy).to.not.be.called;
+ });
+
+ it('should restore focus on close after Tab to overlay with trigger set to focus', async () => {
+ const focusSpy = sinon.spy(target, 'focus');
+ tab(target);
+ focusout(target, overlay);
+ overlay.$.overlay.focus();
+ esc(overlay.$.overlay);
+ await nextRender();
+
+ expect(focusSpy).to.be.calledOnce;
+ });
+
+ it('should not re-open when restoring focus on Esc with trigger set to focus', async () => {
+ overlay.$.overlay.focus();
+ esc(overlay.$.overlay);
+ await nextRender();
+
+ expect(popover.opened).to.be.false;
+ });
+
+ it('should not re-open when restoring focus on outside click with trigger set to focus', async () => {
+ overlay.$.overlay.focus();
+ outsideClick();
+ await nextRender();
+
+ expect(popover.opened).to.be.false;
+ });
+
+ it('should re-open when re-focusing after closing on outside click with trigger set to focus', async () => {
+ overlay.$.overlay.focus();
+ outsideClick();
+ await nextRender();
+
+ target.blur();
+ target.focus();
+ await nextRender();
+
+ expect(popover.opened).to.be.true;
+ });
+ });
+
+ describe('click trigger', () => {
+ beforeEach(async () => {
+ popover.trigger = ['click'];
+ await nextUpdate(popover);
+
+ target.click();
+ await nextRender();
+ });
+
+ it('should restore focus on Esc with trigger set to click', async () => {
+ const focusSpy = sinon.spy(target, 'focus');
+ overlay.$.overlay.focus();
+ esc(overlay.$.overlay);
+ await nextRender();
+
+ expect(focusSpy).to.be.calledOnce;
+ });
+
+ it('should restore focus on outside click with trigger set to click', async () => {
+ const focusSpy = sinon.spy(target, 'focus');
+ outsideClick();
+ await nextRender();
+
+ expect(focusSpy).to.be.calledOnce;
+ });
+
+ it('should restore focus on close after Tab to overlay with trigger set to click', async () => {
+ const focusSpy = sinon.spy(target, 'focus');
+ tab(target);
+ focusout(target, overlay);
+ overlay.$.overlay.focus();
+ esc(overlay.$.overlay);
+ await nextRender();
+
+ expect(focusSpy).to.be.calledOnce;
+ });
+ });
+
+ describe('hover trigger', () => {
+ beforeEach(async () => {
+ popover.trigger = ['hover'];
+ await nextUpdate(popover);
+ });
+
+ it('should not restore focus on Esc with trigger set to hover', async () => {
+ mouseenter(target);
+ await nextRender();
+
+ const focusSpy = sinon.spy(target, 'focus');
+ overlay.$.overlay.focus();
+ esc(overlay.$.overlay);
+ await nextRender();
+
+ expect(focusSpy).to.not.be.called;
+ });
+
+ it('should set pointer-events: auto on the target when opened if modal', async () => {
+ popover.modal = true;
+ await nextUpdate(popover);
+
+ mouseenter(target);
+ await nextRender();
+
+ expect(target.style.pointerEvents).to.equal('auto');
+ });
+
+ it('should remove pointer-events on the target when closed if modal', async () => {
+ popover.modal = true;
+ await nextUpdate(popover);
+
+ mouseenter(target);
+ await nextRender();
+
+ mouseleave(target);
+ await nextRender();
+
+ expect(target.style.pointerEvents).to.equal('');
+ });
+ });
+
+ describe('hover and focus trigger', () => {
+ beforeEach(async () => {
+ popover.trigger = ['hover', 'focus'];
+ await nextUpdate(popover);
+
+ target.focus();
+ await nextRender();
+ });
+
+ it('should not restore focus when re-opening on hover after being restored', async () => {
+ outsideClick();
+ await nextRender();
+
+ // Re-open overlay
+ mouseenter(target);
+ await nextRender();
+
+ const focusSpy = sinon.spy(target, 'focus');
+ overlay.$.overlay.focus();
+ esc(overlay.$.overlay);
+ await nextRender();
+
+ expect(focusSpy).to.not.be.called;
+ });
+
+ it('should restore focus when hover occurs after opening on focus', async () => {
+ mouseenter(target);
+
+ const focusSpy = sinon.spy(target, 'focus');
+ overlay.$.overlay.focus();
+ esc(overlay.$.overlay);
+ await nextRender();
+
+ expect(focusSpy).to.be.calledOnce;
+ });
+ });
+ });
+});
diff --git a/packages/popover/test/helpers.js b/packages/popover/test/helpers.js
new file mode 100644
index 0000000000..9e32d3b8d7
--- /dev/null
+++ b/packages/popover/test/helpers.js
@@ -0,0 +1,10 @@
+import { fire } from '@vaadin/testing-helpers';
+
+export function mouseenter(target) {
+ fire(target, 'mouseenter');
+}
+
+export function mouseleave(target, relatedTarget) {
+ const eventProps = relatedTarget ? { relatedTarget } : {};
+ fire(target, 'mouseleave', undefined, eventProps);
+}
diff --git a/packages/popover/test/trigger.test.js b/packages/popover/test/trigger.test.js
index 950c775947..91c82cc091 100644
--- a/packages/popover/test/trigger.test.js
+++ b/packages/popover/test/trigger.test.js
@@ -11,19 +11,11 @@ import {
} from '@vaadin/testing-helpers';
import './not-animated-styles.js';
import '../vaadin-popover.js';
+import { mouseenter, mouseleave } from './helpers.js';
describe('trigger', () => {
let popover, target, overlay;
- function mouseenter(target) {
- fire(target, 'mouseenter');
- }
-
- function mouseleave(target, relatedTarget) {
- const eventProps = relatedTarget ? { relatedTarget } : {};
- fire(target, 'mouseleave', undefined, eventProps);
- }
-
beforeEach(async () => {
popover = fixtureSync('');
target = fixtureSync('');