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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions core/src/components/UnifiedSearch/SearchResultSkeleton.vue
Original file line number Diff line number Diff line change
@@ -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>
138 changes: 129 additions & 9 deletions core/src/components/UnifiedSearch/UnifiedSearchModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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'
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default derived from three providers with one result each. We can tweak this over time if its too jumpy.

Another solution is to keep a running average height stored in localstorage, but that feels like overkill

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or not show any placeholders at all on first search (empty list with only loading spinner indicating state)


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
Expand Down Expand Up @@ -316,6 +335,7 @@ export default defineComponent({
NcTextField,
SearchableList,
SearchResult,
SearchResultSkeleton,
},

props: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
Expand Down
26 changes: 26 additions & 0 deletions core/src/tests/components/SearchResultSkeleton.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading