add nova
This commit is contained in:
378
nova/resources/js/components/GlobalSearch.vue
Normal file
378
nova/resources/js/components/GlobalSearch.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="flex items-center w-full max-w-xs h-12">
|
||||
<div class="flex-1 relative">
|
||||
<!-- Search -->
|
||||
<div class="relative z-10" ref="searchInput">
|
||||
<Icon
|
||||
type="search"
|
||||
width="20"
|
||||
class="absolute ml-2 text-gray-400"
|
||||
:style="{ top: '4px' }"
|
||||
/>
|
||||
|
||||
<input
|
||||
dusk="global-search"
|
||||
ref="input"
|
||||
@keydown.enter.stop="goToCurrentlySelectedResource"
|
||||
@keydown.esc.stop="closeSearch"
|
||||
@keydown.down.prevent="move(1)"
|
||||
@keydown.up.prevent="move(-1)"
|
||||
v-model="searchTerm"
|
||||
@focus="focusSearch"
|
||||
type="search"
|
||||
:placeholder="__('Press / to search')"
|
||||
class="appearance-none rounded-full h-8 pl-10 w-full bg-gray-100 dark:bg-gray-900 dark:focus:bg-gray-800 focus:bg-white focus:outline-none focus:ring focus:ring-primary-200 dark:focus:ring-gray-600"
|
||||
role="search"
|
||||
:aria-label="__('Search')"
|
||||
:aria-expanded="resultsVisible === true ? 'true' : 'false'"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<teleport to="body">
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-show="resultsVisible"
|
||||
ref="results"
|
||||
class="w-full max-w-lg z-10"
|
||||
>
|
||||
<!-- Loader -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="bg-white dark:bg-gray-800 py-6 rounded-lg shadow-lg w-full mt-2 max-h-[calc(100vh-5em)] overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<Loader class="text-gray-300" width="40" />
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div
|
||||
v-if="results.length > 0"
|
||||
dusk="global-search-results"
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full mt-2 max-h-[calc(100vh-5em)] overflow-x-hidden overflow-y-auto"
|
||||
ref="container"
|
||||
>
|
||||
<div v-for="group in formattedResults" :key="group.resourceTitle">
|
||||
<h3
|
||||
class="text-xs font-bold uppercase tracking-wide bg-gray-300 dark:bg-gray-900 py-2 px-3"
|
||||
>
|
||||
{{ group.resourceTitle }}
|
||||
</h3>
|
||||
|
||||
<ul>
|
||||
<li
|
||||
v-for="item in group.items"
|
||||
:key="item.resourceName + ' ' + item.index"
|
||||
:ref="item.index === selected ? 'selected' : null"
|
||||
>
|
||||
<button
|
||||
:dusk="item.resourceName + ' ' + item.index"
|
||||
@click.exact="goToSelectedResource(item, false)"
|
||||
@click.ctrl="goToSelectedResource(item, true)"
|
||||
@click.meta="goToSelectedResource(item, true)"
|
||||
class="w-full flex items-center hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300 py-2 px-3 no-underline font-normal"
|
||||
:class="{
|
||||
'bg-white dark:bg-gray-800': selected !== item.index,
|
||||
'bg-gray-100 dark:bg-gray-700': selected === item.index,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="item.avatar"
|
||||
:src="item.avatar"
|
||||
class="flex-none h-8 w-8 mr-3"
|
||||
:class="{
|
||||
'rounded-full': item.rounded,
|
||||
rounded: !item.rounded,
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="flex-auto text-left">
|
||||
<p>{{ item.title }}</p>
|
||||
<p v-if="item.subTitle" class="text-xs mt-1">
|
||||
{{ item.subTitle }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Results Found -->
|
||||
<div
|
||||
v-if="!loading && results.length === 0"
|
||||
dusk="global-search-empty-results"
|
||||
class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg shadow-lg w-full mt-2 max-h-search overflow-y-auto"
|
||||
>
|
||||
<h3
|
||||
class="text-xs font-bold uppercase tracking-wide bg-40 py-4 px-3"
|
||||
>
|
||||
{{ __('No Results Found.') }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<Backdrop
|
||||
@click="closeSearch"
|
||||
:show="showOverlay"
|
||||
class="bg-gray-500/75 dark:bg-gray-900/75 z-0"
|
||||
/>
|
||||
</transition>
|
||||
</teleport>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createPopper } from '@popperjs/core'
|
||||
import { CancelToken, Cancel } from 'axios'
|
||||
import map from 'lodash/map'
|
||||
import debounce from 'lodash/debounce'
|
||||
import filter from 'lodash/filter'
|
||||
import find from 'lodash/find'
|
||||
import isNil from 'lodash/isNil'
|
||||
import uniqBy from 'lodash/uniqBy'
|
||||
|
||||
function fetchSearchResults(search, cancelCallback) {
|
||||
return Nova.request().get('/nova-api/search', {
|
||||
params: { search },
|
||||
cancelToken: new CancelToken(canceller => cancelCallback(canceller)),
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
searchFunction: null,
|
||||
canceller: null,
|
||||
showOverlay: false,
|
||||
loading: false,
|
||||
resultsVisible: false,
|
||||
searchTerm: '',
|
||||
results: [],
|
||||
selected: 0,
|
||||
}),
|
||||
|
||||
watch: {
|
||||
searchTerm(newValue) {
|
||||
if (this.canceller !== null) this.canceller()
|
||||
|
||||
if (newValue !== '') {
|
||||
this.search()
|
||||
return
|
||||
}
|
||||
|
||||
this.resultsVisible = false
|
||||
this.selected = -1
|
||||
this.results = []
|
||||
// this.showOverlay = false
|
||||
},
|
||||
|
||||
resultsVisible(newValue) {
|
||||
if (newValue === true) {
|
||||
document.body.classList.add('overflow-y-hidden')
|
||||
return
|
||||
}
|
||||
|
||||
document.body.classList.remove('overflow-y-hidden')
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.searchFunction = debounce(async () => {
|
||||
this.showOverlay = true
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.popper = createPopper(this.$refs.searchInput, this.$refs.results, {
|
||||
placement: 'bottom-start',
|
||||
boundary: 'viewPort',
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 8] } }],
|
||||
})
|
||||
})
|
||||
|
||||
if (this.searchTerm === '') {
|
||||
this.canceller()
|
||||
this.resultsVisible = false
|
||||
this.results = []
|
||||
return
|
||||
}
|
||||
|
||||
this.resultsVisible = true
|
||||
this.loading = true
|
||||
this.results = []
|
||||
this.selected = 0
|
||||
|
||||
try {
|
||||
const { data: results } = await fetchSearchResults(
|
||||
this.searchTerm,
|
||||
canceller => (this.canceller = canceller)
|
||||
)
|
||||
|
||||
this.results = results
|
||||
this.loading = false
|
||||
} catch (e) {
|
||||
if (e instanceof Cancel) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
|
||||
throw e
|
||||
}
|
||||
}, Nova.config('debounce'))
|
||||
},
|
||||
|
||||
mounted() {
|
||||
Nova.addShortcut('/', () => {
|
||||
this.focusSearch()
|
||||
|
||||
return false
|
||||
})
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
if (this.canceller !== null) this.canceller()
|
||||
|
||||
this.resultsVisible = false
|
||||
Nova.disableShortcut('/')
|
||||
},
|
||||
|
||||
methods: {
|
||||
async focusSearch() {
|
||||
if (this.results.length > 0) {
|
||||
this.showOverlay = true
|
||||
this.resultsVisible = true
|
||||
await this.popper.update()
|
||||
}
|
||||
this.$refs.input.focus()
|
||||
},
|
||||
|
||||
closeSearch() {
|
||||
this.$refs.input.blur()
|
||||
this.resultsVisible = false
|
||||
this.showOverlay = false
|
||||
},
|
||||
|
||||
search() {
|
||||
this.searchFunction()
|
||||
},
|
||||
|
||||
move(offset) {
|
||||
if (this.results.length) {
|
||||
let newIndex = this.selected + offset
|
||||
|
||||
if (newIndex < 0) {
|
||||
this.selected = this.results.length - 1
|
||||
this.updateScrollPosition()
|
||||
} else if (newIndex > this.results.length - 1) {
|
||||
this.selected = 0
|
||||
this.updateScrollPosition()
|
||||
} else if (newIndex >= 0 && newIndex < this.results.length) {
|
||||
this.selected = 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
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
goToCurrentlySelectedResource(event) {
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
|
||||
if (this.searchTerm !== '') {
|
||||
const resource = find(
|
||||
this.indexedResults,
|
||||
res => res.index === this.selected
|
||||
)
|
||||
|
||||
this.goToSelectedResource(resource, false)
|
||||
}
|
||||
},
|
||||
|
||||
goToSelectedResource(resource, commandPressed = false) {
|
||||
if (this.canceller !== null) this.canceller()
|
||||
|
||||
this.closeSearch()
|
||||
|
||||
if (isNil(resource)) {
|
||||
return
|
||||
}
|
||||
|
||||
let url = Nova.url(
|
||||
`/resources/${resource.resourceName}/${resource.resourceId}`
|
||||
)
|
||||
|
||||
if (resource.linksTo === 'edit') {
|
||||
url += '/edit'
|
||||
}
|
||||
|
||||
commandPressed
|
||||
? window.open(url, '_blank')
|
||||
: Nova.visit({ url, remote: false })
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
indexedResults() {
|
||||
return map(this.results, (item, index) => ({ index, ...item }))
|
||||
},
|
||||
|
||||
formattedGroups() {
|
||||
return uniqBy(
|
||||
map(this.indexedResults, item => ({
|
||||
resourceName: item.resourceName,
|
||||
resourceTitle: item.resourceTitle,
|
||||
})),
|
||||
'resourceName'
|
||||
)
|
||||
},
|
||||
|
||||
formattedResults() {
|
||||
return map(this.formattedGroups, group => ({
|
||||
resourceName: group.resourceName,
|
||||
resourceTitle: group.resourceTitle,
|
||||
items: filter(
|
||||
this.indexedResults,
|
||||
item => item.resourceName === group.resourceName
|
||||
),
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user