Skip to content

Commit c43e869

Browse files
committed
feat: add docs search modal
1 parent b32e836 commit c43e869

7 files changed

Lines changed: 367 additions & 1 deletion

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script setup lang="ts">
2+
import { DocsSearchModal, type DocsSearchItem } from "~/registry/blocks/docs-search-modal";
3+
4+
const open = ref(false);
5+
6+
const items: DocsSearchItem[] = [
7+
{
8+
title: "Introduction",
9+
description: "Start here to understand the docs template structure.",
10+
href: "/docs/components",
11+
section: "Getting Started",
12+
content: "overview installation usage documentation template",
13+
},
14+
{
15+
title: "Chat Messages",
16+
description: "Render chat message lists with scrolling behavior.",
17+
href: "/docs/components/chat-messages",
18+
section: "Chat",
19+
content: "chat messages auto scroll assistant actions",
20+
},
21+
{
22+
title: "Chat Prompt",
23+
description: "Build chat input flows with keyboard shortcuts.",
24+
href: "/docs/components/chat-prompt",
25+
section: "Chat",
26+
content: "prompt input enter shift enter submit",
27+
},
28+
];
29+
</script>
30+
31+
<template>
32+
<div class="flex w-full justify-center">
33+
<button
34+
type="button"
35+
class="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
36+
@click="open = true"
37+
>
38+
Search docs
39+
<kbd class="rounded border bg-muted px-1.5 font-mono text-[10px] text-muted-foreground">Cmd K</kbd>
40+
</button>
41+
42+
<DocsSearchModal
43+
v-model:open="open"
44+
:items="items"
45+
:shortcut="false"
46+
/>
47+
</div>
48+
</template>

app/composables/useNavigation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const sectionCategories: Record<string, { id: string; title: string }[]>
66
],
77
"components": [
88
{ id: "overview", title: "Overview" },
9+
{ id: "content", title: "Content" },
910
{ id: "element", title: "Element" },
1011
{ id: "chat", title: "AI Chat" },
1112
],
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from "vue";
3+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
4+
import { Search } from "@lucide/vue";
5+
import { cn } from "@/lib/utils";
6+
7+
export interface DocsSearchItem {
8+
title: string;
9+
href: string;
10+
description?: string;
11+
section?: string;
12+
content?: string;
13+
}
14+
15+
export interface DocsSearchModalProps {
16+
/** Searchable items supplied by the consuming app */
17+
items?: DocsSearchItem[];
18+
/** Search input placeholder */
19+
placeholder?: string;
20+
/** Label used for the dialog and search input */
21+
searchLabel?: string;
22+
/** Text shown when no query is entered and no items are available */
23+
emptyText?: string;
24+
/** Text shown when a query has no matches */
25+
noResultsText?: string;
26+
/** Enable Cmd/Ctrl+K keyboard shortcut */
27+
shortcut?: boolean;
28+
/** Additional CSS classes for the modal panel */
29+
class?: HTMLAttributes["class"];
30+
}
31+
32+
const props = withDefaults(defineProps<DocsSearchModalProps>(), {
33+
items: () => [],
34+
placeholder: "Search documentation...",
35+
searchLabel: "Search documentation",
36+
emptyText: "No pages available.",
37+
noResultsText: "No results found.",
38+
shortcut: true,
39+
});
40+
41+
const open = defineModel<boolean>("open", { default: false });
42+
const query = ref("");
43+
const inputRef = ref<HTMLInputElement>();
44+
const panelRef = ref<HTMLElement>();
45+
46+
const trimmedQuery = computed(() => query.value.trim().toLowerCase());
47+
48+
const results = computed(() => {
49+
if (!trimmedQuery.value) return props.items;
50+
51+
return props.items.filter((item) => {
52+
const haystack = [
53+
item.title,
54+
item.description,
55+
item.section,
56+
item.content,
57+
].filter(Boolean).join(" ").toLowerCase();
58+
59+
return haystack.includes(trimmedQuery.value);
60+
});
61+
});
62+
63+
function closeSearch() {
64+
open.value = false;
65+
query.value = "";
66+
}
67+
68+
function onKeydown(event: KeyboardEvent) {
69+
if (props.shortcut && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
70+
event.preventDefault();
71+
open.value = true;
72+
}
73+
74+
if (event.key === "Escape" && open.value) {
75+
closeSearch();
76+
}
77+
}
78+
79+
function onDialogKeydown(event: KeyboardEvent) {
80+
if (event.key !== "Tab") return;
81+
82+
const focusable = panelRef.value?.querySelectorAll<HTMLElement>(
83+
"a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])",
84+
);
85+
if (!focusable?.length) return;
86+
87+
const first = focusable[0];
88+
const last = focusable[focusable.length - 1];
89+
if (!first || !last) return;
90+
91+
if (event.shiftKey && document.activeElement === first) {
92+
event.preventDefault();
93+
last.focus();
94+
}
95+
else if (!event.shiftKey && document.activeElement === last) {
96+
event.preventDefault();
97+
first.focus();
98+
}
99+
}
100+
101+
onMounted(() => {
102+
window.addEventListener("keydown", onKeydown);
103+
});
104+
105+
onBeforeUnmount(() => {
106+
window.removeEventListener("keydown", onKeydown);
107+
});
108+
109+
watch(open, async (value) => {
110+
if (value) {
111+
await nextTick();
112+
inputRef.value?.focus();
113+
}
114+
else {
115+
query.value = "";
116+
}
117+
});
118+
</script>
119+
120+
<template>
121+
<Teleport to="body">
122+
<div
123+
v-if="open"
124+
class="fixed inset-0 z-50"
125+
role="dialog"
126+
aria-modal="true"
127+
:aria-label="searchLabel"
128+
data-slot="docs-search-modal"
129+
@keydown="onDialogKeydown"
130+
>
131+
<button
132+
class="absolute inset-0 bg-background/80 backdrop-blur-sm"
133+
type="button"
134+
aria-label="Close search"
135+
@click="closeSearch"
136+
/>
137+
138+
<div
139+
ref="panelRef"
140+
:class="cn('relative mx-auto mt-20 w-[calc(100%-2rem)] max-w-2xl overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-xl', props.class)"
141+
>
142+
<div class="flex items-center gap-3 border-b px-4 py-3">
143+
<Search
144+
class="size-4.5 text-muted-foreground"
145+
aria-hidden="true"
146+
/>
147+
<input
148+
ref="inputRef"
149+
v-model="query"
150+
type="search"
151+
:aria-label="searchLabel"
152+
:placeholder="placeholder"
153+
class="h-10 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
154+
>
155+
<kbd class="hidden h-6 select-none items-center rounded border bg-muted px-2 font-mono text-xs text-muted-foreground sm:inline-flex">Esc</kbd>
156+
</div>
157+
158+
<div class="max-h-[min(28rem,calc(100vh-10rem))] overflow-y-auto p-2">
159+
<a
160+
v-for="item in results"
161+
:key="item.href"
162+
:href="item.href"
163+
class="block rounded-lg p-3 hover:bg-accent hover:text-accent-foreground"
164+
@click="closeSearch"
165+
>
166+
<slot
167+
name="item"
168+
:item="item"
169+
:query="query"
170+
>
171+
<div class="flex items-center justify-between gap-3">
172+
<p class="font-medium">
173+
{{ item.title }}
174+
</p>
175+
<span
176+
v-if="item.section"
177+
class="text-xs text-muted-foreground"
178+
>{{ item.section }}</span>
179+
</div>
180+
<p
181+
v-if="item.description"
182+
class="mt-1 line-clamp-2 text-sm text-muted-foreground"
183+
>
184+
{{ item.description }}
185+
</p>
186+
</slot>
187+
</a>
188+
189+
<p
190+
v-if="!results.length"
191+
class="px-3 py-8 text-center text-sm text-muted-foreground"
192+
>
193+
{{ trimmedQuery ? noResultsText : emptyText }}
194+
</p>
195+
</div>
196+
</div>
197+
</div>
198+
</Teleport>
199+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as DocsSearchModal, type DocsSearchModalProps, type DocsSearchItem } from "./DocsSearchModal.vue";

content.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default defineContentConfig({
99
},
1010
schema: z.object({
1111
category: z
12-
.enum(["element", "chat", "overview"])
12+
.enum(["element", "content", "chat", "overview"])
1313
.optional(),
1414
}),
1515
}),
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
---
2+
title: DocsSearchModal
3+
description: Add a lightweight local search modal to Nuxt documentation interfaces.
4+
category: content
5+
---
6+
7+
::component-preview
8+
---
9+
name: DocsSearchModalDemo
10+
---
11+
::
12+
13+
## Installation
14+
15+
```bash
16+
npx shadcn-vue@latest add "https://ui.stackhacker.io/r/docs-search-modal.json"
17+
```
18+
19+
## Usage
20+
21+
```vue
22+
<script setup lang="ts">
23+
import { DocsSearchModal, type DocsSearchItem } from '@/components/docs-search-modal'
24+
25+
const open = ref(false)
26+
27+
const items: DocsSearchItem[] = [
28+
{
29+
title: 'Introduction',
30+
description: 'Start here to understand the docs structure.',
31+
href: '/docs/introduction',
32+
section: 'Getting Started',
33+
content: 'overview installation usage'
34+
}
35+
]
36+
</script>
37+
38+
<template>
39+
<button type="button" @click="open = true">
40+
Search
41+
</button>
42+
43+
<DocsSearchModal v-model:open="open" :items="items" />
44+
</template>
45+
```
46+
47+
## App-Owned Data
48+
49+
`DocsSearchModal` owns the modal UI, keyboard shortcut, local filtering, and result rendering boundary. Your app owns the search data.
50+
51+
Map static docs, Nuxt Content results, or any other source into `DocsSearchItem[]` before passing it to the component.
52+
53+
```ts
54+
import type { DocsSearchItem } from '@/components/docs-search-modal'
55+
56+
const items: DocsSearchItem[] = pages.map(page => ({
57+
title: page.title,
58+
description: page.description,
59+
href: page.path,
60+
section: page.category,
61+
content: page.bodyText
62+
}))
63+
```
64+
65+
The component intentionally does not crawl pages, query Nuxt Content, call a search provider, rank results, or persist recent searches.
66+
67+
## Examples
68+
69+
### Default
70+
71+
::component-preview
72+
---
73+
name: DocsSearchModalDemo
74+
---
75+
::
76+
77+
## API Reference
78+
79+
### Props
80+
81+
| Prop | Type | Default | Description |
82+
|------|------|---------|-------------|
83+
| `items` | `DocsSearchItem[]` | `[]` | Searchable items supplied by your app. |
84+
| `placeholder` | `string` | `'Search documentation...'` | Search input placeholder. |
85+
| `searchLabel` | `string` | `'Search documentation'` | Accessible label for the dialog and search input. |
86+
| `emptyText` | `string` | `'No pages available.'` | Text shown when no query is entered and there are no items. |
87+
| `noResultsText` | `string` | `'No results found.'` | Text shown when a query has no matches. |
88+
| `shortcut` | `boolean` | `true` | Enable `Cmd+K` / `Ctrl+K` to open the modal. |
89+
| `class` | `string` || Additional CSS classes for the modal panel. |
90+
91+
### Models
92+
93+
| Model | Type | Description |
94+
|-------|------|-------------|
95+
| `open` | `boolean` | Controls whether the modal is open. |
96+
97+
### Slots
98+
99+
| Slot | Props | Description |
100+
|------|-------|-------------|
101+
| `item` | `{ item, query }` | Custom result renderer. |
102+
103+
### Types
104+
105+
```ts
106+
interface DocsSearchItem {
107+
title: string
108+
href: string
109+
description?: string
110+
section?: string
111+
content?: string
112+
}
113+
```

scripts/registry-verify.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
9191
dependencies: ["@lucide/vue", "ai"],
9292
registryDependencies: ["button", "tooltip"],
9393
},
94+
"docs-search-modal": {
95+
dependencies: ["@lucide/vue"],
96+
registryDependencies: [],
97+
},
9498
};
9599

96100
if (registryIndex) {

0 commit comments

Comments
 (0)