nextcloud/core/src/views/UnifiedSearch.vue

550 lines
13 KiB
Vue

<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<HeaderMenu id="unified-search"
class="unified-search"
:open.sync="open"
@open="onOpen"
@close="onClose">
<!-- Header icon -->
<template #trigger>
<Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" />
</template>
<!-- Search input -->
<div class="unified-search__input-wrapper">
<input ref="input"
v-model="query"
class="unified-search__input"
type="search"
:placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ').toLowerCase() })"
@input="onInputDebounced"
@keypress.enter.prevent.stop="onInputEnter">
</div>
<template v-if="!hasResults">
<!-- Loading placeholders -->
<ul v-if="isLoading">
<li v-for="placeholder in [1, 2, 3]" :key="placeholder">
<SearchResultPlaceholder />
</li>
</ul>
<EmptyContent v-else-if="isValidQuery && isDoneSearching" icon="icon-search">
{{ t('core', 'No results for {query}', {query}) }}
</EmptyContent>
<EmptyContent v-else-if="!isLoading || isShortQuery" icon="icon-search">
{{ t('core', 'Start typing to search') }}
<template v-if="isShortQuery" #desc>
{{ n('core',
'Please enter {minSearchLength} character or more to search',
'Please enter {minSearchLength} characters or more to search',
minSearchLength,
{minSearchLength}) }}
</template>
</EmptyContent>
</template>
<!-- Grouped search results -->
<template v-else>
<ul v-for="(list, type, typesIndex) in orderedResults"
:key="type"
class="unified-search__results"
:class="`unified-search__results-${type}`"
:aria-label="typesMap[type]">
<!-- Search results -->
<li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
<SearchResult v-bind="result"
:query="query"
:focused="focused === 0 && typesIndex === 0 && index === 0"
@focus="setFocusedIndex" />
</li>
<!-- Load more button -->
<li>
<SearchResult v-if="!reached[type]"
class="unified-search__result-more"
:title="loading[type]
? t('core', 'Loading more results …')
: t('core', 'Load more results')"
:icon-class="loading[type] ? 'icon-loading-small' : ''"
@click.prevent="loadMore(type)"
@focus="setFocusedIndex" />
</li>
</ul>
</template>
</HeaderMenu>
</template>
<script>
import { getTypes, search, defaultLimit, activeApp } from '../services/UnifiedSearchService'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import Magnify from 'vue-material-design-icons/Magnify'
import debounce from 'debounce'
import HeaderMenu from '../components/HeaderMenu'
import SearchResult from '../components/UnifiedSearch/SearchResult'
import SearchResultPlaceholder from '../components/UnifiedSearch/SearchResultPlaceholder'
const minSearchLength = 2
export default {
name: 'UnifiedSearch',
components: {
EmptyContent,
HeaderMenu,
Magnify,
SearchResult,
SearchResultPlaceholder,
},
data() {
return {
types: [],
cursors: {},
limits: {},
loading: {},
reached: {},
results: {},
query: '',
focused: null,
activeApp,
defaultLimit,
minSearchLength,
open: false,
}
},
computed: {
typesIDs() {
return this.types.map(type => type.id)
},
typesNames() {
return this.types.map(type => type.name)
},
typesMap() {
return this.types.reduce((prev, curr) => {
prev[curr.id] = curr.name
return prev
}, {})
},
/**
* Is there any result to display
* @returns {boolean}
*/
hasResults() {
return Object.keys(this.results).length !== 0
},
/**
* Order results by putting the active app first
* @returns {Object}
*/
orderedResults() {
const ordered = {}
Object.keys(this.results)
.sort((a, b) => {
if (a.startsWith(activeApp) && b.startsWith(activeApp)) {
return this.typesMap[a].order - this.typesMap[b].order
}
if (a.startsWith(activeApp)) {
return -1
}
if (b.startsWith(activeApp)) {
return 1
}
return 0
})
.forEach(type => {
ordered[type] = this.results[type]
})
return ordered
},
/**
* Is the current search too short
* @returns {boolean}
*/
isShortQuery() {
return this.query && this.query.trim().length < minSearchLength
},
/**
* Is the current search valid
* @returns {boolean}
*/
isValidQuery() {
return this.query && this.query.trim() !== '' && !this.isShortQuery
},
/**
* Have we reached the end of all types searches
* @returns {boolean}
*/
isDoneSearching() {
return Object.values(this.reached).every(state => state === false)
},
/**
* Is there any search in progress
* @returns {boolean}
*/
isLoading() {
return Object.values(this.loading).some(state => state === true)
},
},
async created() {
this.types = await getTypes()
console.debug('Unified Search initialized with the following providers', this.types)
},
mounted() {
document.addEventListener('keydown', (event) => {
// if not already opened, allows us to trigger default browser on second keydown
if (event.ctrlKey && event.key === 'f' && !this.open) {
event.preventDefault()
this.open = true
this.focusInput()
}
// https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
if (this.open) {
// If arrow down, focus next result
if (event.key === 'ArrowDown') {
this.focusNext(event)
}
// If arrow up, focus prev result
if (event.key === 'ArrowUp') {
this.focusPrev(event)
}
}
})
},
methods: {
async onOpen() {
this.focusInput()
// Update types list in the background
this.types = await getTypes()
},
onClose() {
this.resetState()
this.query = ''
},
resetState() {
this.cursors = {}
this.limits = {}
this.loading = {}
this.reached = {}
this.results = {}
this.focused = null
},
/**
* Focus the search input on next tick
*/
focusInput() {
this.$nextTick(() => {
this.$refs.input.focus()
this.$refs.input.select()
})
},
/**
* If we have results already, open first one
* If not, trigger the search again
*/
onInputEnter() {
if (this.hasResults) {
const results = this.getResultsList()
results[0].click()
return
}
this.onInput()
},
/**
* Start searching on input
*/
async onInput() {
// Do not search if not long enough
if (this.query.trim() === '' || this.isShortQuery) {
return
}
// reset search if the query changed
this.resetState()
this.typesIDs.forEach(async type => {
this.$set(this.loading, type, true)
const request = await search(type, this.query)
// Process results
if (request.data.entries.length > 0) {
this.$set(this.results, type, request.data.entries)
} else {
this.$delete(this.results, type)
}
// Save cursor if any
if (request.data.cursor) {
this.$set(this.cursors, type, request.data.cursor)
} else if (!request.data.isPaginated) {
// If no cursor and no pagination, we save the default amount
// provided by server's initial state `defaultLimit`
this.$set(this.limits, type, this.defaultLimit)
}
// Check if we reached end of pagination
if (request.data.entries.length < this.defaultLimit) {
this.$set(this.reached, type, true)
}
// If none already focused, focus the first rendered result
if (this.focused === null) {
this.focused = 0
}
this.$set(this.loading, type, false)
})
},
onInputDebounced: debounce(function(e) {
this.onInput(e)
}, 200),
/**
* Load more results for the provided type
* @param {String} type type
*/
async loadMore(type) {
// If already loading, ignore
if (this.loading[type]) {
return
}
this.$set(this.loading, type, true)
if (this.cursors[type]) {
const request = await search(type, this.query)
// Save cursor if any
if (request.data.cursor) {
this.$set(this.cursors, type, request.data.cursor)
}
if (request.data.entries.length > 0) {
this.results[type].push(...request.data.entries)
}
// Check if we reached end of pagination
if (request.data.entries.length < this.defaultLimit) {
this.$set(this.reached, type, true)
}
} else
// If no cursor, we might have all the results already,
// let's fake pagination and show the next xxx entries
if (this.limits[type] && this.limits[type] >= 0) {
this.limits[type] += this.defaultLimit
// Check if we reached end of pagination
if (this.limits[type] >= this.results[type].length) {
this.$set(this.reached, type, true)
}
}
// Focus result after render
if (this.focused !== null) {
this.$nextTick(() => {
this.focusIndex(this.focused)
})
}
this.$set(this.loading, type, false)
},
/**
* Return a subset of the array if the search provider
* doesn't supports pagination
*
* @param {Array} list the results
* @param {string} type the type
* @returns {Array}
*/
limitIfAny(list, type) {
if (!this.limits[type]) {
return list
}
return list.slice(0, this.limits[type])
},
getResultsList() {
return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
},
/**
* Focus the first result if any
* @param {Event} event the keydown event
*/
focusFirst(event) {
const results = this.getResultsList()
if (results && results.length > 0) {
if (event) {
event.preventDefault()
}
this.focused = 0
this.focusIndex(this.focused)
}
},
/**
* Focus the next result if any
* @param {Event} event the keydown event
*/
focusNext(event) {
if (this.focused === null) {
this.focusFirst()
return
}
const results = this.getResultsList()
// If we're not focusing the last, focus the next one
if (results && results.length > 0 && this.focused + 1 < results.length) {
event.preventDefault()
this.focused++
this.focusIndex(this.focused)
}
},
/**
* Focus the previous result if any
* @param {Event} event the keydown event
*/
focusPrev(event) {
if (this.focused === null) {
this.focusFirst(event)
return
}
const results = this.getResultsList()
// If we're not focusing the first, focus the previous one
if (results && results.length > 0 && this.focused > 0) {
event.preventDefault()
this.focused--
this.focusIndex(this.focused)
}
},
/**
* Focus the specified result index if it exists
* @param {number} index the result index
*/
focusIndex(index) {
const results = this.getResultsList()
if (results && results[index]) {
results[index].focus()
}
},
/**
* Set the current focused element based on the target
* @param {Event} event the focus event
*/
setFocusedIndex(event) {
const entry = event.target
const results = this.getResultsList()
const index = [...results].findIndex(search => search === entry)
if (index > -1) {
// let's not use focusIndex as the entry is already focused
this.focused = index
}
},
},
}
</script>
<style lang="scss" scoped>
$margin: 10px;
$input-padding: 6px;
.unified-search {
&__trigger {
width: 20px;
height: 20px;
}
&__input-wrapper {
position: sticky;
// above search results
z-index: 2;
top: 0;
background-color: var(--color-main-background);
}
&__input {
// Minus margins
width: calc(100% - 2 * #{$margin});
height: 34px;
margin: $margin;
padding: $input-padding;
&,
&[placeholder],
&::placeholder {
overflow: hidden;
text-overflow:ellipsis;
white-space: nowrap;
}
}
&__results {
&::before {
display: block;
margin: $margin;
margin-left: $margin + $input-padding;
content: attr(aria-label);
color: var(--color-primary-element);
}
}
.unified-search__result-more::v-deep {
color: var(--color-text-maxcontrast);
}
.empty-content {
margin: 10vh 0;
}
}
</style>