Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/components/demo/DocsSearchModalDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { DocsSearchModal, type DocsSearchItem } from "~/registry/blocks/docs-search-modal";

const open = ref(false);

const items: DocsSearchItem[] = [
{
title: "Introduction",
description: "Start here to understand the docs template structure.",
href: "/docs/components",
section: "Getting Started",
content: "overview installation usage documentation template",
},
{
title: "Chat Messages",
description: "Render chat message lists with scrolling behavior.",
href: "/docs/components/chat-messages",
section: "Chat",
content: "chat messages auto scroll assistant actions",
},
{
title: "Chat Prompt",
description: "Build chat input flows with keyboard shortcuts.",
href: "/docs/components/chat-prompt",
section: "Chat",
content: "prompt input enter shift enter submit",
},
];
</script>

<template>
<div class="flex w-full justify-center">
<button
type="button"
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"
@click="open = true"
>
Search docs
<kbd class="rounded border bg-muted px-1.5 font-mono text-[10px] text-muted-foreground">Cmd K</kbd>
</button>

<DocsSearchModal
v-model:open="open"
:items="items"
:shortcut="false"
/>
</div>
</template>
1 change: 1 addition & 0 deletions app/composables/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const sectionCategories: Record<string, { id: string; title: string }[]>
],
"components": [
{ id: "overview", title: "Overview" },
{ id: "content", title: "Content" },
{ id: "element", title: "Element" },
{ id: "chat", title: "AI Chat" },
],
Expand Down
199 changes: 199 additions & 0 deletions app/registry/blocks/docs-search-modal/DocsSearchModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Search } from "@lucide/vue";
import { cn } from "@/lib/utils";

export interface DocsSearchItem {
title: string;
href: string;
description?: string;
section?: string;
content?: string;
}

export interface DocsSearchModalProps {
/** Searchable items supplied by the consuming app */
items?: DocsSearchItem[];
/** Search input placeholder */
placeholder?: string;
/** Label used for the dialog and search input */
searchLabel?: string;
/** Text shown when no query is entered and no items are available */
emptyText?: string;
/** Text shown when a query has no matches */
noResultsText?: string;
/** Enable Cmd/Ctrl+K keyboard shortcut */
shortcut?: boolean;
/** Additional CSS classes for the modal panel */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<DocsSearchModalProps>(), {
items: () => [],
placeholder: "Search documentation...",
searchLabel: "Search documentation",
emptyText: "No pages available.",
noResultsText: "No results found.",
shortcut: true,
});

const open = defineModel<boolean>("open", { default: false });
const query = ref("");
const inputRef = ref<HTMLInputElement>();
const panelRef = ref<HTMLElement>();

const trimmedQuery = computed(() => query.value.trim().toLowerCase());

const results = computed(() => {
if (!trimmedQuery.value) return props.items;

return props.items.filter((item) => {
const haystack = [
item.title,
item.description,
item.section,
item.content,
].filter(Boolean).join(" ").toLowerCase();

return haystack.includes(trimmedQuery.value);
});
});

function closeSearch() {
open.value = false;
query.value = "";
}

function onKeydown(event: KeyboardEvent) {
if (props.shortcut && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
open.value = true;
}

if (event.key === "Escape" && open.value) {
closeSearch();
}
}

function onDialogKeydown(event: KeyboardEvent) {
if (event.key !== "Tab") return;

const focusable = panelRef.value?.querySelectorAll<HTMLElement>(
"a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])",
);
if (!focusable?.length) return;

const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;

if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
}
else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}

onMounted(() => {
window.addEventListener("keydown", onKeydown);
});

onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown);
});

watch(open, async (value) => {
if (value) {
await nextTick();
inputRef.value?.focus();
}
else {
query.value = "";
}
});
</script>

<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50"
role="dialog"
aria-modal="true"
:aria-label="searchLabel"
data-slot="docs-search-modal"
@keydown="onDialogKeydown"
>
<button
class="absolute inset-0 bg-background/80 backdrop-blur-sm"
type="button"
aria-label="Close search"
@click="closeSearch"
/>

<div
ref="panelRef"
: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)"
>
<div class="flex items-center gap-3 border-b px-4 py-3">
<Search
class="size-4.5 text-muted-foreground"
aria-hidden="true"
/>
<input
ref="inputRef"
v-model="query"
type="search"
:aria-label="searchLabel"
:placeholder="placeholder"
class="h-10 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
>
<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>
</div>

<div class="max-h-[min(28rem,calc(100vh-10rem))] overflow-y-auto p-2">
<a
v-for="item in results"
:key="item.href"
:href="item.href"
class="block rounded-lg p-3 hover:bg-accent hover:text-accent-foreground"
@click="closeSearch"
>
<slot
name="item"
:item="item"
:query="query"
>
<div class="flex items-center justify-between gap-3">
<p class="font-medium">
{{ item.title }}
</p>
<span
v-if="item.section"
class="text-xs text-muted-foreground"
>{{ item.section }}</span>
</div>
<p
v-if="item.description"
class="mt-1 line-clamp-2 text-sm text-muted-foreground"
>
{{ item.description }}
</p>
</slot>
</a>

<p
v-if="!results.length"
class="px-3 py-8 text-center text-sm text-muted-foreground"
>
{{ trimmedQuery ? noResultsText : emptyText }}
</p>
</div>
</div>
</div>
</Teleport>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/docs-search-modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as DocsSearchModal, type DocsSearchModalProps, type DocsSearchItem } from "./DocsSearchModal.vue";
2 changes: 1 addition & 1 deletion content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default defineContentConfig({
},
schema: z.object({
category: z
.enum(["element", "chat", "overview"])
.enum(["element", "content", "chat", "overview"])
.optional(),
}),
}),
Expand Down
113 changes: 113 additions & 0 deletions content/docs/2.components/6.docs-search-modal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
---
title: DocsSearchModal
description: Add a lightweight local search modal to Nuxt documentation interfaces.
category: content
---

::component-preview
---
name: DocsSearchModalDemo
---
::

## Installation

```bash
npx shadcn-vue@latest add "https://ui.stackhacker.io/r/docs-search-modal.json"
```

## Usage

```vue
<script setup lang="ts">
import { DocsSearchModal, type DocsSearchItem } from '@/components/docs-search-modal'

const open = ref(false)

const items: DocsSearchItem[] = [
{
title: 'Introduction',
description: 'Start here to understand the docs structure.',
href: '/docs/introduction',
section: 'Getting Started',
content: 'overview installation usage'
}
]
</script>

<template>
<button type="button" @click="open = true">
Search
</button>

<DocsSearchModal v-model:open="open" :items="items" />
</template>
```

## App-Owned Data

`DocsSearchModal` owns the modal UI, keyboard shortcut, local filtering, and result rendering boundary. Your app owns the search data.

Map static docs, Nuxt Content results, or any other source into `DocsSearchItem[]` before passing it to the component.

```ts
import type { DocsSearchItem } from '@/components/docs-search-modal'

const items: DocsSearchItem[] = pages.map(page => ({
title: page.title,
description: page.description,
href: page.path,
section: page.category,
content: page.bodyText
}))
```

The component intentionally does not crawl pages, query Nuxt Content, call a search provider, rank results, or persist recent searches.

## Examples

### Default

::component-preview
---
name: DocsSearchModalDemo
---
::

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `items` | `DocsSearchItem[]` | `[]` | Searchable items supplied by your app. |
| `placeholder` | `string` | `'Search documentation...'` | Search input placeholder. |
| `searchLabel` | `string` | `'Search documentation'` | Accessible label for the dialog and search input. |
| `emptyText` | `string` | `'No pages available.'` | Text shown when no query is entered and there are no items. |
| `noResultsText` | `string` | `'No results found.'` | Text shown when a query has no matches. |
| `shortcut` | `boolean` | `true` | Enable `Cmd+K` / `Ctrl+K` to open the modal. |
| `class` | `string` | — | Additional CSS classes for the modal panel. |

### Models

| Model | Type | Description |
|-------|------|-------------|
| `open` | `boolean` | Controls whether the modal is open. |

### Slots

| Slot | Props | Description |
|------|-------|-------------|
| `item` | `{ item, query }` | Custom result renderer. |

### Types

```ts
interface DocsSearchItem {
title: string
href: string
description?: string
section?: string
content?: string
}
```
4 changes: 4 additions & 0 deletions scripts/registry-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
dependencies: ["@lucide/vue", "ai"],
registryDependencies: ["button", "tooltip"],
},
"docs-search-modal": {
dependencies: ["@lucide/vue"],
registryDependencies: [],
},
};

if (registryIndex) {
Expand Down
Loading