fix(editor): Prevent expression result popover from covering CodeMirror tooltips (#30981)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mike Repeć 2026-05-26 13:59:08 +02:00 committed by GitHub
parent d7f70b637c
commit cadba03974
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 131 additions and 6 deletions

View File

@ -63,6 +63,25 @@ describe('components', () => {
expect(wrapper.emitted()).toBeDefined();
});
it('emits update:open when opened via Cmd+K and when closed via Escape', async () => {
const wrapper = render(N8nCommandBar, {
props: { items: createSampleItems() },
});
await openCommandBar();
const openEvents = wrapper.emitted('update:open') ?? [];
expect(openEvents.length).toBeGreaterThanOrEqual(1);
expect(openEvents[openEvents.length - 1]).toEqual([true]);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await waitFor(() => {
const events = wrapper.emitted('update:open') ?? [];
expect(events[events.length - 1]).toEqual([false]);
});
});
it('emits inputChange and filters results as user types', async () => {
const wrapper = render(N8nCommandBar, {
props: { items: createSampleItems() },

View File

@ -33,7 +33,7 @@ const emit = defineEmits<{
const NUM_LOADING_ITEMS_FULL = 8;
const NUM_LOADING_ITEMS_PARTIAL = 3;
const isOpen = ref(false);
const isOpen = defineModel<boolean>('open', { default: false });
const inputRef = ref<HTMLInputElement>();
const selectedIndex = ref(-1);
const inputValue = ref('');

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/vue';
import { render, waitFor } from '@testing-library/vue';
import { mount } from '@vue/test-utils';
import { vi } from 'vitest';
@ -140,5 +140,34 @@ describe('N8nPopover', () => {
expect(popover.contains(document.activeElement)).toBe(false);
});
it('should suppress focus restore on close when suppressAutoFocus is true', async () => {
const triggerHandle = document.createElement('button');
triggerHandle.textContent = 'trigger';
document.body.appendChild(triggerHandle);
const externalInput = document.createElement('input');
document.body.appendChild(externalInput);
const wrapper = render(N8nPopover, {
props: { open: true, suppressAutoFocus: true },
slots: {
trigger: '<button>trigger</button>',
content: '<input />',
},
});
await wrapper.findByRole('dialog');
externalInput.focus();
expect(document.activeElement).toBe(externalInput);
await wrapper.rerender({ open: false, suppressAutoFocus: true });
await waitFor(() => expect(wrapper.queryByRole('dialog')).not.toBeInTheDocument());
expect(document.activeElement).toBe(externalInput);
triggerHandle.remove();
externalInput.remove();
});
});
});

View File

@ -42,7 +42,7 @@ interface Props
*/
enableSlideIn?: boolean;
/**
* Whether to suppress auto-focus behavior when the content includes focusable element
* Whether to suppress auto-focus behavior when the popover opens or closes.
*/
suppressAutoFocus?: boolean;
/**
@ -108,6 +108,12 @@ function handleOpenAutoFocus(e: Event) {
}
}
function handleCloseAutoFocus(e: Event) {
if (props.suppressAutoFocus) {
e.preventDefault();
}
}
/**
* Handles outside interaction events to prevent Reka UI from interfering
* with Element Plus dropdown selections. Element Plus teleports dropdowns
@ -155,6 +161,7 @@ watch(
:force-mount="forceMount"
:position-strategy="positionStrategy"
@open-auto-focus="handleOpenAutoFocus"
@close-auto-focus="handleCloseAutoFocus"
@pointer-down-outside="handleOutsideInteraction"
@interact-outside="handleOutsideInteraction"
>

View File

@ -112,7 +112,9 @@ useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloating
</AppLayout>
<AppModals />
<AppCommandBar />
<div :id="CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID" />
<template #overlays>
<div :id="CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID" />
</template>
<template #aside>
<AppChatPanel v-if="layoutRef" :layout-ref="layoutRef" />
</template>

View File

@ -6,6 +6,7 @@ import { VIEWS } from '@/app/constants';
import { useStyles } from '@/app/composables/useStyles';
import { useCommandBar } from '@/features/shared/commandBar/composables/useCommandBar';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { commandBarEventBus } from '@/features/shared/commandBar/commandBar.eventBus';
const route = useRoute();
const { APP_Z_INDEXES } = useStyles();
@ -29,6 +30,12 @@ watch(showCommandBar, (newVal) => {
void initializeCommandBar();
}
});
function onCommandBarOpenChange(open: boolean) {
if (open) {
commandBarEventBus.emit('open');
}
}
</script>
<template>
@ -41,5 +48,6 @@ watch(showCommandBar, (newVal) => {
:z-index="APP_Z_INDEXES.COMMAND_BAR"
@input-change="onCommandBarChange"
@navigate-to="onCommandBarNavigateTo"
@update:open="onCommandBarOpenChange"
/>
</template>

View File

@ -86,6 +86,14 @@ function onFocus() {
emit('focus');
}
function onFocusOut(event: FocusEvent) {
const nextFocus = event.relatedTarget;
if (isEventTargetContainedBy(nextFocus, container)) return;
if (isEventTargetContainedBy(nextFocus, outputPopover.value?.contentRef)) return;
onBlur(event);
}
function onBlur(event?: FocusEvent | KeyboardEvent) {
if (
event?.target instanceof Element &&
@ -191,7 +199,12 @@ defineExpose({ focus, select });
</script>
<template>
<div ref="container" :class="$style['expression-parameter-input']" @keydown.tab="onBlur">
<div
ref="container"
:class="$style['expression-parameter-input']"
@keydown.tab="onBlur"
@focusout="onFocusOut"
>
<div
:class="[
$style['all-sections'],

View File

@ -0,0 +1,8 @@
import { createEventBus } from '@n8n/utils/event-bus';
export interface CommandBarEventBusEvents {
/** Event that the command bar has opened */
open: never;
}
export const commandBarEventBus = createEventBus<CommandBarEventBusEvents>();

View File

@ -1,4 +1,5 @@
import {
closeCompletion,
CompletionContext,
completionStatus,
type Completion,
@ -21,6 +22,7 @@ import {
import type { SyntaxNode } from '@lezer/common';
import type { createInfoBoxRenderer } from '../completions/infoBoxRenderer';
import { CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID } from '@/app/constants';
import { commandBarEventBus } from '@/features/shared/commandBar/commandBar.eventBus';
const findNearestParentOfType =
(type: string) =>
@ -397,6 +399,24 @@ export const closeCursorInfoBox: Command = (view) => {
return true;
};
const closeTooltipsOnCommandBarOpen = ViewPlugin.fromClass(
class {
private readonly listener: () => void;
constructor(view: EditorView) {
this.listener = () => {
closeCompletion(view);
closeCursorInfoBox(view);
};
commandBarEventBus.on('open', this.listener);
}
destroy() {
commandBarEventBus.off('open', this.listener);
}
},
);
export const infoBoxTooltips = (): Extension[] => {
return [
tooltips({
@ -405,6 +425,7 @@ export const infoBoxTooltips = (): Extension[] => {
cursorInfoBoxTooltip,
asyncTooltipLoader,
hoverInfoBoxTooltip,
closeTooltipsOnCommandBarOpen,
keymap.of([
{
key: 'Escape',

View File

@ -8,6 +8,7 @@ import * as utils from '@/features/shared/editors/plugins/codemirror/completions
import * as workflowHelpers from '@/app/composables/useWorkflowHelpers';
import { completionStatus } from '@codemirror/autocomplete';
import { WORKFLOW_DOCUMENT_FACET } from '@/features/shared/editors/plugins/codemirror/completions/constants';
import { commandBarEventBus } from '@/features/shared/commandBar/commandBar.eventBus';
vi.mock('@codemirror/autocomplete', async (importOriginal) => {
const actual = await importOriginal<{}>();
@ -31,6 +32,15 @@ describe('Infobox tooltips', () => {
});
describe('Cursor tooltips', () => {
test('should dismiss the cursor info-box when the command bar opens', async () => {
const view = await setupEditorWithCursor('{{ $max(|) }}');
expect(getCursorTooltips(view).length).toBe(1);
commandBarEventBus.emit('open');
expect(getCursorTooltips(view).length).toBe(0);
});
test('should NOT show a tooltip for: {{ $max(1,2) }} foo|', async () => {
const tooltips = await cursorTooltips('{{ $max(1,2) }} foo|');
expect(tooltips.length).toBe(0);
@ -136,7 +146,7 @@ function infoBoxHeader(infoBox: HTMLElement | undefined) {
return infoBox?.querySelector('.autocomplete-info-header');
}
async function cursorTooltips(docWithCursor: string) {
async function setupEditorWithCursor(docWithCursor: string) {
const cursorPosition = docWithCursor.indexOf('|');
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
@ -157,12 +167,20 @@ async function cursorTooltips(docWithCursor: string) {
view.requestMeasure();
await new Promise((resolve) => setTimeout(resolve, 10));
return view;
}
function getCursorTooltips(view: EditorView) {
return view.state
.facet(showTooltip)
.filter((t): t is Tooltip => !!t)
.map((tooltip) => ({ tooltip, view: getTooltip(view, tooltip)?.dom }));
}
async function cursorTooltips(docWithCursor: string) {
return getCursorTooltips(await setupEditorWithCursor(docWithCursor));
}
async function hoverTooltip(docWithCursor: string) {
const hoverPosition = docWithCursor.indexOf('|');