diff options
author | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2020-07-30 10:25:17 +0200 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2020-08-03 10:51:27 +0200 |
commit | aefdd6406943a40db1a8c594f1d17067b4c4cd00 (patch) | |
tree | cdcfd861fcae5df47043bd101bbbe99760d33a8e /core/src/views | |
parent | fce6df06e2bd1d68ee5614621ae7f92c6f7fa53d (diff) | |
download | nextcloud-server-aefdd6406943a40db1a8c594f1d17067b4c4cd00.tar.gz nextcloud-server-aefdd6406943a40db1a8c594f1d17067b4c4cd00.zip |
Unified search UI
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Diffstat (limited to 'core/src/views')
-rw-r--r-- | core/src/views/UnifiedSearch.vue | 505 |
1 files changed, 505 insertions, 0 deletions
diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue new file mode 100644 index 00000000000..099ed7c325f --- /dev/null +++ b/core/src/views/UnifiedSearch.vue @@ -0,0 +1,505 @@ + <!-- + - @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> + <span class="icon-search-white" /> + </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 for {types} …', { types: typesNames.join(', ') })" + @input="onInputDebounced" + @keypress.enter.prevent.stop="onInputEnter"> + </div> + + <EmptyContent v-if="isLoading" icon="icon-loading"> + {{ t('core', 'Searching …') }} + </EmptyContent> + + <template v-else-if="!hasResults"> + <EmptyContent v-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 results" + :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 } from '../services/UnifiedSearchService' +import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' + +import debounce from 'debounce' + +import HeaderMenu from '../components/HeaderMenu' +import SearchResult from '../components/UnifiedSearch/SearchResult' + +const minSearchLength = 2 + +export default { + name: 'UnifiedSearch', + + components: { + EmptyContent, + HeaderMenu, + SearchResult, + }, + + data() { + return { + types: [], + + cursors: {}, + limits: {}, + loading: {}, + reached: {}, + results: {}, + + query: '', + focused: null, + + 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 + }, + + /** + * 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).indexOf(false) === -1 + }, + + /** + * Is there any search in progress + * @returns {boolean} + */ + isLoading() { + return Object.values(this.loading).indexOf(true) !== -1 + }, + }, + + 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) + console.info(entry, index) + 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 { + &__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; + text-overflow: ellipsis; + } + + &__results { + &::before { + display: block; + margin: $margin; + margin-left: $margin + $input-padding; + content: attr(aria-label); + color: var(--color-primary); + } + } + + .unified-search__result-more::v-deep { + color: var(--color-text-maxcontrast); + } + + .empty-content { + margin: 10vh 0; + } +} + +</style> |