feat(core): redesign unified search result presentation

Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
pull/62605/head
Peter Ringelmann 1 month ago
parent e05c609a2f
commit 6440dd3fac
No known key found for this signature in database
  1. 4
      apps/settings/lib/Search/SectionSearch.php
  2. 6
      core/src/components/AppIcon.vue
  3. 111
      core/src/components/UnifiedSearch/SearchResult.vue
  4. 23
      core/src/components/UnifiedSearch/UnifiedSearchInput.vue
  5. 511
      core/src/components/UnifiedSearch/UnifiedSearchModal.vue
  6. 39
      core/src/services/UnifiedSearchController.ts
  7. 2
      core/src/services/UnifiedSearchService.js
  8. 33
      core/src/tests/components/SearchResult.spec.ts
  9. 47
      core/src/tests/components/UnifiedSearch.spec.ts
  10. 12
      core/src/tests/components/UnifiedSearchInput.spec.ts
  11. 454
      core/src/tests/components/UnifiedSearchModal.spec.ts
  12. 103
      core/src/tests/services/UnifiedSearchController.spec.ts
  13. 24
      core/src/views/UnifiedSearch.vue
  14. 59
      tests/playwright/e2e/core/header-unified-search.spec.ts
  15. 29
      tests/playwright/support/sections/UnifiedSearchPage.ts

@ -121,9 +121,7 @@ class SectionSearch implements IProvider {
continue;
}
// The section's own icon, falling back to a generic cog when it has none.
// These are dark monochrome glyphs; the client inverts them for dark
// themes via --background-invert-if-dark.
// The section's own icon, or a generic cog fallback.
$icon = $section->getIcon();
if ($icon === '') {
$icon = $this->urlGenerator->imagePath('settings', 'settings.svg');

@ -19,7 +19,7 @@
<script setup lang="ts">
withDefaults(defineProps<{
/** URL of the app icon. Painted bright on the coloured circle, like the app menu. */
/** URL of the app icon (painted bright on the coloured circle). */
icon: string
/** Render the circle as an outline only (no fill or gradient). */
outlined?: boolean
@ -55,13 +55,11 @@ withDefaults(defineProps<{
&__img {
width: var(--app-icon-icon-size);
height: var(--app-icon-icon-size);
// App icons are bright by default; flip them to dark when the
// primary color (circle background) is bright (e.g. white in dark mode).
// App icons are bright; flip to dark when the circle background is bright (e.g. white in dark mode).
filter: var(--primary-invert-if-bright);
mask: var(--header-menu-icon-mask);
}
// Outlined variant: no fill or gradient.
&--outlined {
background: transparent;
background-image: none;

@ -12,22 +12,29 @@
:href="resourceUrl"
target="_self">
<template #icon>
<AppIcon
v-if="isAppIcon"
class="result-item__app-icon"
:icon="icon" />
<div
v-else
aria-hidden="true"
class="result-item__icon"
:class="{
'result-item__icon--rounded': rounded,
'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
[icon]: !isValidIconOrPreviewUrl(icon),
}"
:style="{
backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
'result-item__icon--with-thumbnail': hasThumbnail,
[icon]: !iconIsUrl && !hasThumbnail,
}">
<img
v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
v-if="hasThumbnail"
:src="thumbnailUrl"
@error="thumbnailErrorHandler">
<img
v-else-if="iconIsUrl"
class="result-item__icon-img"
:src="icon"
alt=""
aria-hidden="true">
</div>
</template>
<template #subname>
@ -38,10 +45,12 @@
<script>
import NcListItem from '@nextcloud/vue/components/NcListItem'
import AppIcon from '../AppIcon.vue'
export default {
name: 'SearchResult',
components: {
AppIcon,
NcListItem,
},
@ -108,6 +117,26 @@ export default {
}
},
computed: {
/** A usable thumbnail image (a preview/avatar), not errored. */
hasThumbnail() {
return this.isValidIconOrPreviewUrl(this.thumbnailUrl) && !this.thumbnailHasError
},
/** The icon is a real URL we can put in an <img>, not a legacy CSS class string. */
iconIsUrl() {
return this.isValidIconOrPreviewUrl(this.icon)
},
/**
* App-style icon (bright glyph on a primary circle, like the app menu). Providers
* flag it by marking the entry rounded with an icon URL and no thumbnail.
*/
isAppIcon() {
return this.rounded && this.iconIsUrl && !this.hasThumbnail
},
},
watch: {
thumbnailUrl() {
this.thumbnailHasError = false
@ -128,15 +157,23 @@ export default {
<style lang="scss" scoped>
.result-item {
padding-inline: 0;
:deep(a) {
border: 2px solid transparent;
border-radius: var(--border-radius-large) !important;
// Hover/press: neutral gray fill only, no border.
&:active,
&:hover,
&:focus {
&:hover {
background-color: var(--color-background-hover);
border: 2px solid var(--color-border-maxcontrast);
}
// Plain Tab into a result keeps a visible focus ring (a11y). Normally the combobox
// keeps focus in the input and drives selection via `active` below.
&:focus-visible {
background-color: var(--color-background-hover);
border-color: var(--color-border-maxcontrast);
}
* {
@ -144,27 +181,41 @@ export default {
}
}
// NcListItem's `active` state paints a primary fill, white text and a blue stripe.
// We want a neutral look: the gray hover fill plus a maxcontrast border, readable text.
&.list-item__wrapper--active {
:deep(.list-item) {
background-color: var(--color-background-hover);
&:hover {
background-color: var(--color-background-hover);
}
}
// Undo the forced active text colour. Chain through the anchor to outrank
// NcListItem's own !important rule.
:deep(.list-item__anchor .list-item-content__name),
:deep(.list-item__anchor .list-item-content__subname),
:deep(.list-item__anchor .list-item-content__details),
:deep(.list-item__anchor .list-item-details__details) {
color: var(--color-main-text) !important;
}
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
border-radius: var(--border-radius);
background-repeat: no-repeat;
background-position: center center;
background-size: 32px;
margin-inline-start: var(--default-grid-baseline);
&--rounded {
border-radius: calc(var(--default-clickable-area) / 2);
}
&--no-preview {
background-size: 32px;
}
&--with-thumbnail {
background-size: cover;
}
&--with-thumbnail:not(#{&}--rounded) {
border: 1px solid var(--color-border);
// compensate for border
@ -172,7 +223,8 @@ export default {
max-width: calc(var(--default-clickable-area) - 2px);
}
img {
// A full-bleed thumbnail (preview or avatar) fills the box.
&--with-thumbnail img {
// Make sure to keep ratio
width: 100%;
height: 100%;
@ -180,6 +232,21 @@ export default {
object-fit: cover;
object-position: center;
}
// A small monochrome glyph (e.g. a settings section), not a thumbnail.
&-img {
width: 20px;
height: 20px;
object-fit: contain;
// Dark monochrome icons invert to light in dark themes.
filter: var(--background-invert-if-dark);
}
}
// App results reuse the app-menu tile (AppIcon); size its circle to the icon column.
&__app-icon {
--app-icon-circle-size: var(--default-clickable-area);
margin-inline-start: var(--default-grid-baseline);
}
}
</style>

@ -57,6 +57,11 @@
<IconFilterVariant :size="20" />
</template>
</NcButton>
<!-- Loading spinner while a search is in flight. -->
<NcLoadingIcon
v-if="loading"
class="unified-search-input__loading"
:size="20" />
<!-- Trailing X: clears the query, or closes the search when the field is empty. -->
<NcButton
v-if="isActive"
@ -87,6 +92,7 @@ import { computed, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
import NcKbd from '@nextcloud/vue/components/NcKbd'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconClose from 'vue-material-design-icons/Close.vue'
import IconFilterVariant from 'vue-material-design-icons/FilterVariant.vue'
import IconMagnify from 'vue-material-design-icons/Magnify.vue'
@ -106,6 +112,8 @@ const props = defineProps<{
/** Id of the active result row, for aria-activedescendant. Empty when none. */
activeDescendantId?: string
query: string
/** A search is in flight: show the loading spinner. */
loading?: boolean
/** Filters are already revealed, so hide the pre-typing funnel. */
filtersRevealed?: boolean
}>()
@ -391,6 +399,13 @@ defineExpose({ focus })
margin-inline-end: 2px;
}
&__loading {
flex-shrink: 0;
display: flex;
align-items: center;
margin-inline: var(--default-grid-baseline);
}
// Pinned to the trailing edge, overlaid on the input (pointer-events: none so a
// click there still focuses the field).
&__shortcut {
@ -429,9 +444,11 @@ defineExpose({ focus })
--resting-background-hover: color-mix(in srgb, var(--color-primary-element) 22%, transparent);
}
// translateX is physical, so flip the resting slide under RTL to keep it moving
// toward the leading (right) edge.
[dir=rtl] .unified-search-input__resting {
// translateX is physical, so flip the resting slide under RTL to keep it moving toward
// the leading (right) edge. :dir(rtl) tracks the computed direction, so it applies whether
// RTL comes from the body dir attribute or a direction style (an [dir=rtl] attribute
// selector would miss the latter).
.unified-search-input__resting:dir(rtl) {
--slide-sign: -1;
}

@ -22,8 +22,9 @@
</div>
<!-- Unified search form -->
<div
v-show="showHeader"
class="unified-search-modal__header"
:class="{ 'unified-search-modal__header--has-results': hasVisibleResults }">
:class="{ 'unified-search-modal__header--has-results': hasVisibleResults && !detailCategory }">
<div v-if="isSmallMobile" class="unified-search-modal__mobile-input">
<NcTextField
type="search"
@ -33,6 +34,7 @@
:trailingButtonLabel="t('core', 'Clear search')"
@update:modelValue="onMobileSearchInput"
@trailing-button-click="searchQuery = ''" />
<NcLoadingIcon v-if="isBusy" :size="20" />
<NcButton
variant="tertiary"
:aria-label="t('core', 'Close search')"
@ -125,16 +127,8 @@
<IconFilter :size="20" />
</template>
</NcButton>
<NcCheckboxRadioSwitch
v-if="hasExternalResources"
v-model="searchExternalResources"
type="switch"
class="unified-search-modal__search-external-resources"
:class="{ 'unified-search-modal__search-external-resources--aligned': localSearch }">
{{ t('core', 'Search connected services') }}
</NcCheckboxRadioSwitch>
</div>
<div class="unified-search-modal__filters-applied">
<div v-show="!detailCategory && hasAnyActiveFilter" class="unified-search-modal__filters-applied">
<FilterChip
v-for="filter in filters"
:key="filter.id"
@ -162,78 +156,101 @@
<IconMagnify :size="64" />
</template>
</NcEmptyContent>
<!-- Offered even with zero results, so the user can reach external providers. -->
<div v-if="showConnectedServicesButton" class="unified-search-modal__connected-services">
<NcButton variant="secondary" wide @click="toggleExternalResources">
{{ connectedServicesLabel }}
</NcButton>
</div>
</div>
<div v-else class="unified-search-modal__results">
<div v-else ref="resultsContainer" class="unified-search-modal__results">
<h3 class="hidden-visually">
{{ t('core', 'Results') }}
</h3>
<!-- Filtered results section -->
<div v-for="providerResult in filteredResults" :key="providerResult.id" class="result">
<h4 :id="`unified-search-result-${providerResult.id}`" class="result-title">
{{ providerResult.name }}
<!-- Detail view: back control returns to the aggregate list; focus stays in the input. -->
<div v-if="detailCategory && detailGroup" class="unified-search-modal__detail-header">
<NcButton
class="unified-search-modal__detail-back"
variant="tertiary"
:aria-label="t('core', 'Back to all results')"
@click="closeDetailView">
<template #icon>
<IconArrowLeft class="unified-search-modal__rtl-icon" :size="20" />
</template>
{{ t('core', 'Back') }}
</NcButton>
<h4 :id="headingId(detailGroup)" class="unified-search-modal__detail-title">
{{ detailGroup.name }}
</h4>
<ul class="result-items" :role="isSmallMobile ? undefined : 'listbox'" :aria-labelledby="`unified-search-result-${providerResult.id}`">
<SearchResult
v-for="(result, index) in providerResult.results"
:key="index"
v-bind="result"
:role="isSmallMobile ? undefined : 'option'"
:elementId="rowElementId(providerResult.id, index)"
:active="activeDescendantId === rowElementId(providerResult.id, index)" />
</ul>
<div class="result-footer">
<NcButton v-if="providerResult.hasMore" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
{{ t('core', 'Load more results') }}
<template #icon>
<IconDotsHorizontal :size="20" />
</template>
</NcButton>
<NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" variant="tertiary-no-background">
{{ t('core', 'Search in') }} {{ providerResult.name }}
<template #icon>
<IconArrowRight :size="20" />
</template>
</NcButton>
</div>
</div>
<!-- Unfiltered results section -->
<template v-if="unfilteredResults.length > 0">
<div class="unified-search-modal__unfiltered-header">
<!-- One flat loop over renderedGroups so the template and navigableRows stay in lockstep. -->
<div v-for="group in renderedGroups" :key="group.id" class="result-group">
<div
v-if="group.showPartialHeader"
class="unified-search-modal__unfiltered-header">
<span class="unified-search-modal__unfiltered-label">{{ t('core', 'Partial matches') }}</span>
</div>
<div v-for="providerResult in unfilteredResults" :key="`unfiltered-${providerResult.id}`" class="result result--unfiltered">
<h4 :id="`unified-search-result-unfiltered-${providerResult.id}`" class="result-title">
{{ providerResult.name }}
<div class="result" :class="{ 'result--unfiltered': group.unfiltered }">
<NcButton
v-if="group.overflow"
:id="headingId(group)"
alignment="start-reverse"
variant="tertiary-no-background"
class="result-title--more"
@click="openDetailView(group)">
{{ t('core', 'More from {name}', { name: group.name }) }}
<template #icon>
<IconArrowRight class="unified-search-modal__rtl-icon" :size="20" />
</template>
</NcButton>
<!-- In detail view the name is in the header, so skip the in-list heading (avoids a duplicate id). -->
<h4 v-else-if="group.section !== 'detail'" :id="headingId(group)" class="result-title">
{{ group.name }}
</h4>
<ul class="result-items" :role="isSmallMobile ? undefined : 'listbox'" :aria-labelledby="`unified-search-result-unfiltered-${providerResult.id}`">
<ul
class="result-items"
:role="isSmallMobile ? undefined : 'listbox'"
:aria-labelledby="headingId(group)">
<SearchResult
v-for="(result, index) in providerResult.results"
v-for="(result, index) in group.results"
:key="index"
v-bind="result"
:role="isSmallMobile ? undefined : 'option'"
:elementId="rowElementId(providerResult.id, index, true)"
:active="activeDescendantId === rowElementId(providerResult.id, index, true)" />
:elementId="rowElementId(group.id, index, group.unfiltered)"
:active="activeDescendantId === rowElementId(group.id, index, group.unfiltered)" />
</ul>
<div class="result-footer">
<NcButton v-if="providerResult.hasMore" variant="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
<NcButton
v-if="group.section === 'detail' && group.hasMore"
variant="tertiary-no-background"
@click="loadMoreResultsForProvider(group)">
{{ t('core', 'Load more results') }}
<template #icon>
<IconDotsHorizontal :size="20" />
</template>
</NcButton>
<NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" variant="tertiary-no-background">
{{ t('core', 'Search in') }} {{ providerResult.name }}
<NcButton v-if="group.inAppSearch" alignment="end-reverse" variant="tertiary-no-background">
{{ t('core', 'Search in') }} {{ group.name }}
<template #icon>
<IconArrowRight :size="20" />
</template>
</NcButton>
</div>
</div>
</template>
</div>
<!-- Connected-services opt-in. Toggling re-runs find() (searchExternalResources watcher). Hidden in detail view. -->
<div v-if="showConnectedServicesButton" class="unified-search-modal__connected-services">
<NcButton variant="secondary" wide @click="toggleExternalResources">
{{ connectedServicesLabel }}
</NcButton>
</div>
</div>
</div>
<div class="unified-search-modal__scrim" @click="onScrimClick" />
<!-- `modal-mask` is how @nextcloud/vue's useHotKey guard recognises an open modal and
suppresses background app shortcuts (else the Files app's arrow-key nav fires behind
the scrim). NcModal's `.modal-mask` styles are scoped, so no foreign CSS leaks in. -->
<div class="unified-search-modal__scrim modal-mask" @click="onScrimClick" />
</div>
</transition>
</template>
@ -254,10 +271,11 @@ import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconAccountMultipleOutline from 'vue-material-design-icons/AccountMultipleOutline.vue'
import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconCalendarBlankOutline from 'vue-material-design-icons/CalendarBlankOutline.vue'
import IconClose from 'vue-material-design-icons/Close.vue'
@ -274,6 +292,12 @@ import { unifiedSearchLogger } from '../../logger.js'
import { getContacts, getProviders } from '../../services/UnifiedSearchService.js'
import { useSearchStore } from '../../store/unified-search-external-filters.js'
/**
* Rows shown per category in the aggregate list. A view-only cap: the controller keeps
* the full set, and a category with more shows a "More from" button to the detail view.
*/
const RESULTS_PER_CATEGORY = 3
/** One selectable result row in the flat keyboard-navigation list. */
interface NavigableRow {
id: string
@ -284,6 +308,7 @@ export default defineComponent({
name: 'UnifiedSearchModal',
components: {
IconAccountMultipleOutline,
IconArrowLeft,
IconArrowRight,
IconCalendarBlankOutline,
IconClose,
@ -299,7 +324,7 @@ export default defineComponent({
NcAvatar,
NcButton,
NcEmptyContent,
NcCheckboxRadioSwitch,
NcLoadingIcon,
NcTextField,
SearchableList,
SearchResult,
@ -341,7 +366,7 @@ export default defineComponent({
},
},
emits: ['update:open', 'update:query', 'update:activeDescendant'],
emits: ['update:open', 'update:query', 'update:activeDescendant', 'update:loading'],
setup() {
/**
@ -387,7 +412,12 @@ export default defineComponent({
contacts: [],
showDateRangeModal: false,
initialized: false,
// True from the query change until the debounced find() runs, so the busy state
// covers the debounce gap and we don't flash "No results" before searching starts.
pendingSearch: false,
searchExternalResources: false,
// Set = this one category's full result set (detail view); null = the capped aggregate list.
detailCategory: null as string | null,
// Index of the selected row in the flat navigableRows list. -1 = nothing
// selected (no results yet). Focus stays in the input; this drives the
// aria-activedescendant highlight (combobox pattern).
@ -424,18 +454,40 @@ export default defineComponent({
// Desktop hides the filter row until the funnel reveals it, or a query/active
// filter exists. Always shown on mobile, where the modal is the whole search surface.
// The detail view ("More from ") drops filters entirely.
showFilterRow() {
if (this.detailCategory) {
return false
}
return this.isSmallMobile
|| this.filtersRevealed
|| this.searchQuery.length > 0
|| this.hasAnyActiveFilter
},
// The header only takes space when it has something to show: the mobile search
// field, or the filter row. Hidden otherwise (desktop detail view, resting empty
// query) so its padding can't become a dead strip above the content.
showHeader() {
return this.isSmallMobile || this.showFilterRow
},
// True while any category is still fetching.
searching() {
return Object.values(this.searchStates).some((state) => state.status === 'loading')
},
// Drives the search-input spinner: an in-flight category search, or a pending query
// before providers finish. Never spins without a searchable query.
isBusy() {
// A closed modal never reports busy: the header input must not spin for a search
// that is not open, and initialisation may still be pending in the background.
if (!this.open || this.isEmptySearch || this.isSearchQueryTooShort) {
return false
}
return this.searching || this.pendingSearch || !this.initialized
},
hasNoResults() {
return !this.isEmptySearch && this.results.length === 0
},
@ -445,11 +497,13 @@ export default defineComponent({
},
showEmptyContentInfo() {
return this.hasNoResults
// Nothing renders while the spinner is up; only real empty states (too-short query,
// or settled with no results) show.
return this.hasNoResults && !this.isBusy
},
emptyContentMessage() {
// Order matters: a query shrinking below the minimum mid-search shows the prompt, not "searching".
// A query shrinking below the minimum mid-search shows the prompt.
if (this.isSearchQueryTooShort) {
switch (this.minSearchLength) {
case 1:
@ -459,11 +513,6 @@ export default defineComponent({
}
}
// Also "searching" before providers load: a query is pending but nothing is in flight yet.
if ((this.searching || !this.initialized) && this.hasNoResults) {
return t('core', 'Searching …')
}
return t('core', 'No matching results')
},
@ -553,6 +602,49 @@ export default defineComponent({
.filter((provider) => provider.results.length > 0)
},
// The category shown in the detail view (uncapped), or null in aggregate. Read from
// `results` so it carries live entries + hasMore; null once the category drops out.
detailGroup() {
if (!this.detailCategory) {
return null
}
return this.results.find((group) => group.id === this.detailCategory) ?? null
},
// The groups on screen, shaped identically for the template and navigableRows so the
// two can't drift (a11y invariant). Aggregate: filtered then partial-match groups,
// capped to RESULTS_PER_CATEGORY with `overflow` when there's more. Detail: the
// opened category alone, uncapped.
renderedGroups() {
if (this.detailCategory) {
return this.detailGroup
? [this.toRenderedGroup(this.detailGroup, 'detail', false)]
: []
}
return [
...this.filteredResults.map((group) => this.toRenderedGroup(group, 'filtered', false)),
...this.unfilteredResults.map((group, index) => this.toRenderedGroup(group, 'unfiltered', index === 0)),
]
},
// The connected-services opt-in. Shows for any searchable query when external providers
// exist (including zero results, so the user can opt in when local search found nothing),
// never on empty/too-short queries or in detail view. Held until the search settles
// (!isBusy) so it doesn't flash while providers load.
showConnectedServicesButton() {
return this.hasExternalResources
&& !this.detailCategory
&& !this.isEmptySearch
&& !this.isSearchQueryTooShort
&& !this.isBusy
},
connectedServicesLabel() {
return this.searchExternalResources
? t('core', 'Less from connected services')
: t('core', 'More from connected services')
},
// The rendered rows flattened into a single list in visual order (filtered
// groups first, then the partial-matches groups), each with the DOM id of its
// option element. This is the index space the arrow keys walk; it must stay in
@ -566,14 +658,9 @@ export default defineComponent({
return []
}
const rows: NavigableRow[] = []
this.filteredResults.forEach((provider) => {
provider.results.forEach((entry, index) => {
rows.push({ id: this.rowElementId(provider.id, index), resourceUrl: entry.resourceUrl })
})
})
this.unfilteredResults.forEach((provider) => {
provider.results.forEach((entry, index) => {
rows.push({ id: this.rowElementId(provider.id, index, true), resourceUrl: entry.resourceUrl })
this.renderedGroups.forEach((group) => {
group.results.forEach((entry, index) => {
rows.push({ id: this.rowElementId(group.id, index, group.unfiltered), resourceUrl: entry.resourceUrl })
})
})
return rows
@ -602,6 +689,10 @@ export default defineComponent({
if (this.navigableRows.length === 0) {
return t('core', 'No matching results')
}
// In detail view, name the category and keep the count so "Load more" re-announces the grown set.
if (this.detailCategory && this.detailGroup) {
return n('core', 'Showing %n result from {name}', 'Showing %n results from {name}', this.navigableRows.length, { name: this.detailGroup.name })
}
return n('core', '%n result', '%n results', this.navigableRows.length)
},
@ -645,6 +736,12 @@ export default defineComponent({
// Clear them on close so they can't flash on the next open. Close is the
// reliable hook: every close path flips this prop true -> false.
this.reset()
// Drop in-flight search bookkeeping so a preserved query can't keep the header
// input spinning, and cancel the pending debounce so it can't dispatch after close.
this.pendingSearch = false
this.debouncedFind.clear()
// Start the next open on the aggregate list, never mid-detail-view.
this.detailCategory = null
document.removeEventListener('keydown', this.onEscapeKey)
this.deactivateFocusTrap()
}
@ -659,28 +756,65 @@ export default defineComponent({
searchQuery: {
handler() {
// A new query reshapes the category set; leave any open detail view.
this.detailCategory = null
this.$emit('update:query', this.searchQuery)
// Only search while open: the query prop keeps flowing from the header even
// when closed (e.g. the local search bar on deck), so a hidden modal must
// not fire background searches.
if (this.open) {
// Mark busy synchronously so the debounce window doesn't flash the empty state.
this.pendingSearch = true
this.debouncedFind(this.searchQuery)
}
},
},
searchExternalResources() {
// Toggling connected services changes the category set, so return to the aggregate list.
this.detailCategory = null
if (this.searchQuery) {
this.find(this.searchQuery)
}
},
// Auto-select the first result on each new result set, keep the selection on
// its row as slower categories settle, and clamp when results shrink.
// Any filter change reshapes the category set, so drop back to the aggregate list.
filters: {
deep: true,
handler() {
this.detailCategory = null
},
},
// Safety net: if the open category disappears (e.g. a filter removes it), leave the detail view.
detailGroup(group) {
if (this.detailCategory && !group) {
this.closeDetailView()
}
},
// Entering/leaving/switching detail view resets the scroll to the top.
detailCategory() {
this.$nextTick(() => {
if (this.$refs.resultsContainer) {
this.$refs.resultsContainer.scrollTop = 0
}
})
},
// Keep the selection on its row as slower categories settle; auto-select the first row for a fresh set.
navigableRows(next, previous) {
this.reconcileActiveIndex(next, previous)
},
// Surface the loading state so the header input can show its spinner.
isBusy: {
immediate: true,
handler(busy) {
this.$emit('update:loading', busy)
},
},
// Surface the active option id so the header input (a sibling) can point its
// aria-activedescendant at it while keeping focus.
activeDescendantId: {
@ -815,6 +949,9 @@ export default defineComponent({
},
find(query: string) {
// The debounced search is running now; from here `searching` (or `!initialized`) drives busy.
this.pendingSearch = false
if (this.isSearchQueryTooShort) {
return
}
@ -920,6 +1057,75 @@ export default defineComponent({
this.loadMore(provider.id)
},
// Shape one category for rendering: aggregate caps to RESULTS_PER_CATEGORY and sets
// `overflow` (the "More from" button); detail keeps the full set.
toRenderedGroup(group, section: 'filtered' | 'unfiltered' | 'detail', showPartialHeader: boolean) {
const detail = section === 'detail'
return {
id: group.id,
name: group.name,
section,
unfiltered: section === 'unfiltered',
results: detail ? group.results : group.results.slice(0, RESULTS_PER_CATEGORY),
// Count-based per the design: "More from" only when more than the cap was fetched
// (PAGE_SIZE 10 fetched, RESULTS_PER_CATEGORY 3 shown). NOT keyed off hasMore: some
// providers advertise a cursor past their last page, which would show the button and
// then a dead-end detail view.
overflow: detail ? false : group.results.length > RESULTS_PER_CATEGORY,
hasMore: group.hasMore,
inAppSearch: group.inAppSearch ?? false,
showPartialHeader,
}
},
// DOM id for a group heading; the listbox's aria-labelledby target (plain title or "More from" button).
headingId(group): string {
return group.unfiltered
? `unified-search-result-unfiltered-${group.id}`
: `unified-search-result-${group.id}`
},
// Open one category's full result set in the detail view. Focus returns to the input
// (the clicked "More from" trigger is about to unmount).
openDetailView(group) {
this.detailCategory = group.id
this.$nextTick(() => this.focusSearchInput())
},
/**
* Leave the detail view for the aggregate list, returning focus to the input.
*/
closeDetailView() {
this.detailCategory = null
this.$nextTick(() => this.focusSearchInput())
},
/**
* Move focus back to the search input. Prefers the in-panel mobile field, then the
* header input (mirroring the focus trap's initialFocus). Used when a focused control unmounts.
*/
focusSearchInput() {
const panel = this.$refs.panel as HTMLElement | undefined
const mobileInput = panel?.querySelector('input[type="search"]') as HTMLElement | null
if (mobileInput) {
mobileInput.focus()
return
}
const menu = (this.$el as HTMLElement)?.closest?.('.unified-search-menu') ?? null
const headerInput = (menu?.querySelector('.unified-search-input input') ?? null) as HTMLElement | null
headerInput?.focus()
},
/**
* Flip the connected-services opt-in; the searchExternalResources watcher re-runs find().
*/
toggleExternalResources() {
this.searchExternalResources = !this.searchExternalResources
// The re-search flips isBusy, which unmounts this very button; move focus back to
// the input (like the detail-view controls) so keyboard navigation keeps working.
this.$nextTick(() => this.focusSearchInput())
},
addProviderFilter(providerFilter) {
unifiedSearchLogger.debug('Applying provider filter', { providerFilter })
if (!providerFilter.id) {
@ -1180,14 +1386,16 @@ export default defineComponent({
/**
* Open the selected result. Enter reaches here from the input (focus never
* leaves it), so navigate to the row's url programmatically. A no-op when
* nothing is selected (empty / still-searching).
* leaves it), so navigate to the row's url programmatically. With nothing
* highlighted yet, fall back to the first result so typing + Enter still opens
* the top hit. A no-op when there is nothing to open (empty / still-searching).
*/
activateActive() {
if (!this.activeRow?.resourceUrl) {
const row = this.activeRow ?? this.navigableRows[0]
if (!row?.resourceUrl) {
return
}
this.openResourceUrl(this.activeRow.resourceUrl)
this.openResourceUrl(row.resourceUrl)
},
/**
@ -1230,8 +1438,8 @@ export default defineComponent({
if (selectedId !== undefined) {
const at = next.findIndex((row) => row.id === selectedId)
this.activeIndex = at >= 0 ? at : 0
} else if (this.activeIndex < 0 || this.activeIndex >= next.length) {
// No prior selection (or it fell out of range): auto-select the first row.
} else {
// Nothing to preserve (fresh set, or selection out of range): auto-select the first row.
this.activeIndex = 0
}
},
@ -1280,7 +1488,7 @@ export default defineComponent({
max-width: 90vw;
// Leave ~10vh below the panel so it does not reach the bottom of the page
max-height: calc(90vh - var(--header-height));
border-radius: var(--border-radius-container, var(--border-radius-rounded));
border-radius: var(--border-radius-container-large, var(--border-radius-rounded));
// Clip the header/results to the rounded corners
overflow: hidden;
background-color: var(--color-main-background);
@ -1343,20 +1551,30 @@ export default defineComponent({
.unified-search-modal {
&__header {
// Add background to prevent leaking scrolled content (because of sticky position)
background-color: var(--color-main-background);
// Fix padding to have the input centered
padding-inline: 12px;
// Make it sticky with the input margin for the label
position: sticky;
top: 6px;
z-index: 1;
// Owns all its own spacing: the inline inset, the gap above the first row, and the
// gap between stacked rows (mobile input, filters, applied chips). position:
// relative only anchors the divider below; the header never scrolls (the results
// list scrolls in its own box), so it needs no sticky offset.
position: relative;
display: flex;
flex-direction: column;
gap: calc(var(--default-grid-baseline) * 2);
padding-inline: calc(var(--default-grid-baseline) * 4);
// Trim the bottom when the filter row is all there is; results add it back below.
padding-block: calc(var(--default-grid-baseline) * 4) var(--default-grid-baseline);
// Some padding to make elements scrolled under sticky position look nicer.
// Only when there are results to scroll: otherwise it just adds a dead gap.
// With results below, restore the full bottom inset above the divider (which aligns
// to the content edge).
&--has-results {
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
padding-block-end: calc(var(--default-grid-baseline) * 4);
&::after {
content: '';
position: absolute;
inset-inline: calc(var(--default-grid-baseline) * 4);
inset-block-end: 0;
border-block-end: 1px solid var(--color-border);
}
}
}
@ -1364,7 +1582,6 @@ export default defineComponent({
display: flex;
align-items: center;
gap: 4px;
margin-block-end: 8px;
:deep(.input-field) {
flex: 1 1 auto;
@ -1376,10 +1593,9 @@ export default defineComponent({
flex-wrap: wrap;
gap: 4px;
justify-content: start;
padding-top: 6px;
// The three category triggers split the row into thirds; any extra controls
// (local search, connected-services switch) keep their size and wrap below.
// (local search) keep their size and wrap below.
> [data-cy-unified-search-filter="places"],
> [data-cy-unified-search-filter="date"],
> [data-cy-unified-search-filter="people"] {
@ -1401,6 +1617,7 @@ export default defineComponent({
position: relative;
width: 100%;
padding-inline: calc(var(--default-grid-baseline) * 6);
border-radius: var(--border-radius-element);
&::after {
content: '';
@ -1420,32 +1637,73 @@ export default defineComponent({
}
}
&__search-external-resources {
:deep(span.checkbox-content) {
padding-top: 0;
padding-bottom: 0;
}
:deep(.checkbox-content__icon) {
margin: auto !important;
}
&--aligned {
margin-inline-start: auto;
}
}
&__filters-applied {
padding-top: 4px;
display: flex;
flex-wrap: wrap;
}
&__no-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: calc(var(--default-grid-baseline) * 2);
// min-height (not fixed) so the panel grows to keep the button inside, not spilling past the edge.
min-height: 200px;
// Match the results container's inset so the button lines up, not flush to the edges.
padding-inline: calc(var(--default-grid-baseline) * 4);
padding-block-end: calc(var(--default-grid-baseline) * 4);
}
// Detail-view chrome: the back control sits above the category's heading + list.
&__detail-header {
// Three tracks: "Back" at the start, title centred, empty end track to balance it.
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-top: 0.5em;
height: 70%;
gap: calc(var(--default-grid-baseline) * 2);
// Sticky at the top of the scrolling results. Background hides rows underneath; padding
// (not margin) stops bleed-through above.
position: sticky;
top: 0;
z-index: 1;
background-color: var(--color-main-background);
padding-block: calc(var(--default-grid-baseline) * 3) calc(var(--default-grid-baseline) * 2);
border-block-end: 1px solid var(--color-border);
}
&__detail-back {
justify-self: start;
}
&__detail-title {
font-size: var(--default-font-size);
font-weight: bold;
grid-column: 2;
margin: 0;
margin-block-start: -3px;
// Centre the text the same way the Back button centres its label: stretch to the row
// height and flex-centre, instead of a line-height that lands the ink a few px off.
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
}
// End-of-list (and empty-state) connected-services opt-in.
&__connected-services {
display: flex;
flex-wrap: wrap;
// Stretch to panel width so the wide button fills it (the empty-state's centred column
// would otherwise shrink it to content width).
width: 100%;
margin-block-start: calc(var(--default-grid-baseline) * 3);
}
// Directional glyphs (back arrow, more-from chevron) point the other way in RTL.
// :dir(rtl) tracks the computed direction, unlike an [dir=rtl] attribute selector.
&__rtl-icon:dir(rtl) {
transform: scaleX(-1);
}
&__results {
@ -1454,14 +1712,31 @@ export default defineComponent({
min-height: 0;
overflow: hidden auto;
// Adjust padding to match container but keep the scrollbar on the very end
padding-inline: 12px;
padding-block: 0 12px;
padding-inline: calc(var(--default-grid-baseline) * 4);
padding-block: 0 calc(var(--default-grid-baseline) * 4);
.result {
&-title {
color: var(--color-primary-element);
font-size: 16px;
margin-block: 8px 4px;
color: var(--color-text-maxcontrast);
font-size: var(--default-font-size);
// 14px is not a grid multiple; kept raw rather than mixing units in one shorthand.
margin-block: 14px 4px;
margin-inline-start: calc(var(--default-grid-baseline) * 2);
}
// The overflow heading is a real button; make it read like the plain title.
&-title--more {
margin-block: calc(var(--default-grid-baseline) * 2) var(--default-grid-baseline);
:deep(.button-vue__text) {
font-size: var(--default-font-size);
font-weight: normal;
color: var(--color-main-text);
}
:deep(.button-vue__icon) {
color: var(--color-main-text);
}
}
&-footer {

@ -25,6 +25,12 @@ export interface CategorySearchParams {
export const REVEAL_INTERVAL_MS = 1500
/**
* Results fetched per category per page. Sized for the detail view (which shows the
* whole page); the aggregate caps to RESULTS_PER_CATEGORY. Server default 5, design 10.
*/
export const PAGE_SIZE = 10
/**
* Runs a unified search across categories in priority order, blocking
* lower-priority results until their predecessors arrive or a timer reveals them.
@ -49,6 +55,10 @@ export class UnifiedSearchController {
*/
async search(query: string, categories: string[], params?: Record<string, CategorySearchParams>): Promise<void> {
this.cancelPendingRequests()
// Stale-while-revalidate: keep the previous page on screen while the new search is in
// flight, so refining a query swaps results in place instead of flashing an empty panel.
// Each recurring category is reseeded with its prior entries below; dropped ones vanish.
const previous = this.searchStates
this.searchStates = {}
this.searchGeneration++
const generation = this.searchGeneration
@ -57,7 +67,14 @@ export class UnifiedSearchController {
this.startRevealTimer()
await Promise.allSettled(categories.map((category) => this.searchCategory(category, generation, categories)))
await Promise.allSettled(categories.map((category) => {
const prev = previous[category]
// Only entries that were actually on screen seed the stale view. A blocked or failed
// category's entries were fetched but never rendered, so they must not carry over
// (and must not let the category skip the ordered reveal).
const staleEntries = prev && (prev.status === 'loaded' || prev.status === 'loading') ? prev.entries : []
return this.searchCategory(category, generation, categories, staleEntries)
}))
}
/**
@ -80,6 +97,7 @@ export class UnifiedSearchController {
type: category,
query: this.query,
cursor: categoryState.cursor,
limit: PAGE_SIZE,
...this.params[category],
})
@ -92,10 +110,14 @@ export class UnifiedSearchController {
}
const { entries, cursor, isPaginated } = response.data.ocs.data
// A provider can echo a non-null cursor on an empty page, keeping hasMore true and
// leaving a dead "Load more" button. An empty page means exhausted, cursor or not.
const reachedEnd = entries.length === 0
this.patchStates({[category]: {
entries: [...categoryState.entries, ...entries],
cursor,
hasMore: this.hasMorePages(isPaginated, cursor),
hasMore: !reachedEnd && this.hasMorePages(isPaginated, cursor),
status: 'loaded',
}})
} catch {
@ -132,10 +154,13 @@ export class UnifiedSearchController {
category: string,
generation: number,
categories: string[],
staleEntries: unknown[] = [],
): Promise<void> {
// Seed with the prior page (stale-while-revalidate) so it stays visible under the
// spinner until the fresh page replaces it. Empty on a first search.
this.patchStates({ [category]: {
status: 'loading',
entries: [],
entries: staleEntries,
cursor: null,
hasMore: false,
loadMoreFailed: false,
@ -145,6 +170,7 @@ export class UnifiedSearchController {
type: category,
query: this.query,
cursor: null,
limit: PAGE_SIZE,
...this.params[category],
})
@ -159,9 +185,12 @@ export class UnifiedSearchController {
const { entries, cursor, isPaginated } = response.data.ocs.data
// Decide blocked vs loaded once, here at settle. Reconcile only promotes after this
// (never re-blocks), so this is the only place a category becomes blocked.
// (never re-blocks), so this is the only place a category becomes blocked. A category
// that carried stale results skips blocking: it is already on screen, so blocking it
// would blink it off until its predecessors clear. Ordered reveal is only for the
// first paint, when nothing is shown yet.
this.patchStates({ [category]: {
status: this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded',
status: (staleEntries.length === 0 && this.shouldBlockCategory(category, categories)) ? 'blocked' : 'loaded',
entries,
cursor,
hasMore: this.hasMorePages(isPaginated, cursor),

@ -47,7 +47,7 @@ export async function getProviders() {
* @param {number|string|null} [options.cursor] the offset for paginated searches
* @param {string} [options.since] start of the date-range filter
* @param {string} [options.until] end of the date-range filter
* @param {string} [options.limit] maximum number of results
* @param {number} [options.limit] maximum number of results
* @param {string} [options.person] filter results by person
* @param {object} [options.extraQueries] additional queries to filter search results
* @return {object} {request: Promise, cancel: Promise}

@ -37,3 +37,36 @@ describe('SearchResult combobox option', () => {
expect(wrapper.findComponent(NcListItem).props('active')).toBe(false)
})
})
describe('SearchResult icon', () => {
it('renders a URL icon (e.g. a settings section) as an image', () => {
const wrapper = factory({ icon: '/apps/settings/img/password.svg' })
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/apps/settings/img/password.svg')
})
it('renders an app icon (rounded, no thumbnail) as an image', () => {
const wrapper = factory({ icon: '/apps/files/img/app.svg', rounded: true })
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/apps/files/img/app.svg')
})
it('does not render a broken image for a legacy CSS-class icon', () => {
const wrapper = factory({ icon: 'icon-confirm' })
// A class string is not a URL, so no <img> should point at it.
expect(wrapper.findAll('img').length).toBe(0)
})
it('renders the thumbnail image when one is provided', () => {
const wrapper = factory({ thumbnailUrl: '/preview/1', icon: 'icon-confirm' })
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/preview/1')
})
})

@ -208,6 +208,53 @@ describe('UnifiedSearch find shortcut (Ctrl+F) aligns with Ctrl+K', () => {
expect(prevented).not.toHaveBeenCalled()
wrapper.destroy()
})
// Once search is engaged, Ctrl+F belongs to the browser again: a second press must
// reach the native find bar instead of being swallowed to re-focus what is already focused.
it('falls through to the browser once the results are open', () => {
const wrapper = mountWithShortcuts()
const focusInput = vi.spyOn(wrapper.vm, 'focusInput').mockImplementation(() => {})
wrapper.vm.showUnifiedSearch = true
const prevented = pressCtrl('f')
expect(prevented).not.toHaveBeenCalled()
expect(focusInput).not.toHaveBeenCalled()
wrapper.destroy()
})
it('falls through to the browser while the header input already holds focus', () => {
const wrapper = mountWithShortcuts()
const focusInput = vi.spyOn(wrapper.vm, 'focusInput').mockImplementation(() => {})
// The engaged check tests the real focused element against the input's DOM subtree,
// so it needs a focusable node that is actually in the document.
const host = document.createElement('div')
const field = document.createElement('input')
host.appendChild(field)
document.body.appendChild(host)
wrapper.vm.$refs.searchInput = { $el: host }
field.focus()
const prevented = pressCtrl('f')
expect(prevented).not.toHaveBeenCalled()
expect(focusInput).not.toHaveBeenCalled()
host.remove()
wrapper.destroy()
})
// Only Ctrl+F defers to the browser. Ctrl+K has no native meaning worth preserving
// (in Firefox it focuses the address bar), so it stays claimed even when engaged.
it('does not make Ctrl+K fall through as well', () => {
const wrapper = mountWithShortcuts()
vi.spyOn(wrapper.vm, 'focusInput').mockImplementation(() => {})
wrapper.vm.showUnifiedSearch = true
const prevented = pressCtrl('k')
expect(prevented).toHaveBeenCalled()
wrapper.destroy()
})
})
describe('UnifiedSearch combobox expanded state', () => {

@ -305,3 +305,15 @@ describe('UnifiedSearchInput trailing controls', () => {
expect(wrapper.emitted('open-filters')).toBeTruthy()
})
})
describe('UnifiedSearchInput loading spinner', () => {
const hasSpinner = (wrapper: ReturnType<typeof factory>) => wrapper.findComponent({ name: 'NcLoadingIcon' }).exists()
it('shows a spinner while a search is loading', () => {
expect(hasSpinner(factory({ query: 'abc', loading: true }))).toBe(true)
})
it('shows no spinner when not loading', () => {
expect(hasSpinner(factory({ query: 'abc', loading: false }))).toBe(false)
})
})

@ -36,6 +36,12 @@ vi.mock('../../services/UnifiedSearchService.js', () => ({
vi.mock('../../store/unified-search-external-filters.js', () => ({
useSearchStore: () => ({ externalFilters: [], scopeToApp: false }),
}))
// The real module builds a logger at import time (detectUser() and all), and the modal
// logs on provider init. Stubbed to keep the unit off that dependency and out of the
// test output; the vi.fn()s also leave log calls assertable if a test ever needs them.
vi.mock('../../logger.js', () => ({
unifiedSearchLogger: { debug: vi.fn(), error: vi.fn() },
}))
import UnifiedSearchModal from '../../components/UnifiedSearch/UnifiedSearchModal.vue'
@ -146,7 +152,8 @@ describe('UnifiedSearchModal controller wiring', () => {
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.result-title').wrappers.map((w) => w.text())).toEqual(['Files'])
// The already-loaded row stays on screen while the next page loads.
expect(wrapper.findAllComponents({ name: 'SearchResult' })).toHaveLength(1)
expect(wrapper.vm.showEmptyContentInfo).toBe(false)
})
@ -268,8 +275,9 @@ describe('UnifiedSearchModal controller wiring', () => {
// list and settle instantly into "no results". It must be withheld instead...
wrapper.vm.find('hello')
expect(searchSpy).not.toHaveBeenCalled()
// ...and the empty state reads as searching, not "no results".
expect(wrapper.vm.emptyContentMessage).toContain('Searching')
// ...and it reads as busy (input spinner), with no in-modal loading text.
expect(wrapper.vm.isBusy).toBe(true)
expect(wrapper.vm.showEmptyContentInfo).toBe(false)
// Once initialized, the same query dispatches normally.
wrapper.vm.initialized = true
@ -296,6 +304,26 @@ describe('UnifiedSearchModal reset on close', () => {
expect(resetSpy).toHaveBeenCalledOnce()
expect(wrapper.vm.results).toEqual([])
})
it('stops reporting busy and cancels the pending search when it closes with the query kept', async () => {
const wrapper = factory() // starts open
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }]
wrapper.vm.initialized = true
// Typing schedules the debounced search; pendingSearch reports busy across the gap.
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
expect(wrapper.vm.isBusy).toBe(true)
// The pending debounce must be cancelled on close so it can't dispatch for a shut modal.
const cancelPending = vi.spyOn(wrapper.vm.debouncedFind, 'clear')
// searchLocally-style close: keep the query, just shut the popover.
await wrapper.setProps({ open: false })
expect(cancelPending).toHaveBeenCalled()
// A closed modal must not report busy, or the always-mounted header input keeps spinning.
expect(wrapper.vm.isBusy).toBe(false)
})
})
describe('UnifiedSearchModal filter triggers', () => {
@ -385,6 +413,56 @@ describe('UnifiedSearchModal filter row reveal', () => {
})
})
describe('UnifiedSearchModal header visibility', () => {
const header = (wrapper) => wrapper.find('.unified-search-modal__header')
/**
* Mount and let the provider init settle, so its logging cannot land after teardown.
*/
async function mounted(open = true) {
const wrapper = factory(open)
await flushPromises()
return wrapper
}
// The header is padded, so leaving it mounted while empty puts a dead strip above
// the content. It only takes space when it has the mobile field or the filter row.
it('hides the header on a resting empty query', async () => {
const wrapper = await mounted() // desktop, empty query, filtersRevealed defaults to false
expect(wrapper.vm.showHeader).toBe(false)
expect(header(wrapper).isVisible()).toBe(false)
})
it('shows the header once the funnel reveals the filter row', async () => {
const wrapper = await mounted()
await wrapper.setProps({ filtersRevealed: true })
expect(wrapper.vm.showHeader).toBe(true)
expect(header(wrapper).isVisible()).toBe(true)
})
// The detail view drops the filters, leaving the desktop header with nothing to show.
it('hides the header in the desktop detail view', async () => {
const wrapper = await mounted()
await wrapper.setProps({ filtersRevealed: true })
expect(wrapper.vm.showHeader).toBe(true)
wrapper.vm.detailCategory = 'files'
await wrapper.vm.$nextTick()
expect(wrapper.vm.showHeader).toBe(false)
expect(header(wrapper).isVisible()).toBe(false)
})
// Mobile keeps it either way: the header carries the only search field on that layout.
it('keeps the header on mobile even in the detail view', async () => {
mobile.value = true
const wrapper = await mounted()
wrapper.vm.detailCategory = 'files'
await wrapper.vm.$nextTick()
expect(wrapper.vm.showHeader).toBe(true)
expect(header(wrapper).isVisible()).toBe(true)
})
})
describe('UnifiedSearchModal controller wiring (init)', () => {
it('runs a query typed before providers finished loading, once initialized', async () => {
const { getProviders } = await import('../../services/UnifiedSearchService.js')
@ -427,10 +505,11 @@ describe('UnifiedSearchModal keyboard selection', () => {
expect(wrapper.vm.activeDescendantId).toBeNull()
})
it('auto-selects the first result once results arrive', async () => {
it('auto-selects the first result', async () => {
const wrapper = factory()
await withRows(wrapper, [{ resourceUrl: '/a' }, { resourceUrl: '/b' }])
// The first row is selected as soon as results arrive (keyboard users act immediately).
expect(wrapper.vm.activeIndex).toBe(0)
expect(wrapper.vm.activeDescendantId).toBe('unified-search-result-files-0')
})
@ -439,13 +518,14 @@ describe('UnifiedSearchModal keyboard selection', () => {
const wrapper = factory()
await withRows(wrapper, [{ resourceUrl: '/a' }, { resourceUrl: '/b' }])
wrapper.vm.moveActive('next')
expect(wrapper.vm.activeIndex).toBe(0) // first row auto-selected
wrapper.vm.moveActive('next') // 0 → 1
expect(wrapper.vm.activeIndex).toBe(1)
wrapper.vm.moveActive('next')
wrapper.vm.moveActive('next') // clamp at the last row
expect(wrapper.vm.activeIndex).toBe(1)
wrapper.vm.moveActive('prev')
wrapper.vm.moveActive('prev') // 1 → 0
expect(wrapper.vm.activeIndex).toBe(0)
wrapper.vm.moveActive('prev')
wrapper.vm.moveActive('prev') // clamp at the first row
expect(wrapper.vm.activeIndex).toBe(0)
})
@ -472,8 +552,8 @@ describe('UnifiedSearchModal keyboard selection', () => {
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
wrapper.vm.moveActive('next')
// Second row lives in the next provider group.
// From the auto-selected first row (files-0), the next move crosses into the next group.
wrapper.vm.moveActive('next') // 0 (files-0) → 1 (talk-0)
expect(wrapper.vm.activeDescendantId).toBe('unified-search-result-talk-0')
})
@ -482,13 +562,24 @@ describe('UnifiedSearchModal keyboard selection', () => {
await withRows(wrapper, [{ resourceUrl: '/a' }, { resourceUrl: '/b' }])
const open = vi.spyOn(wrapper.vm, 'openResourceUrl').mockImplementation(() => {})
wrapper.vm.moveActive('next')
wrapper.vm.moveActive('next') // 0 (/a) → 1 (/b)
wrapper.vm.activateActive()
expect(open).toHaveBeenCalledWith('/b')
})
it('does nothing on activate when there is no active row', () => {
it('opens the first result on activate when nothing has been navigated to', async () => {
const wrapper = factory()
await withRows(wrapper, [{ resourceUrl: '/a' }, { resourceUrl: '/b' }])
const open = vi.spyOn(wrapper.vm, 'openResourceUrl').mockImplementation(() => {})
// The first row is auto-selected, so Enter opens the top hit without any navigation.
wrapper.vm.activateActive()
expect(open).toHaveBeenCalledWith('/a')
})
it('does nothing on activate when there are no results', () => {
const wrapper = factory()
const open = vi.spyOn(wrapper.vm, 'openResourceUrl').mockImplementation(() => {})
@ -500,6 +591,8 @@ describe('UnifiedSearchModal keyboard selection', () => {
it('emits the active descendant id upward for the input to reference', async () => {
const wrapper = factory()
await withRows(wrapper, [{ resourceUrl: '/a' }])
wrapper.vm.moveActive('next')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:activeDescendant')?.at(-1)).toEqual(['unified-search-result-files-0'])
})
@ -513,7 +606,7 @@ describe('UnifiedSearchModal keyboard selection', () => {
searchStates.value = { files: loaded([{ resourceUrl: '/a' }, { resourceUrl: '/b' }]) }
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
wrapper.vm.moveActive('next')
wrapper.vm.moveActive('next') // 0 → 1
expect(wrapper.vm.activeDescendantId).toBe('unified-search-result-files-1')
// A lower-priority group arrives below; the selected row keeps its identity.
@ -548,7 +641,8 @@ describe('UnifiedSearchModal keyboard selection', () => {
secondRow.scrollIntoView = vi.fn()
document.body.appendChild(secondRow)
wrapper.vm.moveActive('next')
wrapper.vm.moveActive('next') // -1 → 0
wrapper.vm.moveActive('next') // 0 → 1 (below the fold)
await wrapper.vm.$nextTick()
expect(secondRow.scrollIntoView).toHaveBeenCalled()
@ -559,6 +653,7 @@ describe('UnifiedSearchModal keyboard selection', () => {
const wrapper = factory()
await withRows(wrapper, [{ resourceUrl: '/a' }, { resourceUrl: '/b' }])
// The first row is highlighted on its own (auto-selected).
expect(wrapper.findAll('[role=listbox]')).toHaveLength(1)
const rows = wrapper.findAllComponents({ name: 'SearchResult' })
expect(rows.at(0).props('elementId')).toBe('unified-search-result-files-0')
@ -749,3 +844,334 @@ describe('UnifiedSearchModal People filter', () => {
expect(wrapper.vm.filters.some((f: { type: string }) => f.type === 'person')).toBe(true)
})
})
describe('UnifiedSearchModal result presentation', () => {
/**
* Seed one category with the given rows and settle.
*/
async function withGroup(wrapper: ReturnType<typeof factory>, id: string, entries: unknown[], hasMore = false) {
wrapper.vm.providers = [{ id, name: 'Files', order: 0 }]
wrapper.vm.initialized = true
searchStates.value = { [id]: loaded(entries, hasMore) }
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
}
const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ resourceUrl: `/r${i}` }))
// The overflow heading is the only NcButton carrying the group's heading id, so we
// find the "More from" control by that rather than a style class.
const moreFromButton = (wrapper: ReturnType<typeof factory>) => wrapper.findAllComponents({ name: 'NcButton' }).wrappers
.find((w) => w.attributes('id') === 'unified-search-result-files')
const buttonWithText = (wrapper: ReturnType<typeof factory>, text: string) => wrapper.findAllComponents({ name: 'NcButton' }).wrappers
.find((w) => w.text().includes(text))
// Find the back control by its ariaLabel ("Back to all results").
const backButton = (wrapper: ReturnType<typeof factory>) => wrapper.findAllComponents({ name: 'NcButton' }).wrappers
.find((w) => w.props('ariaLabel') === 'Back to all results')
it('caps a category at three rows in the aggregate view, and navigableRows follows', async () => {
const wrapper = factory()
await withGroup(wrapper, 'files', rows(5))
expect(wrapper.findAllComponents({ name: 'SearchResult' })).toHaveLength(3)
expect(wrapper.vm.navigableRows).toHaveLength(3)
})
it('flags overflow purely on the fetched count, not on the pagination cursor', async () => {
const wrapper = factory()
// More than the cap fetched: "More from" button.
await withGroup(wrapper, 'files', rows(4))
expect(wrapper.vm.renderedGroups[0].overflow).toBe(true)
expect(moreFromButton(wrapper)).toBeTruthy()
// At the cap but the provider still advertises a cursor (some do past their last page).
// The button must NOT show: opening the detail view would be a dead end.
await withGroup(wrapper, 'files', rows(2), true)
expect(wrapper.vm.renderedGroups[0].overflow).toBe(false)
expect(moreFromButton(wrapper)).toBeUndefined()
// Exactly the cap and nothing more: plain title, no "More from".
await withGroup(wrapper, 'files', rows(3), false)
expect(wrapper.vm.renderedGroups[0].overflow).toBe(false)
expect(moreFromButton(wrapper)).toBeUndefined()
})
it('opens the uncapped detail view from "More from" and back returns to the capped list', async () => {
const wrapper = factory()
await withGroup(wrapper, 'files', rows(5))
moreFromButton(wrapper)!.vm.$emit('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.detailCategory).toBe('files')
// Uncapped in detail, and navigableRows matches the rendered rows (lockstep).
expect(wrapper.findAllComponents({ name: 'SearchResult' })).toHaveLength(5)
expect(wrapper.vm.navigableRows).toHaveLength(5)
expect(wrapper.vm.liveMessage).toContain('Showing')
backButton(wrapper)!.vm.$emit('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.detailCategory).toBeNull()
expect(wrapper.findAllComponents({ name: 'SearchResult' })).toHaveLength(3)
expect(wrapper.vm.navigableRows).toHaveLength(3)
})
it('scrolls the results back to the top when returning from the detail view', async () => {
const wrapper = factory()
await withGroup(wrapper, 'files', rows(5))
wrapper.vm.openDetailView({ id: 'files' })
await wrapper.vm.$nextTick()
// jsdom has no layout, so stand in for the scroll container and capture writes.
const scrollWrites: number[] = []
Object.defineProperty(wrapper.vm.$refs.resultsContainer, 'scrollTop', {
configurable: true,
get: () => 400,
set: (value) => scrollWrites.push(value),
})
wrapper.vm.closeDetailView()
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(scrollWrites).toContain(0)
})
it('drops the filter row and titles the detail view with the category name', async () => {
const wrapper = factory()
await withGroup(wrapper, 'files', rows(5))
moreFromButton(wrapper)!.vm.$emit('click')
await wrapper.vm.$nextTick()
// Filters are hidden while viewing one category's full results.
expect(wrapper.vm.showFilterRow).toBe(false)
// The category name titles the detail view (shown once, in the header).
const title = wrapper.find('.unified-search-modal__detail-title')
expect(title.exists()).toBe(true)
expect(title.text()).toBe('Files')
})
it('pages the detail view through the controller loadMore', async () => {
const wrapper = factory()
// Over the cap so the aggregate shows "More from", and still paginating so the
// detail view offers "Load more results".
await withGroup(wrapper, 'files', rows(5), true)
moreFromButton(wrapper)!.vm.$emit('click')
await wrapper.vm.$nextTick()
buttonWithText(wrapper, 'Load more results')!.vm.$emit('click')
expect(loadMoreSpy).toHaveBeenCalledWith('files')
})
it('keeps the selected-row highlight working in the detail view', async () => {
const wrapper = factory()
await withGroup(wrapper, 'files', rows(5))
moreFromButton(wrapper)!.vm.$emit('click')
await wrapper.vm.$nextTick()
// The first row is auto-selected; arrow keys drive the highlight in the detail view too.
wrapper.vm.moveActive('next') // 0 → 1
await wrapper.vm.$nextTick()
expect(wrapper.vm.activeDescendantId).toBe('unified-search-result-files-1')
const second = wrapper.findAllComponents({ name: 'SearchResult' }).at(1)
expect(second.props('active')).toBe(true)
})
it('leaves the detail view on a query change, a filter change, or a services toggle', async () => {
const wrapper = factory()
await withGroup(wrapper, 'files', rows(4))
wrapper.vm.openDetailView({ id: 'files' })
await wrapper.vm.$nextTick()
wrapper.vm.searchQuery = 'other'
await wrapper.vm.$nextTick()
expect(wrapper.vm.detailCategory).toBeNull()
await withGroup(wrapper, 'files', rows(4))
wrapper.vm.openDetailView({ id: 'files' })
await wrapper.vm.$nextTick()
wrapper.vm.filters = [{ id: 'date', type: 'date', text: 'Today' }]
await wrapper.vm.$nextTick()
expect(wrapper.vm.detailCategory).toBeNull()
await withGroup(wrapper, 'files', rows(4))
wrapper.vm.openDetailView({ id: 'files' })
await wrapper.vm.$nextTick()
wrapper.vm.toggleExternalResources()
await wrapper.vm.$nextTick()
expect(wrapper.vm.detailCategory).toBeNull()
})
it('leaves the detail view if the open category drops out of the results', async () => {
const wrapper = factory()
await withGroup(wrapper, 'files', rows(4))
wrapper.vm.openDetailView({ id: 'files' })
await wrapper.vm.$nextTick()
expect(wrapper.vm.detailCategory).toBe('files')
searchStates.value = {}
await wrapper.vm.$nextTick()
expect(wrapper.vm.detailCategory).toBeNull()
})
it('labels the connected-services button by the toggle state and re-runs find on toggle', async () => {
const wrapper = factory()
wrapper.vm.providers = [
{ id: 'files', name: 'Files', order: 0 },
{ id: 'ext', name: 'External', order: 1, isExternalProvider: true },
]
wrapper.vm.initialized = true
searchStates.value = { files: loaded([{ resourceUrl: '/a' }]) }
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
// Settle the debounced search so the button's !isBusy gate opens (pendingSearch clears).
wrapper.vm.find('query')
await wrapper.vm.$nextTick()
expect(buttonWithText(wrapper, 'More from connected services')).toBeTruthy()
wrapper.vm.toggleExternalResources()
await wrapper.vm.$nextTick()
expect(searchSpy).toHaveBeenCalled()
expect(buttonWithText(wrapper, 'Less from connected services')).toBeTruthy()
})
it('returns focus to the search input after toggling connected services', async () => {
const wrapper = factory()
wrapper.vm.providers = [
{ id: 'files', name: 'Files', order: 0 },
{ id: 'ext', name: 'External', order: 1, isExternalProvider: true },
]
wrapper.vm.initialized = true
searchStates.value = { files: loaded([{ resourceUrl: '/a' }]) }
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
wrapper.vm.find('query')
await wrapper.vm.$nextTick()
// The toggle re-runs the search, which unmounts the button that held focus. In
// shallowMount focusSearchInput can't move real DOM focus, so assert the modal
// re-homes focus onto the input (the same recovery the detail-view controls use).
const focusSpy = vi.spyOn(wrapper.vm, 'focusSearchInput')
wrapper.vm.toggleExternalResources()
await wrapper.vm.$nextTick()
expect(focusSpy).toHaveBeenCalled()
})
it('offers the connected-services opt-in even when a query returns no results', async () => {
const wrapper = factory()
wrapper.vm.providers = [{ id: 'ext', name: 'External', order: 0, isExternalProvider: true }]
wrapper.vm.initialized = true
searchStates.value = {}
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
// Dispatch the debounced search; it settles with no results, when the empty state should show.
wrapper.vm.find('query')
await wrapper.vm.$nextTick()
expect(wrapper.vm.showEmptyContentInfo).toBe(true)
expect(buttonWithText(wrapper, 'connected services')).toBeTruthy()
})
it('no longer renders the connected-services switch in the filter row', async () => {
const wrapper = factory()
wrapper.vm.providers = [{ id: 'ext', name: 'External', order: 0, isExternalProvider: true }]
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'NcCheckboxRadioSwitch' }).exists()).toBe(false)
})
})
describe('UnifiedSearchModal loading state', () => {
const loadingState = { status: 'loading', entries: [], cursor: null, hasMore: false, loadMoreFailed: false }
it('is busy while a category loads, with no in-modal loading text, and settles when done', async () => {
const wrapper = factory()
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }]
wrapper.vm.initialized = true
searchStates.value = { files: loadingState }
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
// The debounce fires and dispatches the real search, clearing the pending flag; from
// here the controller's loading state alone drives busy.
wrapper.vm.find('query')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isBusy).toBe(true)
// The empty-content block stays hidden while busy.
expect(wrapper.vm.showEmptyContentInfo).toBe(false)
searchStates.value = { files: loaded([{ resourceUrl: '/a' }]) }
await wrapper.vm.$nextTick()
expect(wrapper.vm.isBusy).toBe(false)
})
it('stays busy through the debounce window so it does not flash the empty state', async () => {
const wrapper = factory()
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }]
wrapper.vm.initialized = true
// A fresh query, but the debounced find() (and thus the loading state) has not run yet.
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
expect(wrapper.vm.pendingSearch).toBe(true)
expect(wrapper.vm.isBusy).toBe(true)
expect(wrapper.vm.showEmptyContentInfo).toBe(false)
// When the search actually dispatches, the pending window ends and searching takes over.
wrapper.vm.find('query')
expect(wrapper.vm.pendingSearch).toBe(false)
})
it('is not busy for an empty or too-short query even if a stale request is loading', async () => {
const wrapper = factory()
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }]
wrapper.vm.initialized = true
wrapper.vm.minSearchLength = 3
searchStates.value = { files: loadingState }
wrapper.vm.searchQuery = 'ab'
await wrapper.vm.$nextTick()
expect(wrapper.vm.isBusy).toBe(false)
})
it('emits update:loading as the busy state changes', async () => {
const wrapper = factory()
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }]
wrapper.vm.initialized = true
searchStates.value = { files: loadingState }
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
// The debounce fires and dispatches; the pending flag clears and the loading category
// alone keeps it busy.
wrapper.vm.find('query')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:loading')?.at(-1)).toEqual([true])
searchStates.value = { files: loaded([{ resourceUrl: '/a' }]) }
await wrapper.vm.$nextTick()
expect(wrapper.emitted('update:loading')?.at(-1)).toEqual([false])
})
it('shows a spinner in the mobile input while busy', async () => {
mobile.value = true
const wrapper = factory()
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }]
wrapper.vm.initialized = true
searchStates.value = { files: loadingState }
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'NcLoadingIcon' }).exists()).toBe(true)
})
})

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { REVEAL_INTERVAL_MS, UnifiedSearchController } from '../../services/UnifiedSearchController.ts'
import { PAGE_SIZE, REVEAL_INTERVAL_MS, UnifiedSearchController } from '../../services/UnifiedSearchController.ts'
const service = vi.hoisted(() => ({
search: vi.fn(),
@ -287,6 +287,69 @@ describe('UnifiedSearchController', () => {
})
})
describe('stale-while-revalidate', () => {
it('keeps the previous results visible while a refetch is in flight', async () => {
const first = mockProviders(['files'])
const searchController = new UnifiedSearchController()
searchController.search('old', ['files'])
first.files.resolve(['Old result'])
await vi.advanceTimersByTimeAsync(0)
// A refined query starts a new search. The prior entries must stay on screen
// (status loading, entries kept) so the panel does not flash empty mid-request.
const second = mockProviders(['files'])
searchController.search('new', ['files'])
expect(searchController.getSnapshot().files).toEqual({
status: 'loading',
entries: ['Old result'],
cursor: null,
hasMore: false,
loadMoreFailed: false,
})
// The fresh page replaces them once it lands.
second.files.resolve(['New result'])
await vi.advanceTimersByTimeAsync(0)
expect(searchController.getSnapshot().files).toEqual({
status: 'loaded',
entries: ['New result'],
cursor: null,
hasMore: false,
loadMoreFailed: false,
})
})
it('settles a refetched category that carried results straight to loaded, never blocked', async () => {
const first = mockProviders(['files', 'talk'])
const searchController = new UnifiedSearchController()
searchController.search('old', ['files', 'talk'])
first.files.resolve(['Old files'])
await vi.advanceTimersByTimeAsync(0)
first.talk.resolve(['Old talk'])
await vi.advanceTimersByTimeAsync(0)
// Refine. talk (lower priority) comes back before files this time. It already had
// results, so it must not drop into blocked (which excludes it from the rendered
// set and blinks it off screen); it stays visible by settling straight to loaded.
const second = mockProviders(['files', 'talk'])
searchController.search('new', ['files', 'talk'])
second.talk.resolve(['New talk'])
await vi.advanceTimersByTimeAsync(0)
expect(searchController.getSnapshot().talk.status).toBe('loaded')
// files is still fetching; its stale page stays up meanwhile.
expect(searchController.getSnapshot().files).toEqual({
status: 'loading',
entries: ['Old files'],
cursor: null,
hasMore: false,
loadMoreFailed: false,
})
})
})
describe('cancellation', () => {
it('cancels the previous search\'s in-flight requests when a new search starts', () => {
const first = mockProviders(['files', 'talk'])
@ -506,6 +569,32 @@ describe('UnifiedSearchController', () => {
expect(searchController.getSnapshot().files.hasMore).toBe(false)
})
it('stops paging when a page yields nothing new, even if the provider echoes a cursor', async () => {
const files = pagedProvider()
service.search.mockReturnValue(files)
const searchController = new UnifiedSearchController()
searchController.search('query', ['files'])
files.resolvePage(0, { entries: ['a'], cursor: 'cursor-1', isPaginated: true })
await vi.advanceTimersByTimeAsync(0)
expect(searchController.getSnapshot().files.hasMore).toBe(true)
// The next page comes back empty but still carries a paginated cursor. Without
// the guard this would leave hasMore true and a "Load more" button that no-ops.
searchController.loadMore('files')
files.resolvePage(1, { entries: [], cursor: 'cursor-2', isPaginated: true })
await vi.advanceTimersByTimeAsync(0)
expect(searchController.getSnapshot().files).toEqual({
status: 'loaded',
entries: ['a'],
cursor: 'cursor-2',
hasMore: false,
loadMoreFailed: false,
})
})
it('appends the next page of results when loadMore is called', async () => {
const files = pagedProvider()
service.search.mockReturnValue(files)
@ -577,7 +666,17 @@ describe('UnifiedSearchController', () => {
searchController.loadMore('files')
expect(service.search).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'files', query: 'query', cursor: 'cursor-1' }))
expect(service.search).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'files', query: 'query', cursor: 'cursor-1', limit: PAGE_SIZE }))
})
it('requests the configured page size on the initial category search', async () => {
const files = pagedProvider()
service.search.mockReturnValue(files)
const searchController = new UnifiedSearchController()
searchController.search('query', ['files'])
expect(service.search).toHaveBeenLastCalledWith(expect.objectContaining({ type: 'files', query: 'query', limit: PAGE_SIZE }))
})
it('flags a page-load failure without dropping the results already loaded', async () => {

@ -9,6 +9,7 @@
:query="queryText"
:expanded="showUnifiedSearch"
:activeDescendantId="activeDescendantId"
:loading="searching"
:filtersRevealed="filtersRevealed"
@click="openModal"
@open-filters="onOpenFilters"
@ -31,7 +32,8 @@
:filtersRevealed="filtersRevealed"
@update:query="queryText = $event"
@update:open="showUnifiedSearch = $event"
@update:activeDescendant="activeDescendantId = $event || ''" />
@update:activeDescendant="activeDescendantId = $event || ''"
@update:loading="searching = $event" />
</div>
</template>
@ -81,6 +83,8 @@ export default defineComponent({
* sibling input can point aria-activedescendant at it. '' = nothing selected.
*/
activeDescendantId: '',
/** Whether a search is in flight, driving the input spinner */
searching: false,
/** Whether the funnel has revealed the filter row before typing */
filtersRevealed: false,
}
@ -197,7 +201,11 @@ export default defineComponent({
return
}
// Everywhere else, behave like Ctrl+K: focus the input (desktop) / open the
// modal (mobile), rather than opening it on an empty query.
// modal (mobile). Once search is already engaged, let a second press fall
// through to the browser's native find instead of claiming Ctrl+F again.
if (this.isSearchEngaged()) {
return
}
event.preventDefault()
this.focusSearch()
} else if ((event.metaKey || event.ctrlKey) && key === 'k') {
@ -234,6 +242,18 @@ export default defineComponent({
input?.focus?.()
},
/**
* Whether search is already engaged: the modal is open, or the header input holds
* focus. Lets a second Ctrl+F fall through to the browser's native find.
*/
isSearchEngaged(): boolean {
if (this.showUnifiedSearch) {
return true
}
const el = (this.$refs.searchInput as { $el?: HTMLElement } | undefined)?.$el
return Boolean(el && el.contains(document.activeElement))
},
/**
* Relay an arrow-navigation intent from the input to the results modal, which
* owns the selection state.

@ -144,4 +144,63 @@ test.describe('Header: unified search keyboard navigation', () => {
await expect(search.input()).not.toBeFocused()
await expect(search.panel()).toHaveCount(0)
})
test('"More from" opens the uncapped detail view, pages it, and Back returns to the aggregate list', async ({ page, user }) => {
// The three files from beforeEach plus nine more give twelve matches: past the
// aggregate cap of three (so "More from" shows) and past the ten-row first page
// (so the detail view offers "Load more results").
for (let i = 0; i < 9; i++) {
await uploadContent(page.request, user, 'content', 'text/plain', `/${TOKEN}-more-${i}.txt`)
}
const search = new UnifiedSearchPage(page)
await search.input().fill(TOKEN)
// Aggregate view: capped at three rows, with the overflow control.
await expect(search.options()).toHaveCount(3)
await expect(search.moreFrom('Files')).toBeVisible()
// The detail view shows the full first page (PAGE_SIZE) for that one category.
await search.moreFrom('Files').click()
await expect(search.detailBack()).toBeVisible()
await expect(search.detailHeading('Files')).toBeVisible()
await expect(search.options()).toHaveCount(10)
await expect(search.loadMore()).toBeVisible()
// Paging appends the next page.
await search.loadMore().click()
await expect(search.options()).toHaveCount(12)
// Back returns to the capped aggregate list.
await search.detailBack().click()
await expect(search.moreFrom('Files')).toBeVisible()
await expect(search.options()).toHaveCount(3)
})
test('the open search overlay suppresses the app keyboard shortcuts behind it', async ({ page }) => {
const search = new UnifiedSearchPage(page)
// `v` toggles the Files grid/list view (a useHotKey). The toggle button's name
// flips with the mode, so use it to observe whether the shortcut fired.
const toGrid = page.getByRole('button', { name: 'Switch to grid view' })
const toList = page.getByRole('button', { name: 'Switch to list view' })
// It fires on the bare page: list -> grid -> list.
await expect(toGrid).toBeVisible()
await page.keyboard.press('v')
await expect(toList).toBeVisible()
await page.keyboard.press('v')
await expect(toGrid).toBeVisible()
// Open the search and move focus onto a result, off the input (where the app's
// `v` would otherwise fire). The scrim's `modal-mask` makes useHotKey suppress
// background shortcuts, the same guard that stops arrow keys driving the file
// list behind the overlay.
await search.input().fill(TOKEN)
await expect(search.options().first()).toBeVisible()
await page.keyboard.press('Tab')
await expect(search.input()).not.toBeFocused()
await page.keyboard.press('v')
// The view did not toggle: the shortcut was suppressed while the overlay was open.
await expect(toGrid).toBeVisible()
})
})

@ -115,4 +115,33 @@ export class UnifiedSearchPage {
option(id: string): Locator {
return this.page.locator(`[id="${id}"]`)
}
/**
* The "More from {name}" overflow control: a category heading rendered as a button
* when the category has more than the aggregate cap of rows. Opens the detail view.
*
* @param name the category name, e.g. "Files"
*/
moreFrom(name: string): Locator {
return this.panel().getByRole('button', { name: `More from ${name}` })
}
/** The detail view's back control, which returns to the aggregate list. */
detailBack(): Locator {
return this.panel().getByRole('button', { name: 'Back to all results' })
}
/**
* The heading titling the detail view (shown once a category is expanded).
*
* @param name the category name
*/
detailHeading(name: string): Locator {
return this.panel().getByRole('heading', { name })
}
/** The detail view's pagination control, present while the category has more pages. */
loadMore(): Locator {
return this.panel().getByRole('button', { name: 'Load more results' })
}
}

Loading…
Cancel
Save