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,140 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useTemplateRef } from 'vue';
|
||||||
|
import { useAnimate } from './index';
|
||||||
|
|
||||||
|
const target = useTemplateRef<HTMLElement>('target');
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSupported,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
reverse,
|
||||||
|
finish,
|
||||||
|
cancel,
|
||||||
|
playState,
|
||||||
|
currentTime,
|
||||||
|
playbackRate,
|
||||||
|
} = useAnimate(
|
||||||
|
target,
|
||||||
|
[
|
||||||
|
{ transform: 'translateX(-3.5rem) rotate(0deg)', borderRadius: '0.75rem' },
|
||||||
|
{ transform: 'translateX(0) rotate(180deg)', borderRadius: '50%' },
|
||||||
|
{ transform: 'translateX(3.5rem) rotate(360deg)', borderRadius: '0.75rem' },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 2000,
|
||||||
|
iterations: Infinity,
|
||||||
|
direction: 'alternate',
|
||||||
|
easing: 'ease-in-out',
|
||||||
|
immediate: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const elapsed = computed(() => {
|
||||||
|
const t = currentTime.value;
|
||||||
|
return typeof t === 'number' ? `${(t / 1000).toFixed(2)}s` : '—';
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateColor = computed(() => {
|
||||||
|
switch (playState.value) {
|
||||||
|
case 'running': return 'bg-emerald-500';
|
||||||
|
case 'paused': return 'bg-amber-500';
|
||||||
|
case 'finished': return 'bg-sky-500';
|
||||||
|
default: return 'bg-(--border-strong)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rates = [0.5, 1, 2] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-if="!isSupported"
|
||||||
|
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
The Web Animations API is not supported in this browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex h-28 items-center justify-center overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset)">
|
||||||
|
<div
|
||||||
|
ref="target"
|
||||||
|
class="size-12 bg-(--accent) shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
State
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<span class="inline-block size-2 rounded-full transition" :class="stateColor" />
|
||||||
|
<span class="font-mono text-sm text-(--fg)">{{ playState }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Current time
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 font-mono text-sm tabular-nums text-(--fg)">
|
||||||
|
{{ elapsed }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<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="play"
|
||||||
|
>
|
||||||
|
Play
|
||||||
|
</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="pause"
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</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="reverse"
|
||||||
|
>
|
||||||
|
Reverse
|
||||||
|
</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="finish"
|
||||||
|
>
|
||||||
|
Finish
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="col-span-2 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="cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Playback rate
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="rate in rates"
|
||||||
|
:key="rate"
|
||||||
|
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium tabular-nums transition active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="playbackRate === rate
|
||||||
|
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||||
|
@click="playbackRate = rate"
|
||||||
|
>
|
||||||
|
{{ rate }}×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useCountdown } from './index';
|
||||||
|
|
||||||
|
const initial = ref(60);
|
||||||
|
const justFinished = ref(false);
|
||||||
|
|
||||||
|
const { remaining, isActive, start, stop, pause, resume } = useCountdown(initial, {
|
||||||
|
onComplete: () => {
|
||||||
|
justFinished.value = true;
|
||||||
|
},
|
||||||
|
onTick: () => {
|
||||||
|
justFinished.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const minutes = computed(() => String(Math.floor(remaining.value / 60)).padStart(2, '0'));
|
||||||
|
const seconds = computed(() => String(remaining.value % 60).padStart(2, '0'));
|
||||||
|
|
||||||
|
const progress = computed(() =>
|
||||||
|
initial.value > 0 ? Math.max(0, Math.min(1, remaining.value / initial.value)) : 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const presets = [30, 60, 300] as const;
|
||||||
|
|
||||||
|
function setPreset(value: number) {
|
||||||
|
initial.value = value;
|
||||||
|
start(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (isActive.value)
|
||||||
|
pause();
|
||||||
|
else
|
||||||
|
resume();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5 text-center">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Time remaining
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 font-mono text-5xl font-bold tabular-nums transition-colors"
|
||||||
|
:class="justFinished
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: remaining <= 10 && remaining > 0
|
||||||
|
? 'text-amber-600 dark:text-amber-400'
|
||||||
|
: 'text-(--fg)'"
|
||||||
|
>
|
||||||
|
{{ minutes }}:{{ seconds }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-(--bg-inset)">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-(--accent) transition-[width] duration-300 ease-linear"
|
||||||
|
:style="{ width: `${progress * 100}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
|
||||||
|
<span
|
||||||
|
class="inline-block size-2 rounded-full transition"
|
||||||
|
:class="isActive ? 'bg-emerald-500' : justFinished ? 'bg-sky-500' : 'bg-(--border-strong)'"
|
||||||
|
/>
|
||||||
|
{{ justFinished ? 'Completed' : isActive ? 'Counting down' : 'Paused' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in presets"
|
||||||
|
:key="preset"
|
||||||
|
class="rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium tabular-nums text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="setPreset(preset)"
|
||||||
|
>
|
||||||
|
{{ preset < 60 ? `${preset}s` : `${preset / 60}m` }}
|
||||||
|
</button>
|
||||||
|
</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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
:disabled="remaining === 0 && isActive"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
{{ isActive ? 'Pause' : 'Resume' }}
|
||||||
|
</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="start()"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</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="stop"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useDateFormat } from './index';
|
||||||
|
|
||||||
|
// A fixed, real-looking moment so the demo is deterministic across renders.
|
||||||
|
const date = ref('2026-06-08T14:37:09');
|
||||||
|
const format = ref('dddd, MMMM Do YYYY — hh:mm:ss a');
|
||||||
|
const locale = ref('en-US');
|
||||||
|
|
||||||
|
const formatted = useDateFormat(date, format, { locales: locale });
|
||||||
|
|
||||||
|
const formats = [
|
||||||
|
'YYYY-MM-DD HH:mm:ss',
|
||||||
|
'dddd, MMMM Do YYYY — hh:mm:ss a',
|
||||||
|
'ddd D MMM \'YY',
|
||||||
|
'h:mm A',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const locales = [
|
||||||
|
{ value: 'en-US', label: 'English' },
|
||||||
|
{ value: 'fr-FR', label: 'Français' },
|
||||||
|
{ value: 'de-DE', label: 'Deutsch' },
|
||||||
|
{ value: 'ja-JP', label: '日本語' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const isValid = computed(() => formatted.value !== 'Invalid Date');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Formatted output
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 font-mono text-lg font-semibold tabular-nums"
|
||||||
|
:class="isValid ? 'text-(--fg)' : 'text-red-600 dark:text-red-400'"
|
||||||
|
>
|
||||||
|
{{ formatted }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Date input
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="date"
|
||||||
|
type="datetime-local"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Format token string
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="format"
|
||||||
|
type="text"
|
||||||
|
spellcheck="false"
|
||||||
|
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
<button
|
||||||
|
v-for="f in formats"
|
||||||
|
:key="f"
|
||||||
|
class="rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 font-mono text-xs text-(--fg-muted) transition hover:bg-(--bg-elevated) hover:text-(--fg) active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="{ 'border-(--accent) text-(--accent-text)': format === f }"
|
||||||
|
@click="format = f"
|
||||||
|
>
|
||||||
|
{{ f }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Locale
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="loc in locales"
|
||||||
|
:key="loc.value"
|
||||||
|
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="locale === loc.value
|
||||||
|
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||||
|
@click="locale = loc.value"
|
||||||
|
>
|
||||||
|
{{ loc.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useInterval } from './index';
|
||||||
|
|
||||||
|
const interval = ref(1000);
|
||||||
|
|
||||||
|
const { counter, isActive, pause, resume, reset } = useInterval(interval, {
|
||||||
|
controls: true,
|
||||||
|
immediate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A simple visual pulse driven purely off the reactive tick counter.
|
||||||
|
const beats = computed(() => Array.from({ length: 8 }, (_, i) => i === counter.value % 8));
|
||||||
|
|
||||||
|
const speeds = [
|
||||||
|
{ value: 2000, label: 'Slow' },
|
||||||
|
{ value: 1000, label: 'Normal' },
|
||||||
|
{ value: 400, label: 'Fast' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (isActive.value)
|
||||||
|
pause();
|
||||||
|
else
|
||||||
|
resume();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5 text-center">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Ticks elapsed
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 font-mono text-5xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ counter }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-center gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="(on, i) in beats"
|
||||||
|
:key="i"
|
||||||
|
class="size-2.5 rounded-full transition-colors duration-200"
|
||||||
|
:class="on ? 'bg-(--accent)' : 'bg-(--bg-inset)'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
|
||||||
|
<span
|
||||||
|
class="inline-block size-2 rounded-full transition"
|
||||||
|
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
|
||||||
|
/>
|
||||||
|
{{ isActive ? `Ticking every ${interval}ms` : 'Paused' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Interval speed
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="speed in speeds"
|
||||||
|
:key="speed.value"
|
||||||
|
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="interval === speed.value
|
||||||
|
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||||
|
@click="interval = speed.value"
|
||||||
|
>
|
||||||
|
{{ speed.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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="toggle"
|
||||||
|
>
|
||||||
|
{{ isActive ? 'Pause' : 'Resume' }}
|
||||||
|
</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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
:disabled="counter === 0"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useIntervalFn } from './index';
|
||||||
|
|
||||||
|
const interval = ref(800);
|
||||||
|
const logs = ref<{ id: number; time: string }[]>([]);
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
const { isActive, pause, resume, toggle } = useIntervalFn(
|
||||||
|
() => {
|
||||||
|
logs.value.unshift({
|
||||||
|
id: nextId++,
|
||||||
|
time: new Date().toLocaleTimeString(undefined, { hour12: false }),
|
||||||
|
});
|
||||||
|
if (logs.value.length > 6)
|
||||||
|
logs.value.length = 6;
|
||||||
|
},
|
||||||
|
interval,
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const speeds = [
|
||||||
|
{ value: 1500, label: 'Slow' },
|
||||||
|
{ value: 800, label: 'Normal' },
|
||||||
|
{ value: 300, label: 'Fast' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
logs.value = [];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Interval callback
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-sm text-(--fg-muted)">
|
||||||
|
<span
|
||||||
|
class="inline-block size-2 rounded-full transition"
|
||||||
|
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
|
||||||
|
/>
|
||||||
|
{{ isActive ? `Firing every ${interval}ms` : 'Stopped' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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="toggle"
|
||||||
|
>
|
||||||
|
{{ isActive ? 'Pause' : 'Start' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Interval
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="speed in speeds"
|
||||||
|
:key="speed.value"
|
||||||
|
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="interval === speed.value
|
||||||
|
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||||
|
@click="interval = speed.value"
|
||||||
|
>
|
||||||
|
{{ speed.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Changing the interval while running restarts the timer with the new duration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Tick log
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-xs text-(--accent-text) transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
|
||||||
|
:disabled="logs.length === 0"
|
||||||
|
@click="clear"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-32 rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<p v-if="logs.length === 0" class="py-6 text-center text-sm text-(--fg-subtle)">
|
||||||
|
No ticks yet — press Start.
|
||||||
|
</p>
|
||||||
|
<ul v-else class="flex flex-col gap-1.5">
|
||||||
|
<li
|
||||||
|
v-for="log in logs"
|
||||||
|
:key="log.id"
|
||||||
|
class="flex items-center gap-2 font-mono text-sm tabular-nums text-(--fg)"
|
||||||
|
>
|
||||||
|
<span class="inline-block size-1.5 rounded-full bg-(--accent)" />
|
||||||
|
{{ log.time }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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-(--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="isActive"
|
||||||
|
@click="resume"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</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="!isActive"
|
||||||
|
@click="pause"
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useNow } from './index';
|
||||||
|
|
||||||
|
const { now, isActive, pause, resume, toggle } = useNow({ controls: true, interval: 'requestAnimationFrame' });
|
||||||
|
|
||||||
|
const time = computed(() =>
|
||||||
|
now.value.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }),
|
||||||
|
);
|
||||||
|
const millis = computed(() => now.value.getMilliseconds().toString().padStart(3, '0'));
|
||||||
|
const date = computed(() =>
|
||||||
|
now.value.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A sweeping second hand driven entirely by the reactive `now`.
|
||||||
|
const secondAngle = computed(() => {
|
||||||
|
const seconds = now.value.getSeconds() + now.value.getMilliseconds() / 1000;
|
||||||
|
return seconds / 60 * 360;
|
||||||
|
});
|
||||||
|
</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">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Reactive now</div>
|
||||||
|
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ time }}</span>
|
||||||
|
<span class="font-mono text-lg font-semibold tabular-nums text-(--fg-subtle)">.{{ millis }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-(--fg-muted)">{{ date }}</div>
|
||||||
|
|
||||||
|
<div class="relative mt-1 size-24 rounded-full border-2 border-(--border-strong) bg-(--bg-inset)">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="size-1.5 rounded-full bg-(--accent)" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-1/2 left-1/2 h-9 w-0.5 origin-bottom rounded-full bg-(--accent)"
|
||||||
|
:style="{ transform: `translateX(-50%) rotate(${secondAngle}deg)` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<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-(--fg-subtle)'"
|
||||||
|
/>
|
||||||
|
{{ isActive ? 'Ticking (RAF)' : 'Paused' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex items-center 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="toggle"
|
||||||
|
>
|
||||||
|
{{ isActive ? 'Pause' : 'Resume' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="isActive"
|
||||||
|
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"
|
||||||
|
@click="resume"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!isActive"
|
||||||
|
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"
|
||||||
|
@click="pause"
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, shallowRef } from 'vue';
|
||||||
|
import { useRafFn } from './index';
|
||||||
|
|
||||||
|
const fpsLimit = ref(0);
|
||||||
|
|
||||||
|
const position = ref(0);
|
||||||
|
const direction = ref(1);
|
||||||
|
const delta = shallowRef(0);
|
||||||
|
const fps = shallowRef(0);
|
||||||
|
const frames = ref(0);
|
||||||
|
|
||||||
|
const { isActive, pause, resume, toggle } = useRafFn(
|
||||||
|
({ delta: d }) => {
|
||||||
|
delta.value = d;
|
||||||
|
fps.value = d > 0 ? Math.round(1000 / d) : 0;
|
||||||
|
frames.value++;
|
||||||
|
|
||||||
|
// Bounce a marker across the track using real frame delta (px per second).
|
||||||
|
position.value += direction.value * (d / 1000) * 120;
|
||||||
|
|
||||||
|
if (position.value >= 100) {
|
||||||
|
position.value = 100;
|
||||||
|
direction.value = -1;
|
||||||
|
}
|
||||||
|
else if (position.value <= 0) {
|
||||||
|
position.value = 0;
|
||||||
|
direction.value = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ fpsLimit: fpsLimit.value || undefined },
|
||||||
|
);
|
||||||
|
|
||||||
|
const limitLabel = computed(() => (fpsLimit.value === 0 ? 'Unlimited' : `${fpsLimit.value} fps`));
|
||||||
|
</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 gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">requestAnimationFrame</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-(--fg-subtle)'" />
|
||||||
|
{{ isActive ? 'Running' : 'Paused' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The animated track: marker position is updated every frame -->
|
||||||
|
<div class="relative mx-2.5 h-8 rounded-lg border border-(--border) bg-(--bg-inset)">
|
||||||
|
<div
|
||||||
|
class="absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-(--accent) shadow"
|
||||||
|
:style="{ left: `${position}%` }"
|
||||||
|
/>
|
||||||
|
</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)">{{ fps }}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">fps</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)">{{ delta.toFixed(1) }}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">delta ms</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)">{{ frames }}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">frames</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="fps-limit">FPS limit</label>
|
||||||
|
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ limitLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="fps-limit"
|
||||||
|
v-model.number="fpsLimit"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="60"
|
||||||
|
step="5"
|
||||||
|
class="w-full accent-(--accent) cursor-pointer"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-(--fg-subtle)">Changing the limit takes effect on the next mount; toggle below to see it live.</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="toggle"
|
||||||
|
>
|
||||||
|
{{ isActive ? 'Pause loop' : 'Resume loop' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useTimeAgo } from './index';
|
||||||
|
|
||||||
|
interface Preset {
|
||||||
|
label: string;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offsets in ms relative to now; negative = past, positive = future.
|
||||||
|
const presets: Preset[] = [
|
||||||
|
{ label: '15 s ago', offset: -15_000 },
|
||||||
|
{ label: '3 min ago', offset: -3 * 60_000 },
|
||||||
|
{ label: '2 h ago', offset: -2 * 3_600_000 },
|
||||||
|
{ label: 'Yesterday', offset: -86_400_000 },
|
||||||
|
{ label: 'Last week', offset: -7 * 86_400_000 },
|
||||||
|
{ label: '5 months ago', offset: -5 * 2_592_000_000 },
|
||||||
|
{ label: 'In 45 min', offset: 45 * 60_000 },
|
||||||
|
{ label: 'Next year', offset: 31_536_000_000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const offset = ref(presets[1]!.offset);
|
||||||
|
|
||||||
|
// Reactive getter recomputed each tick so the string stays live.
|
||||||
|
const target = computed(() => Date.now() + offset.value);
|
||||||
|
|
||||||
|
const { timeAgo, isActive, toggle } = useTimeAgo(target, {
|
||||||
|
controls: true,
|
||||||
|
updateInterval: 1000,
|
||||||
|
showSecond: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const absolute = computed(() =>
|
||||||
|
new Date(target.value).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
</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-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Relative time</span>
|
||||||
|
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg) text-center">{{ timeAgo }}</span>
|
||||||
|
<span class="text-xs text-(--fg-muted)">{{ absolute }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Pick an instant</span>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in presets"
|
||||||
|
:key="preset.label"
|
||||||
|
type="button"
|
||||||
|
class="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="offset === preset.offset
|
||||||
|
? '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="offset = preset.offset"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<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-(--fg-subtle)'" />
|
||||||
|
{{ isActive ? 'Updating every 1s' : 'Updates paused' }}
|
||||||
|
</span>
|
||||||
|
<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="toggle"
|
||||||
|
>
|
||||||
|
{{ isActive ? 'Pause' : 'Resume' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useTimeout } from './index';
|
||||||
|
|
||||||
|
const delay = ref(3000);
|
||||||
|
const firedAt = ref<string | null>(null);
|
||||||
|
|
||||||
|
const { ready, start, stop } = useTimeout(delay, {
|
||||||
|
controls: true,
|
||||||
|
immediate: false,
|
||||||
|
callback: () => {
|
||||||
|
firedAt.value = new Date().toLocaleTimeString('en-US', { hour12: false });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
firedAt.value = null;
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
</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">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Status</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex size-20 items-center justify-center rounded-full border-2 transition"
|
||||||
|
:class="ready
|
||||||
|
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold">{{ ready ? 'Ready' : 'Pending' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-(--fg-muted)">
|
||||||
|
<template v-if="ready && firedAt">Fired at <span class="font-mono tabular-nums text-(--fg)">{{ firedAt }}</span></template>
|
||||||
|
<template v-else-if="ready">Idle — start the timer below</template>
|
||||||
|
<template v-else>Counting down… stays pending until the delay elapses</template>
|
||||||
|
</p>
|
||||||
|
</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="delay">Delay</label>
|
||||||
|
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ (delay / 1000).toFixed(1) }}s</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="delay"
|
||||||
|
v-model.number="delay"
|
||||||
|
type="range"
|
||||||
|
min="500"
|
||||||
|
max="5000"
|
||||||
|
step="500"
|
||||||
|
class="w-full accent-(--accent) cursor-pointer"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="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="restart"
|
||||||
|
>
|
||||||
|
{{ ready ? 'Start' : 'Restart' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="ready"
|
||||||
|
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"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useTimeoutFn } from './index';
|
||||||
|
|
||||||
|
interface Mail {
|
||||||
|
id: number;
|
||||||
|
from: string;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inbox = ref<Mail[]>([
|
||||||
|
{ id: 1, from: 'Ada Lovelace', subject: 'Notes on the Analytical Engine' },
|
||||||
|
{ id: 2, from: 'Grace Hopper', subject: 'Found a bug in the relay' },
|
||||||
|
{ id: 3, from: 'Alan Turing', subject: 'Re: Halting problem' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pendingDelete = ref<Mail | null>(null);
|
||||||
|
|
||||||
|
// After the grace period elapses the mail is permanently removed.
|
||||||
|
const { isPending, start, stop } = useTimeoutFn(
|
||||||
|
() => {
|
||||||
|
if (pendingDelete.value)
|
||||||
|
inbox.value = inbox.value.filter(m => m.id !== pendingDelete.value!.id);
|
||||||
|
|
||||||
|
pendingDelete.value = null;
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
{ immediate: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
function archive(mail: Mail) {
|
||||||
|
stop();
|
||||||
|
pendingDelete.value = mail;
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
stop();
|
||||||
|
pendingDelete.value = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full max-w-sm flex flex-col gap-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Inbox · undo with grace period</span>
|
||||||
|
|
||||||
|
<ul v-if="inbox.length" class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="mail in inbox"
|
||||||
|
:key="mail.id"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-3 transition"
|
||||||
|
:class="{ 'opacity-40': pendingDelete?.id === mail.id }"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-sm font-medium text-(--fg)">{{ mail.subject }}</div>
|
||||||
|
<div class="truncate text-xs text-(--fg-muted)">{{ mail.from }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="isPending"
|
||||||
|
class="shrink-0 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"
|
||||||
|
@click="archive(mail)"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-else class="rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center text-sm text-(--fg-subtle)">
|
||||||
|
Inbox zero — everything archived.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200" enter-from-class="opacity-0 translate-y-1"
|
||||||
|
leave-active-class="transition duration-150" leave-to-class="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isPending && pendingDelete"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
<span class="size-1.5 animate-pulse rounded-full bg-amber-500" />
|
||||||
|
Archiving “{{ pendingDelete.subject }}”…
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-0.5 text-sm font-semibold text-amber-700 underline-offset-2 transition hover:underline dark:text-amber-400 cursor-pointer"
|
||||||
|
@click="undo"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useTimestamp } from './index';
|
||||||
|
|
||||||
|
const interval = ref(1000);
|
||||||
|
const offset = ref(0);
|
||||||
|
|
||||||
|
const { timestamp, isActive, pause, resume } = useTimestamp({
|
||||||
|
controls: true,
|
||||||
|
interval: 1000,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clockTime = computed(() =>
|
||||||
|
new Date(timestamp.value).toLocaleTimeString(undefined, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clockDate = computed(() =>
|
||||||
|
new Date(timestamp.value).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (isActive.value)
|
||||||
|
pause();
|
||||||
|
else
|
||||||
|
resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shift(ms: number) {
|
||||||
|
offset.value += ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetOffset() {
|
||||||
|
offset.value = 0;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-center">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Reactive timestamp
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 font-mono text-3xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ clockTime }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-(--fg-muted)">
|
||||||
|
{{ clockDate }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-center gap-2 text-xs text-(--fg-subtle)">
|
||||||
|
<span
|
||||||
|
class="inline-block size-2 rounded-full transition"
|
||||||
|
:class="isActive ? 'bg-emerald-500' : 'bg-(--border-strong)'"
|
||||||
|
/>
|
||||||
|
{{ isActive ? 'Updating every second' : 'Paused' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
{{ Math.round(timestamp) }} ms
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<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="toggle"
|
||||||
|
>
|
||||||
|
{{ isActive ? 'Pause' : 'Resume' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<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="shift(-3600_000)"
|
||||||
|
>
|
||||||
|
-1h
|
||||||
|
</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="shift(3600_000)"
|
||||||
|
>
|
||||||
|
+1h
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-sm text-(--fg-muted)">
|
||||||
|
<span>
|
||||||
|
Offset:
|
||||||
|
<span class="font-mono text-(--fg) tabular-nums">{{ (offset / 3600_000).toFixed(0) }}h</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="text-(--accent-text) transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"
|
||||||
|
:disabled="offset === 0"
|
||||||
|
@click="resetOffset"
|
||||||
|
>
|
||||||
|
Reset offset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { TransitionPresets, useTransition } from './index';
|
||||||
|
|
||||||
|
type PresetName = keyof typeof TransitionPresets;
|
||||||
|
|
||||||
|
const presetNames = Object.keys(TransitionPresets) as PresetName[];
|
||||||
|
const preset = ref<PresetName>('easeOutCubic');
|
||||||
|
const duration = ref(800);
|
||||||
|
|
||||||
|
// Animated progress value (0–100).
|
||||||
|
const target = ref(72);
|
||||||
|
const value = useTransition(target, {
|
||||||
|
duration,
|
||||||
|
transition: computed(() => TransitionPresets[preset.value]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animated color tuple (RGB).
|
||||||
|
const swatches: Array<[string, [number, number, number]]> = [
|
||||||
|
['Indigo', [99, 102, 241]],
|
||||||
|
['Emerald', [16, 185, 129]],
|
||||||
|
['Amber', [245, 158, 11]],
|
||||||
|
['Rose', [244, 63, 94]],
|
||||||
|
];
|
||||||
|
const colorTarget = ref<[number, number, number]>([99, 102, 241]);
|
||||||
|
const color = useTransition(colorTarget, {
|
||||||
|
duration: 600,
|
||||||
|
transition: TransitionPresets.easeInOutQuad,
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorCss = computed(() => {
|
||||||
|
const [r, g, b] = color.value;
|
||||||
|
return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function randomize() {
|
||||||
|
target.value = Math.round(Math.random() * 100);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-5">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Eased value
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ value.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-(--bg-inset)">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-(--accent)"
|
||||||
|
:style="{ width: `${Math.max(0, Math.min(100, value))}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
v-model.number="target"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="h-1.5 flex-1 cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
<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="randomize"
|
||||||
|
>
|
||||||
|
Random
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Easing preset
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="preset"
|
||||||
|
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<option v-for="name in presetNames" :key="name" :value="name">
|
||||||
|
{{ name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="mt-1 flex items-center justify-between text-sm text-(--fg-muted)">
|
||||||
|
<span>Duration</span>
|
||||||
|
<span class="font-mono text-(--fg) tabular-nums">{{ duration }}ms</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="duration"
|
||||||
|
type="range"
|
||||||
|
min="100"
|
||||||
|
max="2000"
|
||||||
|
step="100"
|
||||||
|
class="h-1.5 w-full cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="size-12 shrink-0 rounded-lg border border-(--border)"
|
||||||
|
:style="{ backgroundColor: colorCss }"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Animated tuple
|
||||||
|
</div>
|
||||||
|
<div class="font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
{{ colorCss }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="[label, rgb] in swatches"
|
||||||
|
:key="label"
|
||||||
|
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) transition hover:border-(--border-strong) cursor-pointer"
|
||||||
|
@click="colorTarget = [...rgb]"
|
||||||
|
>
|
||||||
|
<span class="size-2.5 rounded-full" :style="{ backgroundColor: `rgb(${rgb.join(',')})` }" />
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useArrayDifference } from './index';
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const library: Track[] = [
|
||||||
|
{ id: 1, title: 'Midnight City' },
|
||||||
|
{ id: 2, title: 'Strobe' },
|
||||||
|
{ id: 3, title: 'Open Eye Signal' },
|
||||||
|
{ id: 4, title: 'Innerbloom' },
|
||||||
|
{ id: 5, title: 'Teardrop' },
|
||||||
|
{ id: 6, title: 'Resonance' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const list = ref<Track[]>([...library]);
|
||||||
|
const playlist = ref<Track[]>([library[1], library[3]]);
|
||||||
|
const symmetric = ref(false);
|
||||||
|
|
||||||
|
// Compare tracks by their `id` key.
|
||||||
|
const diff = useArrayDifference(list, playlist, 'id', {
|
||||||
|
get symmetric() {
|
||||||
|
return symmetric.value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function inPlaylist(track: Track) {
|
||||||
|
return playlist.value.some(t => t.id === track.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(track: Track) {
|
||||||
|
if (inPlaylist(track))
|
||||||
|
playlist.value = playlist.value.filter(t => t.id !== track.id);
|
||||||
|
else
|
||||||
|
playlist.value = [...playlist.value, track];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Library — tap to add / remove from playlist
|
||||||
|
</span>
|
||||||
|
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
|
||||||
|
<input
|
||||||
|
v-model="symmetric"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
Symmetric
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="track in library"
|
||||||
|
:key="track.id"
|
||||||
|
class="inline-flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="inPlaylist(track)
|
||||||
|
? '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="toggle(track)"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ track.title }}</span>
|
||||||
|
<span class="shrink-0 text-xs opacity-70">{{ inPlaylist(track) ? '−' : '+' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
{{ symmetric ? 'In exactly one (XOR)' : 'Not in playlist' }}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-(--fg-muted)">
|
||||||
|
{{ diff.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="diff.length" class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<li
|
||||||
|
v-for="track in diff"
|
||||||
|
:key="track.id"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
{{ track.title }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="mt-3 text-sm text-(--fg-subtle)">
|
||||||
|
No difference — every track matches.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useArrayEvery } from './index';
|
||||||
|
|
||||||
|
interface Check {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checklist = ref<Check[]>([
|
||||||
|
{ id: 1, label: 'Build passes', done: true },
|
||||||
|
{ id: 2, label: 'Tests green', done: true },
|
||||||
|
{ id: 3, label: 'Types check', done: false },
|
||||||
|
{ id: 4, label: 'Docs updated', done: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// True only when every item is done.
|
||||||
|
const allDone = useArrayEvery(checklist, item => item.done);
|
||||||
|
|
||||||
|
const completed = computed(() => checklist.value.filter(c => c.done).length);
|
||||||
|
|
||||||
|
function toggle(item: Check) {
|
||||||
|
item.done = !item.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
checklist.value.forEach(c => (c.done = false));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div
|
||||||
|
class="rounded-xl border p-4 transition"
|
||||||
|
:class="allDone
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated)'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Release readiness
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="allDone
|
||||||
|
? 'border-emerald-500/30 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-2 rounded-full"
|
||||||
|
:class="allDone ? 'bg-emerald-500' : 'bg-amber-500'"
|
||||||
|
/>
|
||||||
|
{{ allDone ? 'Ready to ship' : 'Blocked' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 font-mono text-sm tabular-nums text-(--fg-muted)">
|
||||||
|
{{ completed }} / {{ checklist.length }} complete
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<li v-for="item in checklist" :key="item.id">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2 text-left text-sm text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.99] cursor-pointer"
|
||||||
|
@click="toggle(item)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex size-5 shrink-0 items-center justify-center rounded-md border text-xs transition"
|
||||||
|
:class="item.done
|
||||||
|
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'border-(--border-strong) text-transparent'"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span :class="item.done ? 'line-through text-(--fg-subtle)' : ''">
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center gap-1.5 self-start 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="reset"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useArrayFilter } from './index';
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
inStock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = ref<Product[]>([
|
||||||
|
{ name: 'Mechanical Keyboard', price: 129, inStock: true },
|
||||||
|
{ name: 'USB-C Hub', price: 49, inStock: false },
|
||||||
|
{ name: '4K Monitor', price: 399, inStock: true },
|
||||||
|
{ name: 'Webcam', price: 79, inStock: true },
|
||||||
|
{ name: 'Desk Mat', price: 25, inStock: false },
|
||||||
|
{ name: 'Wireless Mouse', price: 59, inStock: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query = ref('');
|
||||||
|
const maxPrice = ref(400);
|
||||||
|
const inStockOnly = ref(true);
|
||||||
|
|
||||||
|
// Reactive filter: name match + price ceiling + stock toggle.
|
||||||
|
const visible = useArrayFilter(products, (product) => {
|
||||||
|
const matchesName = product.name.toLowerCase().includes(query.value.trim().toLowerCase());
|
||||||
|
const matchesPrice = product.price <= maxPrice.value;
|
||||||
|
const matchesStock = !inStockOnly.value || product.inStock;
|
||||||
|
return matchesName && matchesPrice && matchesStock;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatted = computed(() =>
|
||||||
|
visible.value.map(p => ({ ...p, priceLabel: `$${p.price}` })),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search products…"
|
||||||
|
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 class="flex flex-col gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<label class="flex items-center justify-between text-sm text-(--fg-muted)">
|
||||||
|
<span>Max price</span>
|
||||||
|
<span class="font-mono text-(--fg) tabular-nums">${{ maxPrice }}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="maxPrice"
|
||||||
|
type="range"
|
||||||
|
min="25"
|
||||||
|
max="400"
|
||||||
|
step="5"
|
||||||
|
class="h-1.5 w-full cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
|
||||||
|
<input
|
||||||
|
v-model="inStockOnly"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
In stock only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="font-medium uppercase tracking-wide text-(--fg-subtle)">Results</span>
|
||||||
|
<span class="font-mono tabular-nums text-(--fg-muted)">
|
||||||
|
{{ formatted.length }} / {{ products.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="formatted.length" class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="product in formatted"
|
||||||
|
:key="product.name"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-(--fg)">{{ product.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="!product.inStock"
|
||||||
|
class="inline-flex items-center rounded-md border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
Out
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-(--fg-muted)">{{ product.priceLabel }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-dashed border-(--border) bg-(--bg-inset) px-3 py-6 text-center text-sm text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
No products match your filters.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useArrayFind } from './index';
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
inStock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = ref<Product[]>([
|
||||||
|
{ id: 1, name: 'Mechanical Keyboard', price: 129, inStock: false },
|
||||||
|
{ id: 2, name: 'USB-C Hub', price: 49, inStock: true },
|
||||||
|
{ id: 3, name: 'Desk Mat', price: 24, inStock: true },
|
||||||
|
{ id: 4, name: '4K Monitor', price: 399, inStock: true },
|
||||||
|
{ id: 5, name: 'Webcam', price: 89, inStock: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const maxPrice = ref(100);
|
||||||
|
const inStockOnly = ref(true);
|
||||||
|
|
||||||
|
// Reactive Array.prototype.find — re-evaluates when the list, the price
|
||||||
|
// threshold or the toggle change.
|
||||||
|
const firstMatch = useArrayFind(
|
||||||
|
catalog,
|
||||||
|
product =>
|
||||||
|
product.price <= maxPrice.value
|
||||||
|
&& (!inStockOnly.value || product.inStock),
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchIndex = computed(() =>
|
||||||
|
firstMatch.value ? catalog.value.indexOf(firstMatch.value) : -1,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="maxPrice">
|
||||||
|
Max price
|
||||||
|
</label>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-(--fg)">${{ maxPrice }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="maxPrice"
|
||||||
|
v-model.number="maxPrice"
|
||||||
|
type="range"
|
||||||
|
min="20"
|
||||||
|
max="400"
|
||||||
|
step="5"
|
||||||
|
class="w-full accent-(--accent)"
|
||||||
|
>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
|
||||||
|
<input v-model="inStockOnly" type="checkbox" class="accent-(--accent)">
|
||||||
|
In stock only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<p class="mb-1 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
First match
|
||||||
|
</p>
|
||||||
|
<template v-if="firstMatch">
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-sm font-medium text-(--fg)">{{ firstMatch.name }}</span>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-(--fg)">${{ firstMatch.price }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 font-mono text-xs text-(--fg-subtle)">
|
||||||
|
index {{ matchIndex }} · id {{ firstMatch.id }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-sm text-(--fg-subtle)">
|
||||||
|
No product matches the filters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-1.5">
|
||||||
|
<li
|
||||||
|
v-for="product in catalog"
|
||||||
|
:key="product.id"
|
||||||
|
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
|
||||||
|
:class="product.id === firstMatch?.id
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
{{ product.name }}
|
||||||
|
<span
|
||||||
|
v-if="!product.inStock"
|
||||||
|
class="inline-flex items-center rounded-md border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
out of stock
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono tabular-nums">${{ product.price }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useArrayFindIndex } from './index';
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
label: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = ref<Step[]>([
|
||||||
|
{ label: 'Clone repository', done: true },
|
||||||
|
{ label: 'Install dependencies', done: true },
|
||||||
|
{ label: 'Run database migrations', done: false },
|
||||||
|
{ label: 'Seed sample data', done: false },
|
||||||
|
{ label: 'Start dev server', done: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reactive Array.prototype.findIndex — points at the first step still pending.
|
||||||
|
const nextIndex = useArrayFindIndex(steps, step => !step.done);
|
||||||
|
|
||||||
|
function toggle(index: number) {
|
||||||
|
steps.value[index]!.done = !steps.value[index]!.done;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Next pending index
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 font-mono text-3xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ nextIndex }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-(--fg-subtle)">
|
||||||
|
{{ nextIndex === -1 ? 'All steps complete' : `“${steps[nextIndex]!.label}”` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="flex flex-col gap-1.5">
|
||||||
|
<li
|
||||||
|
v-for="(step, index) in steps"
|
||||||
|
:key="step.label"
|
||||||
|
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
|
||||||
|
:class="index === nextIndex
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs tabular-nums text-(--fg-subtle)">{{ index }}</span>
|
||||||
|
<span :class="step.done ? 'line-through opacity-60' : ''">{{ step.label }}</span>
|
||||||
|
</span>
|
||||||
|
<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="toggle(index)"
|
||||||
|
>
|
||||||
|
{{ step.done ? 'Undo' : 'Done' }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useArrayFindLast } from './index';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number;
|
||||||
|
level: 'info' | 'warn' | 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 6;
|
||||||
|
const log = ref<LogEntry[]>([
|
||||||
|
{ id: 1, level: 'info', message: 'Server started on :3000' },
|
||||||
|
{ id: 2, level: 'warn', message: 'Cache miss for key "user:42"' },
|
||||||
|
{ id: 3, level: 'error', message: 'Failed to reach payments API' },
|
||||||
|
{ id: 4, level: 'info', message: 'Request handled in 12ms' },
|
||||||
|
{ id: 5, level: 'warn', message: 'Deprecated header received' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filter = ref<LogEntry['level']>('warn');
|
||||||
|
|
||||||
|
// Reactive Array.prototype.findLast — the most recent entry at this level.
|
||||||
|
const latest = useArrayFindLast(log, entry => entry.level === filter.value);
|
||||||
|
|
||||||
|
const samples: LogEntry['message'][] = [
|
||||||
|
'Disk usage at 82%',
|
||||||
|
'New WebSocket connection',
|
||||||
|
'Token refresh failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
function append(level: LogEntry['level']) {
|
||||||
|
const message = samples[Math.floor(Math.random() * samples.length)]!;
|
||||||
|
log.value.push({ id: nextId++, level, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels: LogEntry['level'][] = ['info', 'warn', 'error'];
|
||||||
|
const tone: Record<LogEntry['level'], string> = {
|
||||||
|
info: 'text-sky-600 dark:text-sky-400',
|
||||||
|
warn: 'text-amber-600 dark:text-amber-400',
|
||||||
|
error: 'text-red-600 dark:text-red-400',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="level in levels"
|
||||||
|
:key="level"
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="filter === level
|
||||||
|
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset)'"
|
||||||
|
@click="filter = level"
|
||||||
|
>
|
||||||
|
{{ level }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Latest “{{ filter }}” entry
|
||||||
|
</p>
|
||||||
|
<template v-if="latest">
|
||||||
|
<p class="mt-1 font-mono text-sm text-(--fg)">{{ latest.message }}</p>
|
||||||
|
<p class="mt-1 font-mono text-xs text-(--fg-subtle)">#{{ latest.id }}</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="mt-1 text-sm text-(--fg-subtle)">
|
||||||
|
No “{{ filter }}” entries yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex max-h-44 flex-col gap-1 overflow-y-auto rounded-lg border border-(--border) bg-(--bg-elevated) p-2">
|
||||||
|
<li
|
||||||
|
v-for="entry in log"
|
||||||
|
:key="entry.id"
|
||||||
|
class="flex items-center gap-2 rounded-md px-2 py-1 font-mono text-xs transition"
|
||||||
|
:class="entry.id === latest?.id ? 'bg-(--accent-subtle)' : ''"
|
||||||
|
>
|
||||||
|
<span class="w-10 shrink-0 font-semibold uppercase" :class="tone[entry.level]">
|
||||||
|
{{ entry.level }}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-(--fg-muted)">{{ entry.message }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="level in levels"
|
||||||
|
:key="level"
|
||||||
|
type="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"
|
||||||
|
@click="append(level)"
|
||||||
|
>
|
||||||
|
+ {{ level }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useArrayIncludes } from './index';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = ref<User[]>([
|
||||||
|
{ id: 11, name: 'Ada' },
|
||||||
|
{ id: 22, name: 'Linus' },
|
||||||
|
{ id: 33, name: 'Grace' },
|
||||||
|
{ id: 44, name: 'Dennis' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Search by an object key — `id` is compared against the searched value.
|
||||||
|
const searchId = ref(33);
|
||||||
|
const isMember = useArrayIncludes(team, searchId, 'id');
|
||||||
|
|
||||||
|
// Plain primitive membership with a reactive `fromIndex` option.
|
||||||
|
const tags = ref(['vue', 'reactive', 'composable', 'reactive']);
|
||||||
|
const query = ref('reactive');
|
||||||
|
const fromIndex = ref(2);
|
||||||
|
const hasTag = useArrayIncludes(tags, query, { fromIndex: fromIndex.value });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Member by key
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="user in team"
|
||||||
|
:key="user.id"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
|
||||||
|
:class="user.id === searchId
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
<span class="font-mono text-(--fg-subtle)">#{{ user.id }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model.number="searchId"
|
||||||
|
type="number"
|
||||||
|
class="w-24 rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium"
|
||||||
|
:class="isMember
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'bg-(--bg-inset) text-(--fg-subtle)'"
|
||||||
|
>
|
||||||
|
{{ isMember ? 'includes id' : 'not found' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Primitive search (fromIndex 2)
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="(tag, i) in tags"
|
||||||
|
:key="i"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="i < 2
|
||||||
|
? 'border-(--border) bg-(--bg-inset) text-(--fg-subtle) opacity-50 line-through'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="search a tag…"
|
||||||
|
class="mt-3 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)"
|
||||||
|
>
|
||||||
|
<p class="mt-2 text-sm text-(--fg-muted)">
|
||||||
|
Searching from index 2 →
|
||||||
|
<span
|
||||||
|
class="font-mono font-semibold"
|
||||||
|
:class="hasTag ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg-subtle)'"
|
||||||
|
>{{ hasTag }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useArrayJoin } from './index';
|
||||||
|
|
||||||
|
const segments = ref<string[]>(['users', 'robonen', 'projects', 'tools']);
|
||||||
|
const separator = ref('/');
|
||||||
|
const draft = ref('');
|
||||||
|
|
||||||
|
// Reactive Array.prototype.join — recomputes on item edits and separator change.
|
||||||
|
const joined = useArrayJoin(segments, separator);
|
||||||
|
|
||||||
|
const separators: { label: string; value: string }[] = [
|
||||||
|
{ label: 'slash', value: '/' },
|
||||||
|
{ label: 'comma', value: ', ' },
|
||||||
|
{ label: 'dash', value: ' - ' },
|
||||||
|
{ label: 'none', value: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
const value = draft.value.trim();
|
||||||
|
if (!value)
|
||||||
|
return;
|
||||||
|
segments.value.push(value);
|
||||||
|
draft.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
segments.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<p class="mb-1 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Joined result
|
||||||
|
</p>
|
||||||
|
<p class="break-all font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
<span v-if="joined">{{ joined }}</span>
|
||||||
|
<span v-else class="text-(--fg-subtle)">empty</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Separator</span>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="sep in separators"
|
||||||
|
:key="sep.label"
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg border px-2 py-1.5 text-xs font-medium transition active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="separator === sep.value
|
||||||
|
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset)'"
|
||||||
|
@click="separator = sep.value"
|
||||||
|
>
|
||||||
|
{{ sep.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-1.5">
|
||||||
|
<li
|
||||||
|
v-for="(segment, index) in segments"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm text-(--fg)"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs text-(--fg-subtle)">{{ index }}</span>
|
||||||
|
{{ segment }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove segment"
|
||||||
|
class="rounded-md px-2 py-0.5 text-xs font-medium text-(--fg-subtle) transition hover:bg-(--bg-inset) hover:text-(--fg) cursor-pointer"
|
||||||
|
@click="remove(index)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form class="flex gap-2" @submit.prevent="add">
|
||||||
|
<input
|
||||||
|
v-model="draft"
|
||||||
|
type="text"
|
||||||
|
placeholder="add a segment…"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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="!draft.trim()"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useArrayMap } from './index';
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = ref<Product[]>([
|
||||||
|
{ name: 'Mechanical keyboard', price: 129 },
|
||||||
|
{ name: 'Ultrawide monitor', price: 549 },
|
||||||
|
{ name: 'Noise-cancelling headset', price: 299 },
|
||||||
|
{ name: 'Standing desk', price: 419 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const taxRate = ref(8);
|
||||||
|
|
||||||
|
// Reactive Array.prototype.map — recomputes when products or taxRate change.
|
||||||
|
const priced = useArrayMap(products, (product) => {
|
||||||
|
const gross = product.price * (1 + taxRate.value / 100);
|
||||||
|
return { ...product, gross };
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
const total = computed(() => priced.value.reduce((sum, p) => sum + p.gross, 0));
|
||||||
|
|
||||||
|
function bump(index: number, delta: number) {
|
||||||
|
const next = products.value.slice();
|
||||||
|
next[index] = { ...next[index], price: Math.max(0, next[index].price + delta) };
|
||||||
|
products.value = next;
|
||||||
|
}
|
||||||
|
</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)">Cart</span>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-(--fg-muted)">
|
||||||
|
Tax {{ taxRate }}%
|
||||||
|
<input
|
||||||
|
v-model.number="taxRate"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="25"
|
||||||
|
class="accent-(--accent)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in priced"
|
||||||
|
:key="item.name"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate text-sm font-medium text-(--fg)">
|
||||||
|
{{ item.name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
base {{ formatter.format(item.price) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
|
||||||
|
aria-label="Decrease price"
|
||||||
|
@click="bump(index, -10)"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
|
||||||
|
aria-label="Increase price"
|
||||||
|
@click="bump(index, 10)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="w-20 text-right font-mono text-sm tabular-nums text-(--fg)">
|
||||||
|
{{ formatter.format(item.gross) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Total with tax</span>
|
||||||
|
<span class="font-mono text-xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ formatter.format(total) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useArrayReduce } from './index';
|
||||||
|
|
||||||
|
interface Expense {
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenses = ref<Expense[]>([
|
||||||
|
{ label: 'Cloud hosting', amount: 86 },
|
||||||
|
{ label: 'Domain renewal', amount: 18 },
|
||||||
|
{ label: 'Design assets', amount: 42 },
|
||||||
|
{ label: 'Coffee', amount: 7 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A reactive seed: the running balance grows as you raise the starting budget.
|
||||||
|
const startingBudget = ref(500);
|
||||||
|
|
||||||
|
// Reactive Array.prototype.reduce with a reactive initial value.
|
||||||
|
const remaining = useArrayReduce(
|
||||||
|
expenses,
|
||||||
|
(balance, expense) => balance - expense.amount,
|
||||||
|
startingBudget,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
expenses.value = [...expenses.value, { label: 'New charge', amount: 25 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
expenses.value = expenses.value.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full max-w-md flex flex-col gap-4">
|
||||||
|
<label class="flex items-center justify-between gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Starting budget</span>
|
||||||
|
<input
|
||||||
|
v-model.number="startingBudget"
|
||||||
|
type="number"
|
||||||
|
step="50"
|
||||||
|
class="w-28 rounded-lg border border-(--border) bg-(--bg) px-3 py-1.5 text-right font-mono text-sm tabular-nums text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="(expense, index) in expenses"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="flex-1 truncate text-sm text-(--fg)">{{ expense.label }}</span>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-rose-600 dark:text-rose-400">
|
||||||
|
−{{ formatter.format(expense.amount) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="inline-flex size-6 items-center justify-center rounded-md text-(--fg-subtle) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-[0.98] cursor-pointer"
|
||||||
|
aria-label="Remove expense"
|
||||||
|
@click="removeAt(index)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="expenses.length === 0" class="rounded-lg border border-dashed border-(--border) px-3 py-4 text-center text-sm text-(--fg-subtle)">
|
||||||
|
No expenses — full budget remains.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<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="add"
|
||||||
|
>
|
||||||
|
+ Add charge
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Remaining</span>
|
||||||
|
<span
|
||||||
|
class="font-mono text-2xl font-bold tabular-nums"
|
||||||
|
:class="remaining < 0 ? 'text-rose-600 dark:text-rose-400' : 'text-emerald-600 dark:text-emerald-400'"
|
||||||
|
>
|
||||||
|
{{ formatter.format(remaining) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useArraySome } from './index';
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
name: string;
|
||||||
|
cpu: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = ref<Server[]>([
|
||||||
|
{ name: 'api-west-1', cpu: 32 },
|
||||||
|
{ name: 'api-east-1', cpu: 54 },
|
||||||
|
{ name: 'worker-1', cpu: 71 },
|
||||||
|
{ name: 'db-primary', cpu: 48 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const threshold = ref(80);
|
||||||
|
|
||||||
|
// Reactive Array.prototype.some — true if ANY server is over the threshold.
|
||||||
|
const hasOverloaded = useArraySome(servers, server => server.cpu > threshold.value);
|
||||||
|
|
||||||
|
function load(index: number, delta: number) {
|
||||||
|
const next = servers.value.slice();
|
||||||
|
next[index] = { ...next[index], cpu: Math.min(100, Math.max(0, next[index].cpu + delta)) };
|
||||||
|
servers.value = next;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full max-w-md flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 rounded-xl border p-4 transition"
|
||||||
|
:class="hasOverloaded
|
||||||
|
? 'border-amber-500/30 bg-amber-500/10'
|
||||||
|
: 'border-emerald-500/30 bg-emerald-500/10'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex size-2.5 rounded-full"
|
||||||
|
:class="hasOverloaded ? 'bg-amber-500' : 'bg-emerald-500'"
|
||||||
|
/>
|
||||||
|
<p class="text-sm font-medium" :class="hasOverloaded ? 'text-amber-700 dark:text-amber-300' : 'text-emerald-700 dark:text-emerald-300'">
|
||||||
|
{{ hasOverloaded ? 'At least one server is overloaded' : 'All servers within limits' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between gap-3 text-sm text-(--fg-muted)">
|
||||||
|
<span>Alert threshold</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model.number="threshold"
|
||||||
|
type="range"
|
||||||
|
min="40"
|
||||||
|
max="100"
|
||||||
|
class="accent-(--accent)"
|
||||||
|
>
|
||||||
|
<span class="w-10 text-right font-mono tabular-nums text-(--fg)">{{ threshold }}%</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="(server, index) in servers"
|
||||||
|
:key="server.name"
|
||||||
|
class="rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div class="mb-1.5 flex items-center justify-between">
|
||||||
|
<span class="font-mono text-sm text-(--fg)">{{ server.name }}</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="font-mono text-sm tabular-nums"
|
||||||
|
:class="server.cpu > threshold ? 'text-amber-600 dark:text-amber-400' : 'text-(--fg-muted)'"
|
||||||
|
>{{ server.cpu }}%</span>
|
||||||
|
<button
|
||||||
|
class="inline-flex size-6 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
|
||||||
|
aria-label="Decrease load"
|
||||||
|
@click="load(index, -10)"
|
||||||
|
>−</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex size-6 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
|
||||||
|
aria-label="Increase load"
|
||||||
|
@click="load(index, 10)"
|
||||||
|
>+</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 w-full overflow-hidden rounded-full bg-(--bg-inset)">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all"
|
||||||
|
:class="server.cpu > threshold ? 'bg-amber-500' : 'bg-(--accent)'"
|
||||||
|
:style="{ width: `${server.cpu}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useArrayUnique } from './index';
|
||||||
|
|
||||||
|
const raw = ref<string[]>([
|
||||||
|
'design',
|
||||||
|
'Design',
|
||||||
|
'frontend',
|
||||||
|
'design',
|
||||||
|
'FRONTEND',
|
||||||
|
'vue',
|
||||||
|
'Vue',
|
||||||
|
'a11y',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caseInsensitive = ref(true);
|
||||||
|
|
||||||
|
// Switch de-dup strategy reactively:
|
||||||
|
// - identity (===): "Design" and "design" are distinct
|
||||||
|
// - comparator: case-insensitive equality folds them together
|
||||||
|
const exact = useArrayUnique(raw);
|
||||||
|
const folded = useArrayUnique(raw, (a, b) => a.toLowerCase() === b.toLowerCase());
|
||||||
|
|
||||||
|
const unique = computed(() => (caseInsensitive.value ? folded.value : exact.value));
|
||||||
|
|
||||||
|
const draft = ref('');
|
||||||
|
|
||||||
|
function addTag() {
|
||||||
|
const value = draft.value.trim();
|
||||||
|
if (!value)
|
||||||
|
return;
|
||||||
|
raw.value = [...raw.value, value];
|
||||||
|
draft.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full max-w-md flex flex-col gap-4">
|
||||||
|
<form class="flex gap-2" @submit.prevent="addTag">
|
||||||
|
<input
|
||||||
|
v-model="draft"
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a tag, e.g. TypeScript"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2.5 text-sm text-(--fg)">
|
||||||
|
<span>Case-insensitive comparator</span>
|
||||||
|
<input
|
||||||
|
v-model="caseInsensitive"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 accent-(--accent) cursor-pointer"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Source ({{ raw.length }})
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap gap-1.5 rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<span
|
||||||
|
v-for="(tag, index) in raw"
|
||||||
|
:key="`${tag}-${index}`"
|
||||||
|
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-elevated) px-2 py-0.5 text-xs font-medium text-(--fg-muted)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Unique ({{ unique.length }})
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap gap-1.5 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
|
||||||
|
<span
|
||||||
|
v-for="tag in unique"
|
||||||
|
:key="tag"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-(--accent) bg-(--accent-subtle) px-2 py-0.5 text-xs font-medium text-(--accent-text)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
<span v-if="unique.length === 0" class="text-xs text-(--fg-subtle)">No tags yet.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useSorted } from './index';
|
||||||
|
|
||||||
|
interface Player {
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = ref<Player[]>([
|
||||||
|
{ name: 'Nova', score: 1840, level: 12 },
|
||||||
|
{ name: 'Echo', score: 2310, level: 9 },
|
||||||
|
{ name: 'Pixel', score: 1840, level: 7 },
|
||||||
|
{ name: 'Drift', score: 990, level: 14 },
|
||||||
|
{ name: 'Sable', score: 2310, level: 11 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
type SortKey = 'score' | 'level' | 'name';
|
||||||
|
|
||||||
|
const sortKey = ref<SortKey>('score');
|
||||||
|
const descending = ref(true);
|
||||||
|
|
||||||
|
// Reactive, stable sorted copy — the source `players` is never mutated.
|
||||||
|
// Stable ordering means ties (equal score) keep their original relative order.
|
||||||
|
const sorted = useSorted(players, (a, b) => {
|
||||||
|
const direction = descending.value ? -1 : 1;
|
||||||
|
if (sortKey.value === 'name')
|
||||||
|
return a.name.localeCompare(b.name) * direction;
|
||||||
|
return (a[sortKey.value] - b[sortKey.value]) * direction;
|
||||||
|
});
|
||||||
|
|
||||||
|
const keys: { id: SortKey; label: string }[] = [
|
||||||
|
{ id: 'score', label: 'Score' },
|
||||||
|
{ id: 'level', label: 'Level' },
|
||||||
|
{ id: 'name', label: 'Name' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full max-w-md flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="inline-flex rounded-lg border border-(--border) bg-(--bg-elevated) p-0.5">
|
||||||
|
<button
|
||||||
|
v-for="key in keys"
|
||||||
|
:key="key.id"
|
||||||
|
class="rounded-md px-3 py-1 text-sm font-medium transition cursor-pointer"
|
||||||
|
:class="sortKey === key.id
|
||||||
|
? 'bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'text-(--fg-muted) hover:text-(--fg)'"
|
||||||
|
@click="sortKey = key.id"
|
||||||
|
>
|
||||||
|
{{ key.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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="descending = !descending"
|
||||||
|
>
|
||||||
|
{{ descending ? 'Desc ↓' : 'Asc ↑' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="(player, index) in sorted"
|
||||||
|
:key="player.name"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<span class="w-6 text-center font-mono text-sm tabular-nums text-(--fg-subtle)">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-sm font-medium text-(--fg)">{{ player.name }}</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)">
|
||||||
|
Lv {{ player.level }}
|
||||||
|
</span>
|
||||||
|
<span class="w-16 text-right font-mono text-sm font-semibold tabular-nums text-(--fg)">
|
||||||
|
{{ player.score.toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Stable sort — players with an equal {{ sortKey }} keep their original order. The source array is left untouched.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { broadcastedRef } from './index';
|
||||||
|
|
||||||
|
interface CartState {
|
||||||
|
item: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = ['Mechanical Keyboard', 'USB-C Hub', 'Desk Mat', 'Wrist Rest'];
|
||||||
|
|
||||||
|
// Synced across every open tab via BroadcastChannel
|
||||||
|
const cart = broadcastedRef<CartState>('docs-demo-cart', { item: products[0], quantity: 1 }, { immediate: true });
|
||||||
|
const theme = broadcastedRef<'light' | 'dark'>('docs-demo-theme', 'light');
|
||||||
|
|
||||||
|
const supported = typeof BroadcastChannel !== 'undefined';
|
||||||
|
|
||||||
|
const subtotal = computed(() => cart.value.quantity * 49);
|
||||||
|
|
||||||
|
function pick(item: string): void {
|
||||||
|
cart.value = { ...cart.value, item };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuantity(delta: number): void {
|
||||||
|
cart.value = { ...cart.value, quantity: Math.max(1, cart.value.quantity + delta) };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Shared cart</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="supported
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full" :class="supported ? 'bg-emerald-500' : 'bg-amber-500'" />
|
||||||
|
{{ supported ? 'Broadcasting' : 'Not supported' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="product in products"
|
||||||
|
:key="product"
|
||||||
|
class="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="cart.item === product
|
||||||
|
? '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="pick(product)"
|
||||||
|
>
|
||||||
|
{{ product }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-(--fg-muted)">Quantity</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg-elevated) 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="cart.quantity <= 1"
|
||||||
|
@click="setQuantity(-1)"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span class="w-8 text-center font-mono text-lg font-bold tabular-nums text-(--fg)">{{ cart.quantity }}</span>
|
||||||
|
<button
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg-elevated) text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="setQuantity(1)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-(--fg-muted)">{{ cart.item }} × {{ cart.quantity }}</span>
|
||||||
|
<span class="font-bold">${{ subtotal }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="theme = theme === 'light' ? 'dark' : 'light'"
|
||||||
|
>
|
||||||
|
Toggle shared theme: <span class="font-mono">{{ theme }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Open this page in a second tab. Every change you make here is broadcast and mirrored instantly in the other tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useBreakpoints, breakpointsTailwind } from './index';
|
||||||
|
|
||||||
|
const bp = useBreakpoints(breakpointsTailwind);
|
||||||
|
|
||||||
|
const active = bp.active();
|
||||||
|
const current = bp.current();
|
||||||
|
|
||||||
|
const isMobile = bp.smaller('md');
|
||||||
|
const isDesktop = bp.greaterOrEqual('lg');
|
||||||
|
|
||||||
|
const rows: { key: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; width: string }[] = [
|
||||||
|
{ key: 'sm', width: '640px' },
|
||||||
|
{ key: 'md', width: '768px' },
|
||||||
|
{ key: 'lg', width: '1024px' },
|
||||||
|
{ key: 'xl', width: '1280px' },
|
||||||
|
{ key: '2xl', width: '1536px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const device = (): string => (isDesktop.value ? 'Desktop' : isMobile.value ? 'Mobile' : 'Tablet');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Active breakpoint</span>
|
||||||
|
<div class="mt-1 flex items-baseline gap-2">
|
||||||
|
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ active || 'none' }}</span>
|
||||||
|
<span class="rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">{{ device() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Tailwind breakpoints</span>
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.key"
|
||||||
|
class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm transition"
|
||||||
|
:class="bp[row.key].value
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
<span class="font-mono font-medium">{{ row.key }}</span>
|
||||||
|
<span class="font-mono tabular-nums text-(--fg-subtle)">≥ {{ row.width }}</span>
|
||||||
|
<span
|
||||||
|
class="h-2 w-2 rounded-full transition"
|
||||||
|
:class="bp[row.key].value ? 'bg-(--accent)' : 'bg-(--border-strong)'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-(--fg-muted)">current()</span>
|
||||||
|
<span>[{{ current.length ? current.join(', ') : '—' }}]</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Resize your browser window — the matched breakpoints update live.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useClipboard } from './index';
|
||||||
|
|
||||||
|
const { text, copied, copyPending, isSupported, copy } = useClipboard({ copiedDuring: 1500 });
|
||||||
|
|
||||||
|
const draft = ref('npm install @robonen/toolkit');
|
||||||
|
|
||||||
|
const snippets = [
|
||||||
|
'git switch -c feature/clipboard',
|
||||||
|
'sk-live-9f2a4c7e1b8d6033',
|
||||||
|
'https://robonen.dev/docs/useClipboard',
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Clipboard API</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isSupported
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'"
|
||||||
|
>
|
||||||
|
{{ isSupported ? 'Supported' : 'Not supported' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isSupported">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
v-model="draft"
|
||||||
|
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)"
|
||||||
|
placeholder="Type something to copy…"
|
||||||
|
>
|
||||||
|
<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="copyPending || !draft"
|
||||||
|
@click="copy(draft)"
|
||||||
|
>
|
||||||
|
{{ copyPending ? 'Copying…' : copied ? 'Copied!' : 'Copy to clipboard' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Quick copy</span>
|
||||||
|
<button
|
||||||
|
v-for="snippet in snippets"
|
||||||
|
:key="snippet"
|
||||||
|
class="inline-flex items-center justify-between gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2 text-left text-sm font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.99] cursor-pointer"
|
||||||
|
@click="copy(snippet)"
|
||||||
|
>
|
||||||
|
<span class="truncate font-mono text-xs text-(--fg-muted)">{{ snippet }}</span>
|
||||||
|
<span class="shrink-0 text-xs text-(--fg-subtle)">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Last copied</span>
|
||||||
|
<p class="mt-1 break-all font-mono text-sm text-(--fg)">{{ text || '—' }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
The Clipboard API is not available in this browser.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useClipboardItems } from './index';
|
||||||
|
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
const { content, copied, copyPending, isSupported, copy, read } = useClipboardItems({
|
||||||
|
onError: (error) => {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plain = 'Ada Lovelace — first computer programmer';
|
||||||
|
const html = '<strong>Ada Lovelace</strong> — <em>first computer programmer</em>';
|
||||||
|
|
||||||
|
function copyRich(): void {
|
||||||
|
errorMessage.value = '';
|
||||||
|
copy([
|
||||||
|
new ClipboardItem({
|
||||||
|
'text/plain': new Blob([plain], { type: 'text/plain' }),
|
||||||
|
'text/html': new Blob([html], { type: 'text/html' }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readClipboard(): Promise<void> {
|
||||||
|
errorMessage.value = '';
|
||||||
|
await read();
|
||||||
|
}
|
||||||
|
|
||||||
|
function typesOf(item: ClipboardItem): string {
|
||||||
|
return item.types.join(', ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">ClipboardItem API</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isSupported
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'"
|
||||||
|
>
|
||||||
|
{{ isSupported ? 'Supported' : 'Not supported' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isSupported">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Rich payload</span>
|
||||||
|
<p class="mt-1 text-sm text-(--fg)" v-html="html" />
|
||||||
|
<p class="mt-1 font-mono text-xs text-(--fg-subtle)">text/plain · text/html</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
:disabled="copyPending"
|
||||||
|
@click="copyRich"
|
||||||
|
>
|
||||||
|
{{ copyPending ? 'Copying…' : copied ? 'Copied!' : 'Copy rich content' }}
|
||||||
|
</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="readClipboard"
|
||||||
|
>
|
||||||
|
Read clipboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
content ({{ content.length }} {{ content.length === 1 ? 'item' : 'items' }})
|
||||||
|
</span>
|
||||||
|
<ul v-if="content.length" class="mt-2 flex flex-col gap-1">
|
||||||
|
<li
|
||||||
|
v-for="(item, i) in content"
|
||||||
|
:key="i"
|
||||||
|
class="font-mono text-xs text-(--fg)"
|
||||||
|
>
|
||||||
|
#{{ i + 1 }}: {{ typesOf(item) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="mt-2 font-mono text-xs text-(--fg-subtle)">No items read yet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
The async ClipboardItem API is not available in this browser.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useCloseWatcher } from './index';
|
||||||
|
|
||||||
|
const { isSupported, onClose, close } = useCloseWatcher();
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const lastClosedAt = ref<string | null>(null);
|
||||||
|
const closeCount = ref(0);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onClose(() => {
|
||||||
|
if (!open.value)
|
||||||
|
return;
|
||||||
|
open.value = false;
|
||||||
|
closeCount.value++;
|
||||||
|
lastClosedAt.value = new Date().toLocaleTimeString();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">CloseWatcher API</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isSupported
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
{{ isSupported ? 'Native' : 'Esc fallback' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="open"
|
||||||
|
@click="open = true"
|
||||||
|
>
|
||||||
|
Open dialog
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-150 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-y-1"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-to-class="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<div v-if="open" class="rounded-xl border border-(--border-strong) bg-(--bg-elevated) p-4 shadow-lg">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-(--fg)">Unsaved changes</p>
|
||||||
|
<p class="mt-1 text-sm text-(--fg-muted)">
|
||||||
|
Press <kbd class="rounded border border-(--border) bg-(--bg-inset) px-1.5 py-0.5 font-mono text-xs text-(--fg)">Esc</kbd>
|
||||||
|
(or the Android back gesture) to dismiss.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<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="close()"
|
||||||
|
>
|
||||||
|
Dismiss via close()
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-(--fg-muted)">closes</span>
|
||||||
|
<span class="font-bold">{{ closeCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex justify-between">
|
||||||
|
<span class="text-(--fg-muted)">last</span>
|
||||||
|
<span>{{ lastClosedAt ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Open the dialog, then dismiss it with Esc, the system back gesture, or the programmatic <code class="font-mono">close()</code> call.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useColorMode } from './index';
|
||||||
|
|
||||||
|
// Scope the applied class/attribute to the demo card so it does not
|
||||||
|
// fight the docs site's own theme. `emitAuto` keeps the tri-state value.
|
||||||
|
const target = ref<HTMLElement>();
|
||||||
|
|
||||||
|
const mode = useColorMode({
|
||||||
|
selector: target,
|
||||||
|
attribute: 'data-demo-theme',
|
||||||
|
storageKey: null,
|
||||||
|
emitAuto: true,
|
||||||
|
modes: {
|
||||||
|
auto: '',
|
||||||
|
light: 'light',
|
||||||
|
dark: 'dark',
|
||||||
|
sepia: 'sepia',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'auto', label: 'Auto', icon: '◐' },
|
||||||
|
{ value: 'light', label: 'Light', icon: '☀' },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: '☾' },
|
||||||
|
{ value: 'sepia', label: 'Sepia', icon: '✦' },
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="target"
|
||||||
|
class="flex w-full max-w-sm flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Color mode</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)">
|
||||||
|
system: {{ mode.system.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-col items-center justify-center gap-1 rounded-lg border px-2 py-3 text-xs font-medium transition active:scale-[0.98] cursor-pointer"
|
||||||
|
:class="mode === opt.value
|
||||||
|
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||||
|
@click="mode = opt.value"
|
||||||
|
>
|
||||||
|
<span class="text-base leading-none">{{ opt.icon }}</span>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Reactive state</p>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<dt class="text-(--fg-muted)">selected (emitAuto)</dt>
|
||||||
|
<dd class="font-mono tabular-nums text-(--fg)">{{ mode }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<dt class="text-(--fg-muted)">resolved state</dt>
|
||||||
|
<dd class="font-mono tabular-nums text-(--fg)">{{ mode.state.value }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<dt class="text-(--fg-muted)">store</dt>
|
||||||
|
<dd class="font-mono tabular-nums text-(--fg)">{{ mode.store.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
The chosen mode is applied as <code class="font-mono text-(--fg-muted)">data-demo-theme</code> on this card.
|
||||||
|
Pick "Auto" to follow your OS preference.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useCssVar } from './index';
|
||||||
|
|
||||||
|
const target = ref<HTMLElement>();
|
||||||
|
|
||||||
|
// Read/write live CSS custom properties on the preview box.
|
||||||
|
const hue = useCssVar('--demo-hue', target, { initialValue: '210' });
|
||||||
|
const radius = useCssVar('--demo-radius', target, { initialValue: '16' });
|
||||||
|
const size = useCssVar('--demo-size', target, { initialValue: '96' });
|
||||||
|
|
||||||
|
const swatches = ['12', '90', '150', '210', '270', '330'];
|
||||||
|
|
||||||
|
const accent = computed(() => `hsl(${hue.value} 80% 55%)`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div
|
||||||
|
ref="target"
|
||||||
|
class="flex items-center justify-center rounded-xl border border-(--border) bg-(--bg-inset) p-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="shadow-lg transition-all duration-300 ease-out"
|
||||||
|
:style="{
|
||||||
|
width: 'calc(var(--demo-size) * 1px)',
|
||||||
|
height: 'calc(var(--demo-size) * 1px)',
|
||||||
|
borderRadius: 'calc(var(--demo-radius) * 1px)',
|
||||||
|
background: 'hsl(var(--demo-hue) 80% 55%)',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<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="hue">Hue</label>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-hue: {{ hue }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="hue"
|
||||||
|
v-model="hue"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
class="w-full accent-(--accent) cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="s in swatches"
|
||||||
|
:key="s"
|
||||||
|
type="button"
|
||||||
|
class="h-6 w-6 rounded-md border border-(--border) transition hover:scale-110 active:scale-95 cursor-pointer"
|
||||||
|
:style="{ background: `hsl(${s} 80% 55%)` }"
|
||||||
|
:aria-label="`Set hue ${s}`"
|
||||||
|
@click="hue = s"
|
||||||
|
/>
|
||||||
|
</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="radius">Radius</label>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-radius: {{ radius }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="radius"
|
||||||
|
v-model="radius"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="48"
|
||||||
|
class="w-full accent-(--accent) cursor-pointer"
|
||||||
|
>
|
||||||
|
</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">Size</label>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-(--fg)">--demo-size: {{ size }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="size"
|
||||||
|
v-model="size"
|
||||||
|
type="range"
|
||||||
|
min="48"
|
||||||
|
max="140"
|
||||||
|
class="w-full accent-(--accent) cursor-pointer"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
background: {{ accent }};
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useDark } from './index';
|
||||||
|
|
||||||
|
const target = ref<HTMLElement>();
|
||||||
|
|
||||||
|
// Scope the toggle to the demo card via a data attribute so it does not
|
||||||
|
// override the docs site's own theme. `storageKey: null` keeps it in memory.
|
||||||
|
const isDark = useDark({
|
||||||
|
selector: target,
|
||||||
|
attribute: 'data-demo-mode',
|
||||||
|
valueDark: 'dark',
|
||||||
|
valueLight: 'light',
|
||||||
|
storageKey: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isDark.value = !isDark.value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="target"
|
||||||
|
data-demo-mode
|
||||||
|
class="flex w-full max-w-sm flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors"
|
||||||
|
:class="isDark
|
||||||
|
? 'bg-indigo-500/15 text-indigo-400'
|
||||||
|
: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
{{ isDark ? '☾' : '☀' }}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-(--fg)">{{ isDark ? 'Dark mode' : 'Light mode' }}</p>
|
||||||
|
<p class="text-xs text-(--fg-subtle)">isDark = {{ isDark }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="isDark"
|
||||||
|
class="relative inline-flex h-7 w-12 items-center rounded-full border border-(--border) transition focus:outline-none focus:ring-2 focus:ring-(--ring) cursor-pointer"
|
||||||
|
:class="isDark ? 'bg-(--accent)' : 'bg-(--bg-inset)'"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-5 w-5 transform rounded-full bg-(--bg) shadow transition-transform"
|
||||||
|
:class="isDark ? 'translate-x-6' : 'translate-x-1'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Preview surface</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
|
||||||
|
data-demo-mode = "{{ isDark ? 'dark' : 'light' }}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Writing the boolean toggles the attribute on this card. When the requested state
|
||||||
|
matches your OS preference, <code class="font-mono text-(--fg-muted)">useDark</code>
|
||||||
|
falls back to <code class="font-mono text-(--fg-muted)">auto</code> to keep tracking it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useDocumentPiP } from './index';
|
||||||
|
|
||||||
|
const { isSupported, isOpen, error, open, close } = useDocumentPiP();
|
||||||
|
|
||||||
|
// A live element we move into (and back out of) the PiP window.
|
||||||
|
const player = ref<HTMLElement>();
|
||||||
|
const host = ref<HTMLElement>();
|
||||||
|
const elapsed = ref(0);
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
elapsed.value += 1;
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer)
|
||||||
|
clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function popOut() {
|
||||||
|
const win = await open({ width: 320, height: 180 });
|
||||||
|
|
||||||
|
if (win && player.value) {
|
||||||
|
// Carry over the document styles so the moved DOM keeps its look.
|
||||||
|
for (const sheet of Array.from(document.styleSheets)) {
|
||||||
|
try {
|
||||||
|
const css = Array.from(sheet.cssRules).map(r => r.cssText).join('');
|
||||||
|
const style = win.document.createElement('style');
|
||||||
|
style.textContent = css;
|
||||||
|
win.document.head.append(style);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Cross-origin stylesheet — skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
win.document.body.style.margin = '0';
|
||||||
|
win.document.body.append(player.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the PiP window closes, pull the element back into the page.
|
||||||
|
watch(isOpen, (openNow) => {
|
||||||
|
if (!openNow && player.value && host.value)
|
||||||
|
host.value.append(player.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-if="!isSupported"
|
||||||
|
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
Document Picture-in-Picture is not supported in this browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
ref="host"
|
||||||
|
class="min-h-[7rem] rounded-xl border border-(--border) bg-(--bg-inset) p-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="player"
|
||||||
|
class="flex h-full flex-col items-center justify-center gap-1 rounded-lg bg-(--bg-elevated) p-6"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Live timer</span>
|
||||||
|
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ String(Math.floor(elapsed / 60)).padStart(2, '0') }}:{{ String(elapsed % 60).padStart(2, '0') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center 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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
:disabled="isOpen"
|
||||||
|
@click="popOut"
|
||||||
|
>
|
||||||
|
Pop out
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-1 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="!isOpen"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Close window
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-sm">
|
||||||
|
<span class="text-(--fg-muted)">isOpen</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isOpen
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="isOpen ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
|
||||||
|
/>
|
||||||
|
{{ isOpen ? 'floating' : 'docked' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
class="text-xs text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ String(error) }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-xs text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
"Pop out" moves the live timer into an always-on-top window. Closing it returns the element to the page.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, shallowRef } from 'vue';
|
||||||
|
import { useEventListener } from './index';
|
||||||
|
|
||||||
|
// 1. Element target via template ref — track pointer position over the pad.
|
||||||
|
const pad = ref<HTMLElement>();
|
||||||
|
const pos = ref({ x: 0, y: 0 });
|
||||||
|
const inside = ref(false);
|
||||||
|
|
||||||
|
useEventListener(pad, 'pointermove', (e: PointerEvent) => {
|
||||||
|
const rect = pad.value?.getBoundingClientRect();
|
||||||
|
if (!rect)
|
||||||
|
return;
|
||||||
|
pos.value = {
|
||||||
|
x: Math.round(e.clientX - rect.left),
|
||||||
|
y: Math.round(e.clientY - rect.top),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
useEventListener(pad, 'pointerenter', () => (inside.value = true));
|
||||||
|
useEventListener(pad, 'pointerleave', () => (inside.value = false));
|
||||||
|
|
||||||
|
// 2. Global window target with a stoppable listener — capture last key.
|
||||||
|
const lastKey = shallowRef<string>('');
|
||||||
|
const keyCount = ref(0);
|
||||||
|
const listening = ref(true);
|
||||||
|
|
||||||
|
const stop = useEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
lastKey.value = e.key === ' ' ? 'Space' : e.key;
|
||||||
|
keyCount.value += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleListening() {
|
||||||
|
if (listening.value) {
|
||||||
|
stop();
|
||||||
|
listening.value = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Re-arm by registering a fresh listener.
|
||||||
|
useEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
lastKey.value = e.key === ' ' ? 'Space' : e.key;
|
||||||
|
keyCount.value += 1;
|
||||||
|
});
|
||||||
|
listening.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">pointermove on element</span>
|
||||||
|
<div
|
||||||
|
ref="pad"
|
||||||
|
class="relative h-32 overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset) touch-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-(--accent) transition-opacity"
|
||||||
|
:class="inside ? 'opacity-100' : 'opacity-0'"
|
||||||
|
:style="{ left: `${pos.x}px`, top: `${pos.y}px` }"
|
||||||
|
/>
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span class="text-xs text-(--fg-subtle)">{{ inside ? '' : 'Hover here' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pointer-events-none absolute bottom-2 left-2 rounded-md border border-(--border) bg-(--bg) px-2 py-0.5 font-mono text-xs tabular-nums text-(--fg-muted)">
|
||||||
|
x: {{ pos.x }} · y: {{ pos.y }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">keydown on window</span>
|
||||||
|
<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-xs font-medium text-(--fg) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="toggleListening"
|
||||||
|
>
|
||||||
|
{{ listening ? 'Stop listening' : 'Start listening' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<kbd
|
||||||
|
class="flex min-w-[3.5rem] items-center justify-center rounded-lg border border-(--border-strong) bg-(--bg-inset) px-3 py-2 font-mono text-sm font-medium text-(--fg)"
|
||||||
|
>
|
||||||
|
{{ lastKey || '—' }}
|
||||||
|
</kbd>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-(--fg-subtle)">presses captured</span>
|
||||||
|
<span class="font-mono text-lg font-bold tabular-nums text-(--fg)">{{ keyCount }}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ml-auto inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="listening
|
||||||
|
? '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="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="listening ? 'bg-emerald-500 animate-pulse' : 'bg-(--fg-subtle)'"
|
||||||
|
/>
|
||||||
|
{{ listening ? 'active' : 'stopped' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Listeners auto-detach on unmount. The returned stop function lets you detach early — press any key, then toggle listening.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useEyeDropper } from './index';
|
||||||
|
|
||||||
|
const { isSupported, sRGBHex, open } = useEyeDropper({ initialValue: '#6366f1' });
|
||||||
|
|
||||||
|
const history = ref<string[]>([]);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const hex = computed(() => sRGBHex.value || '#000000');
|
||||||
|
|
||||||
|
function relativeLuminance(color: string): number {
|
||||||
|
const value = color.replace('#', '');
|
||||||
|
const r = Number.parseInt(value.slice(0, 2), 16) / 255;
|
||||||
|
const g = Number.parseInt(value.slice(2, 4), 16) / 255;
|
||||||
|
const b = Number.parseInt(value.slice(4, 6), 16) / 255;
|
||||||
|
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||||
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readableText = computed(() => (relativeLuminance(hex.value) > 0.5 ? '#000000' : '#ffffff'));
|
||||||
|
|
||||||
|
async function pick() {
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const result = await open();
|
||||||
|
if (result && !history.value.includes(result.sRGBHex))
|
||||||
|
history.value = [result.sRGBHex, ...history.value].slice(0, 6);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = 'Selection cancelled';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm 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-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
The EyeDropper API is not supported in this browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
class="flex h-32 items-center justify-center rounded-xl border border-(--border) transition-colors duration-300"
|
||||||
|
:style="{ backgroundColor: hex, color: readableText }"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-2xl font-bold tabular-nums">{{ hex }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="pick">
|
||||||
|
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m2 22 1-1h3l9-9" /><path d="M3 21v-3l9-9" /><path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L21 6l3 3-3 3-3-3-9 9" />
|
||||||
|
</svg>
|
||||||
|
Pick a color from screen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-center text-xs text-(--fg-subtle)">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="history.length" class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Recent</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="color in history"
|
||||||
|
:key="color"
|
||||||
|
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) transition hover:border-(--border-strong) cursor-pointer"
|
||||||
|
@click="sRGBHex = color"
|
||||||
|
>
|
||||||
|
<span class="size-3 rounded-full border border-(--border)" :style="{ backgroundColor: color }" />
|
||||||
|
{{ color }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useFavicon } from './index';
|
||||||
|
|
||||||
|
interface Preset {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny inline SVG data-URIs so the demo needs no network/assets.
|
||||||
|
function emojiFavicon(emoji: string): string {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">${emoji}</text></svg>`;
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets: Preset[] = [
|
||||||
|
{ label: 'Rocket', emoji: '🚀', href: emojiFavicon('🚀') },
|
||||||
|
{ label: 'Fire', emoji: '🔥', href: emojiFavicon('🔥') },
|
||||||
|
{ label: 'Heart', emoji: '💜', href: emojiFavicon('💜') },
|
||||||
|
{ label: 'Star', emoji: '⭐', href: emojiFavicon('⭐') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const favicon = useFavicon(presets[0].href);
|
||||||
|
const active = ref(presets[0].label);
|
||||||
|
|
||||||
|
function select(preset: Preset) {
|
||||||
|
active.value = preset.label;
|
||||||
|
favicon.value = preset.href;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span class="size-2.5 rounded-full bg-red-500/70" />
|
||||||
|
<span class="size-2.5 rounded-full bg-amber-500/70" />
|
||||||
|
<span class="size-2.5 rounded-full bg-emerald-500/70" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex flex-1 items-center gap-2 rounded-md bg-(--bg) px-2 py-1">
|
||||||
|
<span class="text-base leading-none">{{ presets.find(p => p.label === active)?.emoji }}</span>
|
||||||
|
<span class="truncate text-xs text-(--fg-muted)">My Awesome App</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-center text-xs text-(--fg-subtle)">
|
||||||
|
Look at the real browser tab — its favicon updates live.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Choose a favicon</span>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in presets"
|
||||||
|
:key="preset.label"
|
||||||
|
class="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="active === preset.label
|
||||||
|
? '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="select(preset)"
|
||||||
|
>
|
||||||
|
<span class="text-base leading-none">{{ preset.emoji }}</span>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg) break-all">
|
||||||
|
favicon.value = "{{ presets.find(p => p.label === active)?.emoji }} svg"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useFileDialog } from './index';
|
||||||
|
|
||||||
|
const { files, open, reset, onChange, onCancel } = useFileDialog({
|
||||||
|
accept: 'image/*',
|
||||||
|
multiple: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = ref('Idle');
|
||||||
|
const multiple = ref(true);
|
||||||
|
|
||||||
|
const selected = computed(() => (files.value ? Array.from(files.value) : []));
|
||||||
|
const totalBytes = computed(() => selected.value.reduce((sum, file) => sum + file.size, 0));
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0)
|
||||||
|
return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / 1024 ** index).toFixed(1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange((list) => {
|
||||||
|
status.value = list && list.length ? `Selected ${list.length} file(s)` : 'Cleared';
|
||||||
|
});
|
||||||
|
|
||||||
|
onCancel(() => {
|
||||||
|
status.value = 'Dialog dismissed';
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick() {
|
||||||
|
open({ multiple: multiple.value });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<label class="inline-flex cursor-pointer items-center gap-2 text-sm text-(--fg-muted)">
|
||||||
|
<input v-model="multiple" type="checkbox" class="size-4 rounded border-(--border) accent-(--accent)">
|
||||||
|
Allow multiple
|
||||||
|
</label>
|
||||||
|
<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)">
|
||||||
|
{{ status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<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="pick">
|
||||||
|
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
Choose images
|
||||||
|
</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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
:disabled="!selected.length"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!selected.length"
|
||||||
|
class="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-(--fg-muted)">No files selected</span>
|
||||||
|
<span class="text-xs text-(--fg-subtle)">Click “Choose images” to open the native dialog</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">{{ selected.length }} file(s)</span>
|
||||||
|
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ formatBytes(totalBytes) }} total</span>
|
||||||
|
</div>
|
||||||
|
<ul class="flex max-h-44 flex-col gap-1.5 overflow-auto">
|
||||||
|
<li
|
||||||
|
v-for="file in selected"
|
||||||
|
:key="file.name + file.lastModified"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="truncate text-sm text-(--fg)">{{ file.name }}</span>
|
||||||
|
<span class="shrink-0 font-mono text-xs tabular-nums text-(--fg-subtle)">{{ formatBytes(file.size) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useFileSystemAccess } from './index';
|
||||||
|
|
||||||
|
const lastError = ref('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSupported,
|
||||||
|
data,
|
||||||
|
fileName,
|
||||||
|
fileMIME,
|
||||||
|
fileSize,
|
||||||
|
open,
|
||||||
|
create,
|
||||||
|
save,
|
||||||
|
saveAs,
|
||||||
|
} = useFileSystemAccess({
|
||||||
|
dataType: 'Text',
|
||||||
|
types: [{ description: 'Text files', accept: { 'text/plain': ['.txt', '.md'] } }],
|
||||||
|
onError: (error) => {
|
||||||
|
lastError.value = error instanceof Error && error.name === 'AbortError'
|
||||||
|
? 'Cancelled'
|
||||||
|
: 'Something went wrong';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Writable string proxy so the textarea can v-model the (union-typed) data ref.
|
||||||
|
const text = computed({
|
||||||
|
get: () => (typeof data.value === 'string' ? data.value : ''),
|
||||||
|
set: (value: string) => { data.value = value; },
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0)
|
||||||
|
return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB'];
|
||||||
|
const index = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / 1024 ** index).toFixed(1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newFile() {
|
||||||
|
lastError.value = '';
|
||||||
|
await create({ suggestedName: 'untitled.txt' });
|
||||||
|
if (typeof data.value !== 'string')
|
||||||
|
data.value = 'Hello from the File System Access API!\n';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md 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-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
The File System Access API is not supported in this browser. Try Chrome or Edge.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<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="open()">
|
||||||
|
Open…
|
||||||
|
</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="newFile">
|
||||||
|
New…
|
||||||
|
</button>
|
||||||
|
<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="data === undefined" @click="save()">
|
||||||
|
Save
|
||||||
|
</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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100" :disabled="data === undefined" @click="saveAs()">
|
||||||
|
Save As…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="fileName" class="flex flex-wrap items-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)">
|
||||||
|
{{ fileName }}
|
||||||
|
</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)">
|
||||||
|
{{ fileMIME || 'text/plain' }}
|
||||||
|
</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-mono tabular-nums text-(--fg-muted)">
|
||||||
|
{{ formatBytes(fileSize) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-if="data !== undefined"
|
||||||
|
v-model="text"
|
||||||
|
rows="6"
|
||||||
|
spellcheck="false"
|
||||||
|
class="w-full resize-none rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
placeholder="File contents…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-(--fg-muted)">No file open</span>
|
||||||
|
<span class="text-xs text-(--fg-subtle)">Open an existing file or create a new one, edit it, then save back to disk.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="lastError" class="text-center text-xs text-(--fg-subtle)">
|
||||||
|
{{ lastError }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script setup lang="ts">import { useFps } from './index';
|
|
||||||
|
|
||||||
const { fps, min, max, isActive, reset, toggle } = useFps({ every: 10 });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-end gap-8">
|
|
||||||
<div>
|
|
||||||
<div class="text-4xl font-mono font-bold tabular-nums text-(--color-text)">{{ fps }}</div>
|
|
||||||
<div class="text-xs text-(--color-text-mute) mt-1">FPS</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ min === Infinity ? '—' : min }}</div>
|
|
||||||
<div class="text-xs text-(--color-text-mute) mt-1">Min</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ max || '—' }}</div>
|
|
||||||
<div class="text-xs text-(--color-text-mute) mt-1">Max</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-2 rounded-full border border-(--color-border) overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full transition-all duration-300"
|
|
||||||
:class="fps >= 50 ? 'bg-emerald-500' : fps >= 30 ? 'bg-amber-500' : 'bg-red-500'"
|
|
||||||
:style="{ width: `${Math.min(fps / 60 * 100, 100)}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
|
|
||||||
@click="toggle"
|
|
||||||
>
|
|
||||||
{{ isActive ? 'Pause' : 'Resume' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
|
|
||||||
@click="reset"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTemplateRef } from 'vue';
|
||||||
|
import { useFullscreen } from './index';
|
||||||
|
|
||||||
|
const target = useTemplateRef<HTMLElement>('target');
|
||||||
|
const { isSupported, isFullscreen, enter, exit, toggle } = useFullscreen(target);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm 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-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
The Fullscreen API is not supported in this browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="target"
|
||||||
|
class="relative flex h-44 flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-(--border) bg-(--bg-elevated) transition-colors"
|
||||||
|
:class="isFullscreen && 'bg-(--bg-inset)'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isFullscreen
|
||||||
|
? '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="isFullscreen ? 'bg-emerald-500' : 'bg-(--fg-subtle)'" />
|
||||||
|
{{ isFullscreen ? 'Fullscreen' : 'Windowed' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p class="px-6 text-center text-sm text-(--fg-muted)">
|
||||||
|
This panel becomes the fullscreen target. Press
|
||||||
|
<kbd class="rounded border border-(--border) bg-(--bg) px-1.5 py-0.5 font-mono text-xs text-(--fg)">Esc</kbd>
|
||||||
|
to leave.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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="!isSupported"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<template v-if="isFullscreen">
|
||||||
|
<path d="M8 3v3a2 2 0 0 1-2 2H3" /><path d="M21 8h-3a2 2 0 0 1-2-2V3" /><path d="M3 16h3a2 2 0 0 1 2 2v3" /><path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M21 8V5a2 2 0 0 0-2-2h-3" /><path d="M3 16v3a2 2 0 0 0 2 2h3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
{{ isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="inline-flex flex-1 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="!isSupported || isFullscreen"
|
||||||
|
@click="enter"
|
||||||
|
>
|
||||||
|
Enter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex flex-1 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="!isSupported || !isFullscreen"
|
||||||
|
@click="exit"
|
||||||
|
>
|
||||||
|
Exit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useImage } from './index';
|
||||||
|
|
||||||
|
const samples = [
|
||||||
|
{ label: 'Mountains', src: 'https://picsum.photos/id/1018/640/400' },
|
||||||
|
{ label: 'Forest', src: 'https://picsum.photos/id/1015/640/400' },
|
||||||
|
{ label: 'Broken URL', src: 'https://picsum.photos/this-image-does-not-exist.jpg' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const current = ref(0);
|
||||||
|
const src = computed(() => samples[current.value]!.src);
|
||||||
|
|
||||||
|
// Reactive getter source: useImage reloads whenever `src` changes.
|
||||||
|
const { isLoading, isReady, error, state, execute } = useImage(
|
||||||
|
() => ({ src: src.value, alt: samples[current.value]!.label }),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="(sample, index) in samples"
|
||||||
|
:key="sample.src"
|
||||||
|
type="button"
|
||||||
|
class="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="current === index
|
||||||
|
? '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="current = index"
|
||||||
|
>
|
||||||
|
{{ sample.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative aspect-[8/5] w-full overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset)">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-300"
|
||||||
|
enter-from-class="opacity-0 scale-[1.02]"
|
||||||
|
leave-active-class="transition duration-200"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isReady && state"
|
||||||
|
:key="src"
|
||||||
|
:src="state.src"
|
||||||
|
:alt="state.alt"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-else-if="error"
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">⚠️</span>
|
||||||
|
<p class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||||
|
Failed to load image
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<span class="h-7 w-7 animate-spin rounded-full border-2 border-(--border-strong) border-t-(--accent)" />
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isLoading
|
||||||
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||||
|
: isReady
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'"
|
||||||
|
>
|
||||||
|
{{ isLoading ? 'loading' : isReady ? 'ready' : 'error' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="isReady && state"
|
||||||
|
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) tabular-nums"
|
||||||
|
>
|
||||||
|
{{ state.naturalWidth }}×{{ state.naturalHeight }}
|
||||||
|
</span>
|
||||||
|
</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="isLoading"
|
||||||
|
@click="execute()"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useLocalFonts } from './index';
|
||||||
|
|
||||||
|
const { isSupported, fonts, error, query } = useLocalFonts();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const filter = ref('');
|
||||||
|
|
||||||
|
async function pickFonts() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await query();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const term = filter.value.trim().toLowerCase();
|
||||||
|
if (!term)
|
||||||
|
return fonts.value;
|
||||||
|
return fonts.value.filter(font => font.fullName.toLowerCase().includes(term));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unique families for the summary readout.
|
||||||
|
const familyCount = computed(() => new Set(fonts.value.map(font => font.family)).size);
|
||||||
|
|
||||||
|
// Render each face in its own font (quotes kept out of the template attribute).
|
||||||
|
function familyStyle(name: string) {
|
||||||
|
return { fontFamily: `'${name}', sans-serif` };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Local Font Access
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
v-if="fonts.length"
|
||||||
|
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) tabular-nums"
|
||||||
|
>
|
||||||
|
{{ fonts.length }} faces · {{ familyCount }} families
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isSupported"
|
||||||
|
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
The Local Font Access API is not supported in this browser. Try a recent Chromium-based desktop browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<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="loading"
|
||||||
|
@click="pickFonts"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Requesting permission…' : fonts.length ? 'Re-query fonts' : 'Enumerate installed fonts' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Query failed — permission was likely denied.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="fonts.length">
|
||||||
|
<input
|
||||||
|
v-model="filter"
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter by name…"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
|
||||||
|
<ul class="max-h-56 divide-y divide-(--border) overflow-y-auto rounded-xl border border-(--border) bg-(--bg-elevated)">
|
||||||
|
<li
|
||||||
|
v-for="font in filtered"
|
||||||
|
:key="font.postscriptName"
|
||||||
|
class="flex items-baseline justify-between gap-3 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="truncate text-base text-(--fg)"
|
||||||
|
:style="familyStyle(font.fullName)"
|
||||||
|
>
|
||||||
|
{{ font.fullName }}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 font-mono text-xs text-(--fg-subtle)">
|
||||||
|
{{ font.style }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-if="!filtered.length"
|
||||||
|
class="px-3 py-6 text-center text-sm text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
No fonts match "{{ filter }}"
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="!error && !loading"
|
||||||
|
class="rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-6 text-center text-sm text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
Click above to grant the <code class="font-mono">local-fonts</code> permission and list your fonts.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMediaQuery } from './index';
|
||||||
|
|
||||||
|
// useMediaQuery returns a single ComputedRef<boolean> — bind it directly.
|
||||||
|
const isWide = useMediaQuery('(min-width: 1024px)');
|
||||||
|
const isMedium = useMediaQuery('(min-width: 640px)');
|
||||||
|
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
|
||||||
|
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||||
|
const isPortrait = useMediaQuery('(orientation: portrait)');
|
||||||
|
const isFinePointer = useMediaQuery('(pointer: fine)');
|
||||||
|
|
||||||
|
const breakpoint = isWide;
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
{ label: 'min-width: 1024px', match: isWide },
|
||||||
|
{ label: 'min-width: 640px', match: isMedium },
|
||||||
|
{ label: 'prefers-color-scheme: dark', match: prefersDark },
|
||||||
|
{ label: 'prefers-reduced-motion', match: prefersReducedMotion },
|
||||||
|
{ label: 'orientation: portrait', match: isPortrait },
|
||||||
|
{ label: 'pointer: fine', match: isFinePointer },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<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)">
|
||||||
|
Current layout
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 font-mono text-3xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ breakpoint ? 'desktop' : isMedium ? 'tablet' : 'mobile' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-(--fg-muted)">
|
||||||
|
Resize the window to watch these queries flip live.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-(--border) rounded-xl border border-(--border) bg-(--bg-elevated)">
|
||||||
|
<li
|
||||||
|
v-for="query in queries"
|
||||||
|
:key="query.label"
|
||||||
|
class="flex items-center justify-between gap-3 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<code class="font-mono text-sm text-(--fg)">{{ query.label }}</code>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
|
||||||
|
:class="query.match.value
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-subtle)'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="query.match.value ? 'bg-emerald-500' : 'bg-(--border-strong)'"
|
||||||
|
/>
|
||||||
|
{{ query.match.value ? 'matches' : 'no match' }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, shallowRef } from 'vue';
|
||||||
|
import { useObjectUrl } from './index';
|
||||||
|
|
||||||
|
const file = shallowRef<File>();
|
||||||
|
// useObjectUrl returns a single read-only ShallowRef<string | undefined>.
|
||||||
|
const url = useObjectUrl(file);
|
||||||
|
|
||||||
|
const isImage = computed(() => file.value?.type.startsWith('image/') ?? false);
|
||||||
|
|
||||||
|
const sizeLabel = computed(() => {
|
||||||
|
const bytes = file.value?.size ?? 0;
|
||||||
|
if (bytes < 1024)
|
||||||
|
return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024)
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dragging = ref(false);
|
||||||
|
|
||||||
|
function onFiles(list: FileList | null | undefined) {
|
||||||
|
if (list && list.length)
|
||||||
|
file.value = list[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
dragging.value = false;
|
||||||
|
onFiles(event.dataTransfer?.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
file.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A synthetic blob so the demo works even without a file at hand.
|
||||||
|
function generateSample() {
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160">`
|
||||||
|
+ `<rect width="240" height="160" fill="#6366f1"/>`
|
||||||
|
+ `<text x="120" y="90" font-size="22" font-family="sans-serif" fill="white" text-anchor="middle">useObjectUrl</text>`
|
||||||
|
+ `</svg>`;
|
||||||
|
file.value = new File([svg], 'sample.svg', { type: 'image/svg+xml' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 text-center transition"
|
||||||
|
:class="dragging
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle)'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">📎</span>
|
||||||
|
<span class="text-sm font-medium text-(--fg)">Drop a file or click to choose</span>
|
||||||
|
<span class="text-xs text-(--fg-subtle)">An object URL is created instantly</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFiles(($event.target as HTMLInputElement).files)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex flex-1 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="generateSample"
|
||||||
|
>
|
||||||
|
Use sample image
|
||||||
|
</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="!file"
|
||||||
|
@click="clear"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="file"
|
||||||
|
class="flex flex-col gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isImage && url"
|
||||||
|
:src="url"
|
||||||
|
alt="Selected file preview"
|
||||||
|
class="mx-auto max-h-40 rounded-lg border border-(--border) object-contain"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="truncate font-medium text-(--fg)">{{ file.name }}</span>
|
||||||
|
<span class="shrink-0 font-mono text-xs text-(--fg-muted) tabular-nums">{{ sizeLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg) break-all">
|
||||||
|
{{ url }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-6 text-center text-sm text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
No source — the URL ref is <code class="font-mono">undefined</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { usePermission } from './index';
|
||||||
|
import type { GeneralPermissionDescriptor } from './index';
|
||||||
|
|
||||||
|
const names: GeneralPermissionDescriptor['name'][] = [
|
||||||
|
'geolocation',
|
||||||
|
'camera',
|
||||||
|
'microphone',
|
||||||
|
'notifications',
|
||||||
|
'clipboard-read',
|
||||||
|
];
|
||||||
|
|
||||||
|
// usePermission takes a static descriptor, so instantiate one per permission
|
||||||
|
// up-front and switch which reactive state we read via the dropdown.
|
||||||
|
const permissions = names.map(name => ({
|
||||||
|
name,
|
||||||
|
...usePermission(name, { controls: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selected = ref<GeneralPermissionDescriptor['name']>('geolocation');
|
||||||
|
|
||||||
|
const active = computed(() => permissions.find(perm => perm.name === selected.value)!);
|
||||||
|
const isSupported = active.value.isSupported;
|
||||||
|
|
||||||
|
const meta = computed(() => {
|
||||||
|
switch (active.value.state.value) {
|
||||||
|
case 'granted':
|
||||||
|
return { label: 'granted', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400', ring: 'border-emerald-500/30 bg-emerald-500/10' };
|
||||||
|
case 'denied':
|
||||||
|
return { label: 'denied', dot: 'bg-red-500', text: 'text-red-600 dark:text-red-400', ring: 'border-red-500/30 bg-red-500/10' };
|
||||||
|
case 'prompt':
|
||||||
|
return { label: 'prompt', dot: 'bg-amber-500', text: 'text-amber-600 dark:text-amber-400', ring: 'border-amber-500/30 bg-amber-500/10' };
|
||||||
|
default:
|
||||||
|
return { label: 'unknown', dot: 'bg-(--border-strong)', text: 'text-(--fg-subtle)', ring: 'border-(--border) bg-(--bg-inset)' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-if="!isSupported"
|
||||||
|
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
The Permissions API is not supported in this browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Permission
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="selected"
|
||||||
|
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<option v-for="name in names" :key="name" :value="name">
|
||||||
|
{{ name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-(--border) rounded-xl border border-(--border) bg-(--bg-elevated)">
|
||||||
|
<li
|
||||||
|
v-for="perm in permissions"
|
||||||
|
:key="perm.name"
|
||||||
|
class="flex items-center justify-between gap-3 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<code class="font-mono text-sm text-(--fg)">{{ perm.name }}</code>
|
||||||
|
<span class="font-mono text-xs text-(--fg-muted)">{{ perm.state.value ?? 'unknown' }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
{{ selected }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm font-semibold transition"
|
||||||
|
:class="[meta.ring, meta.text]"
|
||||||
|
>
|
||||||
|
<span class="h-2 w-2 rounded-full" :class="meta.dot" />
|
||||||
|
{{ meta.label }}
|
||||||
|
</span>
|
||||||
|
</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="active.query()"
|
||||||
|
>
|
||||||
|
Re-check "{{ selected }}"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-center text-xs text-(--fg-subtle)">
|
||||||
|
Status updates live if you change a permission in your browser settings.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePreferredColorScheme } from './index';
|
||||||
|
|
||||||
|
const scheme = usePreferredColorScheme();
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'light', label: 'Light', icon: '☀️', hint: 'prefers-color-scheme: light' },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: '🌙', hint: 'prefers-color-scheme: dark' },
|
||||||
|
{ value: 'no-preference', label: 'No preference', icon: '🌓', hint: 'no explicit preference' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const active = computed(() => options.find(o => o.value === scheme.value));
|
||||||
|
|
||||||
|
// A tiny live preview that flips its own palette based on the OS preference,
|
||||||
|
// independent of the docs site theme.
|
||||||
|
const previewClass = computed(() =>
|
||||||
|
scheme.value === 'dark'
|
||||||
|
? 'bg-zinc-900 text-zinc-100 border-zinc-700'
|
||||||
|
: 'bg-white text-zinc-900 border-zinc-200',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Preferred color scheme
|
||||||
|
</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 bg-emerald-500" />
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex items-center gap-3 rounded-lg border px-3 py-2.5 transition"
|
||||||
|
:class="scheme === option.value
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated)'"
|
||||||
|
>
|
||||||
|
<span class="text-lg leading-none">{{ option.icon }}</span>
|
||||||
|
<span class="flex flex-1 flex-col">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="scheme === option.value ? 'text-(--accent-text)' : 'text-(--fg)'"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-xs text-(--fg-subtle)">{{ option.hint }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="scheme === option.value"
|
||||||
|
class="text-(--accent-text)"
|
||||||
|
aria-hidden="true"
|
||||||
|
>✓</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 rounded-xl border p-4 transition-colors"
|
||||||
|
:class="previewClass"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">{{ active?.icon }}</span>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-semibold">Self-themed preview</span>
|
||||||
|
<span class="text-xs opacity-70">
|
||||||
|
Resolved to <span class="font-mono">{{ scheme }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Read-only: change your OS appearance setting to see this update instantly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePreferredContrast } from './index';
|
||||||
|
|
||||||
|
const contrast = usePreferredContrast({ ssrContrast: 'no-preference' });
|
||||||
|
|
||||||
|
const levels = [
|
||||||
|
{ value: 'more', label: 'More', desc: 'Heavier borders, stronger separation', query: 'prefers-contrast: more' },
|
||||||
|
{ value: 'less', label: 'Less', desc: 'Softer, lower-contrast surfaces', query: 'prefers-contrast: less' },
|
||||||
|
{ value: 'custom', label: 'Custom', desc: 'A user-defined contrast scheme', query: 'prefers-contrast: custom' },
|
||||||
|
{ value: 'no-preference', label: 'No preference', desc: 'Default system contrast', query: 'prefers-contrast: no-preference' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const active = computed(() => levels.find(l => l.value === contrast.value));
|
||||||
|
|
||||||
|
// Demonstrate adapting UI intensity to the reported contrast level.
|
||||||
|
const cardClass = computed(() => {
|
||||||
|
switch (contrast.value) {
|
||||||
|
case 'more':
|
||||||
|
return 'border-(--border-strong) bg-(--bg-inset)';
|
||||||
|
case 'less':
|
||||||
|
return 'border-(--border) bg-(--bg-subtle) opacity-90';
|
||||||
|
default:
|
||||||
|
return 'border-(--border) bg-(--bg-elevated)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Preferred contrast
|
||||||
|
</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)">
|
||||||
|
{{ contrast }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="level in levels"
|
||||||
|
:key="level.value"
|
||||||
|
class="flex flex-col gap-1 rounded-lg border px-3 py-2.5 transition"
|
||||||
|
:class="contrast === level.value
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated)'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="contrast === level.value ? 'text-(--accent-text)' : 'text-(--fg)'"
|
||||||
|
>
|
||||||
|
{{ level.label }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs leading-snug text-(--fg-muted)">{{ level.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border p-4 transition-colors" :class="cardClass">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Adaptive surface
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-sm text-(--fg)">
|
||||||
|
This card adjusts its borders and fill to match the
|
||||||
|
<span class="font-mono text-(--fg-muted)">{{ active?.query }}</span> level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Read-only: toggle your OS accessibility "increase / reduce contrast" setting to update.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePreferredDark } from './index';
|
||||||
|
|
||||||
|
const isDark = usePreferredDark();
|
||||||
|
|
||||||
|
const label = computed(() => (isDark.value ? 'Dark' : 'Light'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
prefers-color-scheme: dark
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors"
|
||||||
|
:class="isDark
|
||||||
|
? 'border-indigo-500/30 bg-indigo-500/10 text-indigo-600 dark:text-indigo-300'
|
||||||
|
: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
{{ isDark ? 'true' : 'false' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- A miniature sky scene that flips between day and night. -->
|
||||||
|
<div
|
||||||
|
class="relative flex h-40 items-end overflow-hidden rounded-xl border border-(--border) p-4 transition-colors duration-500"
|
||||||
|
:class="isDark
|
||||||
|
? 'bg-gradient-to-b from-slate-900 to-slate-700'
|
||||||
|
: 'bg-gradient-to-b from-sky-300 to-sky-100'"
|
||||||
|
>
|
||||||
|
<!-- Sun / Moon -->
|
||||||
|
<div
|
||||||
|
class="absolute right-5 top-5 size-12 rounded-full transition-all duration-500"
|
||||||
|
:class="isDark
|
||||||
|
? 'bg-zinc-200 shadow-[0_0_28px_6px_rgba(228,228,231,0.4)]'
|
||||||
|
: 'bg-amber-300 shadow-[0_0_36px_10px_rgba(252,211,77,0.6)]'"
|
||||||
|
/>
|
||||||
|
<!-- Stars, only at night -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-opacity duration-700"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
leave-active-class="transition-opacity duration-300"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div v-if="isDark" class="absolute inset-0">
|
||||||
|
<span class="absolute left-6 top-7 size-1 rounded-full bg-white" />
|
||||||
|
<span class="absolute left-20 top-12 size-0.5 rounded-full bg-white" />
|
||||||
|
<span class="absolute left-32 top-6 size-1 rounded-full bg-white" />
|
||||||
|
<span class="absolute left-12 top-16 size-0.5 rounded-full bg-white" />
|
||||||
|
<span class="absolute left-40 top-16 size-1 rounded-full bg-white" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<span
|
||||||
|
class="relative z-10 text-2xl font-bold tabular-nums transition-colors duration-500"
|
||||||
|
:class="isDark ? 'text-zinc-100' : 'text-slate-800'"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
const isDark = usePreferredDark()
|
||||||
|
<span class="text-(--fg-subtle)"> // </span>{{ isDark }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Read-only: switch your OS to dark/light mode to watch the scene change.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePreferredLanguages } from './index';
|
||||||
|
|
||||||
|
const languages = usePreferredLanguages();
|
||||||
|
|
||||||
|
// Resolve a human-friendly name + flag for each BCP-47 tag where possible.
|
||||||
|
const displayNames = computed(() => {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames(['en'], { type: 'language' });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function nameOf(tag: string): string {
|
||||||
|
return displayNames.value?.of(tag) ?? tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagOf(tag: string): string {
|
||||||
|
const region = tag.split('-')[1]?.toUpperCase();
|
||||||
|
if (!region || region.length !== 2)
|
||||||
|
return '🌐';
|
||||||
|
// Convert a 2-letter region code to a flag emoji.
|
||||||
|
return String.fromCodePoint(...[...region].map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = computed(() => languages.value[0] ?? 'en');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
navigator.languages
|
||||||
|
</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)">
|
||||||
|
{{ languages.length }} {{ languages.length === 1 ? 'locale' : 'locales' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Primary language
|
||||||
|
</span>
|
||||||
|
<div class="mt-1 flex items-center gap-2.5">
|
||||||
|
<span class="text-2xl leading-none">{{ flagOf(primary) }}</span>
|
||||||
|
<span class="flex flex-col">
|
||||||
|
<span class="text-base font-semibold text-(--fg)">{{ nameOf(primary) }}</span>
|
||||||
|
<span class="font-mono text-xs text-(--fg-subtle)">{{ primary }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="(lang, index) in languages"
|
||||||
|
:key="`${lang}-${index}`"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="w-5 text-center font-mono text-xs text-(--fg-subtle) tabular-nums">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
<span class="text-lg leading-none">{{ flagOf(lang) }}</span>
|
||||||
|
<span class="flex flex-1 flex-col">
|
||||||
|
<span class="text-sm font-medium text-(--fg)">{{ nameOf(lang) }}</span>
|
||||||
|
<span class="font-mono text-xs text-(--fg-subtle)">{{ lang }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="index === 0"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
preferred
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Read-only: updates automatically on the browser's <span class="font-mono">languagechange</span> event.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePreferredReducedMotion } from './index';
|
||||||
|
|
||||||
|
const motion = usePreferredReducedMotion();
|
||||||
|
|
||||||
|
const reduced = computed(() => motion.value === 'reduce');
|
||||||
|
|
||||||
|
// Mirror the recommended pattern: derive a transition duration from the
|
||||||
|
// preference so animations are disabled when the user asks for reduced motion.
|
||||||
|
const duration = computed(() => (reduced.value ? 0 : 1.2));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
prefers-reduced-motion
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors"
|
||||||
|
:class="reduced
|
||||||
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'"
|
||||||
|
>
|
||||||
|
{{ motion }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Animated demo box: the orbiting dot pauses when motion is reduced. -->
|
||||||
|
<div class="flex h-40 items-center justify-center rounded-xl border border-(--border) bg-(--bg-inset)">
|
||||||
|
<div class="relative size-24">
|
||||||
|
<div class="absolute inset-0 rounded-full border-2 border-dashed border-(--border-strong)" />
|
||||||
|
<div
|
||||||
|
class="orbit absolute left-1/2 top-1/2 size-24"
|
||||||
|
:style="{ animationDuration: `${duration}s`, animationPlayState: reduced ? 'paused' : 'running' }"
|
||||||
|
>
|
||||||
|
<span class="absolute -left-2 -top-2 size-4 rounded-full bg-(--accent) shadow-lg" />
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span class="text-2xl">{{ reduced ? '⏸' : '🎞️' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Derived setting
|
||||||
|
</span>
|
||||||
|
<div class="mt-1 flex items-baseline gap-2">
|
||||||
|
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ reduced ? 0 : 1200 }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-(--fg-muted)">ms transition</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-(--fg-muted)">
|
||||||
|
{{ reduced
|
||||||
|
? 'Reduced motion requested — animations are disabled.'
|
||||||
|
: 'Full motion — animations run at normal speed.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Read-only: enable "Reduce motion" in your OS accessibility settings to pause the orbit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.orbit {
|
||||||
|
animation-name: spin;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePreferredReducedTransparency } from './index';
|
||||||
|
|
||||||
|
const transparency = usePreferredReducedTransparency();
|
||||||
|
|
||||||
|
const isReduced = computed(() => transparency.value === 'reduce');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
prefers-reduced-transparency
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isReduced
|
||||||
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-1.5 rounded-full"
|
||||||
|
:class="isReduced ? 'bg-amber-500' : 'bg-emerald-500'"
|
||||||
|
/>
|
||||||
|
{{ transparency }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live preview: a frosted glass panel that flattens when reduce is preferred -->
|
||||||
|
<div class="relative overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset) p-4">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute -left-6 -top-8 size-28 rounded-full bg-(--accent) opacity-60 blur-xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute -bottom-10 right-2 size-24 rounded-full bg-sky-500 opacity-50 blur-xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="relative rounded-lg border border-(--border) p-4 transition"
|
||||||
|
:class="isReduced
|
||||||
|
? 'bg-(--bg-elevated)'
|
||||||
|
: 'bg-(--bg-elevated)/60 backdrop-blur-md'"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-(--fg)">
|
||||||
|
Glass card
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-(--fg-muted)">
|
||||||
|
{{ isReduced
|
||||||
|
? 'Translucency removed for clarity.'
|
||||||
|
: 'Background blurs through the panel.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs leading-relaxed text-(--fg-subtle)">
|
||||||
|
Toggle <span class="font-mono text-(--fg-muted)">Reduce transparency</span> in your OS
|
||||||
|
accessibility settings to see this update live.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, shallowRef } from 'vue';
|
||||||
|
import { useScriptTag } from './index';
|
||||||
|
|
||||||
|
// A tiny, well-known public UMD script (no side effects beyond defining a global).
|
||||||
|
const src = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js';
|
||||||
|
|
||||||
|
const status = ref<'idle' | 'loading' | 'loaded' | 'error'>('idle');
|
||||||
|
const loadedAt = shallowRef<string | null>(null);
|
||||||
|
|
||||||
|
const { scriptTag, load, unload } = useScriptTag(
|
||||||
|
src,
|
||||||
|
() => {
|
||||||
|
status.value = 'loaded';
|
||||||
|
loadedAt.value = new Date().toLocaleTimeString();
|
||||||
|
},
|
||||||
|
{ manual: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onLoad() {
|
||||||
|
if (status.value === 'loading') return;
|
||||||
|
status.value = 'loading';
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
status.value = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUnload() {
|
||||||
|
unload();
|
||||||
|
status.value = 'idle';
|
||||||
|
loadedAt.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
idle: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)',
|
||||||
|
loading: 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||||
|
loaded: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||||
|
error: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400',
|
||||||
|
} as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Script status
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium capitalize"
|
||||||
|
:class="statusStyles[status]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-1.5 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-(--fg-subtle)': status === 'idle',
|
||||||
|
'bg-sky-500 animate-pulse': status === 'loading',
|
||||||
|
'bg-emerald-500': status === 'loaded',
|
||||||
|
'bg-red-500': status === 'error',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
{{ status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 truncate rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg-muted)">
|
||||||
|
{{ src }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-3 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-(--fg-subtle)">
|
||||||
|
<script> element
|
||||||
|
</dt>
|
||||||
|
<dd class="font-mono text-(--fg)">
|
||||||
|
{{ scriptTag ? 'present' : 'null' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs text-(--fg-subtle)">
|
||||||
|
Loaded at
|
||||||
|
</dt>
|
||||||
|
<dd class="font-mono text-(--fg)">
|
||||||
|
{{ loadedAt ?? '—' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="status === 'loading' || status === 'loaded'"
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
@click="onLoad"
|
||||||
|
>
|
||||||
|
{{ status === 'loading' ? 'Loading…' : 'Load script' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="status !== 'loaded'"
|
||||||
|
class="inline-flex flex-1 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"
|
||||||
|
@click="onUnload"
|
||||||
|
>
|
||||||
|
Unload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs leading-relaxed text-(--fg-subtle)">
|
||||||
|
Manual mode — the tag is injected into <span class="font-mono text-(--fg-muted)"><head></span>
|
||||||
|
only when you click, and removed on unload.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import { useShare } from './index';
|
||||||
|
|
||||||
|
const payload = reactive({
|
||||||
|
title: 'Vue Toolkit',
|
||||||
|
text: 'A collection of essential Vue composables.',
|
||||||
|
url: 'https://vuejs.org',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { share, isSupported } = useShare(payload);
|
||||||
|
|
||||||
|
const lastResult = ref<'shared' | 'dismissed' | null>(null);
|
||||||
|
|
||||||
|
async function onShare() {
|
||||||
|
lastResult.value = null;
|
||||||
|
try {
|
||||||
|
await share();
|
||||||
|
lastResult.value = 'shared';
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// User dismissed the share sheet (AbortError) — treat as a non-error.
|
||||||
|
lastResult.value = 'dismissed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Web Share API
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isSupported
|
||||||
|
? '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="isSupported ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
|
||||||
|
/>
|
||||||
|
{{ isSupported ? 'Supported' : 'Unsupported' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Title</span>
|
||||||
|
<input
|
||||||
|
v-model="payload.title"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Text</span>
|
||||||
|
<input
|
||||||
|
v-model="payload.text"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">URL</span>
|
||||||
|
<input
|
||||||
|
v-model="payload.url"
|
||||||
|
type="url"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!isSupported"
|
||||||
|
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"
|
||||||
|
@click="onShare"
|
||||||
|
>
|
||||||
|
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||||
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||||
|
</svg>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="!isSupported" class="text-xs leading-relaxed text-(--fg-subtle)">
|
||||||
|
The Web Share API is not available in this browser. It works on most mobile
|
||||||
|
browsers and Safari.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="lastResult"
|
||||||
|
class="text-xs font-medium"
|
||||||
|
:class="lastResult === 'shared'
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
{{ lastResult === 'shared' ? 'Content shared.' : 'Share sheet dismissed.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useStyleTag } from './index';
|
||||||
|
|
||||||
|
const initialCss = `.styletag-demo-box {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #ec4899);
|
||||||
|
color: white;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const { id, css, isLoaded, load, unload } = useStyleTag(initialCss, { immediate: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Injected style tag
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isLoaded
|
||||||
|
? '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="isLoaded ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
|
||||||
|
/>
|
||||||
|
{{ isLoaded ? 'Loaded' : 'Unloaded' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live target affected by the injected stylesheet -->
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-inset) p-4">
|
||||||
|
<div
|
||||||
|
class="styletag-demo-box rounded-lg border border-(--border) bg-(--bg-elevated) px-4 py-6 text-center text-sm font-semibold text-(--fg) transition-all"
|
||||||
|
>
|
||||||
|
Styled by <style id="{{ id }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">CSS source</span>
|
||||||
|
<textarea
|
||||||
|
v-model="css"
|
||||||
|
rows="5"
|
||||||
|
spellcheck="false"
|
||||||
|
class="w-full resize-none rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-xs leading-relaxed text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="isLoaded"
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
@click="load"
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!isLoaded"
|
||||||
|
class="inline-flex flex-1 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"
|
||||||
|
@click="unload"
|
||||||
|
>
|
||||||
|
Unload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs leading-relaxed text-(--fg-subtle)">
|
||||||
|
Editing the CSS updates the live stylesheet instantly while loaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,54 +1,84 @@
|
|||||||
<script setup lang="ts">import { useTabLeader } from './index';
|
<script setup lang="ts">
|
||||||
|
import { useTabLeader } from './index';
|
||||||
|
|
||||||
const { isLeader, isSupported, acquire, release } = useTabLeader('docs-demo-leader');
|
const { isLeader, isSupported, acquire, release } = useTabLeader('vue-toolkit-demo-leader');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm font-medium text-(--color-text-soft)">Web Locks API:</span>
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
<span
|
Web Locks election
|
||||||
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
|
|
||||||
:class="isSupported ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700' : 'border-red-500/30 bg-red-500/10 text-red-700'"
|
|
||||||
>
|
|
||||||
{{ isSupported ? 'Supported' : 'Not supported' }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm font-medium text-(--color-text-soft)">Leader status:</span>
|
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
:class="isLeader ? 'border-brand-500/30 bg-brand-500/10 text-brand-600' : 'border-(--color-border) bg-(--color-bg-mute) text-(--color-text-soft)'"
|
:class="isSupported
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="w-2 h-2 rounded-full"
|
class="size-1.5 rounded-full"
|
||||||
:class="isLeader ? 'bg-brand-500 animate-pulse' : 'bg-(--color-text-mute)'"
|
:class="isSupported ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
|
||||||
/>
|
/>
|
||||||
{{ isLeader ? 'Leader' : 'Follower' }}
|
{{ isSupported ? 'Supported' : 'Unsupported' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-(--color-text-mute)">
|
<!-- Primary leader state -->
|
||||||
Open this page in multiple tabs — only one will be the leader.
|
<div
|
||||||
Close the leader tab and another will take over automatically.
|
class="flex flex-col items-center gap-2 rounded-xl border p-6 transition-colors"
|
||||||
</p>
|
:class="isLeader
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle)'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated)'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex size-12 items-center justify-center rounded-full transition-colors"
|
||||||
|
:class="isLeader
|
||||||
|
? 'bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'bg-(--bg-inset) text-(--fg-subtle)'"
|
||||||
|
>
|
||||||
|
<svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m12 2 3 7h7l-5.5 4.5L18.5 21 12 16.5 5.5 21 7.5 13.5 2 9h7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-lg font-bold"
|
||||||
|
:class="isLeader ? 'text-(--accent-text)' : 'text-(--fg)'"
|
||||||
|
>
|
||||||
|
{{ isLeader ? 'Leader tab' : 'Follower tab' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-center text-xs text-(--fg-muted)">
|
||||||
|
{{ isLeader
|
||||||
|
? 'This tab holds the lock and would run exclusive work.'
|
||||||
|
: 'Another tab is the leader, or leadership was released.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 pt-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
type="button"
|
||||||
:disabled="!isSupported || isLeader"
|
:disabled="!isSupported || isLeader"
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
@click="acquire"
|
@click="acquire"
|
||||||
>
|
>
|
||||||
Acquire
|
Acquire
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
type="button"
|
||||||
:disabled="!isSupported || !isLeader"
|
:disabled="!isSupported || !isLeader"
|
||||||
|
class="inline-flex flex-1 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"
|
||||||
@click="release"
|
@click="release"
|
||||||
>
|
>
|
||||||
Release
|
Release
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!isSupported" class="text-xs leading-relaxed text-(--fg-subtle)">
|
||||||
|
The Web Locks API is not available in this browser.
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs leading-relaxed text-(--fg-subtle)">
|
||||||
|
Open this page in a second tab — only one tab is the leader at a time. Release
|
||||||
|
here and watch another tab take over.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useTextareaAutosize } from './index';
|
||||||
|
|
||||||
|
const maxHeight = ref(220);
|
||||||
|
const resizes = ref(0);
|
||||||
|
|
||||||
|
const { textarea, input, triggerResize } = useTextareaAutosize({
|
||||||
|
maxHeight,
|
||||||
|
onResize: () => resizes.value++,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = 'Type here and watch the textarea grow with your content.\n\nIt re-fits on every keystroke, on programmatic changes, and when the available width changes (try resizing the panel).';
|
||||||
|
|
||||||
|
function loadSample(): void {
|
||||||
|
input.value = [
|
||||||
|
'Release notes — v0.0.15',
|
||||||
|
'',
|
||||||
|
'- useTextareaAutosize now reacts to width reflow',
|
||||||
|
'- Title sync is SSR-safe',
|
||||||
|
'- URL params decode repeated keys to arrays',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Auto-growing textarea
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
ref="textarea"
|
||||||
|
v-model="input"
|
||||||
|
placeholder="Start typing…"
|
||||||
|
rows="1"
|
||||||
|
class="w-full resize-none overflow-y-auto rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm leading-relaxed text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Max height
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-sm tabular-nums text-(--fg)">{{ maxHeight }}px</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="maxHeight"
|
||||||
|
type="range"
|
||||||
|
min="80"
|
||||||
|
max="400"
|
||||||
|
step="20"
|
||||||
|
class="w-full accent-(--accent)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-t border-(--border) pt-2 text-xs">
|
||||||
|
<span class="text-(--fg-muted)">{{ input.length }} chars</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 font-medium text-(--fg-muted)">
|
||||||
|
{{ resizes }} resizes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap 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"
|
||||||
|
@click="loadSample"
|
||||||
|
>
|
||||||
|
Load sample
|
||||||
|
</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="triggerResize"
|
||||||
|
>
|
||||||
|
Trigger resize
|
||||||
|
</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="!input"
|
||||||
|
@click="clear"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useTitle } from './index';
|
||||||
|
|
||||||
|
const appName = ref('Toolkit');
|
||||||
|
|
||||||
|
// Two-way bound to document.title, formatted through the template.
|
||||||
|
const title = useTitle('Dashboard', {
|
||||||
|
titleTemplate: (t) => `${t} · ${appName.value}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const presets = ['Dashboard', 'Inbox', 'Settings', 'Billing'];
|
||||||
|
|
||||||
|
// Re-apply the template when the app name changes by re-writing the title.
|
||||||
|
watch(appName, () => {
|
||||||
|
// eslint-disable-next-line no-self-assign
|
||||||
|
title.value = title.value;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Live document title
|
||||||
|
</span>
|
||||||
|
<div class="mt-2 flex items-center gap-2 rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2.5">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="size-4 shrink-0 text-(--fg-subtle)">
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate font-mono text-sm text-(--fg)">
|
||||||
|
{{ title || 'Untitled' }} · {{ appName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Page title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter a page title"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
App name (template suffix)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="appName"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in presets"
|
||||||
|
:key="preset"
|
||||||
|
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"
|
||||||
|
:class="title === preset ? 'border-(--accent) text-(--accent-text)' : ''"
|
||||||
|
@click="title = preset"
|
||||||
|
>
|
||||||
|
{{ preset }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Check your browser tab — it updates in real time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useUrlSearchParams } from './index';
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
q: string;
|
||||||
|
sort: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive object mirrored to the URL query string. Mutate it to update the URL.
|
||||||
|
const params = useUrlSearchParams<Filters>('history', {
|
||||||
|
initialValue: { q: 'vue', sort: 'recent', tags: ['ui'] },
|
||||||
|
removeFalsyValues: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorts = ['recent', 'popular', 'name'];
|
||||||
|
const allTags = ['ui', 'browser', 'animation', 'sensors'];
|
||||||
|
|
||||||
|
const activeTags = computed<string[]>(() =>
|
||||||
|
Array.isArray(params.tags) ? params.tags : params.tags ? [params.tags] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleTag(tag: string): void {
|
||||||
|
const next = new Set(activeTags.value);
|
||||||
|
if (next.has(tag))
|
||||||
|
next.delete(tag);
|
||||||
|
else
|
||||||
|
next.add(tag);
|
||||||
|
params.tags = [...next];
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = computed(() => {
|
||||||
|
const usp = new URLSearchParams();
|
||||||
|
if (params.q)
|
||||||
|
usp.set('q', params.q);
|
||||||
|
if (params.sort)
|
||||||
|
usp.set('sort', params.sort);
|
||||||
|
for (const t of activeTags.value)
|
||||||
|
usp.append('tags', t);
|
||||||
|
const s = usp.toString();
|
||||||
|
return s ? `?${s}` : '(empty)';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Search query
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="params.q"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search…"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Sort by</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="s in sorts"
|
||||||
|
:key="s"
|
||||||
|
type="button"
|
||||||
|
class="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="params.sort === s
|
||||||
|
? '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="params.sort = s"
|
||||||
|
>
|
||||||
|
{{ s }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Tags (repeated keys → array)
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="tag in allTags"
|
||||||
|
:key="tag"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition cursor-pointer"
|
||||||
|
:class="activeTags.includes(tag)
|
||||||
|
? 'border-(--accent) bg-(--accent-subtle) text-(--accent-text)'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted) hover:border-(--border-strong)'"
|
||||||
|
@click="toggleTag(tag)"
|
||||||
|
>
|
||||||
|
#{{ tag }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Live URL query
|
||||||
|
</span>
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
<span class="whitespace-nowrap">{{ queryString }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
The browser address bar updates as you edit. Falsy values are dropped.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useVibrate } from './index';
|
||||||
|
|
||||||
|
const presets: Record<string, number[]> = {
|
||||||
|
Pulse: [200],
|
||||||
|
Double: [100, 60, 100],
|
||||||
|
SOS: [100, 60, 100, 60, 100, 200, 250, 60, 250, 60, 250, 200, 100, 60, 100, 60, 100],
|
||||||
|
Heartbeat: [120, 100, 120, 500],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { isSupported, pattern, vibrate, stop, intervalControls } = useVibrate({
|
||||||
|
pattern: presets.Double,
|
||||||
|
interval: 1500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activePreset = ref('Double');
|
||||||
|
const looping = intervalControls?.isActive;
|
||||||
|
|
||||||
|
function applyPreset(name: string): void {
|
||||||
|
activePreset.value = name;
|
||||||
|
pattern.value = presets[name];
|
||||||
|
vibrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLoop(): void {
|
||||||
|
if (intervalControls?.isActive.value)
|
||||||
|
stop();
|
||||||
|
else
|
||||||
|
intervalControls?.resume();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-if="!isSupported"
|
||||||
|
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
The Vibration API is not supported in this browser. Try a mobile device.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Current pattern (ms)
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isSupported
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
{{ isSupported ? 'supported' : 'unsupported' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 overflow-x-auto rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
[{{ Array.isArray(pattern) ? pattern.join(', ') : pattern }}]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Presets</span>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="(_, name) in presets"
|
||||||
|
:key="name"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isSupported"
|
||||||
|
class="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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
:class="activePreset === name
|
||||||
|
? '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="applyPreset(name)"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!isSupported"
|
||||||
|
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"
|
||||||
|
@click="vibrate()"
|
||||||
|
>
|
||||||
|
Vibrate now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!isSupported"
|
||||||
|
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"
|
||||||
|
@click="toggleLoop"
|
||||||
|
>
|
||||||
|
{{ looping ? 'Stop loop' : 'Loop every 1.5s' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!isSupported"
|
||||||
|
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"
|
||||||
|
@click="stop"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useWakeLock } from './index';
|
||||||
|
|
||||||
|
const { isSupported, isActive, sentinel, request, release } = useWakeLock();
|
||||||
|
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function toggle(): Promise<void> {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
if (sentinel.value)
|
||||||
|
await release();
|
||||||
|
else
|
||||||
|
await request('screen');
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-if="!isSupported"
|
||||||
|
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
The Screen Wake Lock API is not supported in this browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-6">
|
||||||
|
<div
|
||||||
|
class="flex size-16 items-center justify-center rounded-full border transition"
|
||||||
|
:class="isActive
|
||||||
|
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-subtle)'"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" class="size-7">
|
||||||
|
<rect x="4" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path d="M8 21h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
<path v-if="isActive" d="m9 9 2 2 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="font-mono text-3xl font-bold tabular-nums text-(--fg)">
|
||||||
|
{{ isActive ? 'AWAKE' : 'IDLE' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-(--fg-muted)">
|
||||||
|
{{ isActive ? 'Screen will stay on' : 'Screen may sleep normally' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2.5 text-sm">
|
||||||
|
<span class="text-(--fg-muted)">Lock held</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)">
|
||||||
|
{{ sentinel ? 'yes' : 'none' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!isSupported"
|
||||||
|
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"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
{{ sentinel ? 'Release wake lock' : 'Request wake lock' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-(--fg-subtle)">
|
||||||
|
The lock auto-releases when the tab is hidden and re-acquires when visible again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useWebNotification } from './index';
|
||||||
|
|
||||||
|
const title = ref('New message from Ada');
|
||||||
|
const body = ref('Hey — the deploy is green. Ship it whenever you are ready.');
|
||||||
|
const lastEvent = ref<string>('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
isSupported,
|
||||||
|
notification,
|
||||||
|
permissionGranted,
|
||||||
|
show,
|
||||||
|
close,
|
||||||
|
ensurePermissionGranted,
|
||||||
|
onClick,
|
||||||
|
onShow,
|
||||||
|
onClose,
|
||||||
|
onError,
|
||||||
|
} = useWebNotification({
|
||||||
|
// Don't prompt on mount — wait for an explicit user gesture below.
|
||||||
|
requestPermissions: false,
|
||||||
|
icon: 'https://vuejs.org/images/logo.png',
|
||||||
|
requireInteraction: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => (lastEvent.value = 'shown'));
|
||||||
|
onClick(() => (lastEvent.value = 'clicked'));
|
||||||
|
onClose(() => (lastEvent.value = 'closed'));
|
||||||
|
onError(() => (lastEvent.value = 'error'));
|
||||||
|
|
||||||
|
async function requestPermission() {
|
||||||
|
await ensurePermissionGranted();
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
show({ title: title.value, body: body.value });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-if="!isSupported"
|
||||||
|
class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
Notifications are not supported in this browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Permission
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-sm text-(--fg-muted)">
|
||||||
|
<span
|
||||||
|
class="inline-block size-2 rounded-full transition"
|
||||||
|
:class="permissionGranted ? 'bg-emerald-500' : 'bg-(--border-strong)'"
|
||||||
|
/>
|
||||||
|
{{ permissionGranted ? 'Granted' : 'Not granted' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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="permissionGranted"
|
||||||
|
@click="requestPermission"
|
||||||
|
>
|
||||||
|
{{ permissionGranted ? 'Allowed' : 'Request access' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Title</span>
|
||||||
|
<input
|
||||||
|
v-model="title"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Body</span>
|
||||||
|
<textarea
|
||||||
|
v-model="body"
|
||||||
|
rows="2"
|
||||||
|
class="w-full resize-none 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>
|
||||||
|
</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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
:disabled="!permissionGranted"
|
||||||
|
@click="notify"
|
||||||
|
>
|
||||||
|
Show notification
|
||||||
|
</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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
:disabled="!notification"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-sm">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Last event</span>
|
||||||
|
<span class="font-mono text-(--fg)">{{ lastEvent || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!permissionGranted" class="text-xs text-(--fg-subtle)">
|
||||||
|
Grant access first, then trigger a notification. Switch back to this tab and it auto-closes.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRenderCount } from './index';
|
||||||
|
|
||||||
|
// Increments on mount and on every subsequent re-render of this component.
|
||||||
|
const renderCount = useRenderCount();
|
||||||
|
|
||||||
|
// Reactive state — touching any of these triggers a re-render, bumping the count.
|
||||||
|
const message = ref('Edit me to force a re-render');
|
||||||
|
const tint = ref(210);
|
||||||
|
|
||||||
|
function nudgeTint() {
|
||||||
|
tint.value = (tint.value + 40) % 360;
|
||||||
|
}
|
||||||
|
</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-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Render count</span>
|
||||||
|
<span
|
||||||
|
class="font-mono text-3xl font-bold tabular-nums text-(--fg) transition-colors"
|
||||||
|
:style="{ color: `hsl(${tint} 70% 55%)` }"
|
||||||
|
>{{ renderCount }}</span>
|
||||||
|
<span class="text-xs text-(--fg-subtle)">renders since mount</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Bound input</span>
|
||||||
|
<input
|
||||||
|
v-model="message"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type to re-render…"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<p class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-sm text-(--fg)">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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="nudgeTint"
|
||||||
|
>
|
||||||
|
Force re-render (shift color)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRenderInfo } from './index';
|
||||||
|
|
||||||
|
// `count` and `duration` are reactive refs; `component` and `lastRendered`
|
||||||
|
// are captured once at setup (component name/uid and the mount timestamp).
|
||||||
|
const { component, count, duration, lastRendered } = useRenderInfo();
|
||||||
|
|
||||||
|
// Reactive state — mutating it triggers re-renders that update count + duration.
|
||||||
|
const rows = ref(40);
|
||||||
|
const grid = computed(() => Array.from({ length: rows.value }, (_, i) => i));
|
||||||
|
|
||||||
|
const mountedAt = new Date(lastRendered).toLocaleTimeString();
|
||||||
|
</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)">Render info</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)">
|
||||||
|
{{ component ?? 'anonymous' }}
|
||||||
|
</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)">{{ count }}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">renders</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)">{{ duration.toFixed(2) }}</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">last render ms</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)">mounted at</span>
|
||||||
|
<span>{{ mountedAt }}</span>
|
||||||
|
</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="rows">Render workload</label>
|
||||||
|
<span class="font-mono text-xs tabular-nums text-(--fg-muted)">{{ rows }} cells</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="rows"
|
||||||
|
v-model.number="rows"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="400"
|
||||||
|
step="1"
|
||||||
|
class="w-full accent-(--accent) cursor-pointer"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-(--fg-subtle)">Drag to re-render a larger DOM subtree and watch the render duration climb.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-10 gap-1">
|
||||||
|
<span
|
||||||
|
v-for="i in grid"
|
||||||
|
:key="i"
|
||||||
|
class="aspect-square rounded-sm bg-(--accent-subtle)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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)"
|
||||||
|
>
|
||||||
|
<{{ activeTag }}>
|
||||||
|
</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)"><{{ parentInfo.tag }}></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>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useField } from './index';
|
||||||
|
|
||||||
|
// Standalone mode: no useForm() ancestor, so the field owns its own value,
|
||||||
|
// errors, touched state, and validation.
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
errorMessage,
|
||||||
|
meta,
|
||||||
|
attrs,
|
||||||
|
validate,
|
||||||
|
reset,
|
||||||
|
} = useField<string>('email', {
|
||||||
|
initialValue: 'ada@example',
|
||||||
|
validateOn: 'value',
|
||||||
|
validate: (input) => {
|
||||||
|
if (!input)
|
||||||
|
return 'Email is required';
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input))
|
||||||
|
return 'Enter a valid email address';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputClass = 'w-full rounded-lg border bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:outline-none focus:ring-2 focus:ring-(--ring)';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"
|
||||||
|
:for="attrs.name"
|
||||||
|
>
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
:id="attrs.name"
|
||||||
|
v-model="value"
|
||||||
|
v-bind="attrs"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
:class="[
|
||||||
|
inputClass,
|
||||||
|
meta.touched.value && errorMessage
|
||||||
|
? 'border-red-500/60 focus:border-red-500'
|
||||||
|
: 'border-(--border) focus:border-(--accent)',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="meta.touched.value && errorMessage"
|
||||||
|
class="text-xs text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-xs text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
Validates on every keystroke.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="meta.valid.value
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'"
|
||||||
|
>
|
||||||
|
{{ meta.valid.value ? 'valid' : 'invalid' }}
|
||||||
|
</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)">
|
||||||
|
dirty: {{ meta.dirty.value }}
|
||||||
|
</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)">
|
||||||
|
touched: {{ meta.touched.value }}
|
||||||
|
</span>
|
||||||
|
</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="validate()"
|
||||||
|
>
|
||||||
|
Validate
|
||||||
|
</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="reset()"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
value: "{{ value }}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '../useForm';
|
||||||
|
import { useFieldArray } from './index';
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
title: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// useFieldArray needs a form. We pass it explicitly via the `form` option so
|
||||||
|
// the demo works as a single self-contained component.
|
||||||
|
const form = useForm<{ tasks: Task[] }>({
|
||||||
|
initialValues: {
|
||||||
|
tasks: [
|
||||||
|
{ title: 'Sketch wireframes', done: true },
|
||||||
|
{ title: 'Wire up the API', done: false },
|
||||||
|
{ title: 'Write release notes', done: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, push, remove, move, swap } = useFieldArray<Task>('tasks', { form });
|
||||||
|
|
||||||
|
function addTask(): void {
|
||||||
|
push({ title: '', done: false });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Tasks ({{ fields.length }})
|
||||||
|
</p>
|
||||||
|
<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="addTask"
|
||||||
|
>
|
||||||
|
+ Add task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransitionGroup
|
||||||
|
tag="ul"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-y-1"
|
||||||
|
leave-active-class="transition duration-150 ease-in absolute"
|
||||||
|
leave-to-class="opacity-0 translate-x-2"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(field, index) in fields"
|
||||||
|
:key="field.key"
|
||||||
|
class="flex items-center gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="field.value.value.done"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 shrink-0 cursor-pointer accent-(--accent)"
|
||||||
|
aria-label="Mark done"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="field.value.value.title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Describe the task…"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-(--border) bg-(--bg) px-3 py-1.5 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
:class="field.value.value.done ? 'line-through text-(--fg-subtle)' : ''"
|
||||||
|
>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="field.isFirst"
|
||||||
|
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.95] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
aria-label="Move up"
|
||||||
|
@click="move(index, index - 1)"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="field.isLast"
|
||||||
|
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:border-(--border-strong) active:scale-[0.95] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
aria-label="Move down"
|
||||||
|
@click="move(index, index + 1)"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex size-7 items-center justify-center rounded-md border border-red-500/30 bg-red-500/10 text-red-600 transition hover:bg-red-500/20 active:scale-[0.95] cursor-pointer dark:text-red-400"
|
||||||
|
aria-label="Remove"
|
||||||
|
@click="remove(index)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="fields.length === 0"
|
||||||
|
class="rounded-xl border border-dashed border-(--border) bg-(--bg-inset) p-6 text-center text-sm text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
No tasks yet. Add one to get started.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t border-(--border) pt-3">
|
||||||
|
<span class="text-xs text-(--fg-subtle)">
|
||||||
|
Stable keys survive reorders
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="fields.length < 2"
|
||||||
|
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"
|
||||||
|
@click="swap(0, fields.length - 1)"
|
||||||
|
>
|
||||||
|
Swap first ↔ last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useForm } from './index';
|
||||||
|
import type { FormErrors } from './index';
|
||||||
|
|
||||||
|
interface SignUp {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
age: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitted = ref<SignUp | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
errors,
|
||||||
|
meta,
|
||||||
|
isSubmitting,
|
||||||
|
submitCount,
|
||||||
|
defineField,
|
||||||
|
handleSubmit,
|
||||||
|
resetForm,
|
||||||
|
} = useForm<SignUp>({
|
||||||
|
initialValues: { name: '', email: '', age: 18 },
|
||||||
|
validateOn: 'blur',
|
||||||
|
revalidateOn: 'value',
|
||||||
|
// A custom resolver — no external schema library needed.
|
||||||
|
resolver: (values) => {
|
||||||
|
const errs: FormErrors = {};
|
||||||
|
if (!values.name.trim())
|
||||||
|
errs.name = ['Name is required'];
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email))
|
||||||
|
errs.email = ['Enter a valid email'];
|
||||||
|
if (values.age < 18)
|
||||||
|
errs.age = ['Must be 18 or older'];
|
||||||
|
return { errors: errs, values };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [name, nameProps] = defineField('name');
|
||||||
|
const [email, emailProps] = defineField('email');
|
||||||
|
const [age, ageProps] = defineField('age');
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (values) => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600));
|
||||||
|
submitted.value = { ...values };
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseInput = 'w-full rounded-lg border bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:outline-none focus:ring-2 focus:ring-(--ring)';
|
||||||
|
|
||||||
|
function cls(path: keyof SignUp): string {
|
||||||
|
return errors[path]?.length
|
||||||
|
? `${baseInput} border-red-500/60 focus:border-red-500`
|
||||||
|
: `${baseInput} border-(--border) focus:border-(--accent)`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
class="flex w-full max-w-sm flex-col gap-4"
|
||||||
|
novalidate
|
||||||
|
@submit.prevent="onSubmit"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="uf-name" class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Name</label>
|
||||||
|
<input
|
||||||
|
id="uf-name"
|
||||||
|
v-model="name"
|
||||||
|
v-bind="nameProps"
|
||||||
|
:class="cls('name')"
|
||||||
|
placeholder="Ada Lovelace"
|
||||||
|
>
|
||||||
|
<p v-if="errors.name?.length" class="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{{ errors.name[0] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="uf-email" class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Email</label>
|
||||||
|
<input
|
||||||
|
id="uf-email"
|
||||||
|
v-model="email"
|
||||||
|
v-bind="emailProps"
|
||||||
|
type="email"
|
||||||
|
:class="cls('email')"
|
||||||
|
placeholder="ada@example.com"
|
||||||
|
>
|
||||||
|
<p v-if="errors.email?.length" class="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{{ errors.email[0] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="uf-age" class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Age</label>
|
||||||
|
<input
|
||||||
|
id="uf-age"
|
||||||
|
v-model.number="age"
|
||||||
|
v-bind="ageProps"
|
||||||
|
type="number"
|
||||||
|
:class="cls('age')"
|
||||||
|
>
|
||||||
|
<p v-if="errors.age?.length" class="text-xs text-red-600 dark:text-red-400">
|
||||||
|
{{ errors.age[0] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="meta.valid
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
{{ meta.valid ? 'valid' : 'invalid' }}
|
||||||
|
</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)">
|
||||||
|
dirty: {{ meta.dirty }}
|
||||||
|
</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)">
|
||||||
|
submits: {{ submitCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
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 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? 'Submitting…' : 'Create account' }}
|
||||||
|
</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="resetForm(); submitted = null"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="submitted"
|
||||||
|
class="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 font-mono text-xs text-emerald-700 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
<p class="mb-1 font-semibold not-italic">
|
||||||
|
Submitted
|
||||||
|
</p>
|
||||||
|
<pre class="whitespace-pre-wrap">{{ JSON.stringify(submitted, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-center text-xs text-(--fg-subtle)">
|
||||||
|
Validates on blur, then live on every change.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
import { useForm } from '../useForm';
|
||||||
|
import { useFormContext } from './index';
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
username: string;
|
||||||
|
bio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A descendant "field" component that reaches the form purely through context —
|
||||||
|
// it receives no props and is never told which form it belongs to.
|
||||||
|
const ContextField = defineComponent({
|
||||||
|
props: {
|
||||||
|
path: { type: String, required: true },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
multiline: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const form = useFormContext<Profile>();
|
||||||
|
|
||||||
|
// Graceful standalone behaviour when no form ancestor is present.
|
||||||
|
if (!form) {
|
||||||
|
return () =>
|
||||||
|
h('p', { class: 'text-xs text-amber-600 dark:text-amber-400' }, 'No form context found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = props.multiline ? 'textarea' : 'input';
|
||||||
|
const inputClass
|
||||||
|
= '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)';
|
||||||
|
|
||||||
|
return () =>
|
||||||
|
h('div', { class: 'flex flex-col gap-1.5' }, [
|
||||||
|
h(
|
||||||
|
'label',
|
||||||
|
{ class: 'text-xs font-medium uppercase tracking-wide text-(--fg-subtle)' },
|
||||||
|
props.label,
|
||||||
|
),
|
||||||
|
h(tag, {
|
||||||
|
'class': inputClass,
|
||||||
|
'rows': props.multiline ? 2 : undefined,
|
||||||
|
'value': form.getFieldValue(props.path as keyof Profile),
|
||||||
|
'aria-invalid': form.getErrors(props.path as keyof Profile).length > 0 || undefined,
|
||||||
|
'onInput': (event: Event) =>
|
||||||
|
form.setFieldValue(
|
||||||
|
props.path as keyof Profile,
|
||||||
|
(event.target as HTMLInputElement).value,
|
||||||
|
{ shouldTouch: true },
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<Profile>({
|
||||||
|
initialValues: { username: 'grace', bio: 'Compiler pioneer.' },
|
||||||
|
});
|
||||||
|
const { values, meta, isDirty } = form;
|
||||||
|
</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">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||||
|
Profile (fields read context, not props)
|
||||||
|
</p>
|
||||||
|
<ContextField path="username" label="Username" />
|
||||||
|
<ContextField path="bio" label="Bio" multiline />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap 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)">
|
||||||
|
dirty: {{ isDirty }}
|
||||||
|
</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)">
|
||||||
|
touched: {{ meta.touched }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg)">
|
||||||
|
<p class="mb-1 text-(--fg-subtle)">
|
||||||
|
Shared form values:
|
||||||
|
</p>
|
||||||
|
<pre class="whitespace-pre-wrap">{{ JSON.stringify(values, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-xs text-(--fg-subtle)">
|
||||||
|
Both inputs are nested children that locate the form via useFormContext().
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { tryOnBeforeMount } from './index';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
phase: 'before-mount' | 'setup';
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const log = ref<LogEntry[]>([]);
|
||||||
|
const order = ref<string[]>([]);
|
||||||
|
|
||||||
|
function push(label: string, phase: LogEntry['phase']) {
|
||||||
|
log.value.push({ id: ++seq, label, phase });
|
||||||
|
order.value.push(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A marker we drop synchronously in setup so the relative ordering is visible.
|
||||||
|
push('setup body ran', 'setup');
|
||||||
|
|
||||||
|
// Synchronous hook: registers via onBeforeMount because we are inside a component.
|
||||||
|
tryOnBeforeMount(() => push('sync callback (onBeforeMount)', 'before-mount'));
|
||||||
|
|
||||||
|
// Async hook: deferred to the next tick.
|
||||||
|
tryOnBeforeMount(() => push('async callback (nextTick)', 'before-mount'), { sync: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">tryOnBeforeMount</span>
|
||||||
|
<p class="text-sm text-(--fg-muted)">
|
||||||
|
Registers a callback on <code class="font-mono text-(--fg)">onBeforeMount</code> when inside a component,
|
||||||
|
otherwise calls it directly. Watch the execution order below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Execution timeline</span>
|
||||||
|
|
||||||
|
<ol class="mt-3 flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="(entry, index) in log"
|
||||||
|
:key="entry.id"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-(--border) bg-(--bg-inset) font-mono text-xs font-medium text-(--fg-muted) tabular-nums"
|
||||||
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-(--fg)">{{ entry.label }}</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="entry.phase === 'before-mount'
|
||||||
|
? 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||||
|
>
|
||||||
|
{{ entry.phase }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
<span class="text-(--fg-subtle)">order:</span> [{{ order.join(' → ') }}]
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
The sync callback fires during the component's before-mount phase; the async one is queued to the next tick,
|
||||||
|
so it always lands last.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { tryOnMounted } from './index';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const start = Date.now();
|
||||||
|
const log = ref<LogEntry[]>([]);
|
||||||
|
const mounted = ref(false);
|
||||||
|
|
||||||
|
function push(label: string) {
|
||||||
|
log.value.push({ id: ++seq, label, at: Date.now() - start });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marker dropped synchronously while setup runs — before the DOM exists.
|
||||||
|
push('setup body executed');
|
||||||
|
|
||||||
|
// Sync callback: registered through onMounted because we're inside a component.
|
||||||
|
tryOnMounted(() => {
|
||||||
|
mounted.value = true;
|
||||||
|
push('sync callback (onMounted)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Async callback: deferred to the next tick, runs after the sync one.
|
||||||
|
tryOnMounted(() => push('async callback (nextTick)'), { sync: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md 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)">tryOnMounted</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="mounted
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="mounted ? 'bg-emerald-500' : 'bg-amber-500'"
|
||||||
|
/>
|
||||||
|
{{ mounted ? 'mounted' : 'pending' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Mount timeline</span>
|
||||||
|
|
||||||
|
<ol class="mt-3 flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="(entry, index) in log"
|
||||||
|
:key="entry.id"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-(--border) bg-(--bg-inset) font-mono text-xs font-medium text-(--fg-muted) tabular-nums"
|
||||||
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-(--fg)">{{ entry.label }}</span>
|
||||||
|
<span class="ml-auto font-mono text-xs text-(--fg-subtle) tabular-nums">+{{ entry.at }}ms</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Both callbacks are safely deferred until the component is mounted. The async variant is queued one extra tick,
|
||||||
|
so it consistently runs after the synchronous one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { effectScope, ref } from 'vue';
|
||||||
|
import { tryOnScopeDispose } from './index';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
kind: 'create' | 'dispose' | 'noop';
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const log = ref<LogEntry[]>([]);
|
||||||
|
const activeScopes = ref(0);
|
||||||
|
|
||||||
|
function push(label: string, kind: LogEntry['kind']) {
|
||||||
|
log.value.unshift({ id: ++seq, label, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: ReturnType<typeof effectScope> | null = null;
|
||||||
|
|
||||||
|
// Create a fresh effect scope and register a dispose hook inside it.
|
||||||
|
function createScope() {
|
||||||
|
const id = activeScopes.value + 1;
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const registered = tryOnScopeDispose(() => {
|
||||||
|
activeScopes.value--;
|
||||||
|
push(`scope #${id} disposed → cleanup ran`, 'dispose');
|
||||||
|
});
|
||||||
|
|
||||||
|
push(
|
||||||
|
registered
|
||||||
|
? `scope #${id} created → hook registered`
|
||||||
|
: `scope #${id} created → no active scope (skipped)`,
|
||||||
|
registered ? 'create' : 'noop',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop any previously open scope first so only one is live at a time.
|
||||||
|
current?.stop();
|
||||||
|
current = scope;
|
||||||
|
activeScopes.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeScope() {
|
||||||
|
current?.stop();
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demonstrates the graceful fallback: outside any scope it returns false.
|
||||||
|
function callOutsideScope() {
|
||||||
|
const registered = tryOnScopeDispose(() => {});
|
||||||
|
push(
|
||||||
|
registered
|
||||||
|
? 'unexpected: registered outside a scope'
|
||||||
|
: 'called outside scope → returned false (no-op)',
|
||||||
|
'noop',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md 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)">tryOnScopeDispose</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="activeScopes > 0
|
||||||
|
? '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="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="activeScopes > 0 ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
|
||||||
|
/>
|
||||||
|
{{ activeScopes > 0 ? 'scope active' : 'no scope' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 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"
|
||||||
|
@click="createScope"
|
||||||
|
>
|
||||||
|
Create scope
|
||||||
|
</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="activeScopes === 0"
|
||||||
|
@click="disposeScope"
|
||||||
|
>
|
||||||
|
Dispose
|
||||||
|
</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="callOutsideScope"
|
||||||
|
>
|
||||||
|
Outside
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Event log</span>
|
||||||
|
|
||||||
|
<ul v-if="log.length" class="mt-3 flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="entry in log"
|
||||||
|
:key="entry.id"
|
||||||
|
class="flex items-center gap-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-emerald-500': entry.kind === 'create',
|
||||||
|
'bg-rose-500': entry.kind === 'dispose',
|
||||||
|
'bg-(--fg-subtle)': entry.kind === 'noop',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<span class="text-(--fg)">{{ entry.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-else class="mt-3 text-sm text-(--fg-subtle)">
|
||||||
|
Create a scope to register a cleanup hook, then dispose it to watch the callback fire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Inside an active effect scope the cleanup is registered and runs on disposal. Called with no scope present, it
|
||||||
|
simply returns <code class="font-mono text-(--fg)">false</code> instead of throwing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMounted } from './index';
|
||||||
|
|
||||||
|
// A readonly ref that flips to true once the component is mounted.
|
||||||
|
const isMounted = useMounted();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">useMounted</span>
|
||||||
|
<p class="text-sm text-(--fg-muted)">
|
||||||
|
Tracks whether the component has finished mounting — handy for SSR-safe rendering and entry animations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">isMounted</span>
|
||||||
|
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ isMounted }}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition"
|
||||||
|
:class="isMounted
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="isMounted ? 'bg-emerald-500' : 'bg-amber-500'"
|
||||||
|
/>
|
||||||
|
{{ isMounted ? 'mounted' : 'mounting…' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mount-gated reveal: only animates in once the client takes over. -->
|
||||||
|
<div
|
||||||
|
class="overflow-hidden rounded-lg border border-(--border) bg-(--bg-inset) p-4 transition-all duration-500 ease-out"
|
||||||
|
:class="isMounted ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0'"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-(--fg)">
|
||||||
|
This panel fades and slides into view the moment <code class="font-mono">isMounted</code> becomes
|
||||||
|
<code class="font-mono text-(--fg)">true</code>.
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-(--fg-subtle)">
|
||||||
|
On the server it renders hidden, avoiding hydration flicker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { logicAnd } from './index';
|
||||||
|
|
||||||
|
// Realistic "ready to publish" checklist — each is a reactive boolean source.
|
||||||
|
const hasTitle = ref(true);
|
||||||
|
const acceptedTerms = ref(false);
|
||||||
|
const draftSaved = ref(true);
|
||||||
|
|
||||||
|
// A getter input, to show logicAnd accepts refs *and* getters.
|
||||||
|
const wordCount = ref(180);
|
||||||
|
const meetsLength = computed(() => wordCount.value >= 150);
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ key: 'title', label: 'Title is filled in', model: hasTitle },
|
||||||
|
{ key: 'terms', label: 'Accepted publishing terms', model: acceptedTerms },
|
||||||
|
{ key: 'draft', label: 'Draft auto-saved', model: draftSaved },
|
||||||
|
];
|
||||||
|
|
||||||
|
// True only when every condition is truthy.
|
||||||
|
const canPublish = logicAnd(hasTitle, acceptedTerms, draftSaved, meetsLength);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-md flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">logicAnd</span>
|
||||||
|
<p class="text-sm text-(--fg-muted)">
|
||||||
|
Reactive logical <code class="font-mono text-(--fg)">AND</code> — the result is true only when every input
|
||||||
|
resolves truthy. Toggle the checklist below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<label
|
||||||
|
v-for="cond in conditions"
|
||||||
|
:key="cond.key"
|
||||||
|
class="flex cursor-pointer items-center gap-3 rounded-lg px-1.5 py-1.5 transition hover:bg-(--bg-inset)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="cond.model.value"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 shrink-0 cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-(--fg)">{{ cond.label }}</span>
|
||||||
|
<span
|
||||||
|
class="ml-auto inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium tabular-nums"
|
||||||
|
:class="cond.model.value
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-subtle)'"
|
||||||
|
>
|
||||||
|
{{ cond.model.value }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- A getter-driven input to prove logicAnd accepts getters too. -->
|
||||||
|
<div class="mt-1 flex flex-col gap-2 rounded-lg bg-(--bg-inset) p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-(--fg)">Word count ≥ 150</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium tabular-nums"
|
||||||
|
:class="meetsLength
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'border-(--border) bg-(--bg-elevated) text-(--fg-subtle)'"
|
||||||
|
>
|
||||||
|
{{ meetsLength }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="wordCount"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="300"
|
||||||
|
step="10"
|
||||||
|
class="w-full cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs text-(--fg-subtle) tabular-nums">{{ wordCount }} words</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-lg border p-4 transition"
|
||||||
|
:class="canPublish
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10'
|
||||||
|
: 'border-(--border) bg-(--bg-inset)'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">canPublish</span>
|
||||||
|
<span
|
||||||
|
class="font-mono text-3xl font-bold tabular-nums"
|
||||||
|
:class="canPublish ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg)'"
|
||||||
|
>
|
||||||
|
{{ canPublish }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!canPublish"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { logicNot } from './index';
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isReady = logicNot(isLoading);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-medium text-(--fg)">Loading</span>
|
||||||
|
<span class="text-xs text-(--fg-subtle)">source value</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="isLoading"
|
||||||
|
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-(--border) transition focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
:class="isLoading ? 'bg-(--accent)' : 'bg-(--bg-inset)'"
|
||||||
|
@click="isLoading = !isLoading"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block size-4 transform rounded-full bg-white shadow transition"
|
||||||
|
:class="isLoading ? 'translate-x-6' : 'translate-x-1'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="flex flex-col items-center gap-1 rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">isLoading</span>
|
||||||
|
<span
|
||||||
|
class="font-mono text-lg font-bold tabular-nums"
|
||||||
|
:class="isLoading ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg-muted)'"
|
||||||
|
>{{ isLoading }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-1 rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">isReady = !isLoading</span>
|
||||||
|
<span
|
||||||
|
class="font-mono text-lg font-bold tabular-nums"
|
||||||
|
:class="isReady ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg-muted)'"
|
||||||
|
>{{ isReady }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center text-sm text-(--fg-muted)">
|
||||||
|
<span v-if="isReady" class="text-emerald-600 dark:text-emerald-400">Ready to continue</span>
|
||||||
|
<span v-else>Please wait, loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { logicOr } from './index';
|
||||||
|
|
||||||
|
const email = ref(true);
|
||||||
|
const sms = ref(false);
|
||||||
|
const push = ref(false);
|
||||||
|
|
||||||
|
const hasChannel = logicOr(email, sms, push);
|
||||||
|
|
||||||
|
const channels = [
|
||||||
|
{ key: 'email', label: 'Email', model: email },
|
||||||
|
{ key: 'sms', label: 'SMS', model: sms },
|
||||||
|
{ key: 'push', label: 'Push', model: push },
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Notification channels</span>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
v-for="ch in channels"
|
||||||
|
:key="ch.key"
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded-lg border border-(--border) bg-(--bg-elevated) px-3 py-2.5 transition hover:border-(--border-strong)"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-(--fg)">{{ ch.label }}</span>
|
||||||
|
<input
|
||||||
|
v-model="ch.model.value"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between rounded-xl border p-4 transition"
|
||||||
|
:class="hasChannel
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10'
|
||||||
|
: 'border-amber-500/30 bg-amber-500/10'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">logicOr(email, sms, push)</span>
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="hasChannel
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'text-amber-600 dark:text-amber-400'"
|
||||||
|
>{{ hasChannel ? 'At least one channel is on' : 'Pick a channel to get notified' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono text-2xl font-bold tabular-nums text-(--fg)">{{ hasChannel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useAbs } from './index';
|
||||||
|
|
||||||
|
const value = ref(-42);
|
||||||
|
const abs = useAbs(value);
|
||||||
|
|
||||||
|
// Map the current value (-100..100) to a 0..100% offset for the marker.
|
||||||
|
const markerLeft = computed(() => `${(value.value + 100) / 2}%`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-5">
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">value</span>
|
||||||
|
<span class="font-mono text-2xl font-bold tabular-nums text-(--fg-muted)">{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">|value|</span>
|
||||||
|
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ abs }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
v-model.number="value"
|
||||||
|
type="range"
|
||||||
|
min="-100"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
class="w-full cursor-pointer accent-(--accent)"
|
||||||
|
>
|
||||||
|
<div class="relative h-2 rounded-full bg-(--bg-inset)">
|
||||||
|
<div class="absolute inset-y-0 left-1/2 w-px bg-(--border-strong)" />
|
||||||
|
<div
|
||||||
|
class="absolute -top-1 size-4 -translate-x-1/2 rounded-full border-2 border-(--bg) bg-(--accent) transition-[left] duration-75"
|
||||||
|
:style="{ left: markerLeft }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between font-mono text-xs text-(--fg-subtle) tabular-nums">
|
||||||
|
<span>-100</span>
|
||||||
|
<span>0</span>
|
||||||
|
<span>100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 text-center font-mono text-sm text-(--fg) tabular-nums">
|
||||||
|
Math.abs({{ value }}) = {{ abs }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useAverage } from './index';
|
||||||
|
|
||||||
|
const scores = ref([88, 92, 76, 95]);
|
||||||
|
const average = useAverage(scores);
|
||||||
|
|
||||||
|
const display = computed(() =>
|
||||||
|
scores.value.length === 0 ? 'NaN' : average.value.toFixed(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
function addScore() {
|
||||||
|
scores.value.push(Math.floor(Math.random() * 41) + 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScore(index: number) {
|
||||||
|
scores.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-4 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Average score</span>
|
||||||
|
<span class="text-xs text-(--fg-subtle)">{{ scores.length }} value{{ scores.length === 1 ? '' : 's' }}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-mono text-3xl font-bold tabular-nums"
|
||||||
|
:class="scores.length === 0 ? 'text-amber-600 dark:text-amber-400' : 'text-(--fg)'"
|
||||||
|
>{{ display }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="scores.length" class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(score, i) in scores"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-inset) px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="w-6 shrink-0 font-mono text-xs text-(--fg-subtle) tabular-nums">#{{ i + 1 }}</span>
|
||||||
|
<input
|
||||||
|
v-model.number="scores[i]"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="w-full rounded-md border border-(--border) bg-(--bg) px-2 py-1 text-sm text-(--fg) tabular-nums transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove value"
|
||||||
|
class="shrink-0 rounded-md border border-(--border) bg-(--bg-elevated) px-2 py-1 text-sm text-(--fg-muted) transition hover:border-(--border-strong) hover:text-(--fg) active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="removeScore(i)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-center text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
No values — mean is NaN (0 / 0)
|
||||||
|
</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="addScore"
|
||||||
|
>
|
||||||
|
+ Add random score
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user