From 7c5a7f287e788a445c5a1a9bf4b25c3f963ec7a4 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 11 May 2026 20:01:10 -0300 Subject: [PATCH] fix(superdoc): restore find input focus after match navigation (SD-3045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Search extension's goToSearchResult calls editor.view.focus() so the new selection is visible. When the user pressed Enter in the built-in find input, the synchronous focus steal blurred the input — subsequent Enter keystrokes were swallowed by the ProseMirror editor, splitting paragraphs at the match position, invalidating the search session, and resetting the active match index. The user had to click back into the input between every navigation. Re-focus the find input synchronously after goNext / goPrev so repeated Enter / Shift+Enter keeps advancing through matches and the editor never receives the keystroke. --- .../surfaces/FindReplaceSurface.test.js | 142 ++++++++++++++++++ .../surfaces/FindReplaceSurface.vue | 5 + 2 files changed, 147 insertions(+) create mode 100644 packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js diff --git a/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js b/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js new file mode 100644 index 0000000000..d6928b5971 --- /dev/null +++ b/packages/superdoc/src/components/surfaces/FindReplaceSurface.test.js @@ -0,0 +1,142 @@ +// @ts-nocheck +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { nextTick, ref, computed } from 'vue'; +import FindReplaceSurface from './FindReplaceSurface.vue'; + +const DEFAULT_TEXTS = { + findPlaceholder: 'Find', + findAriaLabel: 'Find text', + replacePlaceholder: 'Replace', + replaceAriaLabel: 'Replace text', + noResultsLabel: 'No results', + previousMatchLabel: 'Previous', + previousMatchAriaLabel: 'Previous', + nextMatchLabel: 'Next', + nextMatchAriaLabel: 'Next', + closeLabel: 'Close', + closeAriaLabel: 'Close', + replaceLabel: 'Replace', + replaceAllLabel: 'All', + toggleReplaceLabel: 'Toggle replace', + toggleReplaceAriaLabel: 'Toggle replace', + matchCaseLabel: 'Aa', + matchCaseAriaLabel: 'Match case', + ignoreDiacriticsLabel: 'ä≡a', + ignoreDiacriticsAriaLabel: 'Ignore diacritics', +}; + +/** + * Build a ref-shaped findReplace handle that mirrors what useFindReplace provides. + * goNext / goPrev simulate the real Search extension behaviour of focusing the + * editor view (which is what causes SD-3045 in production) by moving focus to a + * detached element supplied via `stealFocusInto`. + */ +function createHandle(overrides = {}) { + const findQuery = ref('Lorem'); + const replaceText = ref(''); + const caseSensitive = ref(false); + const ignoreDiacritics = ref(false); + const showReplace = ref(false); + const matchCount = ref(16); + const activeMatchIndex = ref(0); + + return { + findQuery, + replaceText, + caseSensitive, + ignoreDiacritics, + showReplace, + matchCount, + activeMatchIndex, + matchLabel: computed(() => `${activeMatchIndex.value + 1} of ${matchCount.value}`), + hasMatches: computed(() => matchCount.value > 0), + replaceEnabled: true, + texts: { ...DEFAULT_TEXTS }, + goNext: vi.fn(() => overrides.stealFocusInto?.focus()), + goPrev: vi.fn(() => overrides.stealFocusInto?.focus()), + replaceCurrent: vi.fn(), + replaceAll: vi.fn(), + registerFocusFn: vi.fn(), + close: vi.fn(), + ...overrides.handle, + }; +} + +function mountSurface(handle) { + return mount(FindReplaceSurface, { + attachTo: document.body, + props: { + surfaceId: 'fr-1', + mode: 'floating', + request: {}, + resolve: vi.fn(), + close: vi.fn(), + findReplace: handle, + }, + }); +} + +describe('FindReplaceSurface — keyboard focus (SD-3045)', () => { + it('keeps focus on the find input after pressing Enter, even when goNext steals focus to another element', async () => { + // The editor view focus steal is the real-world cause of SD-3045 — simulate it + // with a detached div that goNext focuses synchronously, matching the Search + // extension's editor.view.focus() side effect. + const stealTarget = document.createElement('div'); + stealTarget.tabIndex = -1; + document.body.appendChild(stealTarget); + + try { + const handle = createHandle({ stealFocusInto: stealTarget }); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + input.focus(); + expect(document.activeElement).toBe(input); + + await wrapper.find('.sd-find-replace__input').trigger('keydown', { key: 'Enter' }); + await nextTick(); + + expect(handle.goNext).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + + wrapper.unmount(); + } finally { + stealTarget.remove(); + } + }); + + it('keeps focus on the find input after Shift+Enter (previous match)', async () => { + const stealTarget = document.createElement('div'); + stealTarget.tabIndex = -1; + document.body.appendChild(stealTarget); + + try { + const handle = createHandle({ stealFocusInto: stealTarget }); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + input.focus(); + + await wrapper.find('.sd-find-replace__input').trigger('keydown', { key: 'Enter', shiftKey: true }); + await nextTick(); + + expect(handle.goPrev).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + + wrapper.unmount(); + } finally { + stealTarget.remove(); + } + }); + + it('prevents the default Enter behaviour so the editor never receives the keystroke', () => { + const handle = createHandle(); + const wrapper = mountSurface(handle); + const input = wrapper.find('.sd-find-replace__input').element; + input.focus(); + + const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + wrapper.unmount(); + }); +}); diff --git a/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue b/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue index 96cc56f390..b6e3eca0b8 100644 --- a/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue +++ b/packages/superdoc/src/components/surfaces/FindReplaceSurface.vue @@ -18,9 +18,14 @@ function handleFindKeydown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); props.findReplace.goNext(); + // goNext synchronously focuses the ProseMirror view (search.js `goToSearchResult`). + // Restore focus here so repeated Enter keeps advancing through matches instead of + // dropping the keystrokes into the editor (SD-3045). + focusFindInput(); } else if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); props.findReplace.goPrev(); + focusFindInput(); } }