Files
fmhy/docs/.vitepress/theme/components/VPLocalSearchBox.vue
bread dea24a8b93 fix exact search (#4640)
* disable select all text when clicked

* fix exact match search

* small fixes

* improve fuzzy search

* ignore invisible characters in search

* feature to navigate and scroll multiple search results in the same section

* add keyboard shortcut for highlight matches and also combine nearby highlighted matches

* comments

---------

Co-authored-by: nbats <44333466+nbats@users.noreply.github.com>
2026-01-26 04:37:10 -08:00

1284 lines
33 KiB
Vue

<script lang="ts" setup>
/**
* VPLocalSearchBox - Enhanced Local Search Modal Component
*
* Base: VitePress default local search component
*
* Custom Features Added:
* ----------------------
* 1. Fuzzy Search Toggle
* - Toggle between exact and fuzzy matching modes
* - Fuzzy mode includes typo tolerance and multi-word queries
* - Searches both space-separated and dash-separated variants
*
* 2. Smart Highlight Merging (Fuzzy Mode)
* - Automatically merges nearby highlights (< 20px apart) in fuzzy mode
* - Reduces navigation tedium when multiple words are highlighted
* - Preserves text between merged highlights
*
* 3. Match Navigation System
* - Navigate through highlights with left/right arrow keys
* - Visual controls: prev/next buttons with match counter (e.g., "2/5")
* - Smooth scrolling to center the active match
* - Yellow highlight for currently focused match
*
*/
import localSearchIndex from '@localSearchIndex'
import {
computedAsync,
debouncedWatch,
onKeyStroke,
useEventListener,
useLocalStorage,
useScrollLock,
useSessionStorage
} from '@vueuse/core'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import Mark from 'mark.js/src/vanilla.js'
import MiniSearch, { type SearchResult } from 'minisearch'
import { dataSymbol, inBrowser, useRouter } from 'vitepress'
import {
computed,
createApp,
markRaw,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
triggerRef,
watch,
watchEffect,
type Ref
} from 'vue'
import type { ModalTranslations } from 'vitepress/types/local-search'
import { pathToFile } from 'vitepress/dist/client/app/utils'
import { escapeRegExp } from 'vitepress/dist/client/shared'
import { useData } from 'vitepress/dist/client/theme-default/composables/data'
import { LRUCache } from 'vitepress/dist/client/theme-default/support/lru'
import { createSearchTranslate } from 'vitepress/dist/client/theme-default/support/translation'
import Tooltip from './Tooltip.vue'
import FloatingVue from 'floating-vue'
const emit = defineEmits<{
(e: 'close'): void
}>()
const el = shallowRef<HTMLElement>()
const resultsEl = shallowRef<HTMLElement>()
/* Search */
const searchIndexData = shallowRef(localSearchIndex)
// Hot Module Replacement - updates search index without full page reload during development
if (import.meta.hot) {
import.meta.hot.accept('/@localSearchIndex', (m) => {
if (m) {
searchIndexData.value = m.default
}
})
}
interface Result {
title: string
titles: string[]
text?: string
}
const vitePressData = useData()
const { activate } = useFocusTrap(el, {
immediate: true,
allowOutsideClick: true,
clickOutsideDeactivates: true,
escapeDeactivates: true
})
const { localeIndex, theme } = vitePressData
/**
* Fuzzy search toggle state.
* - false: Exact phrase matching (default)
* - true: Fuzzy matching with typo tolerance and multi-word queries
* Persisted in localStorage for user preference across sessions.
*/
const isFuzzySearch = useLocalStorage('vitepress:local-search-fuzzy', false)
const searchIndex = computedAsync(async () =>
markRaw(
MiniSearch.loadJSON<Result>(
(await searchIndexData.value[localeIndex.value]?.())?.default,
{
fields: ['title', 'titles', 'text'],
storeFields: ['title', 'titles'],
tokenize: (text: string) =>
text.replace(/[\u2060\u200B]/g, '').split(/[^a-zA-Z0-9\u00C0-\u00FF-]+/).filter((t) => t),
searchOptions: {
fuzzy: false,
prefix: true,
boost: { title: 4, text: 2, titles: 1 },
...(theme.value.search?.provider === 'local' &&
theme.value.search.options?.miniSearch?.searchOptions)
},
...(theme.value.search?.provider === 'local' &&
theme.value.search.options?.miniSearch?.options)
}
)
)
)
const disableQueryPersistence = computed(() => {
return (
theme.value.search?.provider === 'local' &&
theme.value.search.options?.disableQueryPersistence === true
)
})
const filterText = disableQueryPersistence.value
? ref('')
: useSessionStorage('vitepress:local-search-filter', '')
const showDetailedList = useLocalStorage(
'vitepress:local-search-detailed-list',
theme.value.search?.provider === 'local' &&
theme.value.search.options?.detailedView === true
)
const disableDetailedView = computed(() => {
return (
theme.value.search?.provider === 'local' &&
(theme.value.search.options?.disableDetailedView === true ||
theme.value.search.options?.detailedView === false)
)
})
const buttonText = computed(() => {
const options = theme.value.search?.options ?? theme.value.algolia
return (
options?.locales?.[localeIndex.value]?.translations?.button?.buttonText ||
options?.translations?.button?.buttonText ||
'Search'
)
})
watchEffect(() => {
if (disableDetailedView.value) {
showDetailedList.value = false
}
})
const results: Ref<(SearchResult & Result)[]> = shallowRef([])
const enableNoResults = ref(false)
watch(filterText, () => {
enableNoResults.value = false
})
const mark = computedAsync(async () => {
if (!resultsEl.value) return
return markRaw(new Mark(resultsEl.value))
}, null)
// LRU cache for rendered excerpts (16 most recently viewed files)
const cache = new LRUCache<string, Map<string, string>>(16)
/**
* Main search handler - debounced to avoid excessive re-renders while typing.
* Watches: search index, filter text, detail view toggle, and fuzzy search mode.
*/
debouncedWatch(
() => [searchIndex.value, filterText.value, showDetailedList.value, isFuzzySearch.value] as const,
async ([index, filterTextValue, showDetailedListValue, fuzzySearchValue], old, onCleanup) => {
if (old?.[0] !== index) {
// Clear cache on index change (e.g., locale switch or HMR update)
cache.clear()
}
let canceled = false
onCleanup(() => {
canceled = true
})
if (!index) return
/**
* Configure search options based on fuzzy mode.
* Fuzzy search splits multi-word queries and searches for:
* 1. All words present (AND) - matches "PC Optimization Hub"
* 2. Dashed version - matches "PC-Optimization-Hub"
* This allows flexible matching of space-separated or dash-separated content.
*/
const searchOptions = {
fuzzy: isFuzzySearch.value ? 0.2 : false
}
let query: any = filterTextValue
if (isFuzzySearch.value) {
const parts = filterTextValue.split(/\s+/).filter((p) => p)
if (parts.length > 0) {
const dashed = parts.join('-')
query = {
combineWith: 'OR',
queries: [
{
queries: parts,
combineWith: 'AND',
fuzzy: 0.2
},
{
queries: [dashed],
combineWith: 'AND',
fuzzy: 0.2
}
]
}
}
}
results.value = index
.search(query, searchOptions)
.slice(0, 16) as (SearchResult & Result)[]
enableNoResults.value = true
// Fetch and process excerpts for detailed view highlighting
const mods = showDetailedListValue
? await Promise.all(results.value.map((r) => fetchExcerpt(r.id)))
: []
if (canceled) return
await processExcerpts(mods, vitePressData, () => canceled)
if (canceled) return
const terms = new Set<string>()
results.value = results.value.map((r) => {
const [id, anchor] = r.id.split('#')
const map = cache.get(id)
const text = map?.get(anchor) ?? ''
if (isFuzzySearch.value) {
for (const term in r.match) {
terms.add(term)
}
}
return { ...r, text }
})
if (!isFuzzySearch.value) {
terms.add(filterTextValue)
results.value = filterResults(results.value, filterTextValue)
}
await nextTick()
if (canceled) return
await new Promise((r) => {
mark.value?.unmark({
done: () => {
mark.value?.markRegExp(formMarkRegex(terms), { done: r })
}
})
})
/**
* Custom feature: Merge nearby highlights in fuzzy mode.
* Combines individual word highlights that are close together (< 20px apart)
* into single continuous highlights, reducing navigation tedium.
*/
if (isFuzzySearch.value) {
await mergeNearbyMarks()
}
const excerpts = Array.from(el.value?.querySelectorAll('.result .excerpt') ?? [])
for (const excerpt of excerpts) {
excerpt
.querySelector('mark[data-markjs="true"]')
?.scrollIntoView({ block: 'center' })
}
/**
* Custom feature: Initialize match navigation state.
* Collects all highlight marks in each result for prev/next navigation.
* Each result tracks its own array of marks and current position.
*/
const newResultMarks = new Map<number, HTMLElement[]>()
const newCurrentMarkIndex = new Map<number, number>()
results.value.forEach((_, index) => {
const item = el.value?.querySelector(`#localsearch-item-${index}`)
const marks = Array.from(item?.querySelectorAll('.excerpt mark[data-markjs="true"]') ?? []) as HTMLElement[]
if (marks.length > 0) {
newResultMarks.set(index, marks)
newCurrentMarkIndex.set(index, 0)
}
})
resultMarks.value = newResultMarks
currentMarkIndex.value = newCurrentMarkIndex
// FIXME: without this whole page scrolls to the bottom
resultsEl.value?.firstElementChild?.scrollIntoView({ block: 'start' })
},
{ debounce: 200, immediate: true }
)
/* Custom Feature: Match Navigation State */
const resultMarks = shallowRef<Map<number, HTMLElement[]>>(new Map())
const currentMarkIndex = shallowRef<Map<number, number>>(new Map())
/**
* Merges adjacent highlight marks that are visually close together.
* This reduces the number of navigation stops in fuzzy mode where
* each individual word match would otherwise be a separate highlight.
*
* Merging criteria:
* - Marks must be on the same line (within 5px vertical distance)
* - Marks must be close horizontally (< 20px apart)
*/
async function mergeNearbyMarks() {
const excerpts = Array.from(el.value?.querySelectorAll('.result .excerpt') ?? [])
for (const excerpt of excerpts) {
const marks = Array.from(excerpt.querySelectorAll('mark[data-markjs="true"]')) as HTMLElement[]
if (marks.length <= 1) continue
// Process marks to merge those within 20 characters of each other
let i = 0
while (i < marks.length - 1) {
const currentMark = marks[i]
const nextMark = marks[i + 1]
// Calculate distance between marks
const currentEnd = currentMark.offsetLeft + currentMark.offsetWidth
const nextStart = nextMark.offsetLeft
const distance = nextStart - currentEnd
// Also check if they're on the same line (similar offsetTop)
const onSameLine = Math.abs(currentMark.offsetTop - nextMark.offsetTop) < 5
// Merge if they're close (within 20px) and on the same line
if (distance >= 0 && distance < 20 && onSameLine) {
// Create a merged mark element
const textBetween = getTextBetweenMarks(currentMark, nextMark)
const mergedText = currentMark.textContent + textBetween + nextMark.textContent
currentMark.textContent = mergedText
// Remove the next mark
nextMark.remove()
marks.splice(i + 1, 1)
} else {
i++
}
}
}
}
/**
* Extracts the plain text content between two mark elements.
* Used when merging adjacent highlights to preserve the spacing/text between them.
*/
function getTextBetweenMarks(mark1: HTMLElement, mark2: HTMLElement): string {
const parent = mark1.parentNode
if (!parent) return ''
let text = ''
let node: Node | null = mark1.nextSibling
while (node && node !== mark2) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent || ''
}
node = node.nextSibling
}
return text
}
/**
* Custom feature: Navigate to the next highlighted match in the current result.
* Cycles back to the first match when reaching the end.
* Smoothly scrolls the excerpt to center the highlighted match.
*/
function nextMatch(index: number) {
const marks = resultMarks.value.get(index)
let curr = currentMarkIndex.value.get(index) ?? 0
if (!marks) return
// Remove 'current' class from previous mark
marks[curr].classList.remove('current')
curr = (curr + 1) % marks.length
currentMarkIndex.value.set(index, curr)
triggerRef(currentMarkIndex)
// Add 'current' class to new mark
const newMark = marks[curr]
newMark.classList.add('current')
const excerpt = newMark.closest('.excerpt')
if (excerpt) {
const markTop = newMark.offsetTop
const markHeight = newMark.offsetHeight
const excerptHeight = excerpt.clientHeight
excerpt.scrollTo({
top: markTop - excerptHeight / 2 + markHeight / 2,
behavior: 'smooth'
})
}
}
/**
* Custom feature: Navigate to the previous highlighted match in the current result.
* Cycles to the last match when going before the first.
* Smoothly scrolls the excerpt to center the highlighted match.
*/
function prevMatch(index: number) {
const marks = resultMarks.value.get(index)
let curr = currentMarkIndex.value.get(index) ?? 0
if (!marks) return
// Remove 'current' class from previous mark
marks[curr].classList.remove('current')
curr = (curr - 1 + marks.length) % marks.length
currentMarkIndex.value.set(index, curr)
triggerRef(currentMarkIndex)
// Add 'current' class to new mark
const newMark = marks[curr]
newMark.classList.add('current')
const excerpt = newMark.closest('.excerpt')
if (excerpt) {
const markTop = newMark.offsetTop
const markHeight = newMark.offsetHeight
const excerptHeight = excerpt.clientHeight
excerpt.scrollTo({
top: markTop - excerptHeight / 2 + markHeight / 2,
behavior: 'smooth'
})
}
}
async function fetchExcerpt(id: string) {
const file = pathToFile(id.slice(0, id.indexOf('#')))
try {
if (!file) throw new Error(`Cannot find file for id: ${id}`)
return { id, mod: await import(/*@vite-ignore*/ file) }
} catch (e) {
console.error(e)
return { id, mod: {} }
}
}
async function processExcerpts(
mods: { id: string; mod: any }[],
vitePressData: any,
isCanceled: () => boolean
) {
for (const { id, mod } of mods) {
if (isCanceled()) return
const mapId = id.slice(0, id.indexOf('#'))
let map = cache.get(mapId)
if (map) continue
map = new Map()
cache.set(mapId, map)
const comp = mod.default ?? mod
if (comp?.render || comp?.setup) {
const app = createApp(comp)
app.use(FloatingVue)
app.component('Tooltip', Tooltip)
app.config.warnHandler = () => {}
app.provide(dataSymbol, vitePressData)
Object.defineProperties(app.config.globalProperties, {
$frontmatter: {
get() {
return vitePressData.frontmatter.value
}
},
$params: {
get() {
return vitePressData.page.value.params
}
}
})
const div = document.createElement('div')
app.mount(div)
const headings = div.querySelectorAll('h1, h2, h3, h4, h5, h6')
headings.forEach((el) => {
const href = el.querySelector('a')?.getAttribute('href')
const anchor = href?.startsWith('#') && href.slice(1)
if (!anchor) return
let html = ''
while ((el = el.nextElementSibling!) && !/^h[1-6]$/i.test(el.tagName))
html += el.outerHTML
map!.set(anchor, html)
})
app.unmount()
}
}
}
function filterResults(results: (SearchResult & Result)[], filterTextValue: string) {
return results.filter((r) => {
const phrase = filterTextValue.toLowerCase()
const inText = r.text?.toLowerCase().includes(phrase)
const inTitle = r.title.toLowerCase().includes(phrase)
const inTitles = r.titles.some((t) => t.toLowerCase().includes(phrase))
return inText || inTitle || inTitles
})
}
/* Search input focus */
const searchInput = ref<HTMLInputElement>()
const disableReset = computed(() => {
return filterText.value?.length <= 0
})
function focusSearchInput(select = true) {
searchInput.value?.focus()
select && searchInput.value?.select()
}
onMounted(() => {
focusSearchInput()
})
function onSearchBarClick(event: PointerEvent) {
if (event.pointerType === 'mouse') {
focusSearchInput(false)
}
}
/* Search keyboard selection */
const selectedIndex = ref(-1)
const disableMouseOver = ref(true)
watch(results, (r) => {
selectedIndex.value = r.length ? 0 : -1
scrollToSelectedResult()
})
function scrollToSelectedResult() {
nextTick(() => {
const selectedEl = document.querySelector('.result.selected')
selectedEl?.scrollIntoView({ block: 'nearest' })
})
}
onKeyStroke('ArrowUp', (event) => {
event.preventDefault()
selectedIndex.value--
if (selectedIndex.value < 0) {
selectedIndex.value = results.value.length - 1
}
disableMouseOver.value = true
scrollToSelectedResult()
})
onKeyStroke('ArrowDown', (event) => {
event.preventDefault()
selectedIndex.value++
if (selectedIndex.value >= results.value.length) {
selectedIndex.value = 0
}
disableMouseOver.value = true
scrollToSelectedResult()
})
const router = useRouter()
onKeyStroke('Enter', (e) => {
if (e.isComposing) return
if (e.target instanceof HTMLButtonElement && e.target.type !== 'submit')
return
const selectedPackage = results.value[selectedIndex.value]
if (e.target instanceof HTMLInputElement && !selectedPackage) {
e.preventDefault()
return
}
if (selectedPackage) {
router.go(selectedPackage.id)
emit('close')
}
})
onKeyStroke('Escape', () => {
emit('close')
})
/**
* Custom feature: Keyboard navigation for cycling through highlights.
* Left/Right arrow keys navigate prev/next match within the selected result.
* Only active when detailed view is enabled and matches exist.
*/
onKeyStroke('ArrowLeft', (event) => {
// Navigate to previous match - only when viewing detailed excerpts with highlights
if (showDetailedList.value && selectedIndex.value >= 0 && (resultMarks.value.get(selectedIndex.value)?.length ?? 0) > 0) {
event.preventDefault()
prevMatch(selectedIndex.value)
}
})
onKeyStroke('ArrowRight', (event) => {
// Navigate to next match - only when viewing detailed excerpts with highlights
if (showDetailedList.value && selectedIndex.value >= 0 && (resultMarks.value.get(selectedIndex.value)?.length ?? 0) > 0) {
event.preventDefault()
nextMatch(selectedIndex.value)
}
})
// Translations
const defaultTranslations: { modal: ModalTranslations } = {
modal: {
displayDetails: 'Display detailed list',
resetButtonTitle: 'Reset search',
backButtonTitle: 'Close search',
noResultsText: 'No results for',
footer: {
selectText: 'to select',
selectKeyAriaLabel: 'enter',
navigateText: 'to navigate',
navigateUpKeyAriaLabel: 'up arrow',
navigateDownKeyAriaLabel: 'down arrow',
closeText: 'to close',
closeKeyAriaLabel: 'escape'
}
}
}
const translate = createSearchTranslate(defaultTranslations)
// Back
onMounted(() => {
// Prevents going to previous site
window.history.pushState(null, '', null)
})
useEventListener('popstate', (event) => {
event.preventDefault()
emit('close')
})
/** Lock body */
const isLocked = useScrollLock(inBrowser ? document.body : null)
onMounted(() => {
nextTick(() => {
isLocked.value = true
nextTick().then(() => activate())
})
})
onBeforeUnmount(() => {
isLocked.value = false
})
function resetSearch() {
filterText.value = ''
nextTick().then(() => focusSearchInput(false))
}
function handleInput(e: Event) {
filterText.value = (e.target as HTMLInputElement).value
}
function toggleFuzzySearch() {
isFuzzySearch.value = !isFuzzySearch.value
}
function formMarkRegex(terms: Set<string>) {
return new RegExp(
[...terms]
.sort((a, b) => b.length - a.length)
.map((term) => `(${escapeRegExp(term)})`)
.join('|'),
'gi'
)
}
function onMouseMove(e: MouseEvent) {
if (!disableMouseOver.value) return
const el = (e.target as HTMLElement)?.closest<HTMLAnchorElement>('.result')
const index = Number.parseInt(el?.dataset.index!)
if (index >= 0 && index !== selectedIndex.value) {
selectedIndex.value = index
}
disableMouseOver.value = false
}
</script>
<template>
<Teleport to="body">
<div
ref="el"
role="button"
:aria-owns="results?.length ? 'localsearch-list' : undefined"
aria-expanded="true"
aria-haspopup="listbox"
aria-labelledby="localsearch-label"
class="VPLocalSearchBox"
>
<div class="backdrop" @click="$emit('close')" />
<div class="shell">
<form
class="search-bar"
@pointerup="onSearchBarClick($event)"
@submit.prevent=""
>
<label
:title="buttonText"
id="localsearch-label"
for="localsearch-input"
>
<span aria-hidden="true" class="vpi-search search-icon local-search-icon" />
</label>
<div class="search-actions before">
<button
class="back-button"
:title="translate('modal.backButtonTitle')"
@click="$emit('close')"
>
<span class="vpi-arrow-left local-search-icon" />
</button>
</div>
<input
ref="searchInput"
:value="filterText"
@input="handleInput"
:aria-activedescendant="selectedIndex > -1 ? ('localsearch-item-' + selectedIndex) : undefined"
aria-autocomplete="both"
:aria-controls="results?.length ? 'localsearch-list' : undefined"
aria-labelledby="localsearch-label"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
class="search-input"
id="localsearch-input"
enterkeyhint="go"
maxlength="64"
:placeholder="buttonText"
spellcheck="false"
type="search"
/>
<div class="search-actions">
<button
v-if="!disableDetailedView"
class="toggle-layout-button"
type="button"
:class="{ 'detailed-list': showDetailedList }"
:title="translate('modal.displayDetails')"
@click="
selectedIndex > -1 && (showDetailedList = !showDetailedList)
"
>
<span class="vpi-layout-list local-search-icon" />
</button>
<button
class="toggle-fuzzy-button"
type="button"
:class="{ 'fuzzy-active': isFuzzySearch }"
:title="isFuzzySearch ? 'Switch to Exact Search' : 'Switch to Fuzzy Search'"
@click="toggleFuzzySearch"
>
<span v-if="isFuzzySearch" class="fuzzy-icon">~</span>
<span v-else class="exact-icon">=</span>
</button>
<button
class="clear-button"
type="reset"
:disabled="disableReset"
:title="translate('modal.resetButtonTitle')"
@click="resetSearch"
>
<span class="vpi-delete local-search-icon" />
</button>
</div>
</form>
<ul
ref="resultsEl"
:id="results?.length ? 'localsearch-list' : undefined"
:role="results?.length ? 'listbox' : undefined"
:aria-labelledby="results?.length ? 'localsearch-label' : undefined"
class="results"
@mousemove="onMouseMove"
>
<li
v-for="(p, index) in results"
:key="p.id"
:id="'localsearch-item-' + index"
:aria-selected="selectedIndex === index ? 'true' : 'false'"
role="option"
>
<a
:href="p.id"
class="result"
:class="{
selected: selectedIndex === index
}"
:aria-label="[...p.titles, p.title].join(' > ')"
@mouseenter="!disableMouseOver && (selectedIndex = index)"
@focusin="selectedIndex = index"
@click="$emit('close')"
:data-index="index"
>
<div>
<div class="titles">
<span class="title-icon">#</span>
<span
v-for="(t, index) in p.titles"
:key="index"
class="title"
>
<span class="text" v-html="t" />
<span class="vpi-chevron-right local-search-icon" />
</span>
<span class="title main">
<span class="text" v-html="p.title" />
</span>
</div>
<div v-if="showDetailedList" class="excerpt-wrapper">
<div v-if="p.text" class="excerpt" inert>
<div class="vp-doc" v-html="p.text" />
</div>
<div class="excerpt-gradient-bottom" />
<div class="excerpt-gradient-top" />
<div v-if="(resultMarks.get(index)?.length ?? 0) > 1" class="excerpt-actions" @click.prevent.stop @mousedown.prevent.stop>
<button class="match-nav-button" @click.prevent.stop="prevMatch(index)" title="Previous match">
<span class="vpi-chevron-left navigate-icon" />
</button>
<span class="match-count">{{ (currentMarkIndex.get(index) ?? 0) + 1 }}/{{ resultMarks.get(index)?.length }}</span>
<button class="match-nav-button" @click.prevent.stop="nextMatch(index)" title="Next match">
<span class="vpi-chevron-right navigate-icon" />
</button>
</div>
</div>
</div>
</a>
</li>
<li
v-if="filterText && !results.length && enableNoResults"
class="no-results"
>
{{ translate('modal.noResultsText') }} "<strong>{{ filterText }}</strong
>"
</li>
</ul>
<div class="search-keyboard-shortcuts">
<span>
<kbd :aria-label="translate('modal.footer.navigateUpKeyAriaLabel')">
<span class="vpi-arrow-up navigate-icon" />
</kbd>
<kbd :aria-label="translate('modal.footer.navigateDownKeyAriaLabel')">
<span class="vpi-arrow-down navigate-icon" />
</kbd>
{{ translate('modal.footer.navigateText') }}
</span>
<span>
<kbd :aria-label="translate('modal.footer.selectKeyAriaLabel')">
<span class="vpi-corner-down-left navigate-icon" />
</kbd>
{{ translate('modal.footer.selectText') }}
</span>
<span v-if="showDetailedList">
<kbd>
<span class="vpi-arrow-left navigate-icon" />
</kbd>
<kbd>
<span class="vpi-arrow-right navigate-icon" />
</kbd>
to cycle matches
</span>
<span>
<kbd :aria-label="translate('modal.footer.closeKeyAriaLabel')">esc</kbd>
{{ translate('modal.footer.closeText') }}
</span>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.VPLocalSearchBox {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
}
.backdrop {
position: absolute;
inset: 0;
background: var(--vp-backdrop-bg-color);
transition: opacity 0.5s;
}
.shell {
position: relative;
padding: 12px;
margin: 64px auto;
display: flex;
flex-direction: column;
gap: 16px;
background: var(--vp-local-search-bg);
width: min(100vw - 60px, 900px);
height: min-content;
max-height: min(100vh - 128px, 900px);
border-radius: 6px;
}
@media (max-width: 767px) {
.shell {
margin: 0;
width: 100vw;
height: 100vh;
max-height: none;
border-radius: 0;
}
}
.search-bar {
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
display: flex;
align-items: center;
padding: 0 12px;
cursor: text;
}
@media (max-width: 767px) {
.search-bar {
padding: 0 8px;
}
}
.search-bar:focus-within {
border-color: var(--vp-c-brand-1);
}
.local-search-icon {
display: block;
font-size: 18px;
}
.navigate-icon {
display: block;
font-size: 14px;
}
.search-icon {
margin: 8px;
}
@media (max-width: 767px) {
.search-icon {
display: none;
}
}
.search-input {
padding: 6px 12px;
font-size: inherit;
width: 100%;
}
/* Custom Feature: Match navigation controls overlay */
.excerpt-actions {
position: absolute;
bottom: 5px;
right: 5px;
display: flex;
align-items: center;
gap: 4px;
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
padding: 2px 4px;
box-shadow: var(--vp-shadow-1);
}
.match-nav-button {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 2px;
color: var(--vp-c-text-2);
transition: color 0.2s, background-color 0.2s;
}
.match-nav-button:hover {
color: var(--vp-c-text-1);
background-color: var(--vp-c-bg-soft);
}
.match-count {
font-size: 11px;
font-family: var(--vp-font-family-mono);
color: var(--vp-c-text-2);
user-select: none;
min-width: 24px;
text-align: center;
}
@media (max-width: 767px) {
.search-input {
padding: 6px 4px;
}
}
.search-actions {
display: flex;
gap: 4px;
}
@media (any-pointer: coarse) {
.search-actions {
gap: 8px;
}
}
@media (min-width: 769px) {
.search-actions.before {
display: none;
}
}
.search-actions button {
padding: 8px;
}
.search-actions button:not([disabled]):hover,
.toggle-layout-button.detailed-list {
color: var(--vp-c-brand-1);
}
.search-actions button.clear-button:disabled {
opacity: 0.37;
}
/* Custom Feature: Fuzzy search toggle button */
.toggle-fuzzy-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 16px;
font-weight: bold;
border-radius: 4px;
transition: all 0.2s;
}
.toggle-fuzzy-button .fuzzy-icon,
.toggle-fuzzy-button .exact-icon {
font-size: 18px;
font-weight: bold;
line-height: 1;
}
.toggle-fuzzy-button:hover {
background: var(--vp-c-bg-soft);
}
.toggle-fuzzy-button.fuzzy-active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg-soft);
}
.search-keyboard-shortcuts {
font-size: 0.8rem;
opacity: 75%;
display: flex;
flex-wrap: wrap;
gap: 16px;
line-height: 14px;
}
.search-keyboard-shortcuts span {
display: flex;
align-items: center;
gap: 4px;
}
@media (max-width: 767px) {
.search-keyboard-shortcuts {
display: none;
}
}
.search-keyboard-shortcuts kbd {
background: rgba(128, 128, 128, 0.1);
border-radius: 4px;
padding: 3px 6px;
min-width: 24px;
display: inline-block;
text-align: center;
vertical-align: middle;
border: 1px solid rgba(128, 128, 128, 0.15);
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1);
}
.results {
display: flex;
flex-direction: column;
gap: 6px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.result {
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
transition: none;
line-height: 1rem;
border: solid 2px var(--vp-local-search-result-border);
outline: none;
}
.result > div {
margin: 12px;
width: 100%;
overflow: hidden;
}
@media (max-width: 767px) {
.result > div {
margin: 8px;
}
}
.titles {
display: flex;
flex-wrap: wrap;
gap: 4px;
position: relative;
z-index: 1001;
padding: 2px 0;
}
.title {
display: flex;
align-items: center;
gap: 4px;
}
.title.main {
font-weight: 500;
}
.title-icon {
opacity: 0.5;
font-weight: 500;
color: var(--vp-c-brand-1);
}
.title svg {
opacity: 0.5;
}
.result.selected {
--vp-local-search-result-bg: var(--vp-local-search-result-selected-bg);
border-color: var(--vp-local-search-result-selected-border);
}
.excerpt-wrapper {
position: relative;
}
.excerpt {
opacity: 50%;
pointer-events: none;
max-height: 140px;
overflow: hidden;
position: relative;
margin-top: 4px;
}
.result.selected .excerpt {
opacity: 1;
}
.excerpt :deep(*) {
font-size: 0.8rem !important;
line-height: 130% !important;
}
/* Highlight styles - default state */
.titles :deep(mark),
.excerpt :deep(mark) {
background-color: var(--vp-local-search-highlight-bg);
color: var(--vp-local-search-highlight-text);
border-radius: 2px;
padding: 0 2px;
transition: background-color 0.2s;
}
/* Custom Feature: Currently focused highlight (during navigation) */
.excerpt :deep(mark.current) {
background-color: var(--vp-c-yellow-3);
color: #000;
font-weight: bold;
}
.excerpt :deep(.vp-code-group) .tabs {
display: none;
}
.excerpt :deep(.vp-code-group) div[class*='language-'] {
border-radius: 8px !important;
}
.excerpt-gradient-bottom {
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 8px;
background: linear-gradient(transparent, var(--vp-local-search-result-bg));
z-index: 1000;
}
.excerpt-gradient-top {
position: absolute;
top: -1px;
left: 0;
width: 100%;
height: 8px;
background: linear-gradient(var(--vp-local-search-result-bg), transparent);
z-index: 1000;
}
.result.selected .titles,
.result.selected .title-icon {
color: var(--vp-c-brand-1) !important;
}
.no-results {
font-size: 0.9rem;
text-align: center;
padding: 12px;
}
svg {
flex: none;
}
</style>