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,128 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computedAsync } from './index';
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
role: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
const directory: Record<number, User> = {
|
||||
1: { name: 'Ada Lovelace', role: 'Mathematician', city: 'London' },
|
||||
2: { name: 'Grace Hopper', role: 'Rear Admiral', city: 'New York' },
|
||||
3: { name: 'Alan Turing', role: 'Cryptanalyst', city: 'Cambridge' },
|
||||
4: { name: 'Katherine Johnson', role: 'Aerospace Engineer', city: 'Hampton' },
|
||||
};
|
||||
|
||||
const userId = ref(1);
|
||||
const evaluating = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Simulated network fetch with artificial latency.
|
||||
function fetchUser(id: number, signal: AbortSignal): Promise<User> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const found = directory[id];
|
||||
if (found)
|
||||
resolve(found);
|
||||
else
|
||||
reject(new Error(`No user with id ${id}`));
|
||||
}, 700);
|
||||
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const user = computedAsync<User | null>(
|
||||
async (onCancel) => {
|
||||
error.value = null;
|
||||
const controller = new AbortController();
|
||||
onCancel(() => controller.abort());
|
||||
return fetchUser(userId.value, controller.signal);
|
||||
},
|
||||
null,
|
||||
{
|
||||
evaluating,
|
||||
onError: (e) => {
|
||||
if ((e as DOMException)?.name !== 'AbortError')
|
||||
error.value = (e as Error).message;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const ids = [1, 2, 3, 4, 99];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-sm flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Fetch user by id</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="id in ids"
|
||||
:key="id"
|
||||
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="userId === id
|
||||
? '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="userId = id"
|
||||
>
|
||||
#{{ id }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 min-h-32 flex flex-col justify-center">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="evaluating" key="loading" class="flex items-center gap-2 text-sm text-(--fg-muted)">
|
||||
<span class="size-4 animate-spin rounded-full border-2 border-(--border) border-t-(--accent)" />
|
||||
Resolving promise…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" key="error" class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-red-600 dark:text-red-400">Evaluation failed</span>
|
||||
<span class="font-mono text-xs text-(--fg-muted)">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="user" key="user" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-(--fg)">{{ user.name }}</span>
|
||||
<span class="text-sm text-(--fg-muted)">{{ user.role }}</span>
|
||||
<span class="inline-flex w-fit 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)">
|
||||
{{ user.city }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else key="empty" class="text-sm text-(--fg-subtle)">
|
||||
Awaiting first resolution…
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 flex items-center justify-between">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">evaluating</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 font-mono text-sm tabular-nums"
|
||||
:class="evaluating ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'"
|
||||
>
|
||||
<span class="size-1.5 rounded-full" :class="evaluating ? 'bg-amber-500' : 'bg-emerald-500'" />
|
||||
{{ evaluating }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computedEager } from './index';
|
||||
|
||||
const password = ref('hunter2');
|
||||
|
||||
// All derived synchronously and eagerly on every keystroke — the cached
|
||||
// values are always fresh, no lazy read required.
|
||||
const length = computedEager(() => password.value.length);
|
||||
const hasUpper = computedEager(() => /[A-Z]/.test(password.value));
|
||||
const hasNumber = computedEager(() => /\d/.test(password.value));
|
||||
const hasSymbol = computedEager(() => /[^A-Z0-9]/i.test(password.value));
|
||||
|
||||
const score = computedEager(() => {
|
||||
const checks = [length.value >= 8, hasUpper.value, hasNumber.value, hasSymbol.value];
|
||||
return checks.filter(Boolean).length;
|
||||
});
|
||||
|
||||
const label = computedEager(() => ['Empty', 'Weak', 'Fair', 'Good', 'Strong'][score.value]);
|
||||
|
||||
const rules = computedEager(() => [
|
||||
{ ok: length.value >= 8, text: 'At least 8 characters' },
|
||||
{ ok: hasUpper.value, text: 'An uppercase letter' },
|
||||
{ ok: hasNumber.value, text: 'A number' },
|
||||
{ ok: hasSymbol.value, text: 'A symbol' },
|
||||
]);
|
||||
|
||||
const tones = [
|
||||
'bg-(--border)',
|
||||
'bg-red-500',
|
||||
'bg-amber-500',
|
||||
'bg-sky-500',
|
||||
'bg-emerald-500',
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-sm flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="pwd">Password</label>
|
||||
<input
|
||||
id="pwd"
|
||||
v-model="password"
|
||||
type="text"
|
||||
placeholder="Type a password…"
|
||||
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">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-(--fg-muted)">Strength</span>
|
||||
<span class="font-mono text-sm font-medium tabular-nums text-(--fg)">{{ label }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="h-1.5 flex-1 rounded-full transition-colors duration-300"
|
||||
:class="i <= score ? tones[score] : 'bg-(--bg-inset)'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="rule in rules"
|
||||
:key="rule.text"
|
||||
class="flex items-center gap-2 text-sm transition-colors"
|
||||
:class="rule.ok ? 'text-(--fg)' : 'text-(--fg-subtle)'"
|
||||
>
|
||||
<span
|
||||
class="grid size-4 place-items-center rounded-full text-[10px] transition-colors"
|
||||
:class="rule.ok
|
||||
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-(--bg-inset) text-(--fg-subtle)'"
|
||||
>
|
||||
{{ rule.ok ? '✓' : '○' }}
|
||||
</span>
|
||||
{{ rule.text }}
|
||||
</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)">length</span>
|
||||
<span>{{ length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computedWithControl } from './index';
|
||||
|
||||
// `source` controls recomputation; `tax` deliberately does NOT.
|
||||
const source = ref(100);
|
||||
const tax = ref(0);
|
||||
let recomputes = 0;
|
||||
|
||||
const total = computedWithControl(source, () => {
|
||||
recomputes++;
|
||||
return Math.round(source.value * (1 + tax.value / 100));
|
||||
});
|
||||
|
||||
const peeked = ref<number | null>(null);
|
||||
const detached = ref(false);
|
||||
|
||||
function peek() {
|
||||
// Read the cached value without registering tracking or recomputing.
|
||||
peeked.value = total.peek();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
total.stop();
|
||||
detached.value = true;
|
||||
}
|
||||
</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 flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Base price (tracked)</span>
|
||||
<span class="font-mono text-sm tabular-nums text-(--accent-text)">{{ source }}</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="source"
|
||||
type="range"
|
||||
min="0"
|
||||
max="500"
|
||||
step="10"
|
||||
class="w-full accent-(--accent)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div 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)">Tax % (untracked)</span>
|
||||
<span class="font-mono text-sm tabular-nums text-(--fg-muted)">{{ tax }}</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="tax"
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
step="1"
|
||||
class="w-full accent-(--fg-muted)"
|
||||
>
|
||||
<span class="text-xs text-(--fg-subtle)">
|
||||
Changing tax alone won't recompute — trigger to pull it in.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 flex items-center justify-between">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Total</span>
|
||||
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ total }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
<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="total.trigger()"
|
||||
>
|
||||
Trigger
|
||||
</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="peek"
|
||||
>
|
||||
Peek
|
||||
</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="detached"
|
||||
@click="stop"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-(--fg-muted)">Getter runs</span>
|
||||
<span class="font-mono tabular-nums text-(--fg)">{{ recomputes }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-(--fg-muted)">Last peek</span>
|
||||
<span class="font-mono tabular-nums text-(--fg)">{{ peeked ?? '—' }}</span>
|
||||
</div>
|
||||
<div v-if="detached" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
Source watcher stopped — only Trigger updates the total now.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { extendRef } from './index';
|
||||
|
||||
// A plain ref carrying its own derived + imperative API.
|
||||
const baseVolume = ref(40);
|
||||
|
||||
const volume = extendRef(baseVolume, {
|
||||
// reactive (unwrapped) — read without .value
|
||||
percent: computed(() => `${baseVolume.value}%`),
|
||||
isMuted: computed(() => baseVolume.value === 0),
|
||||
// imperative helpers attached to the same ref
|
||||
mute: () => { baseVolume.value = 0; },
|
||||
max: () => { baseVolume.value = 100; },
|
||||
});
|
||||
|
||||
let lastMuted = 50;
|
||||
function toggleMute() {
|
||||
if (volume.isMuted) {
|
||||
volume.value = lastMuted;
|
||||
}
|
||||
else {
|
||||
lastMuted = volume.value || 50;
|
||||
volume.mute();
|
||||
}
|
||||
}
|
||||
</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)">Volume</span>
|
||||
<span
|
||||
class="font-mono text-3xl font-bold tabular-nums transition-colors"
|
||||
:class="volume.isMuted ? 'text-(--fg-subtle)' : 'text-(--fg)'"
|
||||
>{{ volume.percent }}</span>
|
||||
</div>
|
||||
|
||||
<!-- bind the ref directly with v-model — it's still a ref -->
|
||||
<input
|
||||
v-model.number="volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full accent-(--accent)"
|
||||
>
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg) 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="toggleMute"
|
||||
>
|
||||
{{ volume.isMuted ? 'Unmute' : 'Mute' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) bg-(--bg) 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="volume.max()"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 flex flex-col gap-2 font-mono text-sm tabular-nums">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-(--fg-muted)">volume.value</span>
|
||||
<span class="text-(--fg)">{{ volume.value }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-(--fg-muted)">volume.percent</span>
|
||||
<span class="text-(--fg)">{{ volume.percent }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-(--fg-muted)">volume.isMuted</span>
|
||||
<span :class="volume.isMuted ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'">
|
||||
{{ volume.isMuted }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle)">
|
||||
One ref carries its value, derived properties, and methods — no destructuring of an object needed.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { reactiveComputed } from './index';
|
||||
|
||||
const price = ref(24);
|
||||
const quantity = ref(3);
|
||||
const discount = ref(10);
|
||||
|
||||
// One getter, exposed as a reactive object whose fields stay independently
|
||||
// reactive — and writable back through to the source refs.
|
||||
const cart = reactiveComputed(() => {
|
||||
const subtotal = price.value * quantity.value;
|
||||
const saved = Math.round((subtotal * discount.value) / 100);
|
||||
return {
|
||||
subtotal,
|
||||
saved,
|
||||
total: subtotal - saved,
|
||||
discount, // a ref — unwrapped and writable through the proxy
|
||||
};
|
||||
});
|
||||
|
||||
const presets = [0, 10, 25, 50];
|
||||
|
||||
function setDiscount(value: number) {
|
||||
// Writes through the reactive proxy back to the `discount` ref.
|
||||
cart.discount = value;
|
||||
}
|
||||
</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="grid grid-cols-2 gap-3">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Unit price</span>
|
||||
<input
|
||||
v-model.number="price"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) tabular-nums 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)">Quantity</span>
|
||||
<input
|
||||
v-model.number="quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) tabular-nums transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Discount</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="p in presets"
|
||||
:key="p"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border px-3 py-1.5 text-sm font-medium tabular-nums transition active:scale-[0.98] cursor-pointer"
|
||||
:class="cart.discount === p
|
||||
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
|
||||
: 'border-(--border) bg-(--bg) text-(--fg) hover:bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||
@click="setDiscount(p)"
|
||||
>
|
||||
{{ p }}%
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 flex flex-col gap-2 font-mono text-sm tabular-nums">
|
||||
<div class="flex items-center justify-between text-(--fg-muted)">
|
||||
<span>Subtotal</span>
|
||||
<span class="text-(--fg)">${{ cart.subtotal }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-(--fg-muted)">
|
||||
<span>Saved ({{ cart.discount }}%)</span>
|
||||
<span class="text-emerald-600 dark:text-emerald-400">-${{ cart.saved }}</span>
|
||||
</div>
|
||||
<div class="h-px bg-(--border)" />
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-(--fg)">Total</span>
|
||||
<span class="text-2xl font-bold text-(--fg)">${{ cart.total }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle)">
|
||||
Each field reads from a single cached getter; writing <code class="text-(--fg-muted)">cart.discount</code> flows back to the source ref.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { reactiveOmit } from './index';
|
||||
|
||||
const user = reactive({
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@analytical.engine',
|
||||
role: 'admin',
|
||||
token: 'sk_live_8f2a91c3',
|
||||
active: true,
|
||||
});
|
||||
|
||||
// Omit listed keys — stays reactive as the source changes.
|
||||
const safeUser = reactiveOmit(user, 'token', ['email']);
|
||||
|
||||
// Predicate form — drop every boolean field.
|
||||
const noFlags = reactiveOmit(user, value => typeof value === 'boolean');
|
||||
|
||||
const roles = ['admin', 'editor', 'viewer'] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||
<div class="space-y-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Source object
|
||||
</p>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-(--fg-muted)">name</span>
|
||||
<input
|
||||
v-model="user.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)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs font-medium text-(--fg-muted)">role</span>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="r in roles"
|
||||
:key="r"
|
||||
type="button"
|
||||
class="rounded-md border px-2 py-0.5 text-xs font-medium transition cursor-pointer"
|
||||
:class="user.role === r
|
||||
? 'border-transparent bg-(--accent) text-(--accent-fg)'
|
||||
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted) hover:border-(--border-strong)'"
|
||||
@click="user.role = r"
|
||||
>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs font-medium text-(--fg-muted)">active</span>
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-5 w-9 rounded-full transition cursor-pointer"
|
||||
:class="user.active ? 'bg-(--accent)' : 'bg-(--bg-inset)'"
|
||||
role="switch"
|
||||
:aria-checked="user.active"
|
||||
@click="user.active = !user.active"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 size-4 rounded-full bg-white shadow transition-all"
|
||||
:class="user.active ? 'left-4' : 'left-0.5'"
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</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)">
|
||||
Omit <code class="text-(--accent-text)">token</code>, <code class="text-(--accent-text)">email</code>
|
||||
</p>
|
||||
<pre class="overflow-x-auto rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg)">{{ safeUser }}</pre>
|
||||
</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)">
|
||||
Predicate: drop booleans
|
||||
</p>
|
||||
<pre class="overflow-x-auto rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg)">{{ noFlags }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { reactivePick } from './index';
|
||||
|
||||
const settings = reactive({
|
||||
brightness: 60,
|
||||
contrast: 40,
|
||||
theme: 'midnight',
|
||||
autosave: true,
|
||||
syncedAt: '2026-06-08',
|
||||
});
|
||||
|
||||
// Live two-way view limited to the picked keys — writes flow back to `settings`.
|
||||
const display = reactivePick(settings, 'brightness', 'contrast');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||
<div class="space-y-4 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Picked view (two-way)
|
||||
</p>
|
||||
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="flex items-center justify-between text-sm text-(--fg)">
|
||||
<span>brightness</span>
|
||||
<span class="font-mono tabular-nums text-(--fg-muted)">{{ display.brightness }}</span>
|
||||
</span>
|
||||
<input
|
||||
v-model.number="display.brightness"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full accent-(--accent) cursor-pointer"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="flex items-center justify-between text-sm text-(--fg)">
|
||||
<span>contrast</span>
|
||||
<span class="font-mono tabular-nums text-(--fg-muted)">{{ display.contrast }}</span>
|
||||
</span>
|
||||
<input
|
||||
v-model.number="display.contrast"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full accent-(--accent) cursor-pointer"
|
||||
>
|
||||
</label>
|
||||
</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)">
|
||||
Full source object
|
||||
</p>
|
||||
<pre class="overflow-x-auto rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg)">{{ settings }}</pre>
|
||||
<p class="mt-2 text-xs text-(--fg-subtle)">
|
||||
Editing the picked view above writes straight back to the source.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { refAutoReset } from './index';
|
||||
|
||||
// Reactive delay — the ref resets `delay`ms after the most recent write.
|
||||
const delay = ref(1500);
|
||||
|
||||
// Reverts to 'Idle' once writes stop arriving.
|
||||
const status = refAutoReset('Idle', delay);
|
||||
const copied = refAutoReset(false, 1200);
|
||||
|
||||
function flash(message: string) {
|
||||
status.value = message;
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
copied.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||
<div class="space-y-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Live value
|
||||
</p>
|
||||
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||
<span class="font-mono text-lg font-semibold tabular-nums text-(--fg)">{{ status }}</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
|
||||
:class="status === 'Idle'
|
||||
? 'border-(--border) bg-(--bg-elevated) text-(--fg-muted)'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'"
|
||||
>
|
||||
{{ status === 'Idle' ? 'reset' : 'active' }}
|
||||
</span>
|
||||
</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-(--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="flash('Saved')"
|
||||
>
|
||||
Save
|
||||
</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="flash('Synced')"
|
||||
>
|
||||
Sync
|
||||
</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="flash('Uploading…')"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1.5 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<span class="flex items-center justify-between text-sm text-(--fg)">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">reset delay</span>
|
||||
<span class="font-mono tabular-nums text-(--fg-muted)">{{ delay }}ms</span>
|
||||
</span>
|
||||
<input
|
||||
v-model.number="delay"
|
||||
type="range"
|
||||
min="500"
|
||||
max="4000"
|
||||
step="250"
|
||||
class="w-full accent-(--accent) cursor-pointer"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<span class="text-sm text-(--fg)">Copy-to-clipboard pattern</span>
|
||||
<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="copy"
|
||||
>
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { refDebounced } from './index';
|
||||
|
||||
const search = ref('Vue composables');
|
||||
const ms = ref(400);
|
||||
|
||||
// Read-only mirror that only updates after `ms` of quiet, with a maxWait ceiling.
|
||||
const debounced = refDebounced(search, ms, { maxWait: 2000 });
|
||||
|
||||
const pending = computed(() => search.value !== debounced.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Type to search
|
||||
</span>
|
||||
<input
|
||||
v-model="search"
|
||||
placeholder="Start typing…"
|
||||
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 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<span class="flex items-center justify-between text-sm text-(--fg)">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">debounce</span>
|
||||
<span class="font-mono tabular-nums text-(--fg-muted)">{{ ms }}ms</span>
|
||||
</span>
|
||||
<input
|
||||
v-model.number="ms"
|
||||
type="range"
|
||||
min="100"
|
||||
max="1500"
|
||||
step="50"
|
||||
class="w-full accent-(--accent) cursor-pointer"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-3">
|
||||
<p class="mb-1 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Source
|
||||
</p>
|
||||
<p class="truncate font-mono text-sm text-(--fg)">{{ search || '—' }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-3">
|
||||
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Debounced
|
||||
<span
|
||||
v-if="pending"
|
||||
class="size-1.5 animate-pulse rounded-full bg-amber-500"
|
||||
aria-label="pending"
|
||||
/>
|
||||
</p>
|
||||
<p class="truncate font-mono text-sm text-(--accent-text)">{{ debounced || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="rounded-lg border px-3 py-2 text-center text-xs font-medium transition"
|
||||
:class="pending
|
||||
? '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'"
|
||||
>
|
||||
{{ pending ? 'Waiting for input to settle…' : 'Synced — debounced value caught up' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { refDefault } from './index';
|
||||
|
||||
// Source may legitimately hold null (e.g. "not set yet").
|
||||
const raw = ref<string | null>(null);
|
||||
|
||||
// Reactive fallback — `name` reads as `fallback` whenever `raw` is null/undefined.
|
||||
const fallback = ref('Anonymous');
|
||||
const name = refDefault(raw, fallback);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||
<div class="space-y-3 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Resolved value
|
||||
</p>
|
||||
<div class="flex items-center justify-between rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||
<span class="font-mono text-lg font-semibold text-(--fg)">{{ name }}</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||
:class="raw == null
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
|
||||
: 'border-(--border) bg-(--bg-elevated) text-(--fg-muted)'"
|
||||
>
|
||||
{{ raw == null ? 'using default' : 'from source' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Source ref (writes pass through)
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="name"
|
||||
placeholder="Type a 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)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 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="raw == null"
|
||||
@click="raw = null"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-(--fg-subtle)">
|
||||
raw.value = {{ raw === null ? 'null' : `"${raw}"` }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1.5 rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Reactive fallback
|
||||
</span>
|
||||
<input
|
||||
v-model="fallback"
|
||||
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)"
|
||||
>
|
||||
<span class="text-xs text-(--fg-subtle)">
|
||||
Changes here update the resolved value while the source is empty.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { refThrottled } from './index';
|
||||
|
||||
const delay = ref(500);
|
||||
|
||||
// A high-frequency source the throttled ref will rate-limit.
|
||||
const source = ref(0);
|
||||
const throttled = refThrottled(source, delay.value);
|
||||
|
||||
// Track how often each side actually updates so the savings are visible.
|
||||
const sourceUpdates = ref(0);
|
||||
const throttledUpdates = ref(0);
|
||||
|
||||
let lastThrottled = throttled.value;
|
||||
const lag = computed(() => source.value - throttled.value);
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const running = ref(false);
|
||||
|
||||
function tick() {
|
||||
source.value++;
|
||||
sourceUpdates.value++;
|
||||
if (throttled.value !== lastThrottled) {
|
||||
lastThrottled = throttled.value;
|
||||
throttledUpdates.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running.value)
|
||||
return;
|
||||
running.value = true;
|
||||
timer = setInterval(tick, 60);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
running.value = false;
|
||||
if (timer)
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stop();
|
||||
source.value = 0;
|
||||
sourceUpdates.value = 0;
|
||||
throttledUpdates.value = 0;
|
||||
lastThrottled = throttled.value;
|
||||
}
|
||||
|
||||
onUnmounted(stop);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-sm flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-1">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Source</span>
|
||||
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ source }}</span>
|
||||
<span class="text-xs text-(--fg-muted)">{{ sourceUpdates }} updates</span>
|
||||
</div>
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-1">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Throttled</span>
|
||||
<span class="font-mono text-3xl font-bold tabular-nums text-(--accent-text)">{{ throttled }}</span>
|
||||
<span class="text-xs text-(--fg-muted)">{{ throttledUpdates }} updates</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 flex items-center justify-between">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Lag behind source</span>
|
||||
<span class="font-mono text-sm tabular-nums text-(--fg)">+{{ lag }}</span>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
|
||||
Throttle window: {{ delay }}ms
|
||||
</span>
|
||||
<input
|
||||
v-model.number="delay"
|
||||
type="range"
|
||||
min="100"
|
||||
max="1500"
|
||||
step="100"
|
||||
class="w-full accent-(--accent) cursor-pointer"
|
||||
>
|
||||
<span class="text-xs text-(--fg-subtle)">Reset to apply a new window</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="running"
|
||||
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="start"
|
||||
>
|
||||
Run (60ms)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!running"
|
||||
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"
|
||||
>
|
||||
Pause
|
||||
</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>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { refWithControl } from './index';
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
message: string;
|
||||
vetoed: boolean;
|
||||
}
|
||||
|
||||
const log = ref<LogEntry[]>([]);
|
||||
let logId = 0;
|
||||
|
||||
function push(message: string, vetoed = false) {
|
||||
log.value.unshift({ id: logId++, message, vetoed });
|
||||
if (log.value.length > 6)
|
||||
log.value.pop();
|
||||
}
|
||||
|
||||
// Only values within 0..10 are accepted; anything else is vetoed.
|
||||
const volume = refWithControl(5, {
|
||||
onBeforeChange: (value) => {
|
||||
if (value < 0 || value > 10) {
|
||||
push(`vetoed ${value} (out of 0..10)`, true);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onChanged: (value, old) => push(`changed ${old} -> ${value}`),
|
||||
});
|
||||
|
||||
// Read without registering tracking — does not show up reactively.
|
||||
const peeked = ref(volume.peek());
|
||||
function peek() {
|
||||
peeked.value = volume.peek();
|
||||
}
|
||||
|
||||
// Write without triggering effects: the bound display stays stale until
|
||||
// a tracked read/write happens, demonstrating the silent set.
|
||||
function silentBump() {
|
||||
volume.lay(Math.min(10, volume.peek() + 1));
|
||||
peeked.value = volume.peek();
|
||||
}
|
||||
</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-3">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Volume (tracked)</span>
|
||||
<span class="font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ volume }}</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-(--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="volume.value--"
|
||||
>
|
||||
- 1
|
||||
</button>
|
||||
<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="volume.value++"
|
||||
>
|
||||
+ 1
|
||||
</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"
|
||||
@click="volume.value = 99"
|
||||
>
|
||||
Set 99
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">peek() snapshot</span>
|
||||
<span class="text-xs text-(--fg-subtle)">untracked read; lay() writes silently</span>
|
||||
</div>
|
||||
<span class="font-mono text-2xl font-bold tabular-nums text-(--fg)">{{ peeked }}</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-(--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="peek"
|
||||
>
|
||||
peek()
|
||||
</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"
|
||||
@click="silentBump"
|
||||
>
|
||||
lay() +1 silent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Hooks log</span>
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 min-h-24 flex flex-col gap-1">
|
||||
<p v-if="!log.length" class="text-xs text-(--fg-subtle)">
|
||||
Try setting volume to 99 to see onBeforeChange veto.
|
||||
</p>
|
||||
<p
|
||||
v-for="entry in log"
|
||||
:key="entry.id"
|
||||
class="font-mono text-xs tabular-nums"
|
||||
:class="entry.vetoed ? 'text-red-600 dark:text-red-400' : 'text-(--fg-muted)'"
|
||||
>
|
||||
{{ entry.vetoed ? '✗' : '✓' }} {{ entry.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref } from 'vue';
|
||||
import { syncRef } from './index';
|
||||
|
||||
// Two refs kept in sync with a transform: Celsius <-> Fahrenheit.
|
||||
const celsius = ref(20);
|
||||
const fahrenheit = ref(68);
|
||||
|
||||
const { stop } = syncRef(celsius, fahrenheit, {
|
||||
direction: 'both',
|
||||
transform: {
|
||||
ltr: c => Math.round((c * 9) / 5 + 32),
|
||||
rtl: f => Math.round(((f - 32) * 5) / 9),
|
||||
},
|
||||
});
|
||||
|
||||
const synced = ref(true);
|
||||
function toggleSync() {
|
||||
if (synced.value) {
|
||||
stop();
|
||||
synced.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stop);
|
||||
</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)">Two-way sync + transform</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium"
|
||||
:class="synced
|
||||
? '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="synced ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
|
||||
/>
|
||||
{{ synced ? 'live' : 'stopped' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-2">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Celsius</span>
|
||||
<input
|
||||
v-model.number="celsius"
|
||||
type="number"
|
||||
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-lg font-mono tabular-nums text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||
>
|
||||
</label>
|
||||
<label class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-2">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Fahrenheit</span>
|
||||
<input
|
||||
v-model.number="fahrenheit"
|
||||
type="number"
|
||||
class="w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-lg font-mono tabular-nums text-(--fg) transition focus:border-(--accent) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
|
||||
{{ celsius }}°C ⇄ {{ fahrenheit }}°F
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model.number="celsius"
|
||||
type="range"
|
||||
min="-20"
|
||||
max="40"
|
||||
step="1"
|
||||
class="w-full accent-(--accent) cursor-pointer"
|
||||
aria-label="Celsius slider"
|
||||
>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!synced"
|
||||
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="toggleSync"
|
||||
>
|
||||
Stop synchronization
|
||||
</button>
|
||||
<p v-if="!synced" class="text-xs text-(--fg-subtle) -mt-2">
|
||||
Watchers torn down — the two refs now drift independently.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { toReactive } from './index';
|
||||
|
||||
interface Profile {
|
||||
name: string;
|
||||
role: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// A ref holding an object, exposed as a reactive proxy. Writes to the proxy
|
||||
// flow straight through to source.value, and reads survive reassignment.
|
||||
const source = ref<Profile>({ name: 'Ada Lovelace', role: 'Engineer', level: 3 });
|
||||
const profile = toReactive(source);
|
||||
|
||||
const presets: Profile[] = [
|
||||
{ name: 'Ada Lovelace', role: 'Engineer', level: 3 },
|
||||
{ name: 'Alan Turing', role: 'Researcher', level: 5 },
|
||||
{ name: 'Grace Hopper', role: 'Architect', level: 8 },
|
||||
];
|
||||
|
||||
// Reassign the whole underlying ref — the proxy keeps pointing at fresh data.
|
||||
function loadPreset(p: Profile) {
|
||||
source.value = { ...p };
|
||||
}
|
||||
</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-3">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Name</span>
|
||||
<input
|
||||
v-model="profile.name"
|
||||
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)">Role</span>
|
||||
<input
|
||||
v-model="profile.role"
|
||||
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)">
|
||||
Level: {{ profile.level }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="profile.level"
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
class="w-full accent-(--accent) cursor-pointer"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">source.value (the backing ref)</span>
|
||||
<pre class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-xs text-(--fg) overflow-x-auto">{{ JSON.stringify(source, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Reassign the whole ref</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.name"
|
||||
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="loadPreset(preset)"
|
||||
>
|
||||
{{ preset.name.split(' ')[0] }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-(--fg-subtle)">
|
||||
The proxy survives reassignment — fields above update without re-binding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useCached } from './index';
|
||||
|
||||
// Source the user edits freely.
|
||||
const source = ref('Hello');
|
||||
|
||||
// Default cache: updates on any strict-inequality change.
|
||||
const cachedDefault = useCached(source);
|
||||
|
||||
// Custom cache: treats values equal when they match case-insensitively, so
|
||||
// "hello" / "HELLO" never refresh the cache once one of them is stored.
|
||||
const cachedInsensitive = useCached(
|
||||
source,
|
||||
(a, b) => a.toLowerCase() === b.toLowerCase(),
|
||||
);
|
||||
|
||||
const samples = ['Hello', 'hello', 'HELLO', 'World', 'world!'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-sm flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Source ref</span>
|
||||
<input
|
||||
v-model="source"
|
||||
type="text"
|
||||
placeholder="Type to change the source"
|
||||
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 class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="sample in samples"
|
||||
:key="sample"
|
||||
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="source = sample"
|
||||
>
|
||||
{{ sample }}
|
||||
</button>
|
||||
</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 gap-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Default cache</span>
|
||||
<span class="text-xs text-(--fg-subtle)">a === b</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm tabular-nums text-(--fg) truncate">"{{ cachedDefault }}"</span>
|
||||
</div>
|
||||
<div class="h-px bg-(--border)" />
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Case-insensitive cache</span>
|
||||
<span class="text-xs text-(--fg-subtle)">toLowerCase() match</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm tabular-nums text-(--accent-text) truncate">"{{ cachedInsensitive }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle)">
|
||||
Toggle between <span class="font-mono">Hello</span>, <span class="font-mono">hello</span> and
|
||||
<span class="font-mono">HELLO</span>: the default cache follows every change, while the
|
||||
case-insensitive cache keeps its first stored casing.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { useCloned } from './index';
|
||||
|
||||
const original = reactive({
|
||||
name: 'Ada Lovelace',
|
||||
role: 'Maintainer',
|
||||
tags: ['core', 'docs'],
|
||||
});
|
||||
|
||||
const { cloned, isModified, sync } = useCloned(() => ({ ...original, tags: [...original.tags] }));
|
||||
|
||||
const roles = ['Maintainer', 'Contributor', 'Reviewer'];
|
||||
|
||||
function bumpOriginal() {
|
||||
original.role = roles[(roles.indexOf(original.role) + 1) % roles.length];
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
cloned.value.tags.push(`tag-${cloned.value.tags.length + 1}`);
|
||||
}
|
||||
</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)">useCloned</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
|
||||
:class="isModified
|
||||
? '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="isModified ? 'bg-amber-500' : 'bg-emerald-500'"
|
||||
/>
|
||||
{{ isModified ? 'Modified' : 'In sync' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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)">Source</p>
|
||||
<dl class="space-y-1.5 text-sm text-(--fg)">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-(--fg-muted)">name</dt>
|
||||
<dd class="truncate font-medium">{{ original.name }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-(--fg-muted)">role</dt>
|
||||
<dd class="font-medium">{{ original.role }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-(--fg-muted)">tags</dt>
|
||||
<dd class="font-mono text-xs text-(--fg-muted)">{{ original.tags.length }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</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)">Cloned (editable)</p>
|
||||
<dl class="space-y-1.5 text-sm text-(--fg)">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-(--fg-muted)">name</dt>
|
||||
<dd class="truncate font-medium">{{ cloned.name }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-(--fg-muted)">role</dt>
|
||||
<dd class="font-medium">{{ cloned.role }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-end gap-1">
|
||||
<span
|
||||
v-for="tag in cloned.tags"
|
||||
:key="tag"
|
||||
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--fg-muted)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
</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-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:border-(--border-strong) hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
|
||||
@click="bumpOriginal"
|
||||
>
|
||||
Cycle source role
|
||||
</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:border-(--border-strong) hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
|
||||
@click="addTag"
|
||||
>
|
||||
Edit clone
|
||||
</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="!isModified"
|
||||
@click="sync()"
|
||||
>
|
||||
Re-sync from source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle)">
|
||||
Editing the source auto-resyncs the clone. Editing the clone marks it modified without touching the source.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useDebounceFn } from './index';
|
||||
|
||||
const delay = ref(500);
|
||||
const query = ref('');
|
||||
const calls = ref(0);
|
||||
const lastResult = ref('');
|
||||
const log = ref<string[]>([]);
|
||||
|
||||
const runSearch = useDebounceFn((term: string) => {
|
||||
calls.value++;
|
||||
const result = term.trim() ? `${term.length} matches for "${term.trim()}"` : 'idle';
|
||||
lastResult.value = result;
|
||||
log.value = [`#${calls.value} → ${result}`, ...log.value].slice(0, 5);
|
||||
return result;
|
||||
}, delay, { maxWait: 2000 });
|
||||
|
||||
function onInput(event: Event) {
|
||||
query.value = (event.target as HTMLInputElement).value;
|
||||
runSearch(query.value);
|
||||
}
|
||||
</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)">useDebounceFn</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition"
|
||||
:class="runSearch.isPending.value
|
||||
? 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400'
|
||||
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)'"
|
||||
>
|
||||
<span
|
||||
class="size-1.5 rounded-full transition"
|
||||
:class="runSearch.isPending.value ? 'animate-pulse bg-sky-500' : 'bg-(--fg-subtle)'"
|
||||
/>
|
||||
{{ runSearch.isPending.value ? 'Pending' : 'Settled' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="search">
|
||||
Type to search
|
||||
</label>
|
||||
<input
|
||||
id="search"
|
||||
:value="query"
|
||||
type="text"
|
||||
placeholder="Search the docs…"
|
||||
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)"
|
||||
@input="onInput"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="delay">
|
||||
Delay
|
||||
</label>
|
||||
<input
|
||||
id="delay"
|
||||
v-model.number="delay"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1500"
|
||||
step="100"
|
||||
class="flex-1 accent-(--accent)"
|
||||
>
|
||||
<span class="w-16 text-right font-mono text-sm tabular-nums text-(--fg)">{{ delay }}ms</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Invocations</p>
|
||||
<p class="mt-1 font-mono text-3xl font-bold tabular-nums text-(--fg)">{{ calls }}</p>
|
||||
</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)">Last result</p>
|
||||
<p class="mt-1 truncate font-mono text-sm text-(--fg)">{{ lastResult || '—' }}</p>
|
||||
</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-(--border) bg-(--bg-elevated) px-3 py-1.5 text-sm font-medium text-(--fg) transition hover:border-(--border-strong) hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||
:disabled="!runSearch.isPending.value"
|
||||
@click="runSearch.flush()"
|
||||
>
|
||||
Flush now
|
||||
</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:border-(--border-strong) hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
|
||||
:disabled="!runSearch.isPending.value"
|
||||
@click="runSearch.cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="log.length" 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)">Recent calls</p>
|
||||
<ul class="space-y-1 font-mono text-xs text-(--fg-muted)">
|
||||
<li v-for="(entry, i) in log" :key="i" class="truncate">{{ entry }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { usePrevious } from './index';
|
||||
|
||||
const themes = ['Light', 'Dark', 'System', 'Sepia', 'High contrast'];
|
||||
const selected = ref(themes[0]);
|
||||
const previous = usePrevious(selected, 'None');
|
||||
|
||||
function pick(theme: string) {
|
||||
selected.value = theme;
|
||||
}
|
||||
</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)">usePrevious</span>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Current</p>
|
||||
<p class="mt-1 truncate text-2xl font-bold text-(--fg)">{{ selected }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-inset) p-4">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Previous</p>
|
||||
<p class="mt-1 truncate text-2xl font-bold text-(--fg-muted)">{{ previous }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Select a theme</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme"
|
||||
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="theme === selected
|
||||
? 'border-transparent bg-(--accent) text-(--accent-fg) hover:bg-(--accent-hover)'
|
||||
: 'border-(--border) bg-(--bg-elevated) text-(--fg) hover:border-(--border-strong) hover:bg-(--bg-inset)'"
|
||||
@click="pick(theme)"
|
||||
>
|
||||
{{ theme }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle)">
|
||||
Seeded with <span class="font-mono text-(--fg-muted)">"None"</span> as the initial previous value until the source first changes.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useSyncRefs } from './index';
|
||||
|
||||
// A single source kept in lock-step with several independent target refs.
|
||||
const source = ref('#6366f1');
|
||||
|
||||
const swatch = ref(source.value);
|
||||
const label = ref(source.value);
|
||||
const hex = ref(source.value);
|
||||
|
||||
useSyncRefs(source, [swatch, label, hex]);
|
||||
|
||||
const presets = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#0ea5e9'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-md flex-col gap-4">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">useSyncRefs</span>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="color">
|
||||
Source ref
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="color"
|
||||
v-model="source"
|
||||
type="color"
|
||||
class="h-9 w-12 shrink-0 cursor-pointer rounded-lg border border-(--border) bg-(--bg)"
|
||||
>
|
||||
<input
|
||||
v-model="source"
|
||||
type="text"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle)">Three independent target refs stay synced to the source:</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="flex flex-col items-center gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-3">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">swatch</span>
|
||||
<span
|
||||
class="size-10 rounded-lg border border-(--border-strong) shadow-sm transition"
|
||||
:style="{ backgroundColor: swatch }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-2 rounded-xl border border-(--border) bg-(--bg-elevated) p-3">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">label</span>
|
||||
<span class="font-mono text-xs text-(--fg)">{{ label }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-2 rounded-xl border border-(--border) bg-(--bg-inset) p-3">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">hex</span>
|
||||
<span class="font-mono text-xs uppercase text-(--fg)">{{ hex }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset"
|
||||
type="button"
|
||||
class="size-7 rounded-md border border-(--border) transition hover:scale-110 active:scale-95 cursor-pointer"
|
||||
:style="{ backgroundColor: preset }"
|
||||
:aria-label="`Set source to ${preset}`"
|
||||
@click="source = preset"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useThrottleFn } from './index';
|
||||
|
||||
const delay = ref(400);
|
||||
const moves = ref(0);
|
||||
const fires = ref(0);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
|
||||
const onMove = useThrottleFn((x: number, y: number) => {
|
||||
fires.value++;
|
||||
position.value = { x, y };
|
||||
}, delay, true, true);
|
||||
|
||||
function handlePointer(event: PointerEvent) {
|
||||
moves.value++;
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = Math.round(((event.clientX - rect.left) / rect.width) * 100);
|
||||
const y = Math.round(((event.clientY - rect.top) / rect.height) * 100);
|
||||
onMove(Math.max(0, Math.min(100, x)), Math.max(0, Math.min(100, y)));
|
||||
}
|
||||
|
||||
const ratio = () => (moves.value ? Math.round((fires.value / moves.value) * 100) : 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-md flex-col gap-4">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">useThrottleFn</span>
|
||||
|
||||
<div
|
||||
class="relative h-40 w-full overflow-hidden rounded-xl border border-(--border) bg-(--bg-inset) touch-none"
|
||||
@pointermove="handlePointer"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-(--accent-fg) bg-(--accent) shadow-md transition-all duration-150 ease-out"
|
||||
:style="{ left: `${position.x}%`, top: `${position.y}%` }"
|
||||
/>
|
||||
<span class="pointer-events-none absolute inset-x-0 bottom-2 text-center text-xs text-(--fg-subtle)">
|
||||
Move your pointer over this area
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)" for="window">
|
||||
Window
|
||||
</label>
|
||||
<input
|
||||
id="window"
|
||||
v-model.number="delay"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1000"
|
||||
step="50"
|
||||
class="flex-1 accent-(--accent)"
|
||||
>
|
||||
<span class="w-16 text-right font-mono text-sm tabular-nums text-(--fg)">{{ delay }}ms</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Events</p>
|
||||
<p class="mt-1 font-mono text-2xl font-bold tabular-nums text-(--fg)">{{ moves }}</p>
|
||||
</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)">Fired</p>
|
||||
<p class="mt-1 font-mono text-2xl font-bold tabular-nums text-(--accent-text)">{{ fires }}</p>
|
||||
</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)">Ran</p>
|
||||
<p class="mt-1 font-mono text-2xl font-bold tabular-nums text-(--fg)">{{ ratio() }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 font-mono text-xs text-(--fg-muted)">
|
||||
x: {{ position.x }} · y: {{ position.y }}
|
||||
</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:border-(--border-strong) hover:bg-(--bg-inset) active:scale-[0.98] cursor-pointer"
|
||||
@click="onMove.flush()"
|
||||
>
|
||||
Flush trailing
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle)">
|
||||
Leading + trailing throttling caps the handler to once per window — drag faster and watch the fired count lag behind events.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useToNumber } from './index';
|
||||
|
||||
// Source string the user edits — kept as a string so we can demo real parsing.
|
||||
const source = ref('42.50');
|
||||
|
||||
// The composable reads its options once at setup, so each variant below is its
|
||||
// own instance fed the SAME reactive source. They all recompute as you type.
|
||||
const asFloat = useToNumber(() => source.value, { method: 'parseFloat' });
|
||||
const asInt = useToNumber(() => source.value, { method: 'parseInt', radix: 10 });
|
||||
const safe = useToNumber(() => source.value, { nanToZero: true });
|
||||
const clamped = useToNumber(() => source.value, { min: 0, max: 100, nanToZero: true });
|
||||
|
||||
const variants = [
|
||||
{ label: 'parseFloat', desc: 'default', value: asFloat },
|
||||
{ label: 'parseInt', desc: 'radix 10', value: asInt },
|
||||
{ label: 'nanToZero', desc: 'NaN → 0', value: safe },
|
||||
{ label: 'clamp 0–100', desc: 'min / max', value: clamped },
|
||||
];
|
||||
|
||||
const presets = ['42.50', '3.14159', '255.9', 'abc', '-12'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-sm flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Source value</span>
|
||||
<input
|
||||
v-model="source"
|
||||
type="text"
|
||||
placeholder="Type a number…"
|
||||
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 class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="v in presets"
|
||||
:key="v"
|
||||
type="button"
|
||||
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) transition hover:bg-(--bg-elevated) hover:border-(--border-strong) cursor-pointer"
|
||||
@click="source = v"
|
||||
>
|
||||
{{ v }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) divide-y divide-(--border)">
|
||||
<div
|
||||
v-for="variant in variants"
|
||||
:key="variant.label"
|
||||
class="flex items-center justify-between gap-3 px-4 py-2.5"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-mono text-sm text-(--fg)">{{ variant.label }}</span>
|
||||
<span class="text-xs text-(--fg-subtle)">{{ variant.desc }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="font-mono text-lg font-semibold tabular-nums"
|
||||
:class="Number.isNaN(variant.value) ? 'text-amber-600 dark:text-amber-400' : 'text-(--fg)'"
|
||||
>
|
||||
{{ Number.isNaN(variant.value) ? 'NaN' : variant.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle) leading-relaxed">
|
||||
One reactive source, four <span class="font-mono text-(--accent-text)">useToNumber</span>
|
||||
instances. Try <span class="font-mono text-(--fg-muted)">abc</span> to see how
|
||||
<span class="font-mono text-(--fg-muted)">nanToZero</span> and clamping tame invalid input.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useToString } from './index';
|
||||
|
||||
// A handful of representative source types — useToString coerces each with String().
|
||||
const count = ref(7);
|
||||
const ratio = ref(0.5);
|
||||
const enabled = ref(true);
|
||||
const tags = ref(['vue', 'reactivity', 'docs']);
|
||||
const meta = computed(() => ({ id: count.value, ok: enabled.value }));
|
||||
|
||||
// useToString returns a ComputedRef<string> — bind directly, never destructure.
|
||||
const countStr = useToString(count);
|
||||
const ratioStr = useToString(() => ratio.value.toFixed(2));
|
||||
const boolStr = useToString(enabled);
|
||||
const tagsStr = useToString(tags);
|
||||
const metaStr = useToString(meta);
|
||||
|
||||
const rows = [
|
||||
{ type: 'number', value: countStr },
|
||||
{ type: 'getter', value: ratioStr },
|
||||
{ type: 'boolean', value: boolStr },
|
||||
{ type: 'array', value: tagsStr },
|
||||
{ type: 'object', value: metaStr },
|
||||
];
|
||||
</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-3">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Live source values</span>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label class="text-sm text-(--fg)">count</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 w-7 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="count--"
|
||||
>−</button>
|
||||
<span class="font-mono text-sm tabular-nums text-(--fg) w-6 text-center">{{ count }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 w-7 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="count++"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label class="text-sm text-(--fg)" for="ratio">ratio</label>
|
||||
<input id="ratio" v-model.number="ratio" type="range" min="0" max="1" step="0.01" class="accent-(--accent) cursor-pointer">
|
||||
</div>
|
||||
|
||||
<label class="flex items-center justify-between gap-3 cursor-pointer">
|
||||
<span class="text-sm text-(--fg)">enabled</span>
|
||||
<input v-model="enabled" type="checkbox" class="size-4 accent-(--accent) cursor-pointer">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) divide-y divide-(--border) font-mono text-sm">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.type"
|
||||
class="flex items-center gap-3 px-3 py-2"
|
||||
>
|
||||
<span 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) shrink-0">
|
||||
{{ row.type }}
|
||||
</span>
|
||||
<span class="text-(--fg) truncate">"{{ row.value }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle) leading-relaxed">
|
||||
<span class="font-mono text-(--accent-text)">useToString</span> is
|
||||
<span class="font-mono text-(--fg-muted)">computed(() => String(toValue(v)))</span> —
|
||||
it stringifies refs, getters, and reactive objects alike.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user