108 lines
3.7 KiB
TypeScript
108 lines
3.7 KiB
TypeScript
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<Ref<Appearance>>;
|
|
resolvedAppearance: ComputedRef<ResolvedAppearance>;
|
|
updateAppearance: (value: Appearance) => void;
|
|
};
|
|
|
|
const appearanceKey: InjectionKey<UseAppearanceReturn> = 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<Appearance>(normalizeAppearance(initialAppearance));
|
|
const systemDark = ref(false);
|
|
const resolvedAppearance = computed<ResolvedAppearance>(() =>
|
|
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;
|
|
}
|