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:
@@ -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"><FieldWrapper></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>
|
||||
Reference in New Issue
Block a user