Skip to content

Commit

Permalink
feat(search): more performant and fuzzy
Browse files Browse the repository at this point in the history
  • Loading branch information
Akryum committed Apr 24, 2022
1 parent 3993802 commit 02707af
Show file tree
Hide file tree
Showing 13 changed files with 419 additions and 176 deletions.
2 changes: 1 addition & 1 deletion examples/vue3/src/components/Meow.story.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<Story title="🐱 Meow">
<Story title="🐱 Meow Waf Piou Meuh">
<Variant
title="default"
>
Expand Down
2 changes: 2 additions & 0 deletions packages/histoire/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"connect": "^3.7.0",
"defu": "^6.0.0",
"diacritics": "^1.3.0",
"flexsearch": "^0.7.21",
"floating-vue": "^2.0.0-beta.14",
"fs-extra": "^10.0.1",
"globby": "^13.1.1",
Expand Down Expand Up @@ -77,6 +78,7 @@
"devDependencies": {
"@peeky/test": "^0.13.5",
"@tailwindcss/typography": "^0.5.2",
"@types/flexsearch": "^0.7.3",
"@types/fs-extra": "^9.0.13",
"@types/markdown-it": "^12.2.3",
"@types/node": "^17.0.23",
Expand Down
5 changes: 2 additions & 3 deletions packages/histoire/src/client/app/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default {
import { files as rawFiles, tree as rawTree, onUpdate } from '$histoire-stories'
import StoryList from './components/tree/StoryList.vue'
import BaseSplitPane from './components/base/BaseSplitPane.vue'
import { computed, ref, watch, defineAsyncComponent } from 'vue'
import { computed, ref, watch } from 'vue'
import AppHeader from './components/app/AppHeader.vue'
import type { StoryFile, Tree } from './types'
import { useStoryStore } from './stores/story'
Expand All @@ -19,8 +19,7 @@ import { histoireConfig } from './util/config.js'
import { onKeyboardShortcut } from './util/keyboard.js'
import { isMobile } from './util/responsive'
import Breadcrumb from './components/app/Breadcrumb.vue'
const SearchModal = defineAsyncComponent(() => import('./components/search/SearchModal.vue'))
import SearchModal from './components/search/SearchModal.vue'
const files = ref<StoryFile[]>(rawFiles.map(file => mapFile(file)))
const tree = ref<Tree>(rawTree)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
</script>

<template>
<div
class="htw-flex htw-items-center htw-gap-4 htw-pl-6 htw-border htw-border-transparent focus-visible:htw-border-primary-500 htw-h-[51px] htw-opacity-50"
>
<Icon
icon="carbon:search"
class="flex-none htw-w-4 htw-h-4"
/>

Loading...
</div>
</template>
160 changes: 11 additions & 149 deletions packages/histoire/src/client/app/components/search/SearchModal.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
<script lang="ts" setup>
import { useFocus, useDebounce } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { Icon } from '@iconify/vue'
import { useStoryStore } from '../../stores/story'
import BaseEmpty from '../base/BaseEmpty.vue'
import type { SearchResult, Story, Variant } from '../../types'
import SearchItem from './SearchItem.vue'
import { defineAsyncComponent } from 'vue'
import SearchLoading from './SearchLoading.vue'
const SearchPane = defineAsyncComponent({
loader: () => import('./SearchPane.vue'),
loadingComponent: SearchLoading,
})
const props = defineProps({
defineProps({
shown: {
type: Boolean,
default: false,
Expand All @@ -21,111 +20,6 @@ const emit = defineEmits({
function close () {
emit('close')
}
// Autofocus
const input = ref<HTMLInputElement>()
const { focused } = useFocus(input, {
initialValue: true,
})
watch(() => props.shown, value => {
if (value) {
requestAnimationFrame(() => {
focused.value = true
input.value.select()
})
}
})
// Search
const searchInputText = ref('')
const rateLimitedSearch = useDebounce(searchInputText, 300)
const storyStore = useStoryStore()
function storyResultFactory (story: Story, rank: number): SearchResult {
return {
kind: 'story',
rank,
id: story.id,
title: story.title,
route: {
name: 'story',
params: {
storyId: story.id,
},
},
path: story.file.path.slice(0, -1),
icon: story.icon,
iconColor: story.iconColor,
}
}
function variantResultFactory (story: Story, variant: Variant, rank: number): SearchResult {
return {
kind: 'variant',
rank,
id: variant.id,
title: variant.title,
route: {
name: 'story',
params: {
storyId: story.id,
},
query: {
variantId: variant.id,
},
},
path: [...story.file.path ?? [], story.title],
icon: variant.icon,
iconColor: variant.iconColor,
}
}
const results = computed(() => {
const list: SearchResult[] = []
if (rateLimitedSearch.value) {
const s = rateLimitedSearch.value.toLowerCase()
for (const story of storyStore.stories) {
const storyMatched = story.title.toLowerCase().includes(s)
let storyPathMatched = false
if (storyMatched || (storyPathMatched = story.file.path.some(p => p.toLowerCase().includes(s)))) {
list.push(storyResultFactory(story, storyMatched ? 1 : 4))
}
for (const variant of story.variants) {
const variantMatched = variant.title.toLowerCase().includes(s)
if (storyMatched || storyPathMatched || variantMatched) {
list.push(variantResultFactory(story, variant, variantMatched ? 2 : storyMatched ? 3 : 5))
}
}
}
}
return list.sort((a, b) => a.rank - b.rank)
})
// Selection
const selectedIndex = ref(0)
watch(results, () => {
selectedIndex.value = 0
})
function selectNext () {
selectedIndex.value++
if (selectedIndex.value > results.value.length - 1) {
selectedIndex.value = 0
}
}
function selectPrevious () {
selectedIndex.value--
if (selectedIndex.value < 0) {
selectedIndex.value = results.value.length - 1
}
}
</script>

<template>
Expand All @@ -139,42 +33,10 @@ function selectPrevious () {
@click="close()"
/>
<div class="htw-bg-white dark:htw-bg-gray-700 md:htw-mt-16 md:htw-mx-auto htw-w-screen htw-max-w-[512px] htw-shadow-xl htw-border htw-border-gray-200 dark:htw-border-gray-850 htw-rounded-lg htw-relative htw-divide-y htw-divide-gray-200 dark:htw-divide-gray-850">
<div
class="htw-flex htw-items-center htw-gap-4 htw-pl-6 htw-border htw-border-transparent focus-visible:htw-border-primary-500"
@click="focused = true"
>
<Icon
icon="carbon:search"
class="flex-none htw-w-4 htw-h-4"
/>

<input
ref="input"
v-model="searchInputText"
placeholder="Search for stories, variants..."
class="htw-bg-transparent htw-w-full htw-flex-1 htw-pl-0 htw-pr-6 htw-py-4 htw-outline-none"
@keydown.down.prevent="selectNext()"
@keydown.up.prevent="selectPrevious()"
@keydown.escape="close()"
>
</div>

<BaseEmpty v-if="rateLimitedSearch && !results.length">
No results
</BaseEmpty>

<div
v-else-if="results.length"
class="htw-max-h-[400px] htw-overflow-y-auto htw-rounded-b-lg"
>
<SearchItem
v-for="(result, index) of results"
:key="`${result.kind}-${result.id}`"
:result="result"
:selected="index === selectedIndex"
@close="close()"
/>
</div>
<SearchPane
:shown="shown"
@close="close()"
/>
</div>
</div>
</template>
Loading

0 comments on commit 02707af

Please sign in to comment.