fix(editor): Close card dropdowns when workflows list is scrolled (#21630)

This commit is contained in:
Milorad FIlipović 2025-11-18 14:41:08 +01:00 committed by GitHub
parent 81dcf54a0d
commit c6434aca03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 142 additions and 15 deletions

View File

@ -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<UserType extends IUser, Actions extends Array<UserAc
disabled?: boolean;
popperClass?: string;
trigger?: 'click' | 'hover';
closeOnParentScroll?: boolean;
}
type ActionValue = Actions[number]['value'];
defineOptions({ name: 'N8nActionToggle' });
withDefaults(defineProps<ActionToggleProps<UserType, Array<UserAction<UserType>>>>(), {
actions: () => [],
placement: 'bottom',
size: 'medium',
theme: 'default',
iconSize: 'medium',
iconOrientation: 'vertical',
loading: false,
loadingRowCount: 3,
disabled: false,
popperClass: '',
trigger: 'click',
});
const props = withDefaults(
defineProps<ActionToggleProps<UserType, Array<UserAction<UserType>>>>(),
{
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<InstanceType<typeof ElDropdown> | null>(null);
@ -49,8 +55,26 @@ const emit = defineEmits<{
'item-mouseup': [action: UserAction<UserType>];
}>();
// 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();

View File

@ -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<ScrollListener[]>([]);
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,
};
}

View File

@ -22,10 +22,11 @@ import {
N8nIcon,
N8nText,
} from '@n8n/design-system';
type Props = {
data: FolderResource;
personalProject: Project | null;
actions: Array<UserAction<IUser>>;
actions?: Array<UserAction<IUser>>;
readOnly?: boolean;
showOwnershipBadge?: boolean;
};