Doc Search
VdDocSearch is a combobox/listbox search over a collection of documents you supply. Feed it a DocSearchDoc[] and the useDocSearch composable ranks matches (title > category > keywords > content), debounces typing, highlights the matched terms, and wires full keyboard navigation plus a global ⌘K / CtrlK shortcut. The component is a thin, accessible shell — reach for the composable directly when you want to render results your own way.
Live Search (press ⌘K / Ctrl+K)
Try typing switch, toast, menu or install — matched terms are highlighted and results are grouped by category.
<script setup lang="ts">
import { VdDocSearch, type DocSearchDoc } from "@vanduo-oss/vd3";
import { useRouter } from "vue-router";
const router = useRouter();
const docs: DocSearchDoc[] = [
{
title: "Button",
category: "Components",
href: "/components/button",
content: "Buttons trigger actions and submit forms.",
keywords: ["button", "cta", "action"],
},
{ title: "Switch", category: "Components", href: "/components/switch" },
// …more docs
];
function onSelect(result: DocSearchResult) {
router.push(result.href); // result.href defaults to #<id>
}
</script>
<template>
<VdDocSearch
:data="docs"
placeholder="Search the docs… (⌘K)"
@select="onSelect"
/>
</template>Selection & Search Events
Last @search: waiting for input…
Last @select: nothing chosen yet
Tuned: top 3, min 1, bold
:max-results="3", :min-query-length="1" and highlight-tag="strong" — searches on a single character and caps the list at three.
Headless — useDocSearch with custom markup
Same ranking, debounce and keyboard state, but the result list below is hand-rolled from the controller's reactive results — no VdDocSearch shell.
<script setup lang="ts">
import { ref } from "vue";
import { useDocSearch, type DocSearchDoc } from "@vanduo-oss/vd3";
const docs: DocSearchDoc[] = [/* … */];
const input = ref<HTMLInputElement | null>(null);
// Reactive source (ref / getter) is re-indexed automatically when it changes.
const { query, results, isOpen, activeIndex, select, handleKeydown } =
useDocSearch(() => docs, {
input, // ⌘K / Ctrl+K focuses this element
maxResults: 5,
debounceMs: 150,
onSelect: (r) => console.log("chose", r.href),
});
</script>
<template>
<input ref="input" v-model="query" @keydown="handleKeydown" />
<ul v-if="isOpen">
<li
v-for="(r, i) in results"
:key="r.id"
:class="{ active: i === activeIndex }"
@click="select(i)"
>
<!-- titleHtml is HTML-escaped by the composable; only the
highlight tag is injected, so v-html is safe here. -->
<span v-html="r.titleHtml" />
</li>
</ul>
</template>API Reference
Usage
<script setup lang="ts">
import { VdDocSearch, type DocSearchDoc } from "@vanduo-oss/vd3";
import { useRouter } from "vue-router";
const router = useRouter();
const docs: DocSearchDoc[] = [
{
title: "Button",
category: "Components",
href: "/components/button",
content: "Buttons trigger actions and submit forms.",
keywords: ["button", "cta", "action"],
},
{ title: "Switch", category: "Components", href: "/components/switch" },
// …more docs
];
function onSelect(result: DocSearchResult) {
router.push(result.href); // result.href defaults to #<id>
}
</script>
<template>
<VdDocSearch
:data="docs"
placeholder="Search the docs… (⌘K)"
@select="onSelect"
/>
</template>Component API — props
| Prop | Description |
|---|---|
:data | DocSearchDoc[] — the searchable documents (required). |
:min-query-length | Queries shorter than this never search (default 2). |
:max-results | Maximum results shown (default 10). |
:debounce-ms | Debounce applied to typing, in ms (default 150). |
:highlight-tag | Wrapper tag for matched terms; whitelist mark | span | strong | em (default 'mark'). |
:keyboard-shortcut | Enable the ⌘K / Ctrl+K shortcut and its badge (default true). |
:placeholder | Input placeholder (default 'Search...'). |
:empty-title | Empty-state heading (default 'No results found'). |
:empty-text | Empty-state body (default 'Try different keywords or check spelling'). |
:aria-label | Listbox accessible name (default 'Search results'). |
Events
| Event | Description |
|---|---|
@select | (result: DocSearchResult) — a result was chosen by click or Enter. |
@search | (query, results) — fired after a debounced search settles. |
@open | The results panel opened. |
@close | The results panel closed. |
The DocSearchDoc shape
| Field | Description |
|---|---|
title | string — primary label and the highest-weighted match field (required). |
id? | string — stable identifier; falls back to a slug of title. |
content? | string — body text; the lowest-weighted matches and the source of the excerpt. |
category? | string — grouping label; its slug also picks the result's category icon. |
keywords? | string[] — extra match terms; derived from title + content when omitted. |
href? | string — result destination (defaults to #<id>). |
icon? | string — explicit Phosphor icon class; falls back to the category icon. |
The useDocSearch composable
useDocSearch(docs: MaybeRefOrGetter<DocSearchDoc[]>, options?) is the headless engine behind the component. The index is rebuilt whenever the reactive docs source changes, so a live array (ref or getter) stays in sync. Ranking weights a title match at +100 (with +50 for an exact title and +25 for a prefix), a category match at +50, a keyword match at +30, and a content match at +10; results are sorted by score and sliced to maxResults. It is SSR-safe — the only browser access (the global ⌘K listener) is registered on mount and released on unmount.
Headless usage
<script setup lang="ts">
import { ref } from "vue";
import { useDocSearch, type DocSearchDoc } from "@vanduo-oss/vd3";
const docs: DocSearchDoc[] = [/* … */];
const input = ref<HTMLInputElement | null>(null);
// Reactive source (ref / getter) is re-indexed automatically when it changes.
const { query, results, isOpen, activeIndex, select, handleKeydown } =
useDocSearch(() => docs, {
input, // ⌘K / Ctrl+K focuses this element
maxResults: 5,
debounceMs: 150,
onSelect: (r) => console.log("chose", r.href),
});
</script>
<template>
<input ref="input" v-model="query" @keydown="handleKeydown" />
<ul v-if="isOpen">
<li
v-for="(r, i) in results"
:key="r.id"
:class="{ active: i === activeIndex }"
@click="select(i)"
>
<!-- titleHtml is HTML-escaped by the composable; only the
highlight tag is injected, so v-html is safe here. -->
<span v-html="r.titleHtml" />
</li>
</ul>
</template>Options
| Option | Description |
|---|---|
minQueryLength | number — gate below which no search runs (default 2). |
maxResults | number — cap on returned results (default 10). |
debounceMs | number — debounce before a settled search (default 150). |
highlightTag | string — safe wrapper tag for matches, mark | span | strong | em (default 'mark'). |
keyboardShortcut | boolean — enable the ⌘K / Ctrl+K focus shortcut (default true). |
input | Ref — the element ⌘K / Ctrl+K focuses (and selects). |
categoryIcons | Record<string, string> — category-slug → Phosphor icon overrides for results without an icon. |
onSelect / onSearch / onOpen / onClose | Lifecycle hooks. VdDocSearch forwards these as its four emits. |
Controller (return value)
| Member | Description |
|---|---|
query | Ref<string> — live input value; mutate it (v-model) to drive a debounced search. |
results | Ref<DocSearchResult[]> — the current ranked results. |
isOpen | Ref<boolean> — whether the results panel is open. |
activeIndex | Ref<number> — highlighted result index for keyboard nav, or -1. |
search(query?) | Run the ranking synchronously (no debounce); resets activeIndex and returns the results. |
open() / close() | Open or close the results panel. |
navigate(direction) | Move activeIndex by direction, wrapping at the ends. |
select(index?) | Select a result (defaults to the active one): closes, clears the query, fires onSelect. |
handleKeydown(event) | Combobox key handler for the input (Arrow / Enter / Escape / Tab). |
highlight(text, query?) | HTML-escape text and wrap matches in the highlight tag. |
The DocSearchResult shape (passed to @select)
| Field | Description |
|---|---|
score | number — relevance; results sort by it, descending, then keep document order. |
titleHtml / excerptHtml | HTML-escaped title / excerpt with matched terms wrapped in the highlight tag (safe for v-html). |
excerpt | Plain-text window around the first content match. |
categorySlug | Slug of category; drives the category-icon colour via data-category. |
id / title / category / content / href / icon | Carried through from the matching DocSearchDoc (resolved to defaults). |
Accessibility
- The input is a
role="combobox"witharia-autocomplete="list",aria-controlspointing at the listbox, andaria-expandedmirroring the open state. - The results panel is a
role="listbox"(named by:aria-label) and each result is arole="option"witharia-selected;aria-activedescendanttracks the highlighted option. - Fully keyboard operable: ↓ opens/advances, ↑ moves back (both wrap), Enter selects, Esc and Tab close.
- ⌘K / CtrlK focuses and selects the input from anywhere on the page (disable via
:keyboard-shortcut="false"). - Highlighted matches are HTML-escaped before the safe highlight tag is injected, so untrusted document text can be indexed without XSS risk.