Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/web-app-search/src/portals/SearchBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
/>
</template>
</oc-search-bar>
<oc-hidden-announcer ref="resultsAnnouncer" />
<oc-button
v-oc-tooltip="$gettext('Display search bar')"
:aria-label="$gettext('Click to display and focus the search bar')"
Expand All @@ -65,6 +66,8 @@
close-on-click
enforce-drop-on-mobile
:is-menu="false"
@show-drop="isPreviewOpen = true"
@hide-drop="isPreviewOpen = false"
>
<oc-list class="oc-list-divider p-2">
<li
Expand Down Expand Up @@ -181,7 +184,9 @@ const { currentFolder } = storeToRefs(resourcesStore)
const locationFilterId = ref(SearchLocationFilterConstants.allFiles)
const optionsDropRef = useTemplateRef<ComponentPublicInstance<typeof OcDrop>>('optionsDropRef')
const searchBarRef = useTemplateRef<HTMLElement>('searchBar')
const resultsAnnouncer = useTemplateRef('resultsAnnouncer')
const activePreviewIndex = ref<number | null>(null)
const isPreviewOpen = ref(false)
const term = ref('')
const restoreSearchFromRoute = ref(false)
const searchResults = ref<
Expand Down Expand Up @@ -281,6 +286,7 @@ async function search() {
searchResults.value = []
if (!unref(term)?.trim()) {
loading.value = false
unref(resultsAnnouncer)?.announce('')
return
}

Expand Down Expand Up @@ -309,6 +315,32 @@ 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
// 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())
}

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() {
Expand Down
174 changes: 171 additions & 3 deletions packages/web-app-search/tests/unit/portals/SearchBar.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -57,6 +57,32 @@ 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')
}
})

// 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: '<div><slot /></div>'
})

const selectors = {
search: '#files-global-search',
noResults: '#no-results',
Expand Down Expand Up @@ -296,6 +322,145 @@ describe('Search Bar portal component', () => {
expect.stringContaining('folder-id')
)
})
describe('results announcement', () => {
afterEach(() => {
announceMock.mockReset()
})

type Wrapper = ReturnType<typeof getMountedWrapper>['wrapper']

async function typeTerm(wrapper: Wrapper, term: string) {
const input = wrapper.find<HTMLInputElement>(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<SearchResult['values'][number]>()], 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<SearchResult['values'][number]>()],
totalResults: 12
})
providerContacts.previewSearch.search.mockReturnValueOnce({
values: [mock<SearchResult['values'][number]>()],
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<SearchResult['values'][number]>()],
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.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<SearchResult['values'][number]>()], 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' } } },
attachTo: document.body
})
await typeTerm(wrapper, 'albert')
expect(announceMock).toHaveBeenCalledTimes(1)

wrapper.find<HTMLInputElement>(selectors.searchInput).element.blur()
providerFiles.previewSearch.search.mockClear()
resourcesStore.currentFolder = mock<Resource>({ 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({
Expand Down Expand Up @@ -651,7 +816,8 @@ function getMountedWrapper({
route = 'files-spaces-generic',
store = {},
term = '',
query = {} as Record<string, string>
query = {} as Record<string, string>,
attachTo = undefined as HTMLElement | undefined
} = {}) {
vi.mocked(useAvailableProviders).mockReturnValue(ref(providers))

Expand All @@ -673,6 +839,7 @@ function getMountedWrapper({
}

const wrapper = mount(SearchBar, {
attachTo,
global: {
plugins: [
...defaultPlugins({
Expand All @@ -684,7 +851,8 @@ function getMountedWrapper({
provide: { ...localMocks },
stubs: {
'router-link': RouterLinkStub,
OcDrop: ocDropStub
OcDrop: OcDropEmittingStub,
OcHiddenAnnouncer: OcHiddenAnnouncerStub
}
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<span class="text-sm text-role-on-surface-variant" data-testid="editor-search-result-counter">
{{ resultPositionLabel }}
</span>
<oc-hidden-announcer ref="resultAnnouncer" />
<div class="flex items-center gap-1">
<oc-button
v-oc-tooltip="$gettext('Previous result')"
Expand Down Expand Up @@ -133,7 +134,14 @@ import {
} from 'vue'
import { useGettext } from 'vue3-gettext'
import type { Editor } from '@tiptap/core'
import { OcButton, OcIcon, OcSwitch, OcTextInput } from '@opencloud-eu/design-system/components'
import { debounce } from 'lodash-es'
import {
OcButton,
OcHiddenAnnouncer,
OcIcon,
OcSwitch,
OcTextInput
} from '@opencloud-eu/design-system/components'

const {
editor,
Expand Down Expand Up @@ -183,6 +191,7 @@ const { $gettext } = useGettext()
const resultCount = ref(0)
const currentResultPosition = ref(0)
const searchInputRef = useTemplateRef<ComponentPublicInstance<typeof OcTextInput>>('searchInputRef')
const resultAnnouncer = useTemplateRef('resultAnnouncer')
const scrollToCurrentResult = () => {
const storage = editor.storage.findAndReplace
const currentIndex = storage?.currentIndex
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -298,6 +336,7 @@ onMounted(() => {
})

onBeforeUnmount(() => {
announceResultDebounced.cancel()
if (editor?.isDestroyed === false) {
clearActiveSearch()
editor.off('transaction', updateResultState)
Expand Down
Loading
Loading