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 @@
showPopover && openMenu()"
- @mouseleave="closeMenu"
+ @mouseenter="handleMouseEnter"
+ @mouseleave="handleMouseLeave"
>
showPopover && openMenu()"
- @mouseleave.native="closeMenu"
+ @mouseenter.native="handleMouseEnter"
+ @mouseleave.native="handleMouseLeave"
>
@@ -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`] = `
`;
exports[`UserTeaserHelper without linkToProfile renders span 1`] = `
`;