From 6314e6ba3fc493ff87515cecc438271735705441 Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Fri, 23 Oct 2026 00:02:45 +0200 Subject: [PATCH 1/2] fix: announce search results to screen readers --- .../web-app-search/src/portals/SearchBar.vue | 28 ++++ .../tests/unit/portals/SearchBar.spec.ts | 125 +++++++++++++++- .../TextEditorSearchAndReplacePanel.vue | 41 +++++- .../TextEditorSearchAndReplacePanel.spec.ts | 134 +++++++++++++++++- 4 files changed, 323 insertions(+), 5 deletions(-) diff --git a/packages/web-app-search/src/portals/SearchBar.vue b/packages/web-app-search/src/portals/SearchBar.vue index 50b99769fdd..2ca261ff891 100644 --- a/packages/web-app-search/src/portals/SearchBar.vue +++ b/packages/web-app-search/src/portals/SearchBar.vue @@ -42,6 +42,7 @@ /> + >('optionsDropRef') const searchBarRef = useTemplateRef('searchBar') +const resultsAnnouncer = useTemplateRef('resultsAnnouncer') const activePreviewIndex = ref(null) const term = ref('') const restoreSearchFromRoute = ref(false) @@ -281,6 +283,7 @@ async function search() { searchResults.value = [] if (!unref(term)?.trim()) { loading.value = false + unref(resultsAnnouncer)?.announce('') return } @@ -309,6 +312,31 @@ async function search() { searchResults.value.push({ providerId: availableProvider.id, result }) } loading.value = false + announceResults() +} + +function announceResults() { + // only announce the preview the user is looking at, not searches in the background + if (!unref(showDrop) || !unref(searchBarRef)?.contains(document.activeElement)) { + return + } + unref(resultsAnnouncer)?.announce(getResultsAnnouncement()) +} + +function getResultsAnnouncement() { + if (unref(showNoResults)) { + return $gettext('No results') + } + + const totals = unref(displayProviders).map( + (provider) => getSearchResultForProvider(provider).totalResults + ) + if (totals.some((total) => !total)) { + return $gettext('Results found') + } + + const count = totals.reduce((sum, total) => sum + total, 0) + return $ngettext('%{count} result', '%{count} results', count, { count: count.toString() }) } function onKeyUpEnter() { diff --git a/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts b/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts index 536bfffd785..89d31f66602 100644 --- a/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts +++ b/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts @@ -1,7 +1,7 @@ import SearchBar from '../../../src/portals/SearchBar.vue' import { enableAutoUnmount, flushPromises, RouterLinkStub } from '@vue/test-utils' import { mock } from 'vitest-mock-extended' -import { markRaw, nextTick, ref, shallowRef } from 'vue' +import { h, markRaw, nextTick, ref, shallowRef } from 'vue' import { defineComponent } from 'vue' import { defaultPlugins, @@ -57,6 +57,15 @@ const providerContacts = { } } +const announceMock = vi.fn() +// exposes a spy as the announce method, so the tests can check what gets announced +const OcHiddenAnnouncerStub = defineComponent({ + setup(_, { expose }) { + expose({ announce: announceMock }) + return () => h('span') + } +}) + const selectors = { search: '#files-global-search', noResults: '#no-results', @@ -296,6 +305,113 @@ describe('Search Bar portal component', () => { expect.stringContaining('folder-id') ) }) + describe('results announcement', () => { + afterEach(() => { + announceMock.mockReset() + }) + + async function typeTerm( + wrapper: ReturnType['wrapper'], + term: string + ) { + const input = wrapper.find(selectors.searchInput) + input.element.focus() + await input.setValue(term) + await flushPromises() + } + + test('announces the total number of results once the search has settled', async () => { + const { wrapper } = getMountedWrapper({ providers: [providerFiles], attachTo: document.body }) + let resolveSearch: (result: SearchResult) => void + providerFiles.previewSearch.search.mockImplementationOnce( + () => new Promise((resolve) => (resolveSearch = resolve)) + ) + await typeTerm(wrapper, 'albert') + expect(announceMock).not.toHaveBeenCalledWith(expect.stringMatching(/.+/)) + + resolveSearch({ values: [mock()], totalResults: 12 }) + await flushPromises() + expect(announceMock).toHaveBeenLastCalledWith('12 results') + }) + test('announces the total number of results of all providers', async () => { + const { wrapper } = getMountedWrapper({ attachTo: document.body }) + providerFiles.previewSearch.search.mockReturnValueOnce({ + values: [mock()], + totalResults: 12 + }) + providerContacts.previewSearch.search.mockReturnValueOnce({ + values: [mock()], + totalResults: 3 + }) + await typeTerm(wrapper, 'albert') + expect(announceMock).toHaveBeenLastCalledWith('15 results') + }) + test('announces results without a number if a total is unknown', async () => { + const { wrapper } = getMountedWrapper({ attachTo: document.body }) + providerFiles.previewSearch.search.mockReturnValueOnce({ + values: [mock()], + totalResults: 12 + }) + await typeTerm(wrapper, 'albert') + expect(announceMock).toHaveBeenLastCalledWith('Results found') + }) + test('announces "No results" if nothing was found', async () => { + const { wrapper } = getMountedWrapper({ attachTo: document.body }) + providerFiles.previewSearch.search.mockReturnValueOnce({ values: [] }) + providerContacts.previewSearch.search.mockReturnValueOnce({ values: [] }) + await typeTerm(wrapper, 'nothing found') + expect(announceMock).toHaveBeenLastCalledWith('No results') + }) + test.each(['', ' '])('announces nothing for the term "%s"', async (term) => { + const { wrapper } = getMountedWrapper({ attachTo: document.body }) + await typeTerm(wrapper, term) + expect(announceMock).not.toHaveBeenCalledWith(expect.stringMatching(/.+/)) + }) + test('re-announces the results when the same term gets searched again', async () => { + const { wrapper } = getMountedWrapper({ attachTo: document.body }) + await typeTerm(wrapper, 'albert') + expect(announceMock).toHaveBeenCalledTimes(1) + expect(announceMock).toHaveBeenLastCalledWith('Results found') + + await wrapper.find(selectors.searchInput).trigger('click') + await flushPromises() + expect(announceMock).toHaveBeenCalledTimes(2) + expect(announceMock).toHaveBeenLastCalledWith('Results found') + }) + test('clears the announcement when the term gets cleared', async () => { + const { wrapper } = getMountedWrapper({ attachTo: document.body }) + await typeTerm(wrapper, 'albert') + await typeTerm(wrapper, '') + expect(announceMock).toHaveBeenLastCalledWith('') + }) + test('announces nothing if no provider has a preview', async () => { + const providerWithoutPreview = { + ...providerFiles, + previewSearch: { ...providerFiles.previewSearch, available: false } + } + const { wrapper } = getMountedWrapper({ + providers: [providerWithoutPreview], + attachTo: document.body + }) + await typeTerm(wrapper, 'albert') + expect(announceMock).not.toHaveBeenCalledWith(expect.stringMatching(/.+/)) + }) + test('announces nothing for a search in the background', async () => { + const { wrapper, resourcesStore } = getMountedWrapper({ + store: { resourcesStore: { currentFolder: { fileId: 'folder-id' } } }, + attachTo: document.body + }) + await typeTerm(wrapper, 'albert') + expect(announceMock).toHaveBeenCalledTimes(1) + + wrapper.find(selectors.searchInput).element.blur() + providerFiles.previewSearch.search.mockClear() + resourcesStore.currentFolder = mock({ fileId: 'other-folder-id' }) + await flushPromises() + expect(providerFiles.previewSearch.search).toHaveBeenCalled() + expect(announceMock).toHaveBeenCalledTimes(1) + }) + }) describe('search location across views', () => { test('discards an in-flight preview from the previous scope', async () => { const { wrapper } = getMountedWrapper({ @@ -651,7 +767,8 @@ function getMountedWrapper({ route = 'files-spaces-generic', store = {}, term = '', - query = {} as Record + query = {} as Record, + attachTo = undefined as HTMLElement | undefined } = {}) { vi.mocked(useAvailableProviders).mockReturnValue(ref(providers)) @@ -673,6 +790,7 @@ function getMountedWrapper({ } const wrapper = mount(SearchBar, { + attachTo, global: { plugins: [ ...defaultPlugins({ @@ -684,7 +802,8 @@ function getMountedWrapper({ provide: { ...localMocks }, stubs: { 'router-link': RouterLinkStub, - OcDrop: ocDropStub + OcDrop: ocDropStub, + OcHiddenAnnouncer: OcHiddenAnnouncerStub } } }) diff --git a/packages/web-pkg/src/editor/components/TextEditorSearchAndReplacePanel.vue b/packages/web-pkg/src/editor/components/TextEditorSearchAndReplacePanel.vue index a0586698f9e..ac576079664 100644 --- a/packages/web-pkg/src/editor/components/TextEditorSearchAndReplacePanel.vue +++ b/packages/web-pkg/src/editor/components/TextEditorSearchAndReplacePanel.vue @@ -4,6 +4,7 @@ {{ resultPositionLabel }} +
>('searchInputRef') +const resultAnnouncer = useTemplateRef('resultAnnouncer') const scrollToCurrentResult = () => { const storage = editor.storage.findAndReplace const currentIndex = storage?.currentIndex @@ -230,12 +239,38 @@ const hasResults = computed(() => unref(resultCount) > 0) const resultPositionLabel = computed( () => `${unref(currentResultPosition)} / ${unref(resultCount)}` ) + +function getResultAnnouncement() { + if (!unref(searchTerm)) { + return '' + } + if (!unref(hasResults)) { + return $gettext('No results') + } + return $gettext('Result %{current} of %{total}', { + current: unref(currentResultPosition).toString(), + total: unref(resultCount).toString() + }) +} + +function announceResult() { + unref(resultAnnouncer)?.announce(getResultAnnouncement()) +} + +// announce the settled result while typing or navigating, not every intermediate state +const announceResultDebounced = debounce(announceResult, 500) + +watch([searchTerm, resultCount, currentResultPosition], announceResultDebounced, { + immediate: true +}) + const canReplace = computed(() => unref(hasResults) && unref(searchTerm).length > 0) const canReplaceAll = computed(() => unref(hasResults) && unref(searchTerm).length > 0) const showReplace = computed(() => true) const goToNextResult = () => { editor.commands.goToNextResult() + announceResultDebounced() } const setCaseSensitive = (value: boolean) => { @@ -248,14 +283,17 @@ const setWholeWord = (value: boolean) => { const goToPreviousResult = () => { editor.commands.goToPreviousResult() + announceResultDebounced() } const replaceCurrent = () => { editor.commands.replace() + announceResultDebounced() } const replaceAll = () => { editor.commands.replaceAll() + announceResultDebounced() } const clearActiveSearch = () => { @@ -298,6 +336,7 @@ onMounted(() => { }) onBeforeUnmount(() => { + announceResultDebounced.cancel() if (editor?.isDestroyed === false) { clearActiveSearch() editor.off('transaction', updateResultState) diff --git a/packages/web-pkg/tests/unit/editor/components/TextEditorSearchAndReplacePanel.spec.ts b/packages/web-pkg/tests/unit/editor/components/TextEditorSearchAndReplacePanel.spec.ts index d3e8dccbe4c..f0f782618db 100644 --- a/packages/web-pkg/tests/unit/editor/components/TextEditorSearchAndReplacePanel.spec.ts +++ b/packages/web-pkg/tests/unit/editor/components/TextEditorSearchAndReplacePanel.spec.ts @@ -1,9 +1,18 @@ import { mount } from '@vue/test-utils' -import { nextTick } from 'vue' +import { defineComponent, h, nextTick } from 'vue' import { vi } from 'vitest' import TextEditorSearchAndReplacePanel from '../../../../src/editor/components/TextEditorSearchAndReplacePanel.vue' import { defaultPlugins } from '@opencloud-eu/web-test-helpers' +const announceMock = vi.fn() +// exposes a spy as the announce method, so the tests can check what gets announced +const OcHiddenAnnouncerStub = defineComponent({ + setup(_, { expose }) { + expose({ announce: announceMock }) + return () => h('span') + } +}) + function createEditorMock() { const storage = { findAndReplace: { @@ -161,4 +170,127 @@ describe('TextEditorSearchAndReplacePanel', () => { expect(wrapper.find('[data-testid="editor-search-result-counter"]').text()).toBe('2 / 2') }) + + describe('result announcement', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + announceMock.mockReset() + }) + + function getWrapper(searchSearchTerm = 'needle') { + const { editor, triggerTransaction } = createEditorMock() + const wrapper = mount(TextEditorSearchAndReplacePanel, { + props: { + editor, + closeMenu: vi.fn(), + ...createSearchProps(), + searchSearchTerm + }, + global: { + plugins: [...defaultPlugins()], + stubs: { OcIcon: true, OcHiddenAnnouncer: OcHiddenAnnouncerStub } + } + }) + function setResults(results: Array<{ from: number; to: number }>, currentIndex = 0) { + editor.storage.findAndReplace.results = results + editor.storage.findAndReplace.currentIndex = results.length ? currentIndex : null + triggerTransaction() + } + return { wrapper, editor, setResults } + } + + const twoResults = [ + { from: 1, to: 3 }, + { from: 10, to: 12 } + ] + + it('announces the current result once the search has settled', async () => { + const { setResults } = getWrapper() + setResults(twoResults) + await nextTick() + expect(announceMock).not.toHaveBeenCalled() + + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenCalledTimes(1) + expect(announceMock).toHaveBeenLastCalledWith('Result 1 of 2') + }) + + it('announces "No results" if the search term has no matches', async () => { + const { setResults } = getWrapper('nothing') + setResults([]) + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenLastCalledWith('No results') + }) + + it('only announces the latest result while typing', async () => { + const { wrapper, setResults } = getWrapper('n') + setResults(twoResults) + await vi.advanceTimersByTimeAsync(300) + await wrapper.setProps({ searchSearchTerm: 'ne' }) + setResults([twoResults[0]]) + await vi.advanceTimersByTimeAsync(300) + expect(announceMock).not.toHaveBeenCalled() + + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenCalledTimes(1) + expect(announceMock).toHaveBeenLastCalledWith('Result 1 of 1') + }) + + const navigationCommands: Record = { + next: 'goToNextResult', + prev: 'goToPreviousResult' + } + + it.each(['next', 'prev'])( + 'announces the current result after navigating to the %s result', + async (button) => { + const { wrapper, editor, setResults } = getWrapper() + setResults(twoResults) + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenLastCalledWith('Result 1 of 2') + + editor.commands[navigationCommands[button]].mockImplementation(() => { + setResults(twoResults, 1) + return true + }) + await wrapper.find(`[data-testid="editor-search-${button}"]`).trigger('click') + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenCalledTimes(2) + expect(announceMock).toHaveBeenLastCalledWith('Result 2 of 2') + } + ) + + it('announces the result again when navigating with a single match', async () => { + const { wrapper, setResults } = getWrapper() + setResults([twoResults[0]]) + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenCalledTimes(1) + + await wrapper.find('[data-testid="editor-search-next"]').trigger('click') + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenCalledTimes(2) + expect(announceMock).toHaveBeenLastCalledWith('Result 1 of 1') + }) + + it('announces nothing for an empty search term', async () => { + const { setResults } = getWrapper('') + setResults([]) + await vi.runAllTimersAsync() + expect(announceMock).not.toHaveBeenCalledWith(expect.stringMatching(/.+/)) + }) + + it('clears the announcement when the search term gets cleared', async () => { + const { wrapper, setResults } = getWrapper() + setResults(twoResults) + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenLastCalledWith('Result 1 of 2') + + await wrapper.setProps({ searchSearchTerm: '' }) + await vi.runAllTimersAsync() + expect(announceMock).toHaveBeenLastCalledWith('') + }) + }) }) From d929bd59f133d84ba22aed5d8069317891e43bdb Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Fri, 23 Oct 2026 14:25:49 +0200 Subject: [PATCH 2/2] fix: don't announce search results after the preview got closed --- .../web-app-search/src/portals/SearchBar.vue | 6 +- .../tests/unit/portals/SearchBar.spec.ts | 59 +++++++++++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/web-app-search/src/portals/SearchBar.vue b/packages/web-app-search/src/portals/SearchBar.vue index 2ca261ff891..8318ae281ff 100644 --- a/packages/web-app-search/src/portals/SearchBar.vue +++ b/packages/web-app-search/src/portals/SearchBar.vue @@ -66,6 +66,8 @@ close-on-click enforce-drop-on-mobile :is-menu="false" + @show-drop="isPreviewOpen = true" + @hide-drop="isPreviewOpen = false" >
  • >('o const searchBarRef = useTemplateRef('searchBar') const resultsAnnouncer = useTemplateRef('resultsAnnouncer') const activePreviewIndex = ref(null) +const isPreviewOpen = ref(false) const term = ref('') const restoreSearchFromRoute = ref(false) const searchResults = ref< @@ -317,7 +320,8 @@ async function search() { function announceResults() { // only announce the preview the user is looking at, not searches in the background - if (!unref(showDrop) || !unref(searchBarRef)?.contains(document.activeElement)) { + // or searches that settle after the preview got closed (e.g. by opening the results page) + if (!unref(isPreviewOpen) || !unref(searchBarRef)?.contains(document.activeElement)) { return } unref(resultsAnnouncer)?.announce(getResultsAnnouncement()) diff --git a/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts b/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts index 89d31f66602..f1a761c3066 100644 --- a/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts +++ b/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts @@ -66,6 +66,23 @@ const OcHiddenAnnouncerStub = defineComponent({ } }) +// emits the show and hide events like the real drop, so the component knows whether the preview is open +const OcDropEmittingStub = defineComponent({ + name: 'OcDropStub', + emits: ['showDrop', 'hideDrop'], + methods: { + show(...args: unknown[]) { + ocDropStub.methods.show(...args) + this.$emit('showDrop') + }, + hide(...args: unknown[]) { + ocDropStub.methods.hide(...args) + this.$emit('hideDrop') + } + }, + template: '
    ' +}) + const selectors = { search: '#files-global-search', noResults: '#no-results', @@ -310,10 +327,9 @@ describe('Search Bar portal component', () => { announceMock.mockReset() }) - async function typeTerm( - wrapper: ReturnType['wrapper'], - term: string - ) { + type Wrapper = ReturnType['wrapper'] + + async function typeTerm(wrapper: Wrapper, term: string) { const input = wrapper.find(selectors.searchInput) input.element.focus() await input.setValue(term) @@ -396,6 +412,39 @@ describe('Search Bar portal component', () => { await typeTerm(wrapper, 'albert') expect(announceMock).not.toHaveBeenCalledWith(expect.stringMatching(/.+/)) }) + test.each([ + { + closedBy: 'opening the results page', + close: (wrapper: Wrapper) => wrapper.find(selectors.searchInput).trigger('keyup.enter') + }, + { + closedBy: 'tabbing away', + close: (wrapper: Wrapper) => wrapper.find(selectors.searchInput).trigger('keydown.tab') + }, + { + closedBy: 'a click outside', + close: (wrapper: Wrapper) => + wrapper.findComponent({ ref: 'optionsDropRef' }).vm.$emit('hideDrop') + } + ])( + 'announces nothing if the preview got closed by $closedBy before the search settled', + async ({ close }) => { + const { wrapper } = getMountedWrapper({ + providers: [providerFiles], + attachTo: document.body + }) + let resolveSearch: (result: SearchResult) => void + providerFiles.previewSearch.search.mockImplementationOnce( + () => new Promise((resolve) => (resolveSearch = resolve)) + ) + await typeTerm(wrapper, 'albert') + await close(wrapper) + + resolveSearch({ values: [mock()], totalResults: 12 }) + await flushPromises() + expect(announceMock).not.toHaveBeenCalledWith(expect.stringMatching(/.+/)) + } + ) test('announces nothing for a search in the background', async () => { const { wrapper, resourcesStore } = getMountedWrapper({ store: { resourcesStore: { currentFolder: { fileId: 'folder-id' } } }, @@ -802,7 +851,7 @@ function getMountedWrapper({ provide: { ...localMocks }, stubs: { 'router-link': RouterLinkStub, - OcDrop: ocDropStub, + OcDrop: OcDropEmittingStub, OcHiddenAnnouncer: OcHiddenAnnouncerStub } }