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,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>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-(--color-text-soft)">Web Locks API:</span>
|
||||
<span
|
||||
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' }}
|
||||
<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 Locks election
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-(--color-text-soft)">Leader status:</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
|
||||
: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="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="w-2 h-2 rounded-full"
|
||||
:class="isLeader ? 'bg-brand-500 animate-pulse' : 'bg-(--color-text-mute)'"
|
||||
class="size-1.5 rounded-full"
|
||||
:class="isSupported ? 'bg-emerald-500' : 'bg-(--fg-subtle)'"
|
||||
/>
|
||||
{{ isLeader ? 'Leader' : 'Follower' }}
|
||||
{{ isSupported ? 'Supported' : 'Unsupported' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--color-text-mute)">
|
||||
Open this page in multiple tabs — only one will be the leader.
|
||||
Close the leader tab and another will take over automatically.
|
||||
</p>
|
||||
<!-- Primary leader state -->
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 rounded-xl border p-6 transition-colors"
|
||||
: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
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Acquire
|
||||
</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"
|
||||
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"
|
||||
>
|
||||
Release
|
||||
</button>
|
||||
</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>
|
||||
</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>
|
||||
Reference in New Issue
Block a user