diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 7779963fd..c9bc8bbe3 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -19,7 +19,7 @@ import { MESSAGE_STATUS, CONTENT_TYPES, } from './constants'; -import { useGlobalScrollLock } from 'dashboard/composables/useGlobalScrollLock'; +import { useTargetScrollLock } from 'dashboard/composables/useTargetScrollLock'; import Avatar from 'next/avatar/Avatar.vue'; @@ -131,7 +131,7 @@ const props = defineProps({ sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties }); -const conversationPanelScrollLock = useGlobalScrollLock(); +const conversationPanelScrollLock = useTargetScrollLock(); const contextMenuPosition = ref({}); const showBackgroundHighlight = ref(false); const showContextMenu = ref(false); diff --git a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue index 95d369151..5e2e952f7 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue @@ -3,7 +3,7 @@ import { computed, useTemplateRef, ref, onMounted, reactive } from 'vue'; import { Letter } from 'vue-letter'; import { allowedCssProperties } from 'lettersanitizer'; import { useWindowSize, useToggle } from '@vueuse/core'; -import { useGlobalScrollLock } from 'dashboard/composables/useGlobalScrollLock'; +import { useTargetScrollLock } from 'dashboard/composables/useTargetScrollLock'; import Icon from 'next/icon/Icon.vue'; import { EmailQuoteExtractor } from './removeReply.js'; @@ -34,7 +34,7 @@ const contentContainer = useTemplateRef('contentContainer'); // Forward form - managed locally but can be triggered by parent const [showForwardMessageModal, toggleForwardModal] = useToggle(); const forwardFormPosition = reactive({ top: 0, right: 0 }); -const conversationPanelScrollLock = useGlobalScrollLock(); +const conversationPanelScrollLock = useTargetScrollLock(); const { width: windowWidth, height: windowHeight } = useWindowSize(); onMounted(() => { diff --git a/app/javascript/dashboard/composables/spec/useTargetScrollLock.spec.js b/app/javascript/dashboard/composables/spec/useTargetScrollLock.spec.js new file mode 100644 index 000000000..c2d7bf2ea --- /dev/null +++ b/app/javascript/dashboard/composables/spec/useTargetScrollLock.spec.js @@ -0,0 +1,116 @@ +import { ref } from 'vue'; +import { useTargetScrollLock } from '../useTargetScrollLock'; +import { useScrollLock } from '@vueuse/core'; +import { describe, beforeEach, test, expect, vi } from 'vitest'; + +vi.mock('@vueuse/core', () => ({ + useScrollLock: vi.fn(), +})); + +vi.mock('vue', async () => { + const actual = await vi.importActual('vue'); + return { + ...actual, + onUnmounted: vi.fn(fn => { + vi.fn.unmountCallback = fn; + }), + }; +}); + +describe('useTargetScrollLock', () => { + let mockLockRef; + + beforeEach(() => { + document.body.innerHTML = ` +
+ + `; + + mockLockRef = ref(true); + useScrollLock.mockReturnValue(mockLockRef); + + vi.clearAllMocks(); + }); + + test('provides the expected API', () => { + const api = useTargetScrollLock(); + + expect(api).toHaveProperty('lockScroll'); + expect(api).toHaveProperty('unlockScroll'); + expect(api).toHaveProperty('isLocked'); + expect(api.isLocked.value).toBe(false); + }); + + test('handles various target types', () => { + const { lockScroll } = useTargetScrollLock(); + + lockScroll('.conversation-panel'); + expect(useScrollLock).toHaveBeenLastCalledWith( + document.querySelector('.conversation-panel'), + true + ); + + const element = document.querySelector('#modal'); + lockScroll(element); + expect(useScrollLock).toHaveBeenLastCalledWith(element, true); + + const elementRef = ref(element); + lockScroll(elementRef); + expect(useScrollLock).toHaveBeenLastCalledWith(element, true); + }); + + test('handles default target parameter', () => { + const defaultEl = document.querySelector('.conversation-panel'); + const { lockScroll } = useTargetScrollLock(defaultEl); + + lockScroll(); + expect(useScrollLock).toHaveBeenCalledWith(defaultEl, true); + }); + + test('fails gracefully with invalid targets', () => { + const { lockScroll, isLocked } = useTargetScrollLock(); + + expect(lockScroll('.doesnt-exist')).toBe(false); + expect(isLocked.value).toBe(false); + + expect(lockScroll(null)).toBe(false); + expect(isLocked.value).toBe(false); + + expect(useScrollLock).not.toHaveBeenCalled(); + }); + + test('manages lock state correctly', () => { + const { lockScroll, unlockScroll, isLocked } = useTargetScrollLock(); + + expect(isLocked.value).toBe(false); + + lockScroll('.conversation-panel'); + expect(isLocked.value).toBe(true); + unlockScroll(); + expect(isLocked.value).toBe(false); + expect(mockLockRef.value).toBe(false); + }); + + test('handles lock switching correctly', () => { + const { lockScroll } = useTargetScrollLock(); + + lockScroll('.conversation-panel'); + lockScroll('#modal'); + + expect(mockLockRef.value).toBe(false); + expect(useScrollLock).toHaveBeenCalledTimes(2); + }); + + test('cleans up on component unmount', () => { + const { lockScroll, isLocked } = useTargetScrollLock(); + + lockScroll('.conversation-panel'); + expect(isLocked.value).toBe(true); + + if (vi.fn.unmountCallback) { + vi.fn.unmountCallback(); + } + + expect(isLocked.value).toBe(false); + }); +}); diff --git a/app/javascript/dashboard/composables/useTargetScrollLock.js b/app/javascript/dashboard/composables/useTargetScrollLock.js new file mode 100644 index 000000000..35932370f --- /dev/null +++ b/app/javascript/dashboard/composables/useTargetScrollLock.js @@ -0,0 +1,48 @@ +/** + * useTargetScrollLock composable + * + * Usage: + * const { lockScroll, unlockScroll, isLocked } = useTargetScrollLock(target); + * target can be a ref, DOM element, or selector string (class, id, etc) + * eg: lockScroll('.conversation-panel'); unlockScroll(); + */ + +import { ref, unref, shallowRef, onUnmounted } from 'vue'; +import { useScrollLock } from '@vueuse/core'; + +export function useTargetScrollLock(defaultTarget) { + const scrollLockInstance = shallowRef(null); + const isLocked = ref(false); + + function resolveTarget(target) { + if (!target) return null; + if (typeof target === 'string') return document.querySelector(target); // class, id, etc + if (target instanceof HTMLElement) return target; // DOM element + return unref(target); // ref + } + + function unlockScroll() { + if (!scrollLockInstance.value) return false; + + scrollLockInstance.value.value = false; + isLocked.value = false; + scrollLockInstance.value = null; + return true; + } + + function lockScroll(target = defaultTarget) { + unlockScroll(); // Always unlock first + const el = resolveTarget(target); + if (!el) return false; + + scrollLockInstance.value = useScrollLock(el, true); + isLocked.value = true; + return true; + } + + onUnmounted(() => { + unlockScroll(); + }); + + return { lockScroll, unlockScroll, isLocked }; +} diff --git a/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue b/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue index df22bb722..6688cd18b 100644 --- a/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue +++ b/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue @@ -116,11 +116,11 @@ export default { this.$emit('close', e); }, openForwardModal() { + this.handleClose(); this.$emit('forwardEmail', { x: this.contextMenuPosition.x, y: this.contextMenuPosition.y, }); - this.handleClose(); }, handleTranslate() { const { locale } = this.getAccount(this.currentAccountId);