UNPKG

mode-watcher

Version:

SSR-friendly light and dark mode for SvelteKit

236 lines (235 loc) 7.84 kB
import { derived, get, writable } from "svelte/store"; import { withoutTransition } from "./without-transition.js"; import { sanitizeClassNames } from "./utils.js"; // saves having to branch for server vs client const noopStorage = { getItem: (_key) => null, setItem: (_key, _value) => { }, }; // whether we are running on server vs client const isBrowser = typeof document !== "undefined"; /** the modes that are supported, used for validation & type derivation */ export const modes = ["dark", "light", "system"]; /** * The key used to store the `mode` in localStorage. */ export const modeStorageKey = writable("mode-watcher-mode"); /** * The key used to store the `theme` in localStorage. */ export const themeStorageKey = writable("mode-watcher-theme"); /** * Writable store that represents the user's preferred mode (`"dark"`, `"light"` or `"system"`) */ export const userPrefersMode = createUserPrefersMode(); /** * Readable store that represents the system's preferred mode (`"dark"`, `"light"` or `undefined`) */ export const systemPrefersMode = createSystemMode(); /** * Theme colors for light and dark modes. */ export const themeColors = writable(undefined); /** * A custom theme to apply and persist to the root `html` element. */ export const theme = createCustomTheme(); /** * Whether to disable transitions when changing the mode. */ export const disableTransitions = writable(true); /** * The classnames to add to the root `html` element when the mode is dark. */ export const darkClassNames = writable([]); /** * The classnames to add to the root `html` element when the mode is light. */ export const lightClassNames = writable([]); /** * Derived store that represents the current mode (`"dark"`, `"light"` or `undefined`) */ export const derivedMode = createDerivedMode(); /** * Derived store that represents the current custom theme */ export const derivedTheme = createDerivedTheme(); // derived from: https://github.com/CaptainCodeman/svelte-web-storage function createUserPrefersMode() { const defaultValue = "system"; const storage = isBrowser ? localStorage : noopStorage; const initialValue = storage.getItem(getModeStorageKey()); let value = isValidMode(initialValue) ? initialValue : defaultValue; function getModeStorageKey() { return get(modeStorageKey); } const { subscribe, set: _set } = writable(value, () => { if (!isBrowser) return; const handler = (e) => { if (e.key !== getModeStorageKey()) return; const newValue = e.newValue; if (isValidMode(newValue)) { _set((value = newValue)); } else { _set((value = defaultValue)); } }; addEventListener("storage", handler); return () => removeEventListener("storage", handler); }); function set(v) { _set((value = v)); storage.setItem(getModeStorageKey(), value); } return { subscribe, set, }; } function createCustomTheme() { const storage = isBrowser ? localStorage : noopStorage; const initialValue = storage.getItem(getThemeStorageKey()); let value = initialValue === null || initialValue === undefined ? "" : initialValue; function getThemeStorageKey() { return get(themeStorageKey); } const { subscribe, set: _set } = writable(value, () => { if (!isBrowser) return; const handler = (e) => { if (e.key !== getThemeStorageKey()) return; const newValue = e.newValue; if (newValue === null) { _set((value = "")); } else { _set((value = newValue)); } }; addEventListener("storage", handler); return () => removeEventListener("storage", handler); }); function set(v) { _set((value = v)); storage.setItem(getThemeStorageKey(), value); } return { subscribe, set, }; } function createSystemMode() { const defaultValue = undefined; let track = true; const { subscribe, set } = writable(defaultValue, () => { if (!isBrowser) return; const handler = (e) => { if (!track) return; set(e.matches ? "light" : "dark"); }; const mediaQueryState = window.matchMedia("(prefers-color-scheme: light)"); mediaQueryState.addEventListener("change", handler); return () => mediaQueryState.removeEventListener("change", handler); }); /** * Query system preferences and update the store. */ function query() { if (!isBrowser) return; const mediaQueryState = window.matchMedia("(prefers-color-scheme: light)"); set(mediaQueryState.matches ? "light" : "dark"); } /** * Enable or disable tracking of system preference changes. */ function tracking(active) { track = active; } return { subscribe, query, tracking, }; } function createDerivedMode() { const { subscribe } = derived([ userPrefersMode, systemPrefersMode, themeColors, disableTransitions, darkClassNames, lightClassNames, ], ([$userPrefersMode, $systemPrefersMode, $themeColors, $disableTransitions, $darkClassNames, $lightClassNames,]) => { if (!isBrowser) return undefined; const derivedMode = $userPrefersMode === "system" ? $systemPrefersMode : $userPrefersMode; const sanitizedDarkClassNames = sanitizeClassNames($darkClassNames); const sanitizedLightClassNames = sanitizeClassNames($lightClassNames); function update() { const htmlEl = document.documentElement; const themeColorEl = document.querySelector('meta[name="theme-color"]'); if (derivedMode === "light") { if (sanitizedDarkClassNames.length) htmlEl.classList.remove(...sanitizedDarkClassNames); if (sanitizedLightClassNames.length) htmlEl.classList.add(...sanitizedLightClassNames); htmlEl.style.colorScheme = "light"; if (themeColorEl && $themeColors) { themeColorEl.setAttribute("content", $themeColors.light); } } else { if (sanitizedLightClassNames.length) htmlEl.classList.remove(...sanitizedLightClassNames); if (sanitizedDarkClassNames.length) htmlEl.classList.add(...sanitizedDarkClassNames); htmlEl.style.colorScheme = "dark"; if (themeColorEl && $themeColors) { themeColorEl.setAttribute("content", $themeColors.dark); } } } if ($disableTransitions) { withoutTransition(update); } else { update(); } return derivedMode; }); return { subscribe, }; } function createDerivedTheme() { const { subscribe } = derived([theme, disableTransitions], ([$theme, $disableTransitions]) => { if (!isBrowser) return undefined; function update() { const htmlEl = document.documentElement; htmlEl.setAttribute("data-theme", $theme); } if ($disableTransitions) { withoutTransition(update); } else { update(); } return $theme; }); return { subscribe, }; } export function isValidMode(value) { if (typeof value !== "string") return false; return modes.includes(value); }