Properly use form role=search and unify reset button

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-09-15 17:10:44 +02:00 committed by backportbot[bot]
parent 2672f5da59
commit 3c6319f275
2 changed files with 145 additions and 52 deletions

View File

@ -28,6 +28,12 @@ export const minSearchLength = 2
export const regexFilterIn = /[^-]in:([a-z_-]+)/ig export const regexFilterIn = /[^-]in:([a-z_-]+)/ig
export const regexFilterNot = /-in:([a-z_-]+)/ig export const regexFilterNot = /-in:([a-z_-]+)/ig
/**
* Create a cancel token
* @returns {CancelTokenSource}
*/
const createCancelToken = () => axios.CancelToken.source()
/** /**
* Get the list of available search providers * Get the list of available search providers
* *
@ -54,13 +60,20 @@ export async function getTypes() {
/** /**
* Get the list of available search providers * Get the list of available search providers
* *
* @param {string} type the type to search * @param {Object} options destructuring object
* @param {string} query the search * @param {string} options.type the type to search
* @param {int|string|undefined} cursor the offset for paginated searches * @param {string} options.query the search
* @returns {Promise} * @param {int|string|undefined} options.cursor the offset for paginated searches
* @returns {Object} {request: Promise, cancel: Promise}
*/ */
export function search(type, query, cursor) { export function search({ type, query, cursor }) {
return axios.get(generateOcsUrl('search', 2) + `providers/${type}/search`, { /**
* Generate an axios cancel token
*/
const cancelToken = createCancelToken()
const request = async() => axios.get(generateOcsUrl('search', 2) + `providers/${type}/search`, {
cancelToken: cancelToken.token,
params: { params: {
term: query, term: query,
cursor, cursor,
@ -68,4 +81,9 @@ export function search(type, query, cursor) {
from: window.location.pathname.replace('/index.php', '') + window.location.search, from: window.location.pathname.replace('/index.php', '') + window.location.search,
}, },
}) })
return {
request,
cancel: cancelToken.cancel,
}
} }

View File

@ -31,16 +31,30 @@
<Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" /> <Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" />
</template> </template>
<!-- Search input --> <!-- Search form & filters wrapper -->
<div class="unified-search__input-wrapper"> <div class="unified-search__input-wrapper">
<input ref="input" <form class="unified-search__form"
v-model="query" role="search"
class="unified-search__input" @submit.prevent.stop="onInputEnter"
type="search" @reset.prevent.stop="onReset">
:placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ').toLowerCase() })" <!-- Search input -->
@input="onInputDebounced" <input ref="input"
@keypress.enter.prevent.stop="onInputEnter" v-model="query"
@search="onSearch"> class="unified-search__form-input"
type="search"
:class="{'unified-search__form-input--with-reset': !!query}"
:placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ').toLowerCase() })"
@input="onInputDebounced"
@keypress.enter.prevent.stop="onInputEnter">
<!-- Reset search button -->
<input v-if="!!query"
type="reset"
class="unified-search__form-reset icon-close"
:aria-label="t('core','Reset search')"
value="">
</form>
<!-- Search filters --> <!-- Search filters -->
<Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom"> <Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom">
<ActionButton v-for="type in availableFilters" <ActionButton v-for="type in availableFilters"
@ -107,6 +121,7 @@
<script> <script>
import { emit } from '@nextcloud/event-bus' import { emit } from '@nextcloud/event-bus'
import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot } from '../services/UnifiedSearchService' import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot } from '../services/UnifiedSearchService'
import { showError } from '@nextcloud/dialogs'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import Actions from '@nextcloud/vue/dist/Components/Actions' import Actions from '@nextcloud/vue/dist/Components/Actions'
import debounce from 'debounce' import debounce from 'debounce'
@ -116,7 +131,6 @@ import Magnify from 'vue-material-design-icons/Magnify'
import HeaderMenu from '../components/HeaderMenu' import HeaderMenu from '../components/HeaderMenu'
import SearchResult from '../components/UnifiedSearch/SearchResult' import SearchResult from '../components/UnifiedSearch/SearchResult'
import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders' import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders'
import { showError } from '@nextcloud/dialogs'
export default { export default {
name: 'UnifiedSearch', name: 'UnifiedSearch',
@ -135,10 +149,17 @@ export default {
return { return {
types: [], types: [],
// Cursors per types
cursors: {}, cursors: {},
// Various search limits per types
limits: {}, limits: {},
// Loading types
loading: {}, loading: {},
// Reached search types
reached: {}, reached: {},
// Pending cancellable requests
requests: [],
// List of all results
results: {}, results: {},
query: '', query: '',
@ -296,10 +317,12 @@ export default {
/** /**
* Reset the search state * Reset the search state
*/ */
resetSearch() { onReset() {
emit('nextcloud:unified-search:reset') emit('nextcloud:unified-search:reset')
this.logger.debug('Search reset')
this.query = '' this.query = ''
this.resetState() this.resetState()
this.focusInput()
}, },
resetState() { resetState() {
this.cursors = {} this.cursors = {}
@ -308,6 +331,15 @@ export default {
this.reached = {} this.reached = {}
this.results = {} this.results = {}
this.focused = null this.focused = null
this.cancelPendingRequests()
},
/**
* Cancel any ongoing searches
*/
cancelPendingRequests() {
// Cancel all pending requests
this.requests.forEach(cancel => cancel())
}, },
/** /**
@ -320,18 +352,6 @@ export default {
}) })
}, },
/**
* Watch the search event on the input
* Used to detect the reset button press
* @param {Event} event the search event
*/
onSearch(event) {
// If value is empty, the reset button has been pressed
if (event.target.value === '') {
this.resetSearch()
}
},
/** /**
* If we have results already, open first one * If we have results already, open first one
* If not, trigger the search again * If not, trigger the search again
@ -378,29 +398,34 @@ export default {
// Reset search if the query changed // Reset search if the query changed
this.resetState() this.resetState()
types.forEach(async type => { Promise.all(types.map(async type => {
try { try {
// Init cancellable request
const { request, cancel } = search({ type, query })
this.requests.push(cancel)
// Mark this type as loading and fetch results
this.$set(this.loading, type, true) this.$set(this.loading, type, true)
const request = await search(type, query) const { data } = await request()
// Process results // Process results
if (request.data.ocs.data.entries.length > 0) { if (data.ocs.data.entries.length > 0) {
this.$set(this.results, type, request.data.ocs.data.entries) this.$set(this.results, type, data.ocs.data.entries)
} else { } else {
this.$delete(this.results, type) this.$delete(this.results, type)
} }
// Save cursor if any // Save cursor if any
if (request.data.ocs.data.cursor) { if (data.ocs.data.cursor) {
this.$set(this.cursors, type, request.data.ocs.data.cursor) this.$set(this.cursors, type, data.ocs.data.cursor)
} else if (!request.data.ocs.data.isPaginated) { } else if (!data.ocs.data.isPaginated) {
// If no cursor and no pagination, we save the default amount // If no cursor and no pagination, we save the default amount
// provided by server's initial state `defaultLimit` // provided by server's initial state `defaultLimit`
this.$set(this.limits, type, this.defaultLimit) this.$set(this.limits, type, this.defaultLimit)
} }
// Check if we reached end of pagination // Check if we reached end of pagination
if (request.data.ocs.data.entries.length < this.defaultLimit) { if (data.ocs.data.entries.length < this.defaultLimit) {
this.$set(this.reached, type, true) this.$set(this.reached, type, true)
} }
@ -409,13 +434,19 @@ export default {
this.focused = 0 this.focused = 0
} }
} catch (error) { } catch (error) {
this.logger.error(`Error searching for ${this.typesMap[type]}`, error) // If this is not a cancelled throw
showError(this.t('core', 'An error occurred while looking for {type}', { type: this.typesMap[type] })) if (error.response && error.response.status) {
this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
}
this.$delete(this.results, type) this.$delete(this.results, type)
} finally { } finally {
this.$set(this.loading, type, false) this.$set(this.loading, type, false)
} }
})).then(() => {
// We finished all searches
this.loading = {}
}) })
}, },
onInputDebounced: debounce(function(e) { onInputDebounced: debounce(function(e) {
@ -434,7 +465,7 @@ export default {
this.$set(this.loading, type, true) this.$set(this.loading, type, true)
if (this.cursors[type]) { if (this.cursors[type]) {
const request = await search(type, this.query, this.cursors[type]) const request = await search({ type, query: this.query, cursor: this.cursors[type] })
// Save cursor if any // Save cursor if any
if (request.data.ocs.data.cursor) { if (request.data.ocs.data.cursor) {
@ -582,6 +613,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
$margin: 10px; $margin: 10px;
$input-height: 34px;
$input-padding: 6px; $input-padding: 6px;
.unified-search { .unified-search {
@ -591,13 +623,13 @@ $input-padding: 6px;
} }
&__input-wrapper { &__input-wrapper {
width: 100%;
position: sticky; position: sticky;
// above search results // above search results
z-index: 2; z-index: 2;
top: 0; top: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
width: 100%;
background-color: var(--color-main-background); background-color: var(--color-main-background);
} }
@ -609,17 +641,60 @@ $input-padding: 6px;
} }
} }
&__input { &__form {
position: relative;
width: 100%; width: 100%;
height: 34px;
margin: $margin; margin: $margin;
padding: $input-padding;
&, &-input,
&[placeholder], &-reset {
&::placeholder { margin: $input-padding / 2;
overflow: hidden; }
white-space: nowrap;
text-overflow: ellipsis; &-input {
width: 100%;
height: $input-height;
padding: $input-padding;
&,
&[placeholder],
&::placeholder {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// Hide webkit clear search
&::-webkit-search-decoration,
&::-webkit-search-cancel-button,
&::-webkit-search-results-button,
&::-webkit-search-results-decoration {
-webkit-appearance: none;
}
// Ellipsis earlier if reset button is here
&--with-reset {
padding-right: $input-height;
}
}
&-reset {
position: absolute;
top: 0;
right: 0;
width: $input-height - $input-padding;
height: $input-height - $input-padding;
padding: 0;
opacity: .5;
border: none;
background-color: transparent;
margin-right: 0;
&:hover,
&:focus,
&:active {
opacity: 1;
}
} }
} }