feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -0,0 +1,330 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDark } from '.';
type Listener = (event: { matches: boolean }) => void;
interface StubMql {
readonly matches: boolean;
media: string;
addEventListener: (type: string, cb: Listener) => void;
removeEventListener: (type: string, cb: Listener) => void;
dispatch: (value: boolean) => void;
}
function makeMql(initialMatches: boolean, media = ''): StubMql {
const listeners = new Set<Listener>();
let matches = initialMatches;
return {
get matches() {
return matches;
},
media,
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
dispatch(value: boolean) {
matches = value;
for (const cb of listeners) cb({ matches: value });
},
};
}
/**
* Build a stub `window` that reuses the real jsdom `document` (so DOM updates
* applied to `<html>` are observable) but with a controllable `matchMedia` for
* `prefers-color-scheme: dark`, an isolated in-memory `localStorage`, and a
* `getComputedStyle` shim for the transition-disabling reflow.
*/
function makeWindow(prefersDark: StubMql) {
const map = new Map<string, string>();
const storage: Storage = {
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
setItem: (key: string, value: string) => { map.set(key, String(value)); },
removeItem: (key: string) => { map.delete(key); },
clear: () => map.clear(),
key: (index: number) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
const win = {
document: globalThis.document,
matchMedia: vi.fn((query: string) =>
query.includes('dark') ? prefersDark : makeMql(false, query)),
localStorage: storage,
getComputedStyle: () => ({ opacity: '1' }),
dispatchEvent: () => true,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window & typeof globalThis;
return { win, storage, map };
}
function reset() {
document.documentElement.className = '';
document.documentElement.removeAttribute('data-theme');
}
describe(useDark, () => {
beforeEach(() => {
reset();
// Ensure module-captured defaultWindow.matchMedia is undefined so the
// composable must use the injected window.
vi.stubGlobal('matchMedia', undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
reset();
});
it('reflects the system preference in auto mode (dark)', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('reflects the system preference in auto mode (light)', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
// Default valueLight is '' so no light class is applied.
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('writing true while the system prefers light applies the dark class', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('writing true while the system prefers dark falls back to auto', async () => {
const prefersDark = makeMql(true);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
// Start from an explicit light value so the store is not already auto.
isDark = useDark({ window: win });
});
await nextTick();
isDark!.value = false;
await nextTick();
expect(storage.getItem('vuetools-color-scheme')).toBe('light');
// System prefers dark, so requesting dark should resolve to 'auto'.
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
scope.stop();
});
it('writing false while the system prefers dark falls back to auto', async () => {
const prefersDark = makeMql(false);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
// System prefers light, so requesting light resolves to 'auto'.
isDark!.value = false;
await nextTick();
expect(isDark!.value).toBeFalsy();
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
scope.stop();
});
it('reacts to system preference changes while in auto mode', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
prefersDark.dispatch(true);
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('honours custom valueDark / valueLight on a custom attribute', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({
window: win,
attribute: 'data-theme',
valueDark: 'night',
valueLight: 'day',
});
});
await nextTick();
expect(document.documentElement.getAttribute('data-theme')).toBe('day');
isDark!.value = true;
await nextTick();
expect(document.documentElement.getAttribute('data-theme')).toBe('night');
scope.stop();
});
it('persists to a custom storageKey', async () => {
const prefersDark = makeMql(false);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win, storageKey: 'my-dark' });
});
await nextTick();
isDark!.value = true;
await nextTick();
expect(storage.getItem('my-dark')).toBe('dark');
expect(storage.getItem('vuetools-color-scheme')).toBeNull();
scope.stop();
});
it('uses a custom storage backend', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const map = new Map<string, string>();
const storage: Storage = {
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
setItem: (key: string, value: string) => { map.set(key, String(value)); },
removeItem: (key: string) => { map.delete(key); },
clear: () => map.clear(),
key: (index: number) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win, storage });
});
await nextTick();
isDark!.value = true;
await nextTick();
expect(map.get('vuetools-color-scheme')).toBe('dark');
scope.stop();
});
it('invokes a custom onChanged handler with the boolean state', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const onChanged = vi.fn();
const scope = effectScope();
scope.run(() => {
useDark({ window: win, onChanged });
});
await nextTick();
expect(onChanged).toHaveBeenCalled();
const [boolValue, handler, mode] = onChanged.mock.calls[0]!;
expect(boolValue).toBeTruthy();
expect(typeof handler).toBe('function');
expect(mode).toBe('dark');
// Default handler suppressed: no class applied.
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('does not throw on the SSR/unsupported path (no window)', async () => {
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
expect(() => {
scope.run(() => {
isDark = useDark({ window: undefined });
});
}).not.toThrow();
await nextTick();
// System detection unavailable -> defaults to light -> isDark false.
expect(isDark!.value).toBeFalsy();
// Still writable in memory.
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
scope.stop();
});
});