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
29 changes: 29 additions & 0 deletions app/components/demo/DocsPagerDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { DocsPager, type DocsPagerItem } from "~/registry/blocks/docs-pager";

const previous: DocsPagerItem = {
title: "Docs Search Modal",
description: "Add a lightweight local search modal to Nuxt documentation interfaces.",
href: "/docs/components/docs-search-modal",
};

const next: DocsPagerItem = {
title: "Pricing Plans",
description: "Render pricing cards with billing-period controls and feature lists.",
href: "/docs/components/pricing-plans",
};
</script>

<template>
<div class="mx-auto grid w-full max-w-2xl gap-8">
<DocsPager
:previous="previous"
:next="next"
/>

<DocsPager
:next="next"
class="border-dashed"
/>
</div>
</template>
91 changes: 91 additions & 0 deletions app/components/demo/PageTocDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { PageToc, type PageTocItem } from "~/registry/blocks/page-toc";

const activeId = ref("installation");

const items: PageTocItem[] = [
{
id: "overview",
title: "Overview",
depth: 2,
},
{
id: "installation",
title: "Installation",
depth: 2,
},
{
id: "usage",
title: "Usage",
depth: 2,
},
{
id: "nuxt-content-adapter",
title: "Nuxt Content adapter",
depth: 3,
},
{
id: "api-reference",
title: "API reference",
depth: 2,
},
];
</script>

<template>
<div class="grid w-full gap-6 rounded-xl border bg-card/40 p-4 md:grid-cols-[14rem_1fr]">
<PageToc
:active-id="activeId"
:items="items"
:track-active="false"
class="rounded-lg border bg-background p-4 md:sticky md:top-4 md:self-start"
/>

<div class="space-y-4 text-sm">
<div class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Preview state
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="item in items"
:key="item.id"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors hover:bg-accent hover:text-accent-foreground"
:class="item.id === activeId ? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground' : 'bg-background text-muted-foreground'"
@click="activeId = item.id"
>
{{ item.title }}
</button>
</div>
</div>

<div class="rounded-lg border bg-background p-5">
<h3 class="font-semibold">
Documentation article
</h3>
<p class="mt-2 text-muted-foreground">
Supply already-discovered headings from your app, then let PageToc render the aside and active-link treatment.
</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-md bg-muted/60 p-3">
<p class="font-medium">
App owns
</p>
<p class="mt-1 text-muted-foreground">
Markdown parsing, route paths, and page layout.
</p>
</div>
<div class="rounded-md bg-muted/60 p-3">
<p class="font-medium">
Component owns
</p>
<p class="mt-1 text-muted-foreground">
Link list rendering, indentation, and active state display.
</p>
</div>
</div>
</div>
</div>
</div>
</template>
96 changes: 96 additions & 0 deletions app/registry/blocks/docs-pager/DocsPager.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { ArrowLeft, ArrowRight } from "@lucide/vue";
import { cn } from "@/lib/utils";

export interface DocsPagerItem {
title: string;
href: string;
description?: string;
}

export interface DocsPagerProps {
/** Previous page supplied by the consuming app */
previous?: DocsPagerItem | null;
/** Next page supplied by the consuming app */
next?: DocsPagerItem | null;
/** Label shown above the previous page title */
previousLabel?: string;
/** Label shown above the next page title */
nextLabel?: string;
/** Additional CSS classes for the root element */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<DocsPagerProps>(), {
previous: null,
next: null,
previousLabel: "Previous",
nextLabel: "Next",
});
</script>

<template>
<nav
v-if="previous || next"
data-slot="docs-pager"
aria-label="Documentation pagination"
:class="cn('grid gap-3 border-t pt-6 sm:grid-cols-2', props.class)"
>
<a
v-if="previous"
:href="previous.href"
:aria-label="`${previousLabel}: ${previous.title}`"
data-slot="docs-pager-previous"
class="group rounded-xl border bg-card p-4 text-card-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<span class="flex items-center gap-2 text-xs font-medium text-muted-foreground transition-colors group-hover:text-accent-foreground/80">
<ArrowLeft
class="size-3.5"
aria-hidden="true"
/>
{{ previousLabel }}
</span>
<span class="mt-2 block truncate font-medium">
{{ previous.title }}
</span>
<span
v-if="previous.description"
class="mt-1 line-clamp-2 block text-sm text-muted-foreground transition-colors group-hover:text-accent-foreground/80"
>
{{ previous.description }}
</span>
</a>

<div
v-else
class="hidden sm:block"
aria-hidden="true"
/>

<a
v-if="next"
:href="next.href"
:aria-label="`${nextLabel}: ${next.title}`"
data-slot="docs-pager-next"
class="group rounded-xl border bg-card p-4 text-right text-card-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<span class="flex items-center justify-end gap-2 text-xs font-medium text-muted-foreground transition-colors group-hover:text-accent-foreground/80">
{{ nextLabel }}
<ArrowRight
class="size-3.5"
aria-hidden="true"
/>
</span>
<span class="mt-2 block truncate font-medium">
{{ next.title }}
</span>
<span
v-if="next.description"
class="mt-1 line-clamp-2 block text-sm text-muted-foreground transition-colors group-hover:text-accent-foreground/80"
>
{{ next.description }}
</span>
</a>
</nav>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/docs-pager/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as DocsPager, type DocsPagerItem, type DocsPagerProps } from "./DocsPager.vue";
131 changes: 131 additions & 0 deletions app/registry/blocks/page-toc/PageToc.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { cn } from "@/lib/utils";

export interface PageTocItem {
id: string;
title: string;
href?: string;
depth?: number;
}

export interface PageTocProps {
/** Heading links supplied by the consuming app */
items?: PageTocItem[];
/** Label rendered above the links. Use an empty string to hide it. */
title?: string;
/** Observe matching page headings and highlight the active item. */
trackActive?: boolean;
/** Controlled active heading id. Disables internal active tracking when supplied. */
activeId?: string;
/** Additional CSS classes for the root nav */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<PageTocProps>(), {
items: () => [],
title: "On this page",
trackActive: true,
});

const internalActiveId = ref("");
const isMounted = ref(false);
let observer: IntersectionObserver | undefined;
let observerSetupId = 0;

const activeItemId = computed(() => props.activeId ?? internalActiveId.value);

const links = computed(() => props.items.map(item => ({
...item,
href: item.href ?? `#${item.id}`,
depth: item.depth ?? 2,
})));

function getIndentStyle(depth: number) {
return {
paddingInlineStart: `${Math.max(depth - 2, 0) * 1}rem`,
};
}

function disconnectObserver() {
observer?.disconnect();
observer = undefined;
}

async function setupObserver() {
const setupId = ++observerSetupId;
disconnectObserver();

if (!props.trackActive || props.activeId !== undefined) {
internalActiveId.value = "";
}

if (!isMounted.value || !props.trackActive || props.activeId !== undefined || typeof IntersectionObserver === "undefined") return;

await nextTick();

if (!isMounted.value || setupId !== observerSetupId) return;

const elements = links.value
.map(item => document.getElementById(item.id))
.filter((element): element is HTMLElement => Boolean(element));

if (!elements.length) return;

observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
internalActiveId.value = entry.target.id;
}
}
}, { rootMargin: "0% 0% -80% 0%" });

for (const element of elements) {
observer.observe(element);
}
}

onMounted(() => {
isMounted.value = true;
setupObserver();
});

onBeforeUnmount(() => {
isMounted.value = false;
observerSetupId++;
disconnectObserver();
});

watch(() => [props.items, props.trackActive, props.activeId] as const, () => {
setupObserver();
}, { deep: true });
</script>

<template>
<nav
v-if="links.length"
data-slot="page-toc"
:class="cn('flex flex-col gap-2 text-sm', props.class)"
aria-label="Table of contents"
>
<p
v-if="title"
class="text-xs font-medium text-muted-foreground"
>
{{ title }}
</p>

<a
v-for="item in links"
:key="item.id"
:data-active="item.id === activeItemId"
:data-depth="item.depth"
:href="item.href"
:style="getIndentStyle(item.depth)"
class="text-muted-foreground text-[0.8rem] leading-6 no-underline transition-colors hover:text-foreground data-[active=true]:font-medium data-[active=true]:text-foreground"
>
{{ item.title }}
</a>
</nav>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/page-toc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as PageToc, type PageTocItem, type PageTocProps } from "./PageToc.vue";
Loading
Loading