Merge pull request #63343 from nextcloud/feat/search-skeleton-loaders
feat(core): fill the unified search pending window with placeholderspull/63371/merge
commit
2a6ca8b919
@ -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> |
||||
@ -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) |
||||
}) |
||||
}) |
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue