import type { App, ComputedRef, InjectionKey, Ref } from 'vue'; import { computed, inject, readonly, ref } from 'vue'; import type { Appearance, ResolvedAppearance } from '@/types'; export type { Appearance, ResolvedAppearance }; export type UseAppearanceReturn = { appearance: Readonly>; resolvedAppearance: ComputedRef; updateAppearance: (value: Appearance) => void; }; const appearanceKey: InjectionKey = Symbol('appearance'); export function normalizeAppearance(value: unknown): Appearance { return value === 'light' || value === 'dark' ? value : 'system'; } /** Each Vue app owns its state, including each server-rendered request. */ export function createAppearance(initialAppearance: Appearance = 'system') { const appearance = ref(normalizeAppearance(initialAppearance)); const systemDark = ref(false); const resolvedAppearance = computed(() => appearance.value === 'system' ? systemDark.value ? 'dark' : 'light' : appearance.value, ); function applyTheme(): void { document.documentElement.classList.toggle( 'dark', resolvedAppearance.value === 'dark', ); document.documentElement.style.colorScheme = resolvedAppearance.value; document.documentElement.dataset.appearance = appearance.value; } function updateAppearance(value: Appearance): void { appearance.value = normalizeAppearance(value); if (typeof window === 'undefined') { return; } applyTheme(); try { window.localStorage.setItem('appearance', appearance.value); } catch { // Private or embedded browsers may deny storage; the current theme still works. } document.cookie = `appearance=${appearance.value};path=/;max-age=31536000;SameSite=Lax`; } function handleSystemChange(event: MediaQueryListEvent): void { systemDark.value = event.matches; applyTheme(); } function handleStorageChange(event: StorageEvent): void { if (event.key === 'appearance' || event.key === null) { updateAppearance(normalizeAppearance(event.newValue)); } } return { appearance: readonly(appearance), resolvedAppearance, updateAppearance, install(app: App): void { app.provide(appearanceKey, { appearance: readonly(appearance), resolvedAppearance, updateAppearance, }); if (typeof window === 'undefined') { return; } // The blocking head script already reconciled local storage and the SSR cookie. appearance.value = normalizeAppearance( document.documentElement.dataset.appearance ?? initialAppearance, ); const media = window.matchMedia('(prefers-color-scheme: dark)'); systemDark.value = media.matches; applyTheme(); media.addEventListener('change', handleSystemChange); window.addEventListener('storage', handleStorageChange); app.onUnmount(() => { media.removeEventListener('change', handleSystemChange); window.removeEventListener('storage', handleStorageChange); }); }, }; } export function useAppearance(): UseAppearanceReturn { const appearance = inject(appearanceKey); if (!appearance) { throw new Error( 'The appearance plugin must be installed before useAppearance().', ); } return appearance; }