Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/src/content/utilities/clear-session-mode.md
Original file line number Diff line number Diff line change
@@ -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
<script lang="ts">
import { clearTemporaryMode } from "mode-watcher";
</script>

<button onclick={clearTemporaryMode}>Use persisted/system preference</button>
```

## See also

- [setMode](/docs/utilities/set-mode)
- [resetMode](/docs/utilities/reset-mode)
42 changes: 41 additions & 1 deletion docs/src/content/utilities/set-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ description: Sets the current mode to "light", "dark", or "system".
section: Utilities
---

<script>
import { Callout, PropField, Collapsible } from '@svecodocs/kit'
</script>

`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

Expand All @@ -20,3 +24,39 @@ This updates both the visual mode and the persisted preference in `localStorage`
<button onclick={() => setMode("light")}>Set Light Mode</button>
<button onclick={() => setMode("dark")}>Set Dark Mode</button>
```

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
<script lang="ts">
import { setMode } from "mode-watcher";

onMount(() => {
setMode("light", { temporaryOnly: true });
});
</script>
```

`(docs) group - layout.svelte`

```svelte
<script lang="ts">
import { clearTemporaryMode } from "mode-watcher";

onMount(() => {
clearTemporaryMode();
});
</script>
```

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:

<PropField name="temporaryOnly" type="boolean" defaultValue="false">
Whether to apply the mode only temporarily (do not persist to `localStorage`).
</PropField>
8 changes: 7 additions & 1 deletion packages/mode-watcher/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
generateSetInitialModeExpression,
createInitialModeExpression,
resetMode,
clearTemporaryMode,
setMode,
setTheme,
toggleMode,
Expand All @@ -16,6 +17,7 @@ export {
setMode,
toggleMode,
resetMode,
clearTemporaryMode,
modeStorageKey,
userPrefersMode,
systemPrefersMode,
Expand All @@ -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";
44 changes: 34 additions & 10 deletions packages/mode-watcher/src/lib/mode.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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";

Expand All @@ -67,7 +89,7 @@ export function setInitialMode({
if (themeMetaEl) {
themeMetaEl.setAttribute(
"content",
mode === "light" ? themeColors.light : themeColors.dark
mode === "light" ? themeColors.light : themeColors.dark,
);
}
}
Expand All @@ -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)});`;
}

Expand Down
16 changes: 12 additions & 4 deletions packages/mode-watcher/src/lib/states.svelte.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -33,13 +33,21 @@ export const darkClassNames = box<string[]>([]);
*/
export const lightClassNames = box<string[]>([]);

/**
* A non-persistent override for the user's preferred mode.
* When set, this value takes precedence over the persisted `userPrefersMode`.
*/
export const temporaryUserPrefersMode = box<Mode | undefined>(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);

Expand Down
13 changes: 13 additions & 0 deletions packages/mode-watcher/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {
mode,
resetMode,
clearTemporaryMode,
setMode,
systemPrefersMode,
theme,
Expand Down Expand Up @@ -73,4 +74,16 @@
>
Reset
</button>
<button
class="bg-primary text-background rounded-sm px-2 py-1 transition-colors duration-500"
onclick={() => setMode("light", { temporaryOnly: true })}
>
Set Light Mode Without Persisting
</button>
<button
class="bg-primary text-background rounded-sm px-2 py-1 transition-colors duration-500"
onclick={clearTemporaryMode}
>
Clear Temporary Mode
</button>
</div>
31 changes: 31 additions & 0 deletions packages/mode-watcher/src/tests/mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
Loading