]> source.dussan.org Git - nextcloud-server.git/commitdiff
Unified search UI 21971/head
authorJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Thu, 30 Jul 2020 08:25:17 +0000 (10:25 +0200)
committerJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Mon, 3 Aug 2020 08:51:27 +0000 (10:51 +0200)
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
core/src/views/UnifiedSearch.vue [new file with mode: 0644]

diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue
new file mode 100644 (file)
index 0000000..099ed7c
--- /dev/null
@@ -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>