docs(vue): add interactive demo for every composable

A beautiful, SSR-safe demo.vue next to each composable, auto-discovered by the docs extractor and rendered client-only on each composable's page.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 59e995d0b5
commit e83f10fe32
214 changed files with 19584 additions and 74 deletions
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from 'vue';
import { createReusableTemplate } from './index';
interface Member {
name: string;
role: string;
online: boolean;
}
// Define a stat card template once, reuse it for every metric below.
const [DefineStat, ReuseStat] = createReusableTemplate<{ label: string; value: string }>();
// Object form + typed bindings for a richer row template.
const { define: DefineMember, reuse: ReuseMember } = createReusableTemplate<Member>();
const team = ref<Member[]>([
{ name: 'Ada Lovelace', role: 'Engineering', online: true },
{ name: 'Grace Hopper', role: 'Design', online: false },
{ name: 'Alan Turing', role: 'Research', online: true },
]);
function toggle(member: Member) {
member.online = !member.online;
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<!-- Templates are captured here, rendered wherever Reuse* appears -->
<DefineStat v-slot="{ label, value }">
<div class="flex-1 rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
{{ label }}
</div>
<div class="mt-1 font-mono text-2xl font-bold tabular-nums text-(--fg)">
{{ value }}
</div>
</div>
</DefineStat>
<DefineMember v-slot="{ name, role, online }">
<div class="flex items-center gap-3">
<span
class="inline-block size-2 shrink-0 rounded-full transition"
:class="online ? 'bg-emerald-500' : 'bg-(--border-strong)'"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-(--fg)">{{ name }}</div>
<div class="text-xs text-(--fg-subtle)">{{ role }}</div>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
>
{{ online ? 'Online' : 'Away' }}
</span>
</div>
</DefineMember>
<div class="flex gap-2">
<ReuseStat label="Members" :value="String(team.length)" />
<ReuseStat label="Online" :value="String(team.filter(m => m.online).length)" />
</div>
<div class="flex flex-col gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Team click a row to toggle status
</div>
<button
v-for="member in team"
:key="member.name"
class="rounded-lg p-2 text-left transition hover:bg-(--bg-inset) active:scale-[0.99] cursor-pointer"
@click="toggle(member)"
>
<ReuseMember
:name="member.name"
:role="member.role"
:online="member.online"
/>
</button>
</div>
<p class="text-xs text-(--fg-subtle)">
Both card and row markup are declared once via <code class="font-mono">DefineTemplate</code> and
rendered from multiple <code class="font-mono">ReuseTemplate</code> call sites.
</p>
</div>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { unrefElement } from './index';
const boxRef = useTemplateRef<HTMLElement>('box');
const width = ref(280);
const tag = ref('—');
const rect = ref<{ w: number; h: number } | null>(null);
function measure() {
// unrefElement turns the template ref into the raw DOM element, regardless of
// whether it points at an HTMLElement or a component instance.
const el = unrefElement(boxRef);
if (!el)
return;
tag.value = el.tagName.toLowerCase();
const { width: w, height: h } = el.getBoundingClientRect();
rect.value = { w: Math.round(w), h: Math.round(h) };
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div
ref="box"
class="flex items-center justify-center rounded-xl border border-dashed border-(--border-strong) bg-(--bg-inset) py-8 text-sm font-medium text-(--fg-muted) transition-[width] duration-300 ease-out"
:style="{ width: `${width}px` }"
>
Target element
</div>
<label class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Width</span>
<span class="font-mono text-sm tabular-nums text-(--fg-muted)">{{ width }}px</span>
</div>
<input
v-model.number="width"
type="range"
min="120"
max="340"
class="w-full accent-(--accent)"
>
</label>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="measure"
>
Read element via unrefElement
</button>
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="flex items-center justify-between">
<span class="text-(--fg-subtle)">tagName</span>
<span>{{ tag }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-(--fg-subtle)">boundingRect</span>
<span>{{ rect ? `${rect.w} × ${rect.h}` : '—' }}</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
Resize the box, then measure. <code class="font-mono">unrefElement</code> unwraps the template
ref to the real DOM node it also resolves a component ref to its <code class="font-mono">$el</code>.
</p>
</div>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
import { useCurrentElement } from './index';
// Resolves to this component's root DOM element, re-read on mount + every update.
const el = useCurrentElement<HTMLElement>();
const padding = ref(16);
const childCount = ref(3);
const info = ref<{ tag: string; children: number; height: number } | null>(null);
watchEffect(() => {
const node = el.value;
if (!node) {
// SSR / pre-mount: el.value is undefined.
info.value = null;
return;
}
info.value = {
tag: node.tagName.toLowerCase(),
children: node.querySelectorAll('[data-chip]').length,
height: Math.round(node.getBoundingClientRect().height),
};
});
const chips = ['vue', 'reactivity', 'composables', 'ssr', 'typescript', 'dom'];
</script>
<template>
<div
class="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-(--border) bg-(--bg-elevated)"
:style="{ padding: `${padding}px` }"
>
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Live measurement of this component's root
</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="chip in chips.slice(0, childCount)"
:key="chip"
data-chip
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
>
{{ chip }}
</span>
</div>
<label class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Root padding</span>
<span class="font-mono text-sm tabular-nums text-(--fg-muted)">{{ padding }}px</span>
</div>
<input
v-model.number="padding"
type="range"
min="8"
max="40"
class="w-full accent-(--accent)"
>
</label>
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="childCount <= 1"
@click="childCount--"
>
Remove chip
</button>
<button
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="childCount >= chips.length"
@click="childCount++"
>
Add chip
</button>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="flex items-center justify-between">
<span class="text-(--fg-subtle)">el.value</span>
<span>{{ info ? `<${info.tag}>` : 'undefined' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-(--fg-subtle)">chips in DOM</span>
<span>{{ info?.children ?? '' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-(--fg-subtle)">root height</span>
<span>{{ info ? `${info.height}px` : '' }}</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
The computed re-reads <code class="font-mono">$el</code> on every update, so the readout tracks
padding and chip changes automatically.
</p>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { ComponentPublicInstance } from 'vue';
import { defineComponent, h, ref, useTemplateRef, watchEffect } from 'vue';
import { useForwardExpose } from './index';
// A headless wrapper: it renders a child <input> but transparently forwards the
// child's $el (and any exposed API) up to whoever holds a ref to the wrapper.
const FieldWrapper = defineComponent({
name: 'FieldWrapper',
setup() {
// forwardRef is bound to the inner element's :ref; currentElement resolves
// to the underlying HTMLElement, skipping text/comment nodes.
const { forwardRef, currentElement } = useForwardExpose();
return () =>
h('input', {
ref: forwardRef,
placeholder: 'Forwarded input',
// expose the resolved element so the demo can show it changed live
'data-tag': currentElement.value?.tagName.toLowerCase(),
class:
'w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)',
});
},
});
// The parent holds a ref to the wrapper — but thanks to useForwardExpose,
// wrapper.$el points straight at the inner <input>.
const field = useTemplateRef<ComponentPublicInstance>('field');
const resolved = ref<{ tag: string; value: string } | null>(null);
watchEffect(() => {
const el = field.value?.$el as HTMLInputElement | undefined;
resolved.value = el ? { tag: el.tagName.toLowerCase(), value: el.value } : null;
});
function focusForwarded() {
// Reaching through the wrapper straight to the real DOM node.
(field.value?.$el as HTMLInputElement | undefined)?.focus();
}
function fillSample() {
const el = field.value?.$el as HTMLInputElement | undefined;
if (el) {
el.value = 'ada@anthropic.dev';
resolved.value = { tag: el.tagName.toLowerCase(), value: el.value };
}
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="flex flex-col gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Wrapper component
</div>
<FieldWrapper ref="field" />
<p class="text-xs text-(--fg-subtle)">
<code class="font-mono">&lt;FieldWrapper&gt;</code> renders an inner input, but
<code class="font-mono">useForwardExpose</code> makes its <code class="font-mono">$el</code>
resolve to that input.
</p>
</div>
<div class="flex items-center gap-2">
<button
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="focusForwarded"
>
Focus forwarded $el
</button>
<button
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
@click="fillSample"
>
Fill sample
</button>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="flex items-center justify-between">
<span class="text-(--fg-subtle)">field.$el</span>
<span>{{ resolved ? `<${resolved.tag}>` : 'undefined' }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-(--fg-subtle)">value</span>
<span class="truncate">{{ resolved?.value || '""' }}</span>
</div>
</div>
<p class="text-xs text-(--fg-subtle)">
The parent never touches the input directly it holds a ref to the wrapper, whose
<code class="font-mono">$el</code> is forwarded to the inner element.
</p>
</div>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import { useTemplateRefsList } from './index';
interface Track {
id: number;
title: string;
artist: string;
}
let nextId = 6;
const tracks = ref<Track[]>([
{ id: 1, title: 'Midnight City', artist: 'M83' },
{ id: 2, title: 'Resonance', artist: 'Home' },
{ id: 3, title: 'Nightcall', artist: 'Kavinsky' },
{ id: 4, title: 'Strangers', artist: 'Sigrid' },
{ id: 5, title: 'Open Eye Signal', artist: 'Jon Hopkins' },
]);
// Collect a live, document-ordered array of every rendered row element.
const { refs, set } = useTemplateRefsList<HTMLLIElement>();
const lastMeasured = ref<{ index: number; width: number } | null>(null);
// Reads the freshly collected refs to measure the DOM directly.
function measureLast() {
const els = refs.value;
if (els.length === 0)
return;
const index = els.length - 1;
const el = els[index]!;
lastMeasured.value = { index, width: Math.round(el.getBoundingClientRect().width) };
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
async function addTrack() {
const sample = { id: nextId++, title: `Aurora ${nextId}`, artist: 'Synthwave' };
tracks.value.push(sample);
// Wait for the update flush so the new element is in `refs`.
await nextTick();
measureLast();
}
function removeTrack(id: number) {
tracks.value = tracks.value.filter(t => t.id !== id);
lastMeasured.value = null;
}
const collected = computed(() => refs.value.length);
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Playlist</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
{{ collected }} refs collected
</span>
</div>
<ul class="flex flex-col gap-2 max-h-56 overflow-y-auto rounded-xl border border-(--border) bg-(--bg-elevated) p-2">
<li
v-for="(track, index) in tracks"
:key="track.id"
:ref="set"
class="group flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2 transition"
:class="lastMeasured?.index === index ? 'border-(--accent) ring-2 ring-(--ring)' : 'hover:border-(--border-strong)'"
>
<span class="font-mono text-xs tabular-nums text-(--fg-subtle) w-5 text-right">{{ index + 1 }}</span>
<span class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-medium text-(--fg)">{{ track.title }}</span>
<span class="truncate text-xs text-(--fg-muted)">{{ track.artist }}</span>
</span>
<button
type="button"
aria-label="Remove track"
class="rounded-md px-1.5 py-0.5 text-xs text-(--fg-subtle) opacity-0 transition hover:text-(--fg) group-hover:opacity-100 cursor-pointer"
@click="removeTrack(track.id)"
>
</button>
</li>
<li v-if="tracks.length === 0" class="px-3 py-6 text-center text-sm text-(--fg-subtle)">
No tracks add one to collect a ref.
</li>
</ul>
<div
v-if="lastMeasured"
class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums"
>
refs[{{ lastMeasured.index }}].width = {{ lastMeasured.width }}px
</div>
<p v-else class="text-xs text-(--fg-subtle)">
Add a track to measure the newest collected element directly from the DOM.
</p>
<div class="flex gap-2">
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="addTrack"
>
Add track
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="collected === 0"
@click="measureLast"
>
Measure last
</button>
</div>
</div>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { useVirtualList } from './index';
// 10,000 rows — only the visible window (plus overscan) is ever in the DOM.
const total = 10000;
const items = shallowRef(
Array.from({ length: total }, (_, i) => ({
id: i,
label: `Row #${(i + 1).toString().padStart(5, '0')}`,
hue: (i * 37) % 360,
})),
);
const itemHeight = 44;
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(items, {
itemHeight,
overscan: 6,
});
const jumpTo = ref(5000);
function go() {
const index = Math.min(Math.max(jumpTo.value || 0, 0), total - 1);
scrollTo(index, { behavior: 'smooth', block: 'center' });
}
const visibleRange = computed(() => {
if (list.value.length === 0)
return '—';
const first = list.value[0]!.index;
const last = list.value[list.value.length - 1]!.index;
return `${first}${last}`;
});
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Virtual list</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
{{ total.toLocaleString() }} rows
</span>
</div>
<div
v-bind="containerProps"
class="h-64 rounded-xl border border-(--border) bg-(--bg-elevated)"
>
<div v-bind="wrapperProps">
<div
v-for="{ data, index } in list"
:key="index"
class="flex items-center gap-3 border-b border-(--border) px-3"
:style="{ height: `${itemHeight}px` }"
>
<span
class="size-6 shrink-0 rounded-md border border-(--border)"
:style="{ backgroundColor: `hsl(${data.hue} 65% 55%)` }"
/>
<span class="flex-1 truncate font-mono text-sm text-(--fg) tabular-nums">{{ data.label }}</span>
<span class="text-xs text-(--fg-subtle)">idx {{ index }}</span>
</div>
</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums flex items-center justify-between">
<span class="text-(--fg-muted)">rendered</span>
<span>{{ list.length }} nodes · idx {{ visibleRange }}</span>
</div>
<div class="flex items-end gap-2">
<label class="flex flex-1 flex-col gap-1">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Scroll to index</span>
<input
v-model.number="jumpTo"
type="number"
:min="0"
:max="total - 1"
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>
</label>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-(--accent) px-3 py-2 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
@click="go"
>
Jump
</button>
</div>
</div>
</template>