diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts index eda0ca3c1b9..1cdafcb8ef2 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts @@ -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() }, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue index 81c0e8a71de..5f8de0d4ad5 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue @@ -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('open', { default: false }); const inputRef = ref(); const selectedIndex = ref(-1); const inputValue = ref(''); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.test.ts index 8f61869de4d..9e100078c31 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.test.ts @@ -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: '', + content: '', + }, + }); + 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(); + }); }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.vue b/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.vue index 9de0d558f14..208608ea6cf 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nPopover/Popover.vue @@ -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" > diff --git a/packages/frontend/editor-ui/src/app/App.vue b/packages/frontend/editor-ui/src/app/App.vue index 29b0be648ce..41852f4f1b3 100644 --- a/packages/frontend/editor-ui/src/app/App.vue +++ b/packages/frontend/editor-ui/src/app/App.vue @@ -112,7 +112,9 @@ useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloating -
+ diff --git a/packages/frontend/editor-ui/src/app/components/app/AppCommandBar.vue b/packages/frontend/editor-ui/src/app/components/app/AppCommandBar.vue index 0888d910cfd..c88c9dc4379 100644 --- a/packages/frontend/editor-ui/src/app/components/app/AppCommandBar.vue +++ b/packages/frontend/editor-ui/src/app/components/app/AppCommandBar.vue @@ -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'); + } +} diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionParameterInput.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionParameterInput.vue index bc547b8f586..d9e69b57139 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionParameterInput.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionParameterInput.vue @@ -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 });