diff --git a/webapp/components/UserTeaser/UserTeaser.vue b/webapp/components/UserTeaser/UserTeaser.vue index 7f9e0cb4f..361dd602a 100644 --- a/webapp/components/UserTeaser/UserTeaser.vue +++ b/webapp/components/UserTeaser/UserTeaser.vue @@ -15,8 +15,9 @@ :show-avatar="showAvatar" :date-time="dateTime" :show-popover="showPopover" - :injectedText="injectedText" - :injectedDate="injectedDate" + :injected-text="injectedText" + :injected-date="injectedDate" + :hover-delay="hoverDelay" @close="closeMenu" /> @@ -45,6 +46,7 @@ export default { showPopover: { type: Boolean, default: true }, injectedText: { type: String, default: null }, injectedDate: { type: Boolean, default: false }, + hoverDelay: { type: Number, default: 500 }, }, computed: { ...mapGetters({ diff --git a/webapp/components/UserTeaser/UserTeaserHelper.spec.js b/webapp/components/UserTeaser/UserTeaserHelper.spec.js index 0a33c3252..49f52343a 100644 --- a/webapp/components/UserTeaser/UserTeaserHelper.spec.js +++ b/webapp/components/UserTeaser/UserTeaserHelper.spec.js @@ -1,4 +1,4 @@ -import { render } from '@testing-library/vue' +import { render, waitFor, fireEvent } from '@testing-library/vue' import { RouterLinkStub } from '@vue/test-utils' import UserTeaserHelper from './UserTeaserHelper.vue' @@ -20,6 +20,7 @@ describe('UserTeaserHelper', () => { withLinkToProfile = true, onTouchScreen = false, withPopoverEnabled = true, + hoverDelay = 500, }) => { mockIsTouchDevice = onTouchScreen @@ -29,13 +30,109 @@ describe('UserTeaserHelper', () => { userLink, linkToProfile: withLinkToProfile, showPopover: withPopoverEnabled, + hoverDelay: hoverDelay, }, stubs: { NuxtLink: RouterLinkStub, }, + slots: { + default: '
Test Content
', + }, }) } + // Helper function for tests that need timers + const withFakeTimers = (testFn) => { + return async () => { + jest.useFakeTimers() + try { + await testFn() + } finally { + jest.runOnlyPendingTimers() + jest.useRealTimers() + } + } + } + + describe('hover delay functionality', () => { + it( + 'should emit open-menu after hover delay on desktop', + withFakeTimers(async () => { + const wrapper = Wrapper({ + withLinkToProfile: true, + onTouchScreen: false, + withPopoverEnabled: true, + hoverDelay: 1000, + }) + + // Find the NuxtLink stub element + const link = wrapper.container.firstChild + expect(link).toBeTruthy() + + // Trigger mouseenter + fireEvent.mouseEnter(link) + + // Menu should not be opened yet + expect(wrapper.emitted()['open-menu']).toBeFalsy() + + // Advance time + jest.advanceTimersByTime(1000) + + // Now open-menu should have been emitted + await waitFor(() => { + expect(wrapper.emitted()['open-menu']).toBeTruthy() + expect(wrapper.emitted()['open-menu']).toHaveLength(1) + }) + }), + ) + + it( + 'should not emit open-menu if mouse leaves before delay', + withFakeTimers(async () => { + const wrapper = Wrapper({ + withLinkToProfile: true, + onTouchScreen: false, + withPopoverEnabled: true, + hoverDelay: 1000, + }) + + const link = wrapper.container.firstChild + + // Mouseenter + mouseleave + fireEvent.mouseEnter(link) + jest.advanceTimersByTime(500) + fireEvent.mouseLeave(link) + + // Let the rest of the time pass + jest.advanceTimersByTime(500) + + // open-menu should not have been emitted + expect(wrapper.emitted()['open-menu']).toBeFalsy() + // But close-menu should have been emitted + expect(wrapper.emitted()['close-menu']).toBeTruthy() + expect(wrapper.emitted()['close-menu']).toHaveLength(1) + }), + ) + + it('should emit open-menu immediately on touch device button click', async () => { + const wrapper = Wrapper({ + withLinkToProfile: true, + onTouchScreen: true, + withPopoverEnabled: true, + }) + + const button = wrapper.container.querySelector('button') + expect(button).toBeTruthy() + + // Click on button + fireEvent.click(button) + + // Should be emitted immediately, without timer + expect(wrapper.emitted()['open-menu']).toBeTruthy() + expect(wrapper.emitted()['open-menu']).toHaveLength(1) + }) + }) + describe('with linkToProfile and popover enabled, on touch screen', () => { let wrapper beforeEach(() => { @@ -43,6 +140,7 @@ describe('UserTeaserHelper', () => { }) it('renders button', () => { + expect(wrapper.container.querySelector('button')).toBeTruthy() expect(wrapper.container).toMatchSnapshot() }) }) @@ -54,6 +152,7 @@ describe('UserTeaserHelper', () => { }) it('renders span', () => { + expect(wrapper.container.querySelector('span')).toBeTruthy() expect(wrapper.container).toMatchSnapshot() }) }) @@ -65,7 +164,55 @@ describe('UserTeaserHelper', () => { }) it('renders link', () => { + const routerLinkStub = wrapper.container.firstChild + expect(routerLinkStub).toBeTruthy() + expect(routerLinkStub.tagName).toBeTruthy() expect(wrapper.container).toMatchSnapshot() }) }) + + describe('timer cleanup', () => { + it( + 'should clear timer on component unmount', + withFakeTimers(async () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout') + + const wrapper = Wrapper({ + withLinkToProfile: true, + onTouchScreen: false, + withPopoverEnabled: true, + hoverDelay: 5000, + }) + + // Start hover timer + fireEvent.mouseEnter(wrapper.container.firstChild) + + // Unmount while timer is running + wrapper.unmount() + + // clearTimeout should have been called + expect(clearTimeoutSpy).toHaveBeenCalled() + + clearTimeoutSpy.mockRestore() + }), + ) + }) + + describe('popover behavior without showPopover', () => { + it('should not emit events when showPopover is false', () => { + const wrapper = Wrapper({ + withLinkToProfile: true, + onTouchScreen: false, + withPopoverEnabled: false, + }) + + const link = wrapper.container.firstChild + + fireEvent.mouseEnter(link) + fireEvent.mouseLeave(link) + + expect(wrapper.emitted()['open-menu']).toBeUndefined() + expect(wrapper.emitted()['close-menu']).toBeUndefined() + }) + }) }) diff --git a/webapp/components/UserTeaser/UserTeaserHelper.vue b/webapp/components/UserTeaser/UserTeaserHelper.vue index e06e52c0a..0194eed1f 100644 --- a/webapp/components/UserTeaser/UserTeaserHelper.vue +++ b/webapp/components/UserTeaser/UserTeaserHelper.vue @@ -4,16 +4,16 @@ @@ -28,6 +28,13 @@ export default { userLink: { type: Object, default: null }, linkToProfile: { type: Boolean, default: true }, showPopover: { type: Boolean, default: false }, + hoverDelay: { type: Number, default: 500 }, + }, + data() { + return { + hoverTimer: null, + isHovering: false, + } }, computed: { isTouchDevice() { @@ -35,12 +42,46 @@ export default { }, }, methods: { + handleMouseEnter() { + if (!this.showPopover) return + + this.isHovering = true + + this.clearHoverTimer() + this.hoverTimer = setTimeout(() => { + // Only open if still hovering + if (this.isHovering) { + this.openMenu() + } + }, this.hoverDelay) + }, + + handleMouseLeave() { + if (!this.showPopover) return + + this.isHovering = false + this.clearHoverTimer() + this.closeMenu() + }, + + clearHoverTimer() { + if (this.hoverTimer) { + clearTimeout(this.hoverTimer) + this.hoverTimer = null + } + }, + openMenu() { this.$emit('open-menu') }, + closeMenu() { this.$emit('close-menu') }, }, + + beforeDestroy() { + this.clearHoverTimer() + }, } diff --git a/webapp/components/UserTeaser/UserTeaserNonAnonymous.vue b/webapp/components/UserTeaser/UserTeaserNonAnonymous.vue index 28ae536d9..ec820a908 100644 --- a/webapp/components/UserTeaser/UserTeaserNonAnonymous.vue +++ b/webapp/components/UserTeaser/UserTeaserNonAnonymous.vue @@ -6,6 +6,7 @@ :link-to-profile="linkToProfile" :show-popover="showPopover" :user-link="userLink" + :hover-delay="hoverDelay" @open-menu="loadPopover(openMenu)" @close-menu="closeMenu(false)" data-test="avatarUserLink" @@ -18,6 +19,7 @@ :link-to-profile="linkToProfile" :show-popover="showPopover" :user-link="userLink" + :hover-delay="hoverDelay" @open-menu="loadPopover(openMenu)" @close-menu="closeMenu(false)" > @@ -67,7 +69,7 @@ import UserTeaserPopover from './UserTeaserPopover' import UserTeaserHelper from './UserTeaserHelper.vue' export default { - name: 'UserTeaser', + name: 'UserTeaserNonAnonymous', components: { ProfileAvatar, UserTeaserPopover, @@ -85,6 +87,7 @@ export default { showPopover: { type: Boolean, default: true }, injectedText: { type: String, default: null }, injectedDate: { type: Boolean, default: false }, + hoverDelay: { type: Number, default: 500 }, }, computed: { ...mapGetters({ diff --git a/webapp/components/UserTeaser/__snapshots__/UserTeaser.spec.js.snap b/webapp/components/UserTeaser/__snapshots__/UserTeaser.spec.js.snap index b1d196a42..bd4a09327 100644 --- a/webapp/components/UserTeaser/__snapshots__/UserTeaser.spec.js.snap +++ b/webapp/components/UserTeaser/__snapshots__/UserTeaser.spec.js.snap @@ -353,7 +353,6 @@ exports[`UserTeaser given an user with linkToProfile, on desktop when hovering t delay="0" handleresize="true" offset="16" - open="true" openclass="open" opengroup="0" placement="bottom-end" @@ -421,12 +420,7 @@ exports[`UserTeaser given an user with linkToProfile, on desktop when hovering t -
- -
+
@@ -717,7 +711,6 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin delay="0" handleresize="true" offset="16" - open="true" openclass="open" opengroup="0" placement="bottom-end" @@ -785,11 +778,7 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin -
- -
+
@@ -810,7 +799,6 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin delay="0" handleresize="true" offset="16" - open="true" openclass="open" opengroup="0" placement="bottom-end" @@ -878,11 +866,7 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin -
- -
+
diff --git a/webapp/components/UserTeaser/__snapshots__/UserTeaserHelper.spec.js.snap b/webapp/components/UserTeaser/__snapshots__/UserTeaserHelper.spec.js.snap index 2257e8a51..4dea2f6c1 100644 --- a/webapp/components/UserTeaser/__snapshots__/UserTeaserHelper.spec.js.snap +++ b/webapp/components/UserTeaser/__snapshots__/UserTeaserHelper.spec.js.snap @@ -2,18 +2,30 @@ exports[`UserTeaserHelper with linkToProfile and popover enabled, on touch screen renders button 1`] = `
-
`; exports[`UserTeaserHelper with linkToProfile, on desktop renders link 1`] = `
- + +
+ Test Content +
+
`; exports[`UserTeaserHelper without linkToProfile renders span 1`] = `
- + +
+ Test Content +
+
`;