From c6434aca0396e10e6f49bf999c4e93168491901d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Tue, 18 Nov 2025 14:41:08 +0100 Subject: [PATCH] fix(editor): Close card dropdowns when workflows list is scrolled (#21630) --- .../N8nActionToggle/ActionToggle.vue | 52 ++++++--- .../src/composables/useParentScroll.ts | 102 ++++++++++++++++++ .../core/folders/components/FolderCard.vue | 3 +- 3 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/composables/useParentScroll.ts diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.vue index 63ffab9281c..c8299014597 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionToggle/ActionToggle.vue @@ -2,6 +2,7 @@ import { ElDropdown, ElDropdownMenu, ElDropdownItem, type Placement } from 'element-plus'; import { ref } from 'vue'; +import { useParentScroll } from '../../composables/useParentScroll'; import type { IUser, UserAction } from '../../types'; import type { IconOrientation, IconSize } from '../../types/icon'; import N8nIcon from '../N8nIcon'; @@ -22,24 +23,29 @@ interface ActionToggleProps>>>(), { - actions: () => [], - placement: 'bottom', - size: 'medium', - theme: 'default', - iconSize: 'medium', - iconOrientation: 'vertical', - loading: false, - loadingRowCount: 3, - disabled: false, - popperClass: '', - trigger: 'click', -}); +const props = withDefaults( + defineProps>>>(), + { + actions: () => [], + placement: 'bottom', + size: 'medium', + theme: 'default', + iconSize: 'medium', + iconOrientation: 'vertical', + loading: false, + loadingRowCount: 3, + disabled: false, + popperClass: '', + trigger: 'click', + closeOnParentScroll: true, + }, +); const actionToggleRef = ref | null>(null); @@ -49,8 +55,26 @@ const emit = defineEmits<{ 'item-mouseup': [action: UserAction]; }>(); +// Close dropdown when parent scrolls +const { attachScrollListeners, detachScrollListeners } = useParentScroll(actionToggleRef, () => { + if (props.closeOnParentScroll) { + actionToggleRef.value?.handleClose(); + } +}); + const onCommand = (value: string) => emit('action', value); -const onVisibleChange = (value: boolean) => emit('visible-change', value); +const onVisibleChange = (value: boolean) => { + emit('visible-change', value); + + if (props.closeOnParentScroll) { + if (value) { + attachScrollListeners(); + } else { + detachScrollListeners(); + } + } +}; + const openActionToggle = (isOpen: boolean) => { if (isOpen) { actionToggleRef.value?.handleOpen(); diff --git a/packages/frontend/@n8n/design-system/src/composables/useParentScroll.ts b/packages/frontend/@n8n/design-system/src/composables/useParentScroll.ts new file mode 100644 index 00000000000..884d0dbbc98 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/composables/useParentScroll.ts @@ -0,0 +1,102 @@ +import { ref, onBeforeUnmount, nextTick, type Ref } from 'vue'; + +type ScrollListener = { + element: Element; + handler: () => void; +}; + +const isHTMLElement = (element: unknown): element is HTMLElement => { + return element !== undefined && element instanceof HTMLElement; +}; + +const isScrollable = (element: Element): boolean => { + const computedStyle = window.getComputedStyle(element); + const overflowY = computedStyle.overflowY; + const overflowX = computedStyle.overflowX; + + // Check if element has scrollable overflow + const hasScrollableOverflow = + overflowY === 'auto' || + overflowY === 'scroll' || + overflowX === 'auto' || + overflowX === 'scroll'; + + if (!hasScrollableOverflow) { + return false; + } + + // Check if element actually has scrollable content + return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth; +}; + +const findFirstScrollableParent = (element: HTMLElement): Element | null => { + let parent = element.parentElement; + + while (parent && parent !== document.body) { + if (isScrollable(parent)) { + return parent; + } + parent = parent.parentElement; + } + + if (document.body && isScrollable(document.body)) { + return document.body; + } + + if (document.documentElement && isScrollable(document.documentElement)) { + return document.documentElement; + } + + return null; +}; + +/** + * Detects scroll events on parent elements and triggers a callback + * + * @param elementRef - Ref to the element or component instance with $el + * @param onScroll - Callback function to execute when scroll is detected + */ +export function useParentScroll( + elementRef: Ref<{ $el?: unknown } | HTMLElement | null | undefined>, + onScroll: () => void, +) { + const scrollListeners = ref([]); + + const detachScrollListeners = () => { + scrollListeners.value.forEach(({ element, handler }) => { + element.removeEventListener('scroll', handler, { capture: true }); + }); + scrollListeners.value = []; + }; + + const attachScrollListeners = () => { + // Ensure DOM is ready but don't block on it + void nextTick(() => { + const element = elementRef.value; + const dropdownElement = element && '$el' in element ? element.$el : element; + + if (!isHTMLElement(dropdownElement)) { + return; + } + + detachScrollListeners(); + + const scrollableParent = findFirstScrollableParent(dropdownElement); + + if (scrollableParent) { + const handler = () => onScroll(); + scrollableParent.addEventListener('scroll', handler, { passive: true, capture: true }); + scrollListeners.value.push({ element: scrollableParent, handler }); + } + }); + }; + + onBeforeUnmount(() => { + detachScrollListeners(); + }); + + return { + attachScrollListeners, + detachScrollListeners, + }; +} diff --git a/packages/frontend/editor-ui/src/features/core/folders/components/FolderCard.vue b/packages/frontend/editor-ui/src/features/core/folders/components/FolderCard.vue index 03b9ac0fadc..e39844d0789 100644 --- a/packages/frontend/editor-ui/src/features/core/folders/components/FolderCard.vue +++ b/packages/frontend/editor-ui/src/features/core/folders/components/FolderCard.vue @@ -22,10 +22,11 @@ import { N8nIcon, N8nText, } from '@n8n/design-system'; + type Props = { data: FolderResource; personalProject: Project | null; - actions: Array>; + actions?: Array>; readOnly?: boolean; showOwnershipBadge?: boolean; };