diff --git a/packages/popover/src/vaadin-popover.d.ts b/packages/popover/src/vaadin-popover.d.ts index f51a5ce9d1b..b7880969c83 100644 --- a/packages/popover/src/vaadin-popover.d.ts +++ b/packages/popover/src/vaadin-popover.d.ts @@ -66,6 +66,28 @@ declare class Popover extends PopoverPositionMixin( */ contentWidth: string; + /** + * The delay in milliseconds before the popover is opened + * on focus when the corresponding trigger is used. + * @attr {number} focus-delay + */ + focusDelay: number; + + /** + * The delay in milliseconds before the popover is closed + * on losing hover, when the corresponding trigger is used. + * On blur, the popover is closed immediately. + * @attr {number} hide-delay + */ + hideDelay: number; + + /** + * The delay in milliseconds before the popover is opened + * on hover when the corresponding trigger is used. + * @attr {number} hover-delay + */ + hoverDelay: number; + /** * True if the popover overlay is opened, false otherwise. */ diff --git a/packages/popover/src/vaadin-popover.js b/packages/popover/src/vaadin-popover.js index 408064c5cf0..301c852b360 100644 --- a/packages/popover/src/vaadin-popover.js +++ b/packages/popover/src/vaadin-popover.js @@ -16,6 +16,113 @@ import { ThemePropertyMixin } from '@vaadin/vaadin-themable-mixin/vaadin-theme-p import { PopoverPositionMixin } from './vaadin-popover-position-mixin.js'; import { PopoverTargetMixin } from './vaadin-popover-target-mixin.js'; +/** + * Controller for handling popover opened state. + */ +class PopoverOpenedStateController { + constructor(host) { + this.host = host; + } + + /** + * Whether closing is currently in progress. + * @return {boolean} + */ + get isClosing() { + return this.__closeTimeout != null; + } + + /** @private */ + get __focusDelay() { + return this.host.focusDelay || 0; + } + + /** @private */ + get __hoverDelay() { + return this.host.hoverDelay || 0; + } + + /** @private */ + get __hideDelay() { + return this.host.hideDelay || 0; + } + + /** + * Schedule opening the popover. + * @param {Object} options + */ + open(options = { immediate: false }) { + const { immediate, trigger } = options; + const shouldDelayHover = trigger === 'hover' && this.__hoverDelay > 0; + const shouldDelayFocus = trigger === 'focus' && this.__focusDelay > 0; + + if (!immediate && (shouldDelayHover || shouldDelayFocus) && !this.__closeTimeout) { + this.__scheduleOpen(trigger); + } else { + this.__showPopover(); + } + } + + /** + * Schedule closing the popover. + * @param {boolean} immediate + */ + close(immediate) { + if (!immediate && this.__hideDelay > 0) { + this.__scheduleClose(); + } else { + this.__abortClose(); + this.__setOpened(false); + } + } + + /** @private */ + __setOpened(opened) { + this.host.opened = opened; + } + + /** @private */ + __showPopover() { + this.__abortClose(); + this.__setOpened(true); + } + + /** @private */ + __abortClose() { + if (this.__closeTimeout) { + clearTimeout(this.__closeTimeout); + this.__closeTimeout = null; + } + } + + /** @private */ + __abortOpen() { + if (this.__openTimeout) { + clearTimeout(this.__openTimeout); + this.__openTimeout = null; + } + } + + /** @private */ + __scheduleClose() { + this.__closeTimeout = setTimeout(() => { + this.__closeTimeout = null; + this.__setOpened(false); + }, this.__hideDelay); + } + + /** @private */ + __scheduleOpen(trigger) { + this.__abortOpen(); + + const delay = trigger === 'focus' ? this.__focusDelay : this.__hoverDelay; + this.__openTimeout = setTimeout(() => { + this.__openTimeout = null; + this.__showPopover(); + }, delay); + } +} + /** * `` is a Web Component for creating overlays * that are positioned next to specified DOM element (target). @@ -78,6 +185,34 @@ class Popover extends PopoverPositionMixin( type: String, }, + /** + * The delay in milliseconds before the popover is opened + * on focus when the corresponding trigger is used. + * @attr {number} focus-delay + */ + focusDelay: { + type: Number, + }, + + /** + * The delay in milliseconds before the popover is closed + * on losing hover, when the corresponding trigger is used. + * On blur, the popover is closed immediately. + * @attr {number} hide-delay + */ + hideDelay: { + type: Number, + }, + + /** + * The delay in milliseconds before the popover is opened + * on hover when the corresponding trigger is used. + * @attr {number} hover-delay + */ + hoverDelay: { + type: Number, + }, + /** * True if the popover overlay is opened, false otherwise. */ @@ -212,6 +347,8 @@ class Popover extends PopoverPositionMixin( this.__onTargetFocusOut = this.__onTargetFocusOut.bind(this); this.__onTargetMouseEnter = this.__onTargetMouseEnter.bind(this); this.__onTargetMouseLeave = this.__onTargetMouseLeave.bind(this); + + this._openedStateController = new PopoverOpenedStateController(this); } /** @protected */ @@ -285,7 +422,7 @@ class Popover extends PopoverPositionMixin( document.removeEventListener('click', this.__onGlobalClick, true); - this.opened = false; + this._openedStateController.close(true); } /** @@ -365,7 +502,7 @@ class Popover extends PopoverPositionMixin( !event.composedPath().some((el) => el === this._overlayElement || el === this.target) && !this.noCloseOnOutsideClick ) { - this.opened = false; + this._openedStateController.close(true); } } @@ -375,7 +512,11 @@ class Popover extends PopoverPositionMixin( if (!this.opened) { this.__shouldRestoreFocus = true; } - this.opened = !this.opened; + if (this.opened) { + this._openedStateController.close(true); + } else { + this._openedStateController.open({ immediate: true }); + } } } @@ -388,7 +529,7 @@ class Popover extends PopoverPositionMixin( if (event.key === 'Escape' && !this.modal && !this.noCloseOnEsc && this.opened && !this.__isManual) { // Prevent closing parent overlay (e.g. dialog) event.stopPropagation(); - this.opened = false; + this._openedStateController.close(true); } } @@ -415,7 +556,7 @@ class Popover extends PopoverPositionMixin( // Prevent overlay re-opening when restoring focus on close. if (!this.__shouldRestoreFocus) { this.__shouldRestoreFocus = true; - this.opened = true; + this._openedStateController.open({ trigger: 'focus' }); } } } @@ -438,7 +579,7 @@ class Popover extends PopoverPositionMixin( if (this.modal) { this.target.style.pointerEvents = 'auto'; } - this.opened = true; + this._openedStateController.open({ trigger: 'hover' }); } } @@ -474,6 +615,11 @@ class Popover extends PopoverPositionMixin( /** @private */ __onOverlayMouseEnter() { this.__hoverInside = true; + + // Prevent closing if cursor moves to the overlay during hide delay. + if (this.__hasTrigger('hover') && this._openedStateController.isClosing) { + this._openedStateController.open({ immediate: true }); + } } /** @private */ @@ -494,7 +640,7 @@ class Popover extends PopoverPositionMixin( } if (this.__hasTrigger('focus')) { - this.opened = false; + this._openedStateController.close(true); } } @@ -507,7 +653,7 @@ class Popover extends PopoverPositionMixin( } if (this.__hasTrigger('hover')) { - this.opened = false; + this._openedStateController.close(); } } diff --git a/packages/popover/test/timers.test.js b/packages/popover/test/timers.test.js new file mode 100644 index 00000000000..54399525f39 --- /dev/null +++ b/packages/popover/test/timers.test.js @@ -0,0 +1,211 @@ +import { expect } from '@esm-bundle/chai'; +import { + aTimeout, + esc, + fire, + fixtureSync, + focusout, + nextRender, + nextUpdate, + outsideClick, +} from '@vaadin/testing-helpers'; +import './not-animated-styles.js'; +import '../src/vaadin-popover.js'; + +describe('timers', () => { + 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(''); + popover.renderer = (root) => { + root.textContent = 'Content'; + }; + target = fixtureSync(''); + popover.target = target; + await nextRender(); + overlay = popover.shadowRoot.querySelector('vaadin-popover-overlay'); + }); + + describe('hoverDelay', () => { + beforeEach(async () => { + popover.trigger = ['hover', 'focus']; + popover.hoverDelay = 5; + await nextUpdate(popover); + }); + + it('should open the overlay after a delay on mouseenter', async () => { + mouseenter(target); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + + await aTimeout(5); + expect(overlay.opened).to.be.true; + }); + + it('should open the overlay immediately on focus', async () => { + target.focus(); + await nextUpdate(popover); + expect(overlay.opened).to.be.true; + }); + + it('should open the overlay immediately on click', async () => { + popover.trigger = ['click']; + await nextUpdate(popover); + + target.click(); + await nextUpdate(popover); + expect(overlay.opened).to.be.true; + }); + + it('should open the overlay immediately on click during hover delay', async () => { + popover.trigger = ['hover', 'click']; + await nextUpdate(popover); + + mouseenter(target); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + + target.click(); + await nextUpdate(popover); + expect(overlay.opened).to.be.true; + }); + }); + + describe('focusDelay', () => { + beforeEach(async () => { + popover.trigger = ['hover', 'focus']; + popover.focusDelay = 5; + await nextUpdate(popover); + }); + + it('should open the overlay after a delay on focus', async () => { + target.focus(); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + + await aTimeout(5); + expect(overlay.opened).to.be.true; + }); + + it('should open the overlay immediately on mouseenter', async () => { + mouseenter(target); + await nextUpdate(popover); + expect(overlay.opened).to.be.true; + }); + + it('should open the overlay immediately on click', async () => { + popover.trigger = ['click']; + await nextUpdate(popover); + + target.click(); + await nextUpdate(popover); + expect(overlay.opened).to.be.true; + }); + + it('should open the overlay immediately on click during focus delay', async () => { + popover.trigger = ['hover', 'click']; + await nextUpdate(popover); + + target.focus(); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + + target.click(); + await nextUpdate(popover); + expect(overlay.opened).to.be.true; + }); + }); + + describe('hideDelay', () => { + beforeEach(async () => { + popover.trigger = ['hover', 'focus']; + popover.hideDelay = 5; + await nextUpdate(popover); + }); + + it('should close the overlay after a hide delay on mouseleave', async () => { + mouseenter(target); + await nextUpdate(popover); + + mouseleave(target); + await nextUpdate(popover); + expect(overlay.opened).to.be.true; + + await aTimeout(5); + expect(overlay.opened).to.be.false; + }); + + it('should close the overlay immediately on focusout', async () => { + target.focus(); + await nextUpdate(popover); + + focusout(target); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + }); + + it('should close the overlay immediately on Esc keydown', async () => { + target.focus(); + await nextUpdate(popover); + + esc(target); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + }); + + it('should not close on overlay mouseenter during hide delay', async () => { + mouseenter(target); + await nextUpdate(popover); + + mouseleave(target, document.body); + mouseenter(overlay); + + await aTimeout(5); + expect(overlay.opened).to.be.true; + }); + + it('should close the overlay immediately on outside click', async () => { + target.focus(); + await nextUpdate(popover); + + outsideClick(); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + }); + + it('should close the overlay immediately on click', async () => { + popover.trigger = ['click']; + await nextUpdate(popover); + + target.click(); + await nextUpdate(popover); + + target.click(); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + }); + + it('should close the overlay immediately on click during hide delay', async () => { + popover.trigger = ['hover', 'click']; + await nextUpdate(popover); + + mouseenter(target); + await nextUpdate(popover); + + mouseleave(target); + + target.click(); + await nextUpdate(popover); + expect(overlay.opened).to.be.false; + }); + }); +}); diff --git a/packages/popover/test/typings/popover.types.ts b/packages/popover/test/typings/popover.types.ts index 6b0f2d41b18..e8c3130b077 100644 --- a/packages/popover/test/typings/popover.types.ts +++ b/packages/popover/test/typings/popover.types.ts @@ -39,6 +39,9 @@ assertType(popover.modal); assertType(popover.withBackdrop); assertType(popover.noCloseOnEsc); assertType(popover.noCloseOnOutsideClick); +assertType(popover.focusDelay); +assertType(popover.hideDelay); +assertType(popover.hoverDelay); // Events popover.addEventListener('opened-changed', (event) => {