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,82 @@
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { onElementRemoval } from './index';
const watched = useTemplateRef<HTMLDivElement>('watched');
const mounted = ref(true);
const removals = ref(0);
const lastEvent = ref<string | null>(null);
// Fires whenever the watched element (or any ancestor containing it) leaves the DOM.
onElementRemoval(watched, (records) => {
removals.value++;
lastEvent.value = new Date().toLocaleTimeString();
// `records` are the raw MutationRecords describing the removal.
void records;
});
function toggle() {
mounted.value = !mounted.value;
}
function reset() {
removals.value = 0;
lastEvent.value = null;
}
</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)">DOM removal watcher</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)"
>
<span class="size-1.5 rounded-full transition" :class="mounted ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
{{ mounted ? 'In DOM' : 'Removed' }}
</span>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 min-h-28 flex items-center justify-center">
<div
v-if="mounted"
ref="watched"
class="flex w-full flex-col items-center gap-1 rounded-lg border border-(--accent) bg-(--accent-subtle) px-4 py-6 text-center transition"
>
<span class="text-sm font-medium text-(--accent-text)">Watched element</span>
<span class="text-xs text-(--fg-muted)">Remove me and the callback fires</span>
</div>
<span v-else class="text-sm text-(--fg-subtle)">Element detached from the document</span>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ removals }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">removals fired</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center flex flex-col justify-center">
<div class="font-mono text-sm font-medium tabular-nums text-(--fg)">{{ lastEvent ?? '—' }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">last fired</div>
</div>
</div>
<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="toggle"
>
{{ mounted ? 'Remove element' : 'Mount element' }}
</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="removals === 0"
@click="reset"
>
Reset
</button>
</div>
</div>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useActiveElement } from './index';
const activeElement = useActiveElement();
const fields = [
{ id: 'name', label: 'Full name', placeholder: 'Ada Lovelace', type: 'text' },
{ id: 'email', label: 'Email', placeholder: 'ada@analytical.engine', type: 'email' },
{ id: 'city', label: 'City', placeholder: 'London', type: 'text' },
];
const activeId = computed(() => activeElement.value?.id || null);
const activeTag = computed(() => activeElement.value?.tagName.toLowerCase() ?? null);
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="space-y-3">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Focus a field
</p>
<div
v-for="field in fields"
:key="field.id"
class="space-y-1"
>
<label
:for="field.id"
class="text-sm font-medium text-(--fg-muted)"
>{{ field.label }}</label>
<input
:id="field.id"
:type="field.type"
:placeholder="field.placeholder"
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)"
>
</div>
<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"
>
A focusable button
</button>
</div>
<div class="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 gap-3">
<span class="text-(--fg-subtle)">activeElement</span>
<span
v-if="activeTag"
class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-elevated) px-2 py-0.5 text-xs font-medium text-(--accent-text)"
>
&lt;{{ activeTag }}&gt;
</span>
<span
v-else
class="text-xs text-(--fg-subtle)"
>none</span>
</div>
<div class="mt-2 flex items-center justify-between gap-3">
<span class="text-(--fg-subtle)">id</span>
<span class="truncate">{{ activeId ?? '—' }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useDocumentReadyState } from './index';
const readyState = useDocumentReadyState();
const stages: { state: DocumentReadyState; label: string; hint: string }[] = [
{ state: 'loading', label: 'loading', hint: 'Parsing the document' },
{ state: 'interactive', label: 'interactive', hint: 'DOM ready, assets pending' },
{ state: 'complete', label: 'complete', hint: 'Everything loaded' },
];
const activeIndex = computed(() => stages.findIndex(s => s.state === readyState.value));
</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)">
document.readyState
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<span class="size-1.5 rounded-full bg-emerald-500" />
{{ readyState }}
</span>
</div>
<ol class="space-y-2">
<li
v-for="(stage, i) in stages"
:key="stage.state"
class="flex items-center gap-3 rounded-lg border p-3 transition"
:class="i <= activeIndex
? 'border-(--accent) bg-(--accent-subtle)'
: 'border-(--border) bg-(--bg-elevated)'"
>
<span
class="flex size-6 shrink-0 items-center justify-center rounded-full font-mono text-xs font-bold tabular-nums transition"
:class="i < activeIndex
? 'bg-(--accent) text-(--accent-fg)'
: i === activeIndex
? 'bg-(--accent) text-(--accent-fg) ring-4 ring-(--ring)'
: 'bg-(--bg-inset) text-(--fg-subtle)'"
>
{{ i + 1 }}
</span>
<div class="min-w-0">
<p class="font-mono text-sm font-medium text-(--fg)">
{{ stage.label }}
</p>
<p class="text-xs text-(--fg-subtle)">
{{ stage.hint }}
</p>
</div>
</li>
</ol>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useDocumentVisibility } from './index';
const switches = ref(0);
const lastHidden = ref<string | null>(null);
const visibility = useDocumentVisibility({
onChange: (state) => {
if (state === 'hidden') {
switches.value += 1;
lastHidden.value = new Date().toLocaleTimeString();
}
},
});
const isVisible = computed(() => visibility.value === 'visible');
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col items-center gap-3 text-center">
<span
class="flex size-14 items-center justify-center rounded-full text-2xl transition"
:class="isVisible
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400'"
>
{{ isVisible ? '👁️' : '💤' }}
</span>
<div>
<p class="font-mono text-2xl font-bold tabular-nums text-(--fg)">
{{ visibility }}
</p>
<p class="text-xs text-(--fg-subtle)">
{{ isVisible ? 'This tab is in the foreground' : 'This tab is hidden' }}
</p>
</div>
</div>
<p class="text-center text-xs text-(--fg-subtle)">
Switch to another tab or minimize the window to watch this update.
</p>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<p class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ switches }}
</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Times hidden
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<p class="font-mono text-sm font-medium tabular-nums text-(--fg) truncate">
{{ lastHidden ?? '—' }}
</p>
<p class="mt-0.5 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Last hidden at
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { useDraggable } from './index';
const container = useTemplateRef<HTMLElement>('container');
const handle = useTemplateRef<HTMLElement>('handle');
const { x, y, isDragging, style } = useDraggable(
useTemplateRef<HTMLElement>('card'),
{
initialValue: { x: 24, y: 24 },
containerElement: container,
handle,
},
);
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<div class="flex items-center justify-between gap-3">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Draggable, clamped to container
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
:class="isDragging
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span
class="size-1.5 rounded-full transition"
:class="isDragging ? 'bg-(--accent)' : 'bg-(--fg-subtle)'"
/>
{{ isDragging ? 'dragging' : 'idle' }}
</span>
</div>
<div
ref="container"
class="relative h-56 w-full overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset)"
>
<div
ref="card"
:style="style"
class="absolute w-36 select-none rounded-lg border border-(--border-strong) bg-(--bg-elevated) shadow-lg"
:class="isDragging ? 'ring-2 ring-(--ring)' : ''"
>
<div
ref="handle"
class="flex items-center gap-1.5 rounded-t-lg border-b border-(--border) bg-(--bg-subtle) px-3 py-2 cursor-grab active:cursor-grabbing"
>
<span class="text-(--fg-subtle)"></span>
<span class="text-xs font-medium text-(--fg-muted)">Drag me</span>
</div>
<div class="p-3 font-mono text-xs tabular-nums text-(--fg)">
<div>x: {{ Math.round(x) }}</div>
<div>y: {{ Math.round(y) }}</div>
</div>
</div>
</div>
<p class="text-center text-xs text-(--fg-subtle)">
Drag from the header. Movement is clamped to the container bounds.
</p>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue';
import { useDropZone } from './index';
const dropZone = useTemplateRef<HTMLElement>('dropZone');
const { isOverDropZone, files, isSupported } = useDropZone(dropZone, {
dataTypes: ['image/'],
onDrop: (dropped) => {
// eslint-disable-next-line no-console
console.log('dropped', dropped);
},
});
const fileList = computed(() => files.value ?? []);
function formatSize(bytes: number): string {
if (bytes < 1024)
return `${bytes} B`;
if (bytes < 1024 * 1024)
return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<div
v-if="!isSupported"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center text-sm text-amber-700 dark:text-amber-300"
>
Drag and Drop is not supported in this browser.
</div>
<template v-else>
<div
ref="dropZone"
class="flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-8 text-center transition"
:class="isOverDropZone
? 'border-(--accent) bg-(--accent-subtle)'
: 'border-(--border-strong) bg-(--bg-elevated)'"
>
<span class="text-3xl transition" :class="isOverDropZone ? 'scale-110' : ''">
{{ isOverDropZone ? '📥' : '🖼️' }}
</span>
<p class="text-sm font-medium text-(--fg)">
{{ isOverDropZone ? 'Release to drop' : 'Drop image files here' }}
</p>
<p class="text-xs text-(--fg-subtle)">
Only <span class="font-mono">image/*</span> files are accepted
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Dropped files
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-elevated) px-2 py-0.5 text-xs font-medium text-(--fg-muted) tabular-nums">
{{ fileList.length }}
</span>
</div>
<p
v-if="fileList.length === 0"
class="mt-3 text-center text-sm text-(--fg-subtle)"
>
Nothing dropped yet.
</p>
<ul v-else class="mt-3 space-y-1.5">
<li
v-for="file in fileList"
:key="file.name"
class="flex items-center justify-between gap-3 rounded-md bg-(--bg-elevated) px-2.5 py-1.5"
>
<span class="truncate text-sm text-(--fg)">{{ file.name }}</span>
<span class="shrink-0 font-mono text-xs tabular-nums text-(--fg-subtle)">
{{ formatSize(file.size) }}
</span>
</li>
</ul>
</div>
</template>
</div>
</template>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useElementBounding } from './index';
type Timing = 'sync' | 'next-frame';
const timing = ref<Timing>('sync');
const target = ref<HTMLElement>();
// Two independent measurers so toggling the timing mode is visible live.
const sync = useElementBounding(target, { updateTiming: 'sync' });
const nextFrame = useElementBounding(target, { updateTiming: 'next-frame' });
const bounds = computed(() => (timing.value === 'sync' ? sync : nextFrame));
// Resizable, draggable-ish target driven by sliders so every field updates live.
const size = ref(56);
const metrics = computed(() => [
{ label: 'width', value: bounds.value.width.value },
{ label: 'height', value: bounds.value.height.value },
{ label: 'top', value: bounds.value.top.value },
{ label: 'left', value: bounds.value.left.value },
{ label: 'right', value: bounds.value.right.value },
{ label: 'bottom', value: bounds.value.bottom.value },
{ label: 'x', value: bounds.value.x.value },
{ label: 'y', value: bounds.value.y.value },
]);
function fmt(n: number) {
return Math.round(n);
}
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">getBoundingClientRect</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)">
{{ timing }}
</span>
</div>
<!-- The measured target. Resizing it mutates the reactive bounds. -->
<div class="relative h-40 overflow-hidden rounded-lg border border-(--border) bg-(--bg-inset)">
<div
ref="target"
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 grid place-items-center rounded-lg bg-(--accent) text-(--accent-fg) shadow transition-[width,height] duration-150"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<span class="font-mono text-xs font-semibold tabular-nums">{{ fmt(bounds.width.value) }}×{{ fmt(bounds.height.value) }}</span>
</div>
</div>
<div class="grid grid-cols-4 gap-2">
<div
v-for="m in metrics"
:key="m.label"
class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center"
>
<div class="font-mono text-sm font-bold tabular-nums text-(--fg)">{{ fmt(m.value) }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">{{ m.label }}</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="bound-size">Size</label>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ size }}px</span>
</div>
<input
id="bound-size"
v-model.number="size"
type="range"
min="32"
max="140"
step="2"
class="w-full accent-(--accent) cursor-pointer"
>
</div>
<div class="flex items-center gap-2">
<button
v-for="opt in (['sync', 'next-frame'] as Timing[])"
:key="opt"
type="button"
class="flex-1 inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
:class="timing === opt
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
@click="timing = opt"
>
{{ opt }}
</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"
@click="bounds.update()"
>
Update
</button>
</div>
<p class="text-xs text-(--fg-subtle)">
<span class="font-mono">next-frame</span> batches rapid scroll/resize reads into one measurement per animation frame.
</p>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useElementSize } from './index';
const target = ref<HTMLElement>();
// content-box vs border-box is the headline option, so observe it directly.
const { width, height, stop } = useElementSize(target, { width: 0, height: 0 }, { box: 'border-box' });
const padding = ref(16);
const observing = ref(true);
function toggleObserver() {
if (observing.value) {
stop();
observing.value = false;
}
// Re-observing requires a fresh composable instance; keep the demo honest by
// only offering "stop" once and noting it below.
}
function fmt(n: number) {
return n.toFixed(0);
}
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">ResizeObserver</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)">
<span class="size-1.5 rounded-full" :class="observing ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
{{ observing ? 'Observing' : 'Stopped' }}
</span>
</div>
<!-- Native textarea resize handle makes the element user-resizable. -->
<textarea
ref="target"
readonly
:style="{ padding: `${padding}px` }"
class="w-full min-h-24 resize rounded-lg border border-(--border-strong) bg-(--bg-inset) text-sm leading-relaxed text-(--fg-muted) focus:outline-none focus:ring-2 focus:ring-(--ring)"
>Drag the bottom-right corner to resize me. The width and height update live as the ResizeObserver fires. Border-box sizing includes the padding below.</textarea>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ fmt(width) }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">width px</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ fmt(height) }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">height px</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="size-padding">Padding (border-box)</label>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ padding }}px</span>
</div>
<input
id="size-padding"
v-model.number="padding"
type="range"
min="0"
max="40"
step="2"
class="w-full accent-(--accent) cursor-pointer"
>
</div>
<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="!observing"
@click="toggleObserver"
>
{{ observing ? 'Stop observing' : 'Observer stopped' }}
</button>
<p class="text-xs text-(--fg-subtle)">
With <span class="font-mono">box: 'border-box'</span> the reported size includes padding, so the slider changes the numbers without resizing the element.
</p>
</div>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useElementVisibility } from './index';
const root = ref<HTMLElement>();
const target = ref<HTMLElement>();
// controls: true returns { isVisible, isSupported, isActive, pause, resume, stop }.
const { isVisible, isActive, pause, resume } = useElementVisibility(target, {
controls: true,
root,
threshold: 0.5,
});
const seenCount = ref(0);
// Count each time the card crosses into view (rising edge only).
watch(isVisible, (visible, was) => {
if (visible && !was)
seenCount.value++;
});
</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)">IntersectionObserver</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
:class="isVisible
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span class="size-1.5 rounded-full" :class="isVisible ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
{{ isVisible ? 'In view' : 'Hidden' }}
</span>
</div>
<!-- Scrollable root; the target card sits below the fold until scrolled. -->
<div
ref="root"
class="h-44 overflow-y-auto rounded-xl border border-(--border) bg-(--bg-inset) p-3"
>
<p class="text-sm text-(--fg-subtle)">Scroll down inside this box</p>
<div class="h-40" />
<div
ref="target"
class="rounded-lg border border-(--border) bg-(--accent) p-4 text-center text-(--accent-fg) shadow transition"
:class="isVisible ? 'opacity-100 scale-100' : 'opacity-60 scale-95'"
>
<div class="text-sm font-semibold">Target element</div>
<div class="text-xs opacity-80">at least 50% visible to count</div>
</div>
<div class="h-24" />
<p class="text-sm text-(--fg-subtle)">and back up to hide it again.</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ seenCount }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">times seen</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center flex flex-col items-center justify-center">
<div class="text-sm font-semibold" :class="isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg-muted)'">
{{ isActive ? 'Active' : 'Paused' }}
</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">observer</div>
</div>
</div>
<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"
@click="isActive ? pause() : resume()"
>
{{ isActive ? 'Pause observer' : 'Resume observer' }}
</button>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { defineComponent, h, ref } from 'vue';
import { useFocusGuard } from './index';
// useFocusGuard() returns void: on mount it inserts a pair of invisible,
// focusable sentinel elements at the boundaries of the DOM tree. They keep
// focus order consistent (e.g. for modal/overlay focus management) and are
// removed when the last consumer unmounts.
// Mount the guard through a child so we can toggle it on/off in the demo.
const GuardHost = defineComponent({
name: 'GuardHost',
setup() {
useFocusGuard('demo-guard');
return () => h('span', { class: 'sr-only' }, 'focus guards mounted');
},
});
const enabled = ref(false);
const focused = ref<string | null>(null);
const fields = [
{ id: 'name', label: 'Name', value: 'Ada Lovelace' },
{ id: 'email', label: 'Email', value: 'ada@analytical.engine' },
];
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<component :is="GuardHost" v-if="enabled" />
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Focus guards</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
:class="enabled
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
>
<span class="size-1.5 rounded-full" :class="enabled ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
{{ enabled ? 'Mounted' : 'Off' }}
</span>
</div>
<!-- A small focusable form to feel tab order with the guards active. -->
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-3">
<div v-for="f in fields" :key="f.id" class="flex flex-col gap-1">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" :for="f.id">{{ f.label }}</label>
<input
:id="f.id"
:value="f.value"
type="text"
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)"
@focus="focused = f.label"
@blur="focused = null"
>
</div>
<button
type="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"
@focus="focused = 'Submit'"
@blur="focused = null"
>
Submit
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
focused: {{ focused ?? 'nothing' }}
</div>
<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"
@click="enabled = !enabled"
>
{{ enabled ? 'Remove focus guards' : 'Mount focus guards' }}
</button>
<p class="text-xs text-(--fg-subtle)">
Guards are invisible <span class="font-mono">tabindex="0"</span> sentinels inserted at the page boundaries. Tab past the last field with them on to feel focus wrap around for overlays and modals.
</p>
</div>
</template>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useIntersectionObserver } from './index';
const sections = [
{ id: 'intro', label: 'Introduction', color: 'bg-sky-500' },
{ id: 'install', label: 'Installation', color: 'bg-emerald-500' },
{ id: 'usage', label: 'Usage', color: 'bg-violet-500' },
{ id: 'api', label: 'API Reference', color: 'bg-amber-500' },
];
const root = ref<HTMLElement>();
const itemEls = ref<HTMLElement[]>([]);
// Reactive threshold + rootMargin demonstrate live re-observation.
const threshold = ref(0.5);
// Track which section is currently the most-visible "active" one (scroll-spy).
const ratios = ref<Record<string, number>>({});
useIntersectionObserver(
itemEls,
(entries) => {
for (const entry of entries) {
const id = (entry.target as HTMLElement).dataset.id;
if (id)
ratios.value = { ...ratios.value, [id]: entry.intersectionRatio };
}
},
{
root,
threshold: () => [0, 0.25, 0.5, 0.75, 1],
},
);
const activeId = computed(() => {
let best: string | null = null;
let bestRatio = threshold.value;
for (const s of sections) {
const r = ratios.value[s.id] ?? 0;
if (r >= bestRatio) {
bestRatio = r;
best = s.id;
}
}
return best;
});
</script>
<template>
<div class="w-full max-w-md flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Scroll spy</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)">
active: {{ activeId ?? '—' }}
</span>
</div>
<div class="flex gap-3">
<!-- Nav reflects which section dominates the viewport. -->
<nav class="flex w-32 shrink-0 flex-col gap-1">
<span
v-for="s in sections"
:key="s.id"
class="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition"
:class="activeId === s.id
? 'border-(--border-strong) bg-(--bg-inset) text-(--fg)'
: 'border-transparent text-(--fg-muted)'"
>
<span class="size-2 rounded-full transition" :class="[s.color, activeId === s.id ? 'opacity-100' : 'opacity-30']" />
{{ s.label }}
</span>
</nav>
<!-- Scrollable content; each section is an observed target. -->
<div
ref="root"
class="h-56 flex-1 overflow-y-auto rounded-xl border border-(--border) bg-(--bg-inset) p-3 flex flex-col gap-3 scroll-smooth"
>
<section
v-for="s in sections"
:key="s.id"
ref="itemEls"
:data-id="s.id"
class="rounded-lg border border-(--border) bg-(--bg-elevated) p-4 transition"
:class="activeId === s.id ? 'ring-2 ring-(--ring)' : ''"
>
<h3 class="text-sm font-semibold text-(--fg)">{{ s.label }}</h3>
<p class="mt-1 text-xs text-(--fg-subtle)">
Visibility: {{ Math.round((ratios[s.id] ?? 0) * 100) }}%
</p>
<div class="mt-2 h-16 rounded bg-(--bg-inset)" />
</section>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="io-threshold">Active threshold</label>
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ Math.round(threshold * 100) }}%</span>
</div>
<input
id="io-threshold"
v-model.number="threshold"
type="range"
min="0.1"
max="0.9"
step="0.1"
class="w-full accent-(--accent) cursor-pointer"
>
<p class="text-xs text-(--fg-subtle)">A section becomes active once at least this much of it is visible inside the scroll root.</p>
</div>
</div>
</template>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { ref, shallowRef, useTemplateRef } from 'vue';
import { useMutationObserver } from './index';
const target = useTemplateRef<HTMLElement>('target');
// Mutable state that drives the observed element so changes are visible.
const labels = ref(['Inbox', 'Drafts', 'Archive']);
const accent = ref(false);
const fontSize = ref(14);
const mutationCount = shallowRef(0);
const lastType = shallowRef<MutationRecordType | ''>('—');
const log = ref<string[]>([]);
const { isSupported, isActive, pause, resume } = useMutationObserver(
target,
(records) => {
for (const record of records) {
mutationCount.value++;
lastType.value = record.type;
let detail = record.type as string;
if (record.type === 'attributes')
detail = `attribute "${record.attributeName}"`;
else if (record.type === 'childList')
detail = `children +${record.addedNodes.length} ${record.removedNodes.length}`;
log.value.unshift(detail);
if (log.value.length > 5)
log.value.pop();
}
},
{ attributes: true, childList: true, subtree: true, attributeFilter: ['style', 'class'] },
);
function addLabel() {
const pool = ['Spam', 'Sent', 'Starred', 'Trash', 'Snoozed', 'Important'];
labels.value.push(pool[labels.value.length % pool.length]);
}
function removeLabel() {
labels.value.pop();
}
</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)">MutationObserver</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)">
<span class="size-1.5 rounded-full transition" :class="isActive ? 'bg-emerald-500' : 'bg-amber-500'" />
{{ isActive ? 'Observing' : 'Paused' }}
</span>
</div>
<p v-if="!isSupported" class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-600 dark:text-amber-400">
MutationObserver is not supported in this browser.
</p>
<template v-else>
<!-- Observed subtree: every attribute/child change fires the callback -->
<div
ref="target"
class="rounded-xl border p-4 transition"
:class="accent ? 'border-(--accent) bg-(--accent-subtle)' : 'border-(--border) bg-(--bg-elevated)'"
:style="{ fontSize: `${fontSize}px` }"
>
<div class="mb-2 text-xs font-medium uppercase tracking-wide" :class="accent ? 'text-(--accent-text)' : 'text-(--fg-subtle)'">
Observed element
</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="(label, i) in labels"
:key="`${label}-${i}`"
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
>
{{ label }}
</span>
<span v-if="!labels.length" class="text-xs text-(--fg-subtle)">No labels</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ mutationCount }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">mutations</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="truncate font-mono text-sm font-bold text-(--fg)">{{ lastType }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">last record type</div>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<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"
@click="addLabel"
>
Add child
</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="!labels.length"
@click="removeLabel"
>
Remove child
</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"
@click="accent = !accent"
>
Toggle class
</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"
@click="fontSize = fontSize === 14 ? 18 : 14"
>
Toggle style
</button>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
<div class="mb-1.5 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Recent records</div>
<ul v-if="log.length" class="flex flex-col gap-1 font-mono text-xs text-(--fg)">
<li v-for="(entry, i) in log" :key="i" class="truncate">{{ entry }}</li>
</ul>
<p v-else class="font-mono text-xs text-(--fg-subtle)">Mutate the element above to record changes.</p>
</div>
<button
type="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="isActive ? pause() : resume()"
>
{{ isActive ? 'Pause observer' : 'Resume observer' }}
</button>
</template>
</div>
</template>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue';
import { useParentElement } from './index';
const child = useTemplateRef<HTMLElement>('child');
// useParentElement returns a read-only shallow ref of the element's parent.
const parent = useParentElement(child);
// Reparent the child between two wrappers to watch the resolved parent change.
const inSecond = ref(false);
const parentInfo = computed(() => {
const el = parent.value;
if (!el)
return null;
return {
tag: el.tagName.toLowerCase(),
id: el.id || '—',
classes: el.className || '—',
width: Math.round(el.getBoundingClientRect().width),
height: Math.round(el.getBoundingClientRect().height),
};
});
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">parentElement</span>
<!-- Two candidate parents; the child is teleported between them via v-if -->
<div class="flex flex-col gap-3">
<div
id="wrapper-a"
class="rounded-xl border border-dashed p-3 transition"
:class="!inSecond ? 'border-(--accent) bg-(--accent-subtle)' : 'border-(--border) bg-(--bg-elevated)'"
>
<div class="mb-2 text-xs font-medium uppercase tracking-wide" :class="!inSecond ? 'text-(--accent-text)' : 'text-(--fg-subtle)'">
#wrapper-a
</div>
<div
v-if="!inSecond"
ref="child"
class="rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2 text-sm font-medium text-(--fg)"
>
Tracked child
</div>
<div v-else class="text-xs text-(--fg-subtle)">empty</div>
</div>
<div
id="wrapper-b"
class="rounded-xl border border-dashed p-3 transition"
:class="inSecond ? 'border-(--accent) bg-(--accent-subtle)' : 'border-(--border) bg-(--bg-elevated)'"
>
<div class="mb-2 text-xs font-medium uppercase tracking-wide" :class="inSecond ? 'text-(--accent-text)' : 'text-(--fg-subtle)'">
#wrapper-b
</div>
<div
v-if="inSecond"
ref="child"
class="rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2 text-sm font-medium text-(--fg)"
>
Tracked child
</div>
<div v-else class="text-xs text-(--fg-subtle)">empty</div>
</div>
</div>
<button
type="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="inSecond = !inSecond"
>
Move child to #wrapper-{{ inSecond ? 'a' : 'b' }}
</button>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div class="mb-2 font-sans text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Resolved parent</div>
<template v-if="parentInfo">
<div class="flex justify-between gap-2">
<span class="text-(--fg-subtle)">tag</span>
<span class="text-(--accent-text)">&lt;{{ parentInfo.tag }}&gt;</span>
</div>
<div class="flex justify-between gap-2">
<span class="text-(--fg-subtle)">id</span>
<span class="truncate">{{ parentInfo.id }}</span>
</div>
<div class="flex justify-between gap-2">
<span class="text-(--fg-subtle)">size</span>
<span>{{ parentInfo.width }} × {{ parentInfo.height }}</span>
</div>
</template>
<div v-else class="font-sans text-xs text-(--fg-subtle)">Resolving parent on the client</div>
</div>
</div>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { reactive, shallowRef, useTemplateRef } from 'vue';
import { useResizeObserver } from './index';
const target = useTemplateRef<HTMLElement>('target');
const size = reactive({ width: 0, height: 0 });
const callbacks = shallowRef(0);
const { isSupported, isActive, pause, resume } = useResizeObserver(
target,
([entry]) => {
if (!entry)
return;
const { width, height } = entry.contentRect;
size.width = Math.round(width);
size.height = Math.round(height);
callbacks.value++;
},
);
</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)">ResizeObserver</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)">
<span class="size-1.5 rounded-full transition" :class="isActive ? 'bg-emerald-500' : 'bg-amber-500'" />
{{ isActive ? 'Observing' : 'Paused' }}
</span>
</div>
<p v-if="!isSupported" class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-600 dark:text-amber-400">
ResizeObserver is not supported in this browser.
</p>
<template v-else>
<!-- Drag the bottom-right handle to resize; the observer reports new dimensions -->
<div
ref="target"
class="relative grid min-h-32 min-w-40 max-w-full resize overflow-auto rounded-xl border border-(--border) bg-(--bg-elevated) p-4 place-items-center"
style="width: 16rem; height: 8rem;"
>
<div class="pointer-events-none select-none text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ size.width }}<span class="text-(--fg-subtle)"> × </span>{{ size.height }}
</div>
<div class="mt-1 text-xs text-(--fg-subtle)">drag the bottom-right corner</div>
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ size.width }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">width px</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ size.height }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">height px</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-2 text-center">
<div class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ callbacks }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">callbacks</div>
</div>
</div>
<button
type="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="isActive ? pause() : resume()"
>
{{ isActive ? 'Pause observer' : 'Resume observer' }}
</button>
<p class="text-xs text-(--fg-subtle)">
While paused, resizing won't update the readout until you resume.
</p>
</template>
</div>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useWindowFocus } from './index';
// useWindowFocus returns a single ShallowRef<boolean> — bind it directly.
const focused = useWindowFocus();
const blurCount = ref(0);
watch(focused, (isFocused) => {
if (!isFocused)
blurCount.value++;
});
</script>
<template>
<div class="w-full max-w-sm flex flex-col gap-4">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Window focus</span>
<div
class="rounded-xl border p-6 text-center transition"
:class="focused
? 'border-emerald-500/30 bg-emerald-500/10'
: 'border-(--border) bg-(--bg-inset)'"
>
<div
class="mx-auto flex size-14 items-center justify-center rounded-full transition"
:class="focused
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
: 'bg-(--bg-elevated) text-(--fg-subtle)'"
>
<span class="size-3 rounded-full transition" :class="focused ? 'bg-emerald-500 animate-pulse' : 'bg-(--fg-subtle)'" />
</div>
<div class="mt-3 text-lg font-semibold text-(--fg)">
{{ focused ? 'Window focused' : 'Window blurred' }}
</div>
<p class="mt-1 text-sm text-(--fg-muted)">
{{ focused ? 'Click outside or switch apps to blur.' : 'Click back into this window to refocus.' }}
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ focused ? 'on' : 'off' }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">focused</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ blurCount }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">times blurred</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useWindowScroll } from './index';
const { x, y, isScrolling, arrivedState, directions, measure } = useWindowScroll({
behavior: 'smooth',
});
const verticalDirection = computed(() => {
if (directions.bottom)
return 'down';
if (directions.top)
return 'up';
return 'idle';
});
function scrollToTop() {
y.value = 0;
}
function scrollToBottom() {
// Writing past the max is clamped by the browser, taking us to the bottom.
y.value = 1_000_000;
}
</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)">Window scroll</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)">
<span class="size-1.5 rounded-full transition" :class="isScrolling ? 'bg-emerald-500 animate-pulse' : 'bg-(--fg-subtle)'" />
{{ isScrolling ? 'Scrolling' : 'Idle' }}
</span>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ Math.round(x) }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">scroll x</div>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center">
<div class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ Math.round(y) }}</div>
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">scroll y</div>
</div>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Direction</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)">
{{ verticalDirection }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-sm">
<span class="text-(--fg-muted)">Arrived at top</span>
<span
class="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
:class="arrivedState.top
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-(--bg-inset) text-(--fg-subtle)'"
>
{{ arrivedState.top ? 'yes' : 'no' }}
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-(--fg-muted)">Arrived at bottom</span>
<span
class="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
:class="arrivedState.bottom
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-(--bg-inset) text-(--fg-subtle)'"
>
{{ arrivedState.bottom ? 'yes' : 'no' }}
</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<button
type="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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="arrivedState.top"
@click="scrollToTop"
>
Scroll to top
</button>
<button
type="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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="arrivedState.bottom"
@click="scrollToBottom"
>
Scroll to bottom
</button>
</div>
<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"
@click="measure"
>
Re-measure
</button>
<p class="text-xs text-(--fg-subtle)">
Scroll the documentation page to watch the position and arrived edges update live.
</p>
</div>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useWindowSize } from './index';
const { width, height } = useWindowSize();
const orientation = computed(() => (width.value >= height.value ? 'landscape' : 'portrait'));
const aspect = computed(() => (height.value === 0 ? '' : (width.value / height.value).toFixed(2)));
const ratioPercent = computed(() => {
const total = width.value + height.value;
return total === 0 ? 50 : Math.round((width.value / total) * 100);
});
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-4">
<div class="grid grid-cols-2 gap-3">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-center">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Width
</p>
<p class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ width }}
</p>
<p class="text-xs text-(--fg-subtle)">
px
</p>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-center">
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Height
</p>
<p class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
{{ height }}
</p>
<p class="text-xs text-(--fg-subtle)">
px
</p>
</div>
</div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
<div
class="relative flex items-center justify-center overflow-hidden rounded-lg border border-(--border-strong) bg-(--bg-inset) transition-all"
:style="{ aspectRatio: `${Math.max(width, 1)} / ${Math.max(height, 1)}` }"
>
<div
class="absolute inset-y-0 left-0 bg-(--accent-subtle) transition-all"
:style="{ width: `${ratioPercent}%` }"
/>
<span class="relative font-mono text-xs text-(--fg-muted) tabular-nums">
{{ width }} × {{ height }}
</span>
</div>
</div>
<div class="flex flex-wrap items-center justify-center gap-2">
<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)">
{{ orientation }}
</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)">
ratio {{ aspect }}
</span>
</div>
<p class="text-center text-xs text-(--fg-subtle)">
Resize your browser window to watch the values update live.
</p>
</div>
</template>