Files
site/nova/resources/js/components/GlobalSearch.vue

333 lines
8.2 KiB
Vue
Executable File

<template>
<div v-on-clickaway="closeSearch" class="relative z-50 w-full max-w-xs">
<div class="relative">
<!-- Search -->
<div class="relative">
<icon type="search" class="absolute search-icon-center ml-3 text-80" />
<input
dusk="global-search"
ref="input"
@input.stop="search"
@keydown.stop=""
@keydown.enter.stop="goToCurrentlySelectedResource"
@keydown.esc.stop="closeSearch"
@focus="openSearch"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
v-model="searchTerm"
type="search"
:placeholder="__('Press / to search')"
class="pl-search w-full form-global-search"
spellcheck="false"
/>
</div>
<!-- Loader -->
<div
v-if="loading"
class="bg-white py-3 overflow-hidden absolute rounded-lg shadow-lg w-full mt-2 max-h-search overflow-y-auto"
>
<loader class="text-60" width="40" />
</div>
<!-- No Results Found -->
<div
v-if="shouldShowNoResults"
class="bg-white overflow-hidden absolute rounded-lg shadow-lg w-full mt-2 max-h-search overflow-y-auto"
>
<h3 class="text-xs uppercase tracking-wide text-80 bg-40 py-4 px-3">
{{ __('No Results Found.') }}
</h3>
</div>
<!-- Results -->
<div
v-if="shouldShowResults"
class="overflow-hidden absolute rounded-lg shadow-lg w-full mt-2 max-h-search overflow-y-auto"
ref="container"
>
<div v-for="group in formattedResults">
<h3 class="text-xs uppercase tracking-wide text-80 bg-40 py-2 px-3">
{{ group.resourceTitle }}
</h3>
<ul class="list-reset">
<li
v-for="item in group.items"
:key="item.resourceName + ' ' + item.index"
:ref="item.index === highlightedResultIndex ? 'selected' : null"
>
<a
:dusk="item.resourceName + ' ' + item.index"
@click.prevent="navigate(item.index)"
class="cursor-pointer flex items-center hover:bg-20 block py-2 px-3 no-underline font-normal"
:class="{
'bg-white': highlightedResultIndex != item.index,
'bg-20': highlightedResultIndex == item.index,
}"
>
<img
v-if="item.avatar"
:src="item.avatar"
class="h-8 w-8 mr-3"
:class="{
'rounded-full': item.rounded,
rounded: !item.rounded,
}"
/>
<div>
<p class="text-90">{{ item.title }}</p>
<p v-if="item.subTitle" class="text-xs mt-1 text-80">
{{ item.subTitle }}
</p>
</div>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import { Minimum } from 'laravel-nova'
import { mixin as clickaway } from 'vue-clickaway'
import { CancelToken, Cancel } from 'axios'
export default {
mixins: [clickaway],
data: () => ({
debouncer: null,
canceller: null,
loading: false,
currentlySearching: false,
searchTerm: '',
results: [],
highlightedResultIndex: 0,
}),
watch: {
$route: function () {
this.closeSearch()
},
},
created() {
this.debouncer = _.debounce(callback => callback(), Nova.config.debounce)
},
mounted() {
// Open search menu if the user types '/'
Nova.addShortcut('/', () => {
this.openSearch()
return false
})
},
destroyed() {
Nova.disableShortcut('/')
},
methods: {
isNotInputElement(event) {
const tagName = event.target.tagName
return Boolean(tagName !== 'INPUT' && tagName !== 'TEXTAREA')
},
openSearch() {
this.clearSearch()
this.$refs.input.focus()
this.currentlySearching = true
this.clearResults()
},
closeSearch() {
this.clearSearch()
this.clearResults()
this.$refs.input.blur()
this.currentlySearching = false
this.loading = false
},
clearSearch() {
this.searchTerm = ''
},
clearResults() {
this.results = []
},
search(event) {
this.highlightedResultIndex = 0
this.loading = true
if (this.searchTerm == '') {
if (this.canceller !== null) this.canceller()
this.loading = false
this.results = []
} else {
this.debouncer(() => {
if (this.canceller !== null) this.canceller()
this.fetchResults(event.target.value)
}, 500)
}
},
async fetchResults(search) {
this.results = []
if (search !== '') {
try {
const { data: results } = await Minimum(
Nova.request().get('/nova-api/search', {
params: { search },
cancelToken: new CancelToken(canceller => {
this.canceller = canceller
}),
})
)
this.results = results
this.loading = false
} catch (e) {
this.loading = false
if (e instanceof Cancel) {
return
}
throw e
}
}
},
/**
* Move the highlighted results
*/
move(offset) {
if (this.results.length) {
let newIndex = this.highlightedResultIndex + offset
if (newIndex < 0) {
this.highlightedResultIndex = this.results.length - 1
this.updateScrollPosition()
} else if (newIndex > this.results.length - 1) {
this.highlightedResultIndex = 0
this.updateScrollPosition()
} else if (newIndex >= 0 && newIndex < this.results.length) {
this.highlightedResultIndex = newIndex
this.updateScrollPosition()
}
}
},
updateScrollPosition() {
const selection = this.$refs.selected
const container = this.$refs.container
this.$nextTick(() => {
if (selection) {
if (
selection[0].offsetTop >
container.scrollTop +
container.clientHeight -
selection[0].clientHeight
) {
container.scrollTop =
selection[0].offsetTop +
selection[0].clientHeight -
container.clientHeight
}
if (selection[0].offsetTop < container.scrollTop) {
container.scrollTop = selection[0].offsetTop
}
}
})
},
navigate(index) {
this.highlightedResultIndex = index
this.goToCurrentlySelectedResource()
},
goToCurrentlySelectedResource() {
const resource = _.find(
this.indexedResults,
res => res.index == this.highlightedResultIndex
)
this.$router.push({
name: resource.linksTo,
params: {
resourceName: resource.resourceName,
resourceId: resource.resourceId,
},
})
this.closeSearch()
},
},
computed: {
hasResults() {
return this.results.length > 0
},
hasSearchTerm() {
return this.searchTerm !== ''
},
shouldShowNoResults() {
return (
this.currentlySearching &&
!this.loading &&
!this.hasResults &&
this.hasSearchTerm
)
},
shouldShowResults() {
return this.currentlySearching && this.hasResults && !this.loading
},
indexedResults() {
return _.map(this.results, (item, index) => {
return { index, ...item }
})
},
formattedGroups() {
return _.chain(this.indexedResults)
.map(item => {
return {
resourceName: item.resourceName,
resourceTitle: item.resourceTitle,
}
})
.uniqBy('resourceName')
.value()
},
formattedResults() {
return _.map(this.formattedGroups, group => {
return {
resourceName: group.resourceName,
resourceTitle: group.resourceTitle,
items: _.filter(
this.indexedResults,
item => item.resourceName == group.resourceName
),
}
})
},
},
}
</script>