Skip to content

Commit

Permalink
implement ModeWatcher class
Browse files Browse the repository at this point in the history
  • Loading branch information
abdel-17 committed Jan 18, 2025
1 parent e9bbf40 commit dfbd230
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 52 deletions.
2 changes: 1 addition & 1 deletion packages/mode-watcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"!dist/**/*.spec.*"
],
"peerDependencies": {
"svelte": "^5.0.0"
"svelte": "^5.7.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
Expand Down
10 changes: 10 additions & 0 deletions packages/mode-watcher/src/lib/internal/extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { isFunction } from "./is.js";

export type MaybeGetter<T> = T | (() => T);

export function extract<T>(value: MaybeGetter<T>): T;
export function extract<T>(value: MaybeGetter<T | undefined>, defaultValue: T): T;
export function extract<T>(value: MaybeGetter<T>, defaultValue?: T) {
const extracted = isFunction(value) ? value() : value;
return extracted === undefined ? defaultValue : extracted;
}
3 changes: 3 additions & 0 deletions packages/mode-watcher/src/lib/internal/is.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isFunction(value: unknown): value is () => unknown {
return typeof value === "function";
}
43 changes: 43 additions & 0 deletions packages/mode-watcher/src/lib/internal/local-storage.svelte.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
18 changes: 18 additions & 0 deletions packages/mode-watcher/src/lib/internal/without-transitions.ts
Original file line number Diff line number Diff line change
@@ -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);
}
187 changes: 187 additions & 0 deletions packages/mode-watcher/src/lib/mode.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined>;

defaultMode?: MaybeGetter<Mode | undefined>;

defaultTheme?: MaybeGetter<string | null | undefined>;

themeColors?: MaybeGetter<ThemeColors | null | undefined>;

disableTransitions?: MaybeGetter<boolean | undefined>;

darkClassNames?: MaybeGetter<string[] | undefined>;

lightClassNames?: MaybeGetter<string[] | undefined>;

modeStorageKey?: MaybeGetter<string | undefined>;

themeStorageKey?: MaybeGetter<string | undefined>;
};

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);
}
}
51 changes: 0 additions & 51 deletions packages/mode-watcher/src/lib/without-transition.ts

This file was deleted.

0 comments on commit dfbd230

Please sign in to comment.