Merge pull request #63343 from nextcloud/feat/search-skeleton-loaders

feat(core): fill the unified search pending window with placeholders
pull/63371/merge
Peter R. 1 day ago committed by GitHub
commit 2a6ca8b919
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 76
      core/src/components/UnifiedSearch/SearchResultSkeleton.vue
  2. 138
      core/src/components/UnifiedSearch/UnifiedSearchModal.vue
  3. 26
      core/src/tests/components/SearchResultSkeleton.spec.ts
  4. 257
      core/src/tests/components/UnifiedSearchModal.spec.ts
  5. 4
      dist/core-unified-search.js
  6. 2
      dist/core-unified-search.js.map

@ -0,0 +1,76 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="search-result-skeleton" aria-hidden="true">
<div class="search-result-skeleton__bar search-result-skeleton__bar--heading" />
<div
v-for="row in rows"
:key="row"
class="search-result-skeleton__bar" />
</div>
</template>
<script setup lang="ts">
/**
* Results land a whole category at a time, so the block leads with a heading bar.
*
* Decoration only: the modal's live region announces the search state.
*/
defineProps<{
/** Rows below the heading. The caller may overdraw: the panel clips and fades. */
rows: number
}>()
</script>
<style lang="scss" scoped>
.search-result-skeleton {
--bar-block-size: calc(2lh + 2 * (2px + var(--default-grid-baseline) + 2px));
display: flex;
flex-direction: column;
gap: calc(3 * var(--default-grid-baseline));
&__bar {
flex: none;
position: relative;
overflow: hidden;
block-size: var(--bar-block-size);
border-radius: var(--border-radius-element);
background-color: var(--color-background-hover);
// Full width at one line reads as a divider; an explicit size also mirrors in RTL.
&--heading {
block-size: 1lh;
inline-size: 25%;
}
// Transform, not background-position: keeps the animation off the main thread.
&::after {
content: '';
position: absolute;
inset: 0;
background-image: linear-gradient(90deg, transparent, var(--color-placeholder-light), transparent);
transform: translateX(-100%);
animation: search-result-skeleton-sweep 1.6s linear infinite;
}
&:dir(rtl)::after {
animation-direction: reverse;
}
@media (prefers-reduced-motion: reduce) {
&::after {
content: none;
}
}
}
}
@keyframes search-result-skeleton-sweep {
to {
transform: translateX(100%);
}
}
</style>

@ -24,7 +24,7 @@
<div
v-show="showHeader"
class="unified-search-modal__header"
:class="{ 'unified-search-modal__header--has-results': hasVisibleResults && !detailCategory }">
:class="{ 'unified-search-modal__header--has-content-below': hasContentBelowHeader }">
<div v-if="isSmallMobile" class="unified-search-modal__mobile-input">
<NcTextField
type="search"
@ -154,7 +154,12 @@
</div>
</div>
<div v-else ref="resultsContainer" class="unified-search-modal__results">
<div
v-else
ref="resultsContainer"
class="unified-search-modal__results"
:class="{ 'unified-search-modal__results--held': heldHeight !== null }"
:style="heldHeight !== null ? { blockSize: `${heldHeight}px`, boxSizing: 'border-box' } : undefined">
<h3 class="hidden-visually">
{{ t('core', 'Results') }}
</h3>
@ -229,6 +234,8 @@
</div>
</div>
</div>
<!-- Last, so results that land are never pushed down. -->
<SearchResultSkeleton v-if="skeletonRows > 0" :rows="skeletonRows" />
<!-- 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">
@ -276,6 +283,7 @@ import CustomDateRangeModal from './CustomDateRangeModal.vue'
import SearchableList from './SearchableList.vue'
import FilterChip from './SearchFilterChip.vue'
import SearchResult from './SearchResult.vue'
import SearchResultSkeleton from './SearchResultSkeleton.vue'
import { useUnifiedSearch } from '../../composables/useUnifiedSearch.ts'
import { unifiedSearchLogger } from '../../logger.js'
import { getContacts, getProviders } from '../../services/UnifiedSearchService.js'
@ -287,6 +295,17 @@ import { useSearchStore } from '../../store/unified-search-external-filters.js'
*/
const RESULTS_PER_CATEGORY = 3
/** Fallback when there is no results box on screen to measure. */
const DEFAULT_HELD_HEIGHT_PX = 332
const RESIZING_CLASS = 'is-animating-height'
/** The least vertical space one bar takes, gap included, so dividing by it overdraws. */
const SKELETON_MIN_BAR_AND_GAP_PX = 60
/** Room for the heading and one row. Any less and the fade swallows the row. */
const SKELETON_MIN_HELD_HEIGHT_PX = 126
/** One selectable result row in the flat keyboard-navigation list. */
interface NavigableRow {
id: string
@ -316,6 +335,7 @@ export default defineComponent({
NcTextField,
SearchableList,
SearchResult,
SearchResultSkeleton,
},
props: {
@ -404,6 +424,9 @@ export default defineComponent({
// aria-activedescendant highlight (combobox pattern).
activeIndex: -1,
minSearchLength: loadState('unified-search', 'min-search-length', 1),
reservedHeight: 0,
panelFrom: 0,
panelResize: null as Animation | null,
// Focus trap spanning [header input, popover panel]; markRaw'd so Vue
// doesn't make the trap instance reactive.
focusTrap: null as FocusTrap | null,
@ -613,6 +636,21 @@ export default defineComponent({
]
},
// A border box, like getBoundingClientRect hands back; content-box would re-add the padding.
heldHeight() {
// The detail view is not emptied and refilled; its paging has its own button.
if (!this.isBusy || this.detailCategory) {
return null
}
return Math.max(this.reservedHeight || DEFAULT_HELD_HEIGHT_PX, SKELETON_MIN_HELD_HEIGHT_PX)
},
skeletonRows() {
return this.heldHeight === null
? 0
: Math.ceil(this.heldHeight / SKELETON_MIN_BAR_AND_GAP_PX)
},
// 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
@ -682,11 +720,14 @@ export default defineComponent({
return n('core', '%n result', '%n results', this.navigableRows.length)
},
// Whether the results region has anything to render. Drives the region's padding
// so an empty or too-short query leaves no gap under the filters.
hasVisibleResults() {
return this.filteredResults.length > 0 || this.unfilteredResults.length > 0
},
// Placeholders count as content, or the divider flashes in when the first category lands.
hasContentBelowHeader() {
return !this.detailCategory && (this.hasVisibleResults || this.skeletonRows > 0)
},
},
watch: {
@ -722,6 +763,7 @@ 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()
this.reservedHeight = 0
// 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
@ -817,6 +859,16 @@ export default defineComponent({
subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter)
},
// Includes a resize in flight, so it starts from what is on screen.
beforeUpdate() {
const panel = this.$refs.panel as HTMLElement | undefined
this.panelFrom = panel ? panel.getBoundingClientRect().height : 0
},
updated() {
this.animatePanelResize()
},
methods: {
/**
* On close the modal is closed and the query is reset
@ -924,12 +976,59 @@ export default defineComponent({
this.focusTrap = null
},
/** Measuring a held box gives the held height back, so it carries between keystrokes. */
captureReservedHeight() {
const results = this.$refs.resultsContainer as HTMLElement | undefined
this.reservedHeight = results ? results.getBoundingClientRect().height : 0
},
/** Nothing is written to the panel's style, so it follows its content again once done. */
animatePanelResize() {
const panel = this.$refs.panel as HTMLElement | undefined
const from = this.panelFrom
// Cancel before measuring, or `to` comes back mid-animation. Detach onfinish first, or
// it strips the class off the resize that replaces this one.
if (this.panelResize) {
this.panelResize.onfinish = null
this.panelResize.cancel()
this.panelResize = null
}
panel?.classList.remove(RESIZING_CLASS)
// A closing panel keeps its size through the fade-out; jsdom has no Web Animations.
if (!panel || !from || !this.open || typeof panel.animate !== 'function') {
return
}
// Zeroed by the reduced-motion theme, which the OS preference switches on as well.
const duration = parseFloat(getComputedStyle(panel).getPropertyValue('--animation-slow'))
if (!duration) {
return
}
const to = panel.getBoundingClientRect().height
if (Math.abs(to - from) < 1) {
return
}
panel.classList.add(RESIZING_CLASS)
const resize = panel.animate(
[{ height: `${from}px` }, { height: `${to}px` }],
// The curve the panel slides in with, so a resize reads as the same motion.
{ duration, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
)
resize.onfinish = () => panel.classList.remove(RESIZING_CLASS)
this.panelResize = markRaw(resize)
},
/**
* Blank the results, then queue the search. Every query and filter change comes through
* here. The results on screen answer the previous question, so holding them until the
* debounce fires only means they shift once the real ones land.
*/
scheduleSearch() {
this.captureReservedHeight()
this.reset()
// Mark busy synchronously so the debounce window doesn't flash the empty state.
this.pendingSearch = true
@ -1477,8 +1576,9 @@ export default defineComponent({
// 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-large, var(--border-radius-rounded));
// Clip the header/results to the rounded corners
overflow: hidden;
// Clip the header/results to the rounded corners. `clip` rather than `hidden` so this is
// not a scroll container: a squeezed panel would otherwise scroll the filter row away.
overflow: clip;
background-color: var(--color-main-background);
color: var(--color-main-text);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.2);
@ -1487,6 +1587,11 @@ export default defineComponent({
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
// Mid-resize the panel is shorter than its content; clip so no scrollbar flashes.
.unified-search-modal__container.is-animating-height .unified-search-modal__results {
overflow: clip;
}
// Fullscreen on small viewports, mirrors NcModal's responsive breakpoint
@media only screen and ((max-width: 512px) or (max-height: 400px)) {
.unified-search-modal-root {
@ -1546,14 +1651,16 @@ export default defineComponent({
position: relative;
display: flex;
flex-direction: column;
// The results box absorbs the resize; the filter row keeps its size.
flex-shrink: 0;
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) 0;
// With results below, restore the full bottom inset above the divider (which aligns
// With content below, restore the full bottom inset above the divider (which aligns
// to the content edge).
&--has-results {
&--has-content-below {
padding-block-end: calc(var(--default-grid-baseline) * 4);
&::after {
@ -1699,10 +1806,23 @@ export default defineComponent({
flex: 1 1 auto;
min-height: 0;
overflow: hidden auto;
// The placeholders deliberately overfill, so the bottom fades out over the cut.
&--held {
flex-grow: 0;
overflow: clip;
// Capped, so a short box does not spend a third of itself fading.
mask-image: linear-gradient(to bottom, #000 calc(100% - min(2lh, 25%)), transparent);
}
// Adjust padding to match container but keep the scrollbar on the very end
padding-inline: calc(var(--default-grid-baseline) * 4);
padding-block: 0 calc(var(--default-grid-baseline) * 4);
// Matches the gap a category title keeps above itself.
.search-result-skeleton {
margin-block-start: 14px;
}
.result {
&-title {
color: var(--color-text-maxcontrast);
@ -1771,7 +1891,7 @@ export default defineComponent({
// Ensure modal is accessible on small devices
@media only screen and (max-height: 400px) {
.unified-search-modal__results {
.unified-search-modal__results:not(.unified-search-modal__results--held) {
overflow: unset;
}
}

@ -0,0 +1,26 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import SearchResultSkeleton from '../../components/UnifiedSearch/SearchResultSkeleton.vue'
function factory(rows = 3) {
return mount(SearchResultSkeleton, { propsData: { rows } })
}
describe('SearchResultSkeleton', () => {
// The rows asked for, plus the heading the block always leads with.
it('draws a heading bar above the rows', () => {
expect(factory(3).findAll('.search-result-skeleton__bar')).toHaveLength(4)
})
it('is decoration: hidden from assistive tech and out of the tab order', () => {
const wrapper = factory()
expect(wrapper.attributes('aria-hidden')).toBe('true')
// aria-hidden does not remove a focusable child from the tab order.
expect(wrapper.findAll('a, button, input, [tabindex]')).toHaveLength(0)
})
})

@ -1205,6 +1205,263 @@ describe('UnifiedSearchModal loading state', () => {
})
})
describe('UnifiedSearchModal loading skeleton', () => {
const loadingState = { status: 'loading', entries: [], cursor: null, hasMore: false, loadMoreFailed: false }
/** jsdom does no layout, so fake the measurement. */
function measureResultsAt(wrapper: ReturnType<typeof factory>, height: number) {
const box = wrapper.vm.$refs.resultsContainer as HTMLElement
vi.spyOn(box, 'getBoundingClientRect').mockReturnValue({ height } as DOMRect)
}
/** Mount with a settled result on screen, ready for a keystroke to replace it. */
async function withResults() {
const wrapper = factory()
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }]
wrapper.vm.initialized = true
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
wrapper.vm.find('query')
searchStates.value = { files: loaded([{ resourceUrl: '/a' }, { resourceUrl: '/b' }]) }
await wrapper.vm.$nextTick()
return wrapper
}
function skeleton(wrapper: ReturnType<typeof factory>) {
return wrapper.findComponent({ name: 'SearchResultSkeleton' })
}
it('holds the results box at the height it had when the query changed', async () => {
const wrapper = await withResults()
measureResultsAt(wrapper, 300)
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
expect(wrapper.vm.isBusy).toBe(true)
expect(wrapper.vm.heldHeight).toBe(300)
})
it('asks for more rows the taller the panel it is holding', async () => {
const wrapper = await withResults()
measureResultsAt(wrapper, 300)
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
const tall = skeleton(wrapper).props('rows')
expect(tall).toBeGreaterThan(0)
wrapper.vm.reservedHeight = 100
await wrapper.vm.$nextTick()
expect(skeleton(wrapper).props('rows')).toBeLessThan(tall)
})
// The panel clips what does not fit, so asking for too few would leave a gap.
it('asks for more rows than fit the space', async () => {
const wrapper = await withResults()
measureResultsAt(wrapper, 300)
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
// A row plus its gap is never shorter than 60px.
expect(wrapper.vm.skeletonRows * 60).toBeGreaterThanOrEqual(300)
})
it('holds the same height through repeated keystrokes rather than creeping taller', async () => {
const wrapper = await withResults()
const box = wrapper.vm.$refs.resultsContainer as HTMLElement
// Stand in for the browser: report the padding on top of whatever height is set.
const padding = 16
vi.spyOn(box, 'getBoundingClientRect').mockImplementation(() => ({
height: (parseFloat(box.style.blockSize) || 300) + (box.style.boxSizing === 'border-box' ? 0 : padding),
}) as DOMRect)
wrapper.vm.searchQuery = 'q1'
await wrapper.vm.$nextTick()
const first = wrapper.vm.heldHeight
wrapper.vm.searchQuery = 'q12'
await wrapper.vm.$nextTick()
wrapper.vm.searchQuery = 'q123'
await wrapper.vm.$nextTick()
expect(wrapper.vm.heldHeight).toBe(first)
})
it('falls back to a default height on the first search of a session', async () => {
const wrapper = factory()
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }]
wrapper.vm.initialized = true
wrapper.vm.searchQuery = 'query'
await wrapper.vm.$nextTick()
expect(wrapper.vm.heldHeight).toBeGreaterThan(0)
expect(skeleton(wrapper).props('rows')).toBeGreaterThan(0)
})
// Any less and the fade swallows the row, leaving the heading bar on its own.
it('holds room for a row even when the box it measured was shorter than one', async () => {
const wrapper = await withResults()
measureResultsAt(wrapper, 60)
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
expect(wrapper.vm.heldHeight).toBe(126)
})
it('keeps the reservation through a run of quick keystrokes', async () => {
const wrapper = await withResults()
measureResultsAt(wrapper, 300)
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
wrapper.vm.searchQuery = 'queryings'
await wrapper.vm.$nextTick()
expect(wrapper.vm.heldHeight).toBe(300)
})
it('releases the height when the search settles', async () => {
const wrapper = await withResults()
measureResultsAt(wrapper, 300)
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
wrapper.vm.find('querying')
searchStates.value = { files: loaded([{ resourceUrl: '/c' }]) }
await wrapper.vm.$nextTick()
expect(wrapper.vm.isBusy).toBe(false)
expect(wrapper.vm.heldHeight).toBe(null)
expect(skeleton(wrapper).exists()).toBe(false)
})
it('shows no bars on an empty or too-short query', async () => {
const wrapper = await withResults()
wrapper.vm.minSearchLength = 3
wrapper.vm.searchQuery = 'ab'
await wrapper.vm.$nextTick()
expect(wrapper.vm.heldHeight).toBe(null)
expect(skeleton(wrapper).exists()).toBe(false)
})
it('shows no bars in the detail view, which pages one category on its own', async () => {
const wrapper = await withResults()
searchStates.value = { files: { ...loaded([{ resourceUrl: '/a' }]), status: 'loading' } }
wrapper.vm.detailCategory = 'files'
await wrapper.vm.$nextTick()
expect(wrapper.vm.isBusy).toBe(true)
expect(skeleton(wrapper).exists()).toBe(false)
})
it('renders below every real result, so nothing on screen can be displaced', async () => {
const wrapper = await withResults()
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
wrapper.vm.find('querying')
searchStates.value = { files: loaded([{ resourceUrl: '/a' }]), talk: loadingState }
wrapper.vm.providers = [{ id: 'files', name: 'Files', order: 0 }, { id: 'talk', name: 'Talk', order: 1 }]
await wrapper.vm.$nextTick()
const container = wrapper.find('.unified-search-modal__results')
expect(container.find('.result-group').exists()).toBe(true)
expect(container.element.lastElementChild).toBe(skeleton(wrapper).element)
})
it('never enters navigableRows, so arrow keys cannot land on a placeholder', async () => {
const wrapper = await withResults()
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
wrapper.vm.find('querying')
searchStates.value = { files: loaded([{ resourceUrl: '/a' }]), talk: loadingState }
await wrapper.vm.$nextTick()
expect(skeleton(wrapper).exists()).toBe(true)
expect(wrapper.vm.navigableRows.map((row) => row.resourceUrl)).toEqual(['/a'])
})
it('carries the header divider, so it does not flash in when the first category lands', async () => {
const wrapper = await withResults()
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
expect(wrapper.vm.hasVisibleResults).toBe(false)
expect(wrapper.vm.hasContentBelowHeader).toBe(true)
})
it('drops the header divider once the placeholders go and nothing is left below', async () => {
const wrapper = await withResults()
wrapper.vm.searchQuery = 'querying'
await wrapper.vm.$nextTick()
wrapper.vm.find('querying')
searchStates.value = { files: loaded([]) }
await wrapper.vm.$nextTick()
expect(wrapper.vm.hasContentBelowHeader).toBe(false)
})
})
describe('UnifiedSearchModal panel resize', () => {
/** Open modal whose panel reports `height` and records its animations. */
function factoryWithPanel(height: number) {
const wrapper = factory()
const panel = wrapper.vm.$refs.panel as HTMLElement
const animate = vi.fn(() => ({ cancel: vi.fn(), onfinish: null }) as unknown as Animation)
panel.animate = animate
vi.spyOn(panel, 'getBoundingClientRect').mockReturnValue({ height } as DOMRect)
// Stands in for the theming app, which is what hands the panel its duration.
panel.style.setProperty('--animation-slow', '200ms')
return { wrapper, panel, animate }
}
it('plays the panel from its previous height to the one it has now', async () => {
const { wrapper, panel, animate } = factoryWithPanel(420)
wrapper.vm.panelFrom = 200
wrapper.vm.animatePanelResize()
expect(animate).toHaveBeenCalledWith(
[{ height: '200px' }, { height: '420px' }],
expect.objectContaining({ duration: 200 }),
)
expect(panel.classList.contains('is-animating-height')).toBe(true)
})
it('leaves a render that does not move the height alone', async () => {
const { wrapper, animate } = factoryWithPanel(420)
wrapper.vm.panelFrom = 420
wrapper.vm.animatePanelResize()
expect(animate).not.toHaveBeenCalled()
})
it('drops the resize in flight when the panel closes', async () => {
const { wrapper, animate } = factoryWithPanel(420)
wrapper.vm.panelFrom = 200
wrapper.vm.animatePanelResize()
const running = wrapper.vm.panelResize
await wrapper.setProps({ open: false })
wrapper.vm.panelFrom = 100
wrapper.vm.animatePanelResize()
expect(running.cancel).toHaveBeenCalled()
expect(animate).toHaveBeenCalledTimes(1)
})
it('changes size without motion when the reduced-motion theme zeroes the duration', async () => {
const { wrapper, panel, animate } = factoryWithPanel(420)
wrapper.vm.panelFrom = 200
panel.style.setProperty('--animation-slow', '0')
wrapper.vm.animatePanelResize()
expect(animate).not.toHaveBeenCalled()
})
})
describe('UnifiedSearchModal reveal order', () => {
const providers = [
{ id: 'files', name: 'Files', order: 0 },

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save