From dfbd23044dbbc41862472f5651a0b76bdffad315 Mon Sep 17 00:00:00 2001 From: abdel-17 Date: Sat, 18 Jan 2025 18:19:03 +0200 Subject: [PATCH] implement ModeWatcher class --- packages/mode-watcher/package.json | 2 +- .../mode-watcher/src/lib/internal/extract.ts | 10 + packages/mode-watcher/src/lib/internal/is.ts | 3 + .../src/lib/internal/local-storage.svelte.ts | 43 ++++ .../src/lib/internal/without-transitions.ts | 18 ++ packages/mode-watcher/src/lib/mode.svelte.ts | 187 ++++++++++++++++++ .../src/lib/without-transition.ts | 51 ----- 7 files changed, 262 insertions(+), 52 deletions(-) create mode 100644 packages/mode-watcher/src/lib/internal/extract.ts create mode 100644 packages/mode-watcher/src/lib/internal/is.ts create mode 100644 packages/mode-watcher/src/lib/internal/local-storage.svelte.ts create mode 100644 packages/mode-watcher/src/lib/internal/without-transitions.ts create mode 100644 packages/mode-watcher/src/lib/mode.svelte.ts delete mode 100644 packages/mode-watcher/src/lib/without-transition.ts diff --git a/packages/mode-watcher/package.json b/packages/mode-watcher/package.json index ea15f02..4587e3e 100644 --- a/packages/mode-watcher/package.json +++ b/packages/mode-watcher/package.json @@ -31,7 +31,7 @@ "!dist/**/*.spec.*" ], "peerDependencies": { - "svelte": "^5.0.0" + "svelte": "^5.7.0" }, "devDependencies": { "@sveltejs/adapter-auto": "^4.0.0", diff --git a/packages/mode-watcher/src/lib/internal/extract.ts b/packages/mode-watcher/src/lib/internal/extract.ts new file mode 100644 index 0000000..55c8143 --- /dev/null +++ b/packages/mode-watcher/src/lib/internal/extract.ts @@ -0,0 +1,10 @@ +import { isFunction } from "./is.js"; + +export type MaybeGetter = T | (() => T); + +export function extract(value: MaybeGetter): T; +export function extract(value: MaybeGetter, defaultValue: T): T; +export function extract(value: MaybeGetter, defaultValue?: T) { + const extracted = isFunction(value) ? value() : value; + return extracted === undefined ? defaultValue : extracted; +} diff --git a/packages/mode-watcher/src/lib/internal/is.ts b/packages/mode-watcher/src/lib/internal/is.ts new file mode 100644 index 0000000..6a0f7c0 --- /dev/null +++ b/packages/mode-watcher/src/lib/internal/is.ts @@ -0,0 +1,3 @@ +export function isFunction(value: unknown): value is () => unknown { + return typeof value === "function"; +} diff --git a/packages/mode-watcher/src/lib/internal/local-storage.svelte.ts b/packages/mode-watcher/src/lib/internal/local-storage.svelte.ts new file mode 100644 index 0000000..48b5f7c --- /dev/null +++ b/packages/mode-watcher/src/lib/internal/local-storage.svelte.ts @@ -0,0 +1,43 @@ +import { BROWSER } from "esm-env"; +import { on } from "svelte/events"; +import { createSubscriber } from "svelte/reactivity"; + +export class ReactiveLocalStorage { + readonly key: string; + #current: string | null = $state(null); + + constructor(key: string) { + this.key = key; + + if (BROWSER) { + this.#current = localStorage.getItem(key); + } + } + + readonly #subscribe = createSubscriber(() => { + return on(window, "storage", this.#handleStorageEvent); + }); + + readonly #handleStorageEvent = (event: StorageEvent) => { + if (event.key === this.key) { + this.#current = event.newValue; + } + }; + + get current() { + this.#subscribe(); + return this.#current; + } + + set current(value) { + this.#current = value; + + if (BROWSER) { + if (value === null) { + localStorage.removeItem(this.key); + } else { + localStorage.setItem(this.key, value); + } + } + } +} diff --git a/packages/mode-watcher/src/lib/internal/without-transitions.ts b/packages/mode-watcher/src/lib/internal/without-transitions.ts new file mode 100644 index 0000000..949fdcf --- /dev/null +++ b/packages/mode-watcher/src/lib/internal/without-transitions.ts @@ -0,0 +1,18 @@ +// Original Source: https://reemus.dev/article/disable-css-transition-color-scheme-change#heading-ultimate-solution-for-changing-color-scheme-without-transitions + +/** + * Performs a task without any CSS transitions + */ +export function withoutTransitions(action: () => void) { + // Create a style element to disable transitions + const style = document.createElement("style"); + const css = document.createTextNode("* { transition: none !important; }"); + style.appendChild(css); + + document.head.appendChild(style); + action(); + + // getComputedStyle forces the browser to repaint + window.getComputedStyle(style).opacity; + document.head.removeChild(style); +} diff --git a/packages/mode-watcher/src/lib/mode.svelte.ts b/packages/mode-watcher/src/lib/mode.svelte.ts new file mode 100644 index 0000000..a7216d3 --- /dev/null +++ b/packages/mode-watcher/src/lib/mode.svelte.ts @@ -0,0 +1,187 @@ +import { BROWSER } from "esm-env"; +import { MediaQuery } from "svelte/reactivity"; +import { extract, type MaybeGetter } from "./internal/extract.js"; +import { ReactiveLocalStorage } from "./internal/local-storage.svelte.js"; +import { withoutTransitions } from "./internal/without-transitions.js"; + +export type ThemeColors = { + light: string; + dark: string; +}; + +export type Mode = "light" | "dark" | "system"; + +export type DerivedMode = "light" | "dark"; + +export type ModeWatcherProps = { + track?: MaybeGetter; + + defaultMode?: MaybeGetter; + + defaultTheme?: MaybeGetter; + + themeColors?: MaybeGetter; + + disableTransitions?: MaybeGetter; + + darkClassNames?: MaybeGetter; + + lightClassNames?: MaybeGetter; + + modeStorageKey?: MaybeGetter; + + themeStorageKey?: MaybeGetter; +}; + +export const prefersDarkColorScheme = new MediaQuery("prefers-color-scheme: dark"); + +export class ModeWatcher { + readonly #props: ModeWatcherProps; + + constructor(props: ModeWatcherProps = {}) { + this.#props = props; + } + + readonly track: boolean = $derived.by(() => extract(this.#props.track, false)); + + readonly defaultMode: Mode = $derived.by(() => extract(this.#props.defaultMode, "system")); + + readonly defaultTheme: string | null = $derived.by(() => extract(this.#props.defaultTheme, null)); + + readonly themeColors: ThemeColors | null = $derived.by(() => + extract(this.#props.themeColors, null) + ); + + readonly disableTransitions: boolean = $derived.by(() => + extract(this.#props.disableTransitions, false) + ); + + readonly darkClassNames: string[] = $derived.by(() => extract(this.#props.darkClassNames, [])); + + readonly lightClassNames: string[] = $derived.by(() => extract(this.#props.lightClassNames, [])); + + readonly modeStorageKey: string = $derived.by(() => + extract(this.#props.modeStorageKey, "mode-watcher-mode") + ); + + readonly themeStorageKey: string = $derived.by(() => + extract(this.#props.modeStorageKey, "mode-watcher-theme") + ); + + readonly #modeStorage = $derived.by(() => new ReactiveLocalStorage(this.modeStorageKey)); + + readonly #userPrefersMode = $derived.by(() => + validateMode(this.#modeStorage.current, this.defaultMode) + ); + + get userPrefersMode(): Mode { + return this.#userPrefersMode; + } + + set userPrefersMode(value: Mode) { + this.#modeStorage.current = value; + } + + readonly mode?: DerivedMode = $derived.by(() => { + if (!BROWSER) { + return; + } + + let mode: DerivedMode; + if (this.#userPrefersMode === "system") { + mode = prefersDarkColorScheme.current ? "dark" : "light"; + } else { + mode = this.#userPrefersMode; + } + + if (this.disableTransitions) { + withoutTransitions(() => updateDocumentMode(mode, this)); + } else { + updateDocumentMode(mode, this); + } + + return mode; + }); + + readonly #themeStorage = $derived.by(() => new ReactiveLocalStorage(this.themeStorageKey)); + + readonly #theme = $derived.by(() => { + const theme = this.#themeStorage.current ?? this.defaultTheme; + if (BROWSER) { + if (this.disableTransitions) { + withoutTransitions(() => updateDocumentTheme(theme)); + } else { + updateDocumentTheme(theme); + } + } + return theme; + }); + + get theme(): string | null { + return this.#theme; + } + + set theme(value: string | null) { + this.#themeStorage.current = value; + } +} + +function validateMode(value: string | null, defaultValue: Mode): Mode { + if (value === "light" || value === "dark" || value === "system") { + return value; + } + return defaultValue; +} + +function updateDocumentMode(mode: DerivedMode, watcher: ModeWatcher) { + const htmlEl = document.documentElement; + const themeColorEl = document.querySelector('meta[name="theme-color"]'); + + const themeColors = watcher.themeColors; + const darkClassNames = sanitizeClassNames(watcher.darkClassNames); + const lightClassNames = sanitizeClassNames(watcher.lightClassNames); + + switch (mode) { + case "light": { + htmlEl.classList.add(...lightClassNames); + htmlEl.classList.remove(...darkClassNames); + htmlEl.style.colorScheme = "light"; + + if (themeColors === null) { + themeColorEl?.removeAttribute("content"); + } else { + themeColorEl?.setAttribute("content", themeColors.light); + } + break; + } + case "dark": { + htmlEl.classList.add(...darkClassNames); + htmlEl.classList.remove(...lightClassNames); + htmlEl.style.colorScheme = "dark"; + + if (themeColors === null) { + themeColorEl?.removeAttribute("content"); + } else { + themeColorEl?.setAttribute("content", themeColors.dark); + } + break; + } + default: { + // Exhaustive check + const _: never = mode; + } + } +} + +function sanitizeClassNames(classNames: string[]): string[] { + return classNames.filter((className) => className.length > 0); +} + +function updateDocumentTheme(theme: string | null) { + const htmlEl = document.documentElement; + if (theme === null) { + htmlEl.removeAttribute("data-theme"); + } else { + htmlEl.setAttribute("data-theme", theme); + } +} diff --git a/packages/mode-watcher/src/lib/without-transition.ts b/packages/mode-watcher/src/lib/without-transition.ts deleted file mode 100644 index ba44dc3..0000000 --- a/packages/mode-watcher/src/lib/without-transition.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Original Source: https://reemus.dev/article/disable-css-transition-color-scheme-change#heading-ultimate-solution-for-changing-color-scheme-without-transitions - -let timeoutAction: number; -let timeoutEnable: number; - -// Perform a task without any css transitions -export function withoutTransition(action: () => void) { - if (typeof document === "undefined") return; - // Clear fallback timeouts - clearTimeout(timeoutAction); - clearTimeout(timeoutEnable); - - // Create style element to disable transitions - const style = document.createElement("style"); - const css = document.createTextNode(`* { - -webkit-transition: none !important; - -moz-transition: none !important; - -o-transition: none !important; - -ms-transition: none !important; - transition: none !important; - }`); - style.appendChild(css); - - // Functions to insert and remove style element - const disable = () => document.head.appendChild(style); - const enable = () => document.head.removeChild(style); - - // Best method, getComputedStyle forces browser to repaint - if (typeof window.getComputedStyle !== "undefined") { - disable(); - action(); - window.getComputedStyle(style).opacity; - enable(); - return; - } - - // Better method, requestAnimationFrame processes function before next repaint - if (typeof window.requestAnimationFrame !== "undefined") { - disable(); - action(); - window.requestAnimationFrame(enable); - return; - } - - // Fallback - disable(); - timeoutAction = window.setTimeout(() => { - action(); - timeoutEnable = window.setTimeout(enable, 120); - }, 120); -}