Show search results while typing

This commit is contained in:
Grzegorz Leoniec 2019-02-19 10:37:31 +01:00
parent 5410fd1df9
commit 34a863424b
No known key found for this signature in database
GPG Key ID: 3AA43686D4EB1377
5 changed files with 282 additions and 122 deletions

View File

@ -3,11 +3,21 @@
class="search"
aria-label="search"
role="search"
:class="{ 'is-active': isActive }"
:class="{
'is-active': isActive,
'is-open': isOpen
}"
>
<div class="field">
<div class="control">
<ds-input
<a
v-if="isActive"
class="search-clear-btn"
@click="clear"
>
&nbsp;
</a>
<ds-select
:id="id"
ref="input"
v-model="searchValue"
@ -15,17 +25,70 @@
name="search"
type="search"
icon="search"
:icon-right="isActive ? 'times-circle' : null"
label-prop="id"
:no-options-available="emptyText"
:icon-right="isActive ? 'close' : null"
:filter="item => item"
:options="results"
:placeholder="$t('search.placeholder')"
@input="handleInput"
@keyup.native.enter="onEnter"
/>
@keypress.enter.prevent.stop.self="onEnter"
@focus.capture.native="onFocus"
@blur.capture.native="onBlur"
@keyup.delete.native="onDelete"
@keyup.esc.native="clear"
@input.native="handleInput"
@click.capture.native="isOpen = true"
>
<template
slot="option"
slot-scope="{option}"
>
<ds-flex>
<ds-flex-item class="search-option-label">
<ds-text>
{{ option.label | truncate(70) }}
</ds-text>
</ds-flex-item>
<ds-flex-item
class="search-option-meta"
width="280px"
>
<ds-flex>
<ds-flex-item>
<ds-text
size="small"
color="softer"
class="search-meta"
>
<span style="text-align: right;">
<b>{{ option.commentsCount }}</b> <ds-icon name="comments" />
</span>
<span style="width: 36px; display: inline-block; text-align: right;">
<b>{{ option.shoutedCount }}</b> <ds-icon name="bullhorn" />
</span>
</ds-text>
</ds-flex-item>
<ds-flex-item>
<ds-text
size="small"
color="softer"
align="right"
>
{{ option.author.name | truncate(32) }} - {{ option.createdAt | dateTime('dd.MM.yyyy') }}
</ds-text>
</ds-flex-item>
</ds-flex>
</ds-flex-item>
</ds-flex>
</template>
</ds-select>
</div>
</div>
</div>
</template>
<script>
import gql from 'graphql-tag'
import { isEmpty } from 'lodash'
export default {
@ -39,7 +102,6 @@ export default {
type: String,
default: ''
},
// #: Delay after typing before calling a search query.
delay: {
type: Number,
default: 700
@ -47,88 +109,201 @@ export default {
},
data() {
return {
// #: Bind to input text.
searchValue: '',
// #: Returned ID value of the timer given by "setTimeout()".
searchProcess: null,
// #!: Seems to be unused (unquestioned).
typing: false
isOpen: false,
inProgress: false,
lastSearchTerm: '',
unprocessedSearchInput: '',
searchValue: '',
results: []
}
},
computed: {
// #: Unused at the moment?
isActive() {
return !isEmpty(this.searchValue)
return !isEmpty(this.lastSearchTerm)
},
emptyText() {
return this.isActive && !this.inProgress
? this.$t('search.failed')
: this.$t('search.hint')
}
},
watch: {
value(value) {
this.$nextTick(() => {
this.updateValue()
})
searchValue(item) {
if (item && item.slug) {
this.isOpen = false
this.$router.push(`/post/${item.slug}`)
this.$nextTick(() => {
this.searchValue = this.lastSearchTerm
})
}
}
},
mounted() {
this.updateValue()
},
methods: {
// #: Sets "searchValue" same as "value" if they are different or sets "searchValue" to empty string if "value is undef".
updateValue() {
if (!this.value) {
this.searchValue = ''
} else if (this.value.toString() !== this.searchValue.toString()) {
this.searchValue = this.value.toString()
query(value) {
if (isEmpty(value) || value.length < 3) {
this.results = []
return
}
this.inProgress = true
this.$apollo
.query({
query: gql(`
query findPosts($filter: String!) {
findPosts(filter: $filter, limit: 10) {
id
slug
label: title
value: title,
shoutedCount
commentsCount
createdAt
author {
id
name
slug
}
}
}
`),
variables: {
filter: value
}
})
.then(res => {
this.results = res.data.findPosts || []
this.inProgress = false
})
},
handleInput() {
// #: Prevent "setTimeout()" to call parameter function after "delay".
handleInput(e) {
clearTimeout(this.searchProcess)
this.typing = true
// skip on less then three letters
if (this.searchValue && this.searchValue.toString().length < 3) {
return
}
// skip if nothing changed
if (this.searchValue === this.value) {
return
}
// #: Calls function in first parameter after a delay of "this.delay" milliseconds.
const value = e.target ? e.target.value.trim() : ''
this.isOpen = true
this.unprocessedSearchInput = value
this.searchProcess = setTimeout(() => {
this.typing = false
//-- avoid querying for dev -- this.$emit('search', this.searchValue.toString())
}, this.delay)
this.lastSearchTerm = value
this.query(value)
}, 300)
},
onEnter() {
// #: Prevent "setTimeout()" to call parameter function after "delay".
onFocus(e) {
clearTimeout(this.searchProcess)
// #: "Vue.nextTick()": Defer the callback to be executed after the next DOM update cycle.
this.$nextTick(() => {
// #: Prevent "setTimeout()" to call parameter function after "delay".
clearTimeout(this.searchProcess)
})
this.typing = false
//-- avoid querying for dev -- this.$emit('search', this.searchValue.toString())
console.log('Enter !!!!')
this.isOpen = true
},
onBlur(e) {
this.isOpen = false
clearTimeout(this.searchProcess)
this.searchValue = this.lastSearchTerm
},
onDelete(e) {
clearTimeout(this.searchProcess)
if (isEmpty(this.unprocessedSearchInput)) {
this.clear()
}
},
/**
* TODO: on enter we should go to a dedicated seach page!?
*/
onEnter(e) {
// console.log('res', this.unprocessedSearchInput)
// this.isOpen = false
clearTimeout(this.searchProcess)
// e.stopImmediatePropagation()
// e.preventDefault()
if (!this.inProgress) {
// this.lastSearchTerm = this.unprocessedSearchInput
this.query(this.unprocessedSearchInput)
}
},
clear() {
// #: Prevent "setTimeout()" to call parameter function after "delay".
clearTimeout(this.searchProcess)
this.typing = false
this.searchValue = ''
if (this.value !== this.searchValue) {
//-- avoid querying for dev -- this.$emit('search', '')
}
this.isOpen = false
this.searchValue = null
this.lastSearchTerm = null
this.results = []
}
}
}
</script>
<style lang="scss" scoped>
<style lang="scss">
.search {
display: flex;
align-self: center;
width: 100%;
position: relative;
.search-option-label {
align-self: center;
}
.search-option-meta {
align-self: center;
.ds-flex {
flex-direction: column;
}
}
&,
.ds-select-dropdown {
transition: box-shadow 100ms;
}
&.is-open {
.ds-select-dropdown {
box-shadow: $box-shadow-x-large;
}
}
.search-clear-btn {
right: 0;
z-index: 10;
position: absolute;
height: 100%;
width: 36px;
cursor: pointer;
}
.search-meta {
float: right;
padding-top: 2px;
white-space: nowrap;
word-wrap: none;
.ds-icon {
vertical-align: sub;
}
}
.ds-select {
z-index: $z-index-dropdown + 1;
}
.ds-select-option-hover {
.ds-text-size-small,
.ds-text-size-small-x {
color: rgba(#fff, 0.8);
}
}
.ds-select {
transition: border-bottom 0;
}
.ds-select-is-open {
.ds-select {
border-bottom: 0;
}
}
.ds-select-dropdown-message {
opacity: 0.5;
}
.ds-select-dropdown {
max-height: 70vh;
}
.field {
width: 100%;
display: flex;
@ -137,53 +312,6 @@ export default {
.control {
width: 100%;
input {
width: 100%;
border-radius: 2px;
height: 2.5em;
padding-left: 2em;
padding-right: 1em;
font-size: 1em;
transition-duration: 0.15s;
transition-timing-function: ease-out;
transition-property: border, background-color;
& {
border-color: hsl(0, 0%, 96%);
}
}
}
input {
padding-right: 2em !important;
background-color: hsl(0, 0%, 96%);
}
input:hover {
background-color: hsl(0, 0%, 98%);
}
input:focus,
&.is-active input.input {
background-color: hsl(0, 0%, 98%);
}
.icon {
height: 2.5em;
font-size: 1em;
&.btn-clear {
position: absolute;
right: 0.25rem;
cursor: pointer !important;
z-index: 10;
padding-left: 1em;
padding-right: 1em;
}
}
&.is-active .icon {
color: hsl(0, 0%, 71%);
}
}
</style>

View File

@ -26,7 +26,9 @@
"commented": "Kommentiert"
},
"search": {
"placeholder": "Suchen"
"placeholder": "Suchen",
"hint": "Wonach suchst du?",
"failed": "Nichts gefunden"
},
"settings": {
"name": "Einstellungen",

View File

@ -26,7 +26,9 @@
"commented": "Commented"
},
"search": {
"placeholder": "Search"
"placeholder": "Search",
"hint": "What are you searching for?",
"failed": "Nothing found"
},
"settings": {
"name": "Settings",

View File

@ -156,7 +156,10 @@ export default {
},
watch: {
Post(post) {
this.post = post[0]
this.post = post[0] || {}
// if (!this.post.title) {
// throw new Error('404')
// }
this.title = this.post.title
}
},
@ -194,7 +197,7 @@ export default {
name
}
commentsCount
comments(orderBy: createdAt_desc) {
comments(first: 20, orderBy: createdAt_desc) {
id
contentExcerpt
createdAt
@ -233,6 +236,7 @@ export default {
slug: this.$route.params.slug
}
},
prefetch: true,
fetchPolicy: 'cache-and-network'
}
}

View File

@ -111,8 +111,9 @@
v-else-if="!filteredOptions.length">
{{ noOptionsFound }} "{{ searchString }}"
</div>
<ul
class="ds-select-options"
<ul
class="ds-select-options"
ref="options"
v-else>
<li
class="ds-select-option"
@ -136,7 +137,7 @@
<div
v-if="iconRight"
class="ds-select-icon-right">
<ds-icon :name="iconRight"/>
<ds-icon :name="iconRight" />
</div>
</div>
</ds-form-item>
@ -224,6 +225,21 @@ export default {
type: Boolean,
default: true
},
/**
* Function to filter the results
*/
filter: {
type: Function,
default: (option) => {
const value = option.value || option
return searchParts.every(part => {
if (!part) {
return true
}
return value.toLowerCase().includes(part.toLowerCase())
})
}
},
/**
* Message to show when no options are available
*/
@ -246,15 +262,7 @@ export default {
}
const searchParts = this.searchString.split(' ')
return this.options.filter(option => {
const value = option.value || option
return searchParts.every(part => {
if (!part) {
return true
}
return value.toLowerCase().includes(part.toLowerCase())
})
})
return this.options.filter(this.filter)
},
pointerMax() {
return this.filteredOptions.length - 1
@ -327,7 +335,9 @@ export default {
this.pointerNext()
},
setPointer(index) {
this.pointer = index
if (!this.hadKeyboardInput) {
this.pointer = index
}
},
pointerPrev() {
if (this.pointer === 0) {
@ -335,6 +345,7 @@ export default {
} else {
this.pointer--
}
this.scrollToHighlighted()
},
pointerNext() {
if (this.pointer === this.pointerMax) {
@ -342,6 +353,19 @@ export default {
} else {
this.pointer++
}
this.scrollToHighlighted()
},
scrollToHighlighted() {
clearTimeout(this.hadKeyboardInput)
if (!this.$refs.options || !this.$refs.options.children.length) {
return
}
this.hadKeyboardInput = setTimeout(() => {
this.hadKeyboardInput = null
}, 250)
this.$refs.options.children[this.pointer].scrollIntoView({
block: 'nearest'
});
},
selectPointerOption() {
this.handleSelect(this.filteredOptions[this.pointer])