diff --git a/docs/src/content/utilities/clear-session-mode.md b/docs/src/content/utilities/clear-session-mode.md new file mode 100644 index 0000000..9e73e0b --- /dev/null +++ b/docs/src/content/utilities/clear-session-mode.md @@ -0,0 +1,24 @@ +--- +title: clearTemporaryMode +description: Clears the temporary override and reverts to the persisted or system mode. +section: Utilities +--- + +`clearTemporaryMode` removes any temporary mode set via `setMode(..., { temporaryOnly: true })`. + +After calling this, the active mode falls back to the user's persisted preference (from `localStorage`) or to the system preference if none is set. + +## Usage + +```svelte + + + +``` + +## See also + +- [setMode](/docs/utilities/set-mode) +- [resetMode](/docs/utilities/reset-mode) diff --git a/docs/src/content/utilities/set-mode.md b/docs/src/content/utilities/set-mode.md index c044270..864b1e8 100644 --- a/docs/src/content/utilities/set-mode.md +++ b/docs/src/content/utilities/set-mode.md @@ -4,11 +4,15 @@ description: Sets the current mode to "light", "dark", or "system". section: Utilities --- + + `setMode` is a function that updates the user's preferred mode. It accepts one of three string values: `"light"`, `"dark"`, or `"system"`. -This updates both the visual mode and the persisted preference in `localStorage`. +This updates both the visual mode and the persisted preference in `localStorage`. You can also pass it options to customize the behavior, such as applying it temporarily without persisting it to `localStorage`. ## Usage @@ -20,3 +24,39 @@ This updates both the visual mode and the persisted preference in `localStorage` ``` + +You can also apply the mode temporarily (without persisting to `localStorage`) by passing the `temporaryOnly` option as `true`. This can be useful if you need to set the mode to a specific value for a specific part of your website. (ex: marketing part vs docs part, where in marketing part you want to set the mode to `light` and in docs part you want the user to choose the mode). + +`(marketing) group - layout.svelte` + +```svelte + +``` + +`(docs) group - layout.svelte` + +```svelte + +``` + +Then you get the marketing with light mode as you like, and the docs part will revert to the user's local storage preference! + +## Options + +The `setMode` utility accepts the following options: + + + Whether to apply the mode only temporarily (do not persist to `localStorage`). + diff --git a/packages/mode-watcher/src/lib/index.ts b/packages/mode-watcher/src/lib/index.ts index d226336..85cb2ec 100644 --- a/packages/mode-watcher/src/lib/index.ts +++ b/packages/mode-watcher/src/lib/index.ts @@ -2,6 +2,7 @@ import { generateSetInitialModeExpression, createInitialModeExpression, resetMode, + clearTemporaryMode, setMode, setTheme, toggleMode, @@ -16,6 +17,7 @@ export { setMode, toggleMode, resetMode, + clearTemporaryMode, modeStorageKey, userPrefersMode, systemPrefersMode, @@ -24,5 +26,9 @@ export { setTheme, themeStorageKey, }; -export type { SystemModeValue, UserPrefersMode, SystemPrefersMode } from "./mode-states.svelte.js"; +export type { + SystemModeValue, + UserPrefersMode, + SystemPrefersMode, +} from "./mode-states.svelte.js"; export { default as ModeWatcher } from "./components/mode-watcher.svelte"; diff --git a/packages/mode-watcher/src/lib/mode.ts b/packages/mode-watcher/src/lib/mode.ts index 818b43f..ab7838e 100644 --- a/packages/mode-watcher/src/lib/mode.ts +++ b/packages/mode-watcher/src/lib/mode.ts @@ -1,23 +1,40 @@ import { userPrefersMode } from "./mode-states.svelte.js"; import { customTheme } from "./theme-state.svelte.js"; -import { derivedMode } from "./states.svelte.js"; +import { derivedMode, temporaryUserPrefersMode } from "./states.svelte.js"; import type { Mode, ThemeColors } from "./types.js"; /** Toggle between light and dark mode */ export function toggleMode(): void { userPrefersMode.current = derivedMode.current === "dark" ? "light" : "dark"; + temporaryUserPrefersMode.current = undefined; } +export type SetModeOptions = { + /** Apply only temporarily; do not persist to localStorage. */ + temporaryOnly?: boolean; +}; + /** Set the mode to light or dark */ -export function setMode(mode: Mode): void { - userPrefersMode.current = mode; +export function setMode(mode: Mode, options?: SetModeOptions): void { + if (options?.temporaryOnly) { + temporaryUserPrefersMode.current = mode; + } else { + temporaryUserPrefersMode.current = undefined; + userPrefersMode.current = mode; + } } /** Reset the mode to operating system preference */ export function resetMode(): void { + temporaryUserPrefersMode.current = undefined; userPrefersMode.current = "system"; } +/** Reset the current session-only override, falling back to persisted/user/system */ +export function clearTemporaryMode(): void { + temporaryUserPrefersMode.current = undefined; +} + /** Set the theme to a custom value */ export function setTheme(newTheme: string): void { customTheme.current = newTheme; @@ -52,13 +69,18 @@ export function setInitialMode({ const theme = localStorage.getItem(themeStorageKey) ?? defaultTheme; const light = mode === "light" || - (mode === "system" && window.matchMedia("(prefers-color-scheme: light)").matches); + (mode === "system" && + window.matchMedia("(prefers-color-scheme: light)").matches); if (light) { - if (darkClassNames.length) rootEl.classList.remove(...darkClassNames.filter(Boolean)); - if (lightClassNames.length) rootEl.classList.add(...lightClassNames.filter(Boolean)); + if (darkClassNames.length) + rootEl.classList.remove(...darkClassNames.filter(Boolean)); + if (lightClassNames.length) + rootEl.classList.add(...lightClassNames.filter(Boolean)); } else { - if (lightClassNames.length) rootEl.classList.remove(...lightClassNames.filter(Boolean)); - if (darkClassNames.length) rootEl.classList.add(...darkClassNames.filter(Boolean)); + if (lightClassNames.length) + rootEl.classList.remove(...lightClassNames.filter(Boolean)); + if (darkClassNames.length) + rootEl.classList.add(...darkClassNames.filter(Boolean)); } rootEl.style.colorScheme = light ? "light" : "dark"; @@ -67,7 +89,7 @@ export function setInitialMode({ if (themeMetaEl) { themeMetaEl.setAttribute( "content", - mode === "light" ? themeColors.light : themeColors.dark + mode === "light" ? themeColors.light : themeColors.dark, ); } } @@ -83,7 +105,9 @@ export function setInitialMode({ /** * A type-safe way to generate the source expression used to set the initial mode and avoid FOUC. */ -export function createInitialModeExpression(config: SetInitialModeArgs = {}): string { +export function createInitialModeExpression( + config: SetInitialModeArgs = {}, +): string { return `(${setInitialMode.toString()})(${JSON.stringify(config)});`; } diff --git a/packages/mode-watcher/src/lib/states.svelte.ts b/packages/mode-watcher/src/lib/states.svelte.ts index 5660c4c..54ffeac 100644 --- a/packages/mode-watcher/src/lib/states.svelte.ts +++ b/packages/mode-watcher/src/lib/states.svelte.ts @@ -1,6 +1,6 @@ import { box } from "svelte-toolbelt"; import { isBrowser, sanitizeClassNames } from "./utils.js"; -import type { ThemeColors } from "./types.js"; +import type { Mode, ThemeColors } from "./types.js"; import { withoutTransition } from "./without-transition.js"; import { systemPrefersMode, userPrefersMode } from "./mode-states.svelte.js"; import { customTheme } from "./theme-state.svelte.js"; @@ -33,13 +33,21 @@ export const darkClassNames = box([]); */ export const lightClassNames = box([]); +/** + * A non-persistent override for the user's preferred mode. + * When set, this value takes precedence over the persisted `userPrefersMode`. + */ +export const temporaryUserPrefersMode = box(undefined); + function createDerivedMode() { const current = $derived.by(() => { if (!isBrowser) return undefined; - const derivedMode = - userPrefersMode.current === "system" - ? systemPrefersMode.current + const preferredMode = + temporaryUserPrefersMode.current !== undefined + ? temporaryUserPrefersMode.current : userPrefersMode.current; + const derivedMode = + preferredMode === "system" ? systemPrefersMode.current : preferredMode; const sanitizedDarkClassNames = sanitizeClassNames(darkClassNames.current); const sanitizedLightClassNames = sanitizeClassNames(lightClassNames.current); diff --git a/packages/mode-watcher/src/routes/+page.svelte b/packages/mode-watcher/src/routes/+page.svelte index 593722c..6f378c9 100644 --- a/packages/mode-watcher/src/routes/+page.svelte +++ b/packages/mode-watcher/src/routes/+page.svelte @@ -2,6 +2,7 @@ import { mode, resetMode, + clearTemporaryMode, setMode, systemPrefersMode, theme, @@ -73,4 +74,16 @@ > Reset + + diff --git a/packages/mode-watcher/src/tests/mode.spec.ts b/packages/mode-watcher/src/tests/mode.spec.ts index 2fbdc0c..8ab1f41 100644 --- a/packages/mode-watcher/src/tests/mode.spec.ts +++ b/packages/mode-watcher/src/tests/mode.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { userEvent } from "@testing-library/user-event"; import { tick } from "svelte"; import { mediaQueryState } from "../../scripts/setupTest.js"; +import { setMode, clearTemporaryMode, modeStorageKey } from "$lib/index.js"; import Mode from "./Mode.svelte"; import StealthMode from "./StealthMode.svelte"; import type { ModeWatcherProps } from "$lib/types.js"; @@ -353,6 +354,36 @@ describe("mode-watcher", () => { expect(classes2).toContain("custom-l-class"); }); + it("applies mode temporarily without persisting to localStorage", async () => { + const { rootEl } = setup(); + expect(getClasses(rootEl)).toContain("dark"); + expect(localStorage.getItem(modeStorageKey.current)).toBe("system"); + + setMode("light", { temporaryOnly: true }); + await tick(); + + expect(getClasses(rootEl)).not.toContain("dark"); + expect(getColorScheme(rootEl)).toBe("light"); + // localStorage unchanged + expect(localStorage.getItem(modeStorageKey.current)).toBe("system"); + }); + + it("clears temporary override and reverts to persisted value", async () => { + const { rootEl } = setup(); + // start with temporary-only light + setMode("light", { temporaryOnly: true }); + await tick(); + expect(getColorScheme(rootEl)).toBe("light"); + // clear override + clearTemporaryMode(); + await tick(); + // should revert to system -> dark + expect(getClasses(rootEl)).toContain("dark"); + expect(getColorScheme(rootEl)).toBe("dark"); + // localStorage still system + expect(localStorage.getItem(modeStorageKey.current)).toBe("system"); + }); + it("allows the user to set a custom theme via the `defaultTheme` prop", async () => { const { theme, rootEl } = setup({ defaultTheme: "dracula",