Last active
October 29, 2021 06:21
-
-
Save gistlyn/d215e9ff31abd9adce719a663a4bd8af to your computer and use it in GitHub Desktop.
Vue3 TypeSense Search Components for typesense search
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- BSD License: https://docs.servicestack.net/BSD-LICENSE.txt --> | |
<template> | |
<div class="hidden"></div> | |
</template> | |
<script> | |
export default { | |
created() { | |
const component = this; | |
this.handler = function (e) { | |
component.$emit('keydown', e); | |
} | |
window.addEventListener('keydown', this.handler); | |
}, | |
beforeDestroy() { | |
window.removeEventListener('keydown', this.handler); | |
} | |
} | |
</script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- BSD License: https://docs.servicestack.net/BSD-LICENSE.txt --> | |
<!--.vitepress/theme/MyLayout.vue--> | |
<script setup lang="ts"> | |
import { ref, defineAsyncComponent, nextTick } from 'vue' | |
import DefaultTheme from 'vitepress/theme' | |
import { useData } from 'vitepress'; | |
const { Layout } = DefaultTheme | |
const { page, frontmatter } = useData(); | |
const openSearch = ref(false); | |
const showSearch = () => { | |
openSearch.value = true | |
nextTick(() => { | |
const el = document.querySelector('#docsearch-input') as HTMLInputElement; | |
el?.select(); | |
el?.focus(); | |
}) | |
}; | |
const hideSearch = () => openSearch.value = false; | |
const TypeSenseDialog = defineAsyncComponent(() => | |
import('../../src/components/TypeSenseDialog.vue')); | |
const KeyboardEvents = defineAsyncComponent(() => | |
import('../../src/components/KeyboardEvents.vue')); | |
const onKeyDown = (e:KeyboardEvent) => { | |
console.log(e.code) | |
if (e.code === 'Escape') { | |
hideSearch(); | |
} | |
else if ((e.target as HTMLElement).tagName != 'INPUT') { | |
if (e.code == 'Slash' || (e.ctrlKey && e.code == 'KeyK')) { | |
showSearch(); | |
e.preventDefault(); | |
} | |
} | |
}; | |
</script> | |
<template> | |
<div @keydown="onKeyDown"> | |
<ClientOnly> | |
<KeyboardEvents @keydown="onKeyDown" /> | |
<TypeSenseDialog :open="openSearch" @hide="hideSearch" /> | |
</ClientOnly> | |
<Layout> | |
<template #navbar-search> | |
<button class="flex rounded-full p-0 bg-gray-100 border-solid border-gray-100 text-gray-400 cursor-pointer | |
hover:border-green-400 hover:bg-white hover:text-gray-600" @click="showSearch"> | |
<svg class="w-7 h-7 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> | |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> | |
</svg> | |
<span class="text-lg mr-1">Search</span> | |
<span style="opacity:1;" class="hidden sm:block text-gray-400 text-sm leading-5 py-0 px-1.5 my-0.5 mr-1.5 border border-gray-300 border-solid rounded-md"> | |
<span class="sr-only">Press </span><kbd class="font-sans">/</kbd><span class="sr-only"> to search</span> | |
</span> | |
</button> | |
</template> | |
<template #page-top> | |
<h1 v-if="frontmatter.title">{{ frontmatter.title }}</h1> | |
</template> | |
</Layout> | |
</div> | |
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- BSD License: https://docs.servicestack.net/BSD-LICENSE.txt --> | |
<script setup lang="ts"> | |
import { ref, nextTick } from 'vue' | |
import { useRouter } from 'vitepress'; | |
defineProps<{ open: boolean }>(); | |
const emit = defineEmits<{ (event: 'hide'): void }>(); | |
const router = useRouter(); | |
const results = ref({ groups:[], allItems:[] }); | |
const query = ref(""); | |
let lastQuery:string = ""; | |
let timeout:any = null; | |
const search = (txt:any) => { | |
if (!query.value) { | |
results.value = { groups:[], allItems:[] }; | |
return; | |
} | |
timeout = setTimeout(() => { | |
if (timeout != null) { | |
if (lastQuery === query.value) return; | |
lastQuery = query.value; | |
clearTimeout(timeout); | |
// typesense API reference: https://typesense.org/docs/0.21.0/api/documents.html#search | |
fetch('https://search.docs.servicestack.net/collections/typesense_docs/documents/search?q=' | |
+ encodeURIComponent(query.value) | |
+ '&query_by=content,hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3&group_by=hierarchy.lvl0', { | |
headers: { | |
// Search only API key for Typesense. | |
'x-typesense-api-key': 'TYPESENSE_SEARCH_ONLY_API_KEY' | |
} | |
}).then(res => { | |
res.json().then(data => { | |
selectedIndex.value = 1; | |
let idx = 0; | |
const groups:any = {}; | |
const meta:any = { groups:[], allItems:[] }; | |
//console.log(data) | |
data.grouped_hits.forEach((gh:any) => { | |
let groupName = gh.group_key[0]; | |
meta.groups.push({ group: groupName }); | |
let group = groups[groupName] ?? (groups[groupName] = []); | |
gh.hits.forEach((hit:any) => { | |
let doc = hit.document; | |
let highlight = hit.highlights.length > 0 ? hit.highlights[0] : null; | |
let item = { | |
id: ++idx, | |
titleHtml: doc.hierarchy.lvl3 ?? doc.hierarchy.lvl2 ?? doc.hierarchy.lvl1 ?? doc.hierarchy.lvl0, | |
snippetHtml: highlight?.snippet, | |
// search result type for icon | |
type: highlight?.field === 'content' ? 'content' : 'heading', | |
// search results have wrong domain, use relative | |
url: doc.url.substring(doc.url.indexOf('/', 'https://'.length)) | |
}; | |
let titleOnly = stripHtml(item.titleHtml); | |
if (titleOnly === groupName) { | |
item.type = 'doc'; | |
} | |
if (titleOnly === stripHtml(item.snippetHtml)) { | |
item.snippetHtml = ""; | |
} | |
group.push(item); | |
}); | |
}); | |
meta.groups.forEach((g:any) => { | |
g.items = groups[g.group] ?? []; | |
g.items.forEach((item:any) => { | |
meta.allItems.push(item); | |
}); | |
}); | |
//console.log(meta) | |
results.value = meta; | |
}) | |
}) | |
} | |
}, 200); | |
} | |
let selectedIndex = ref(1); | |
const onHover = (index:number) => selectedIndex.value = index; | |
const go = (url:string) => { | |
emit('hide'); | |
return router.go(url); | |
} | |
const next = (id:number, step:number) => { | |
const meta:any = results.value; | |
const pos = meta.allItems.findIndex((x:any) => x.id === id); | |
if (pos === -1) | |
return meta.allItems[0]; | |
const nextPos = (pos + step) % meta.allItems.length; | |
return nextPos >= 0 ? meta.allItems[nextPos] : meta.allItems[meta.allItems.length + nextPos]; | |
} | |
let ScrollCounter = 0; | |
const onKeyDown = (e:KeyboardEvent) => { | |
const meta:any = results.value; | |
if (!meta || meta.allItems.length === 0) return; | |
if (e.code === 'ArrowDown' || e.code === 'ArrowUp' || e.code === 'Home' || e.code === 'End') { | |
selectedIndex.value = e.code === 'Home' | |
? meta.allItems[0]?.id | |
: e.code === 'End' | |
? meta.allItems[meta.allItems.length-1]?.id | |
: next(selectedIndex.value, e.code === 'ArrowUp' ? -1 : 1).id; | |
nextTick(() => { | |
let el = document.querySelector('[aria-selected=true]') as HTMLElement, | |
elGroup = el?.closest('.group-result') as HTMLElement, | |
elParent = elGroup?.closest('.group-results') as HTMLElement; | |
ScrollCounter++; | |
let counter = ScrollCounter; | |
if (el && elGroup && elParent) { | |
if (el === elGroup.firstElementChild?.nextElementSibling && elGroup === elParent.firstElementChild) { | |
//console.log('scrollTop', 0) | |
elParent.scrollTo({ top: 0, left: 0 }); | |
} else if (el === elGroup.lastElementChild && elGroup === elParent.lastElementChild) { | |
//console.log('scrollEnd', elParent.scrollHeight) | |
elParent.scrollTo({ top: elParent.scrollHeight, left: 0 }); | |
} else { | |
if (typeof IntersectionObserver != 'undefined') { | |
let observer = new IntersectionObserver((entries:any[]) => { | |
if (entries[0].intersectionRatio <= 0) { | |
//console.log('el.scrollIntoView()', counter, ScrollCounter) | |
if (counter == ScrollCounter) el.scrollIntoView(); | |
} | |
observer.disconnect(); | |
}); | |
observer.observe(el); | |
} | |
} | |
} | |
}) | |
e.preventDefault(); | |
} else if (e.code === 'Enter') { | |
let match = meta.allItems.find((x:any) => x.id === selectedIndex.value); | |
if (match) { | |
go(match.url); | |
e.preventDefault(); | |
} | |
} | |
}; | |
function stripHtml(s:string) { | |
return s.replace(/<[^>]*>?/gm, ''); | |
} | |
</script> | |
<template> | |
<div class="search-dialog hidden flex bg-black bg-opacity-25 items-center" :class="{ open }" | |
@click="$emit('hide')"> | |
<div class="dialog absolute" style="max-height:70vh;" @click.stop="false"> | |
<div class="p-2 flex flex-col" style="max-height: 70vh;"> | |
<div class="flex"> | |
<label class="pt-4 mt-0.5 pl-2" for="docsearch-input"> | |
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
</label> | |
<input id="docsearch-input" class="search-input" v-model="query" @keyup="search" | |
aria-autocomplete="list" aria-labelledby="docsearch-label" autocomplete="off" autocorrect="off" autocapitalize="off" | |
spellcheck="false" placeholder="Search docs" maxlength="64" type="search" enterkeyhint="go" | |
@focus="selectedIndex=1" @blur="selectedIndex=-1" @keydown="onKeyDown"> | |
<div class="mt-5 mr-3"><button class="search-cancel" @click="$emit('hide')">Cancel</button></div> | |
</div> | |
<div v-if="results.allItems.length" class="group-results border-0 border-t border-solid border-gray-400 mx-2 pr-1 py-2 overflow-y-scroll" style="max-height:60vh"> | |
<div v-for="g in results.groups" :key="g.group" class="group-result mb-2"> | |
<h3 class="m-0 text-lg text-gray-600" v-html="g.group"></h3> | |
<div v-for="result in g.items" :key="result.id" :aria-selected="result.id == selectedIndex" | |
class="group-item rounded-lg bg-gray-50 mb-1 p-2 flex" @mouseover="onHover(result.id)" @click="go(result.url)"> | |
<div class="min-w-min mr-2 flex items-center"> | |
<svg v-if="result.type=='doc'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg> | |
<svg v-else-if="result.type=='content'" class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg> | |
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"></path></svg> | |
</div> | |
<div class="overflow-hidden"> | |
<div class="snippet overflow-ellipsis overflow-hidden whitespace-nowrap text-sm" v-html="result.snippetHtml"></div> | |
<h4><a class="text-sm text-gray-600" :href="result.url" v-html="result.titleHtml"></a></h4> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</template> | |
<style scoped> | |
@media (min-width: 720px) { | |
.search-dialog { | |
transform: translateX(0); | |
} | |
} | |
@media (min-width: 960px) { | |
.search-dialog { | |
width: 20rem; | |
} | |
} | |
input[type=search]::-webkit-search-cancel-button{ | |
-webkit-appearance: none; | |
appearance: none; | |
height: 12px; | |
width: 12px; | |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' x='0px' y='0px' class='w-5 h-5' viewBox='0 0 123.05 123.05' style='enable-background:new 0 0 123.05 123.05'%3E%3Cg%3E%3Cpath d='M121.325,10.925l-8.5-8.399c-2.3-2.3-6.1-2.3-8.5,0l-42.4,42.399L18.726,1.726c-2.301-2.301-6.101-2.301-8.5,0l-8.5,8.5 c-2.301,2.3-2.301,6.1,0,8.5l43.1,43.1l-42.3,42.5c-2.3,2.3-2.3,6.1,0,8.5l8.5,8.5c2.3,2.3,6.1,2.3,8.5,0l42.399-42.4l42.4,42.4 c2.3,2.3,6.1,2.3,8.5,0l8.5-8.5c2.3-2.3,2.3-6.1,0-8.5l-42.5-42.4l42.4-42.399C123.625,17.125,123.625,13.325,121.325,10.925z' fill='%23888' /%3E%3C/g%3E%3C/svg%3E%0A"); | |
background-size: 12px 12px; | |
} | |
.search-dialog { | |
z-index: 11; | |
height: 100vh; | |
left: 0; | |
position: fixed; | |
top: 0; | |
width: 100vw; | |
z-index: 200; | |
flex-direction: column; | |
background: rgba(0,0,0,.25); | |
padding: 12vh; | |
transition: width 0.1s ease-out 0s, opacity 0.5s ease 0.2s; | |
} | |
.search-dialog .dialog { | |
margin: 0 auto; | |
width: 100%; | |
max-width: 47.375rem; | |
display: flex; | |
flex-direction: column; | |
min-height: 0; | |
border-radius: 1rem; | |
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 25%); | |
background: #fff; | |
} | |
.search-dialog.open { | |
display: flex; | |
} | |
.search-input { | |
-webkit-appearance: none; | |
appearance: none; | |
background: transparent; | |
height: 4.5rem; | |
font-size: 1rem; | |
font-weight: 500; | |
color: #000; | |
margin-left: 1rem; | |
margin-right: 1rem; | |
flex: auto; | |
min-width: 0; | |
font-size: 1.5rem; | |
border: 0 solid; | |
} | |
.search-input::placeholder { | |
font-weight: 500; | |
color: rgb(156, 163, 175); | |
} | |
.search-input:focus { | |
outline: 2px dotted transparent; | |
} | |
.search-cancel { | |
flex: none; | |
font-size: 0; | |
border-radius: .375rem; | |
background-color: #f9fafb; | |
border: 1px solid #d1d5db; | |
padding: .125rem .375rem; | |
} | |
.search-cancel:before { | |
content: "esc"; | |
color: #9ca3af; | |
font-size: .875rem; | |
line-height: 1.25rem; | |
cursor: pointer; | |
} | |
.search-dialog ::-webkit-scrollbar { width:4px; } | |
.search-dialog ::-webkit-scrollbar-thumb { background-color:rgb(249, 250, 251); } | |
@media (min-width: 720px) { | |
.nav { | |
display: none; | |
} | |
} | |
</style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment