Skip to content

Commit

Permalink
feat: Custom Themes (#83)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Jul 14, 2024
1 parent 5c49bbc commit 9c2d9e3
Show file tree
Hide file tree
Showing 11 changed files with 249 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/tender-houses-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mode-watcher": patch
---

feat: Add support for custom themes
6 changes: 6 additions & 0 deletions packages/mode-watcher/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import {
localStorageKey,
userPrefersMode,
systemPrefersMode,
themeLocalStorageKey,
mode,
setMode,
toggleMode,
resetMode,
setTheme,
theme
} from './mode.js';

export {
Expand All @@ -16,6 +19,9 @@ export {
userPrefersMode,
systemPrefersMode,
mode,
theme,
setTheme,
themeLocalStorageKey
};

export { default as ModeWatcher } from './mode-watcher.svelte';
3 changes: 2 additions & 1 deletion packages/mode-watcher/src/lib/mode-watcher.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
export let disableTransitions = true;
export let darkClassNames: string[] = ['dark'];
export let lightClassNames: string[] = [];
export let defaultTheme: string | undefined = 'money';
export let nonce: string = '';
themeColorsStore.set(themeColors);
Expand All @@ -51,7 +52,7 @@
const args = `"${defaultMode}"${
themeColors ? `, ${JSON.stringify(themeColors)}` : ', undefined'
}, ${JSON.stringify(darkClassNames)}, ${JSON.stringify(lightClassNames)}`;
}, ${JSON.stringify(darkClassNames)}, ${JSON.stringify(lightClassNames)}, "${defaultTheme}"`;
$: trueNonce = typeof window === 'undefined' ? nonce : '';
</script>
Expand Down
24 changes: 21 additions & 3 deletions packages/mode-watcher/src/lib/mode.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { get } from 'svelte/store';
import {
localStorageKey,
modeLocalStorageKey,
userPrefersMode,
systemPrefersMode,
derivedMode,
themeColors,
theme as themeStore,
disableTransitions,
derivedTheme,
themeLocalStorageKey
} from './stores.js';
import type { Mode, ThemeColors } from './types.js';

Expand All @@ -24,15 +27,23 @@ export function resetMode(): void {
userPrefersMode.set('system');
}

/** Set the theme to a custom value */
export function setTheme(theme: string): void {
themeStore.set(theme)
}


/** Used to set the mode on initial page load to prevent FOUC */
export function setInitialMode(
defaultMode: Mode,
themeColors?: ThemeColors,
darkClassNames: string[] = ['dark'],
lightClassNames: string[] = []
lightClassNames: string[] = [],
defaultTheme: string = '',
) {
const rootEl = document.documentElement;
const mode = localStorage.getItem('mode-watcher-mode') || defaultMode;
const theme = localStorage.getItem('mode-watcher-theme') || defaultTheme
const light =
mode === 'light' ||
(mode === 'system' && window.matchMedia('(prefers-color-scheme: light)').matches);
Expand All @@ -52,11 +63,18 @@ export function setInitialMode(
}
}

if (theme) {
rootEl.setAttribute('data-theme', theme)
localStorage.setItem('mode-watcher-theme', theme)
}

localStorage.setItem('mode-watcher-mode', mode);
}

export {
localStorageKey,
modeLocalStorageKey as localStorageKey,
themeLocalStorageKey,
derivedTheme as theme,
userPrefersMode,
systemPrefersMode,
derivedMode as mode,
Expand Down
87 changes: 82 additions & 5 deletions packages/mode-watcher/src/lib/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ const noopStorage = {
// whether we are running on server vs client
const isBrowser = typeof document !== 'undefined';

// the modes that are supported, used for validation & type derivation
/** the modes that are supported, used for validation & type derivation */
export const modes = ['dark', 'light', 'system'] as const;

/**
* The key used to store the mode in localStorage.
*/
export const localStorageKey = 'mode-watcher-mode';
export const modeLocalStorageKey = 'mode-watcher-mode';
/**
* The key used to store the theme in localStorage.
*/
export const themeLocalStorageKey = 'mode-watcher-theme';
/**
* Writable store that represents the user's preferred mode (`"dark"`, `"light"` or `"system"`)
*/
Expand All @@ -34,6 +38,11 @@ export const systemPrefersMode = createSystemMode();
*/
export const themeColors = writable<ThemeColors>(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.
*/
Expand All @@ -54,19 +63,24 @@ export const lightClassNames = writable<string[]>([]);
*/
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(localStorageKey);
const initialValue = storage.getItem(modeLocalStorageKey);

let value = isValidMode(initialValue) ? initialValue : defaultValue;

const { subscribe, set: _set } = writable(value, () => {
if (!isBrowser) return;
const handler = (e: StorageEvent) => {
if (e.key !== localStorageKey) return;
if (e.key !== modeLocalStorageKey) return;
const newValue = e.newValue;
if (isValidMode(newValue)) {
_set((value = newValue));
Expand All @@ -80,7 +94,38 @@ function createUserPrefersMode() {

function set(v: Mode) {
_set((value = v));
storage.setItem(localStorageKey, value);
storage.setItem(modeLocalStorageKey, value);
}

return {
subscribe,
set,
};
}

function createCustomTheme() {
const storage = isBrowser ? localStorage : noopStorage;
const initialValue = storage.getItem(themeLocalStorageKey)
let value = initialValue === null || initialValue === undefined ? '' : initialValue

const { subscribe, set: _set } = writable(value, () => {
if (!isBrowser) return;
const handler = (e: StorageEvent) => {
if (e.key !== modeLocalStorageKey) return;
const newValue = e.newValue;
if (newValue === null) {
_set((value = ''))
} else {
_set((value = newValue))
}
};
addEventListener('storage', handler);
return () => removeEventListener('storage', handler);
});

function set(v: string) {
_set((value = v));
storage.setItem(themeLocalStorageKey, value);
}

return {
Expand Down Expand Up @@ -188,6 +233,38 @@ function createDerivedMode() {
};
}


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: unknown): value is Mode {
if (typeof value !== 'string') return false;
return modes.includes(value as Mode);
Expand Down
13 changes: 13 additions & 0 deletions packages/mode-watcher/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ export type ModeWatcherProps = {
*/
defaultMode?: Mode;

/**
* The default theme to use, which will be applied to the root `html` element
* and can be managed with the `setTheme` function.
*
* @example
* ```html
* <html data-theme="your-custom-theme"></html>
* ```
*
* @defaultValue `undefined`
*/
defaultTheme?: string;

/**
* The theme colors to use for each mode.
*/
Expand Down
35 changes: 32 additions & 3 deletions sites/docs/content/api-reference/mode-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,17 @@ To set a default mode, use the `defaultMode` prop:
<ModeWatcher defaultMode="dark" />
```

### Themes

In addition to the `dark`, `light`, and `system` modes, `ModeWatcher` can also be configured with a theme which will be applied to the root `html` element like so:

```html
<html data-theme="your-custom-theme"></html>
```

### Theme Colors

`ModeWatcher` can manage the theme-color meta tag for you.
`ModeWatcher` can manage the `theme-color` meta tag for you.

To enable this, set the `themeColors` prop to your preferred colors:

Expand Down Expand Up @@ -76,7 +84,6 @@ The `ModeWatcher` component accepts the following props:
```ts
export type Mode = "system" | "dark" | "light";
export type ThemeColors = { dark: string; light: string };

export type ModeWatcherProps = {
/**
* Whether to automatically track operating system preferences
Expand All @@ -93,6 +100,19 @@ export type ModeWatcherProps = {
*/
defaultMode?: Mode;

/**
* The default theme to use, which will be applied to the root `html` element
* and can be managed with the `setTheme` function.
*
* @example
* ```html
* <html data-theme="your-custom-theme"></html>
* ```
*
* @defaultValue `undefined`
*/
defaultTheme?: string;

/**
* The theme colors to use for each mode.
*
Expand All @@ -101,7 +121,7 @@ export type ModeWatcherProps = {
themeColors?: ThemeColors;

/**
* Whether to disable transitions when the mode changes.
* Whether to disable transitions when updating the mode.
*
* @defaultValue `true`
*/
Expand All @@ -120,5 +140,14 @@ export type ModeWatcherProps = {
* @defaultValue `[]`
*/
lightClassNames?: string[];

/**
* An optional nonce to use for the injected script tag to allow-list mode-watcher
* if you are using a Content Security Policy.
*
* @defaultValue `undefined`
*/
nonce?: string;
};

```
20 changes: 20 additions & 0 deletions sites/docs/content/api-reference/set-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: setTheme
description: Programatically set the custom theme.
tagline: API Reference
---

A function that sets the current custom theme, not to be confused with [`setMode`](/docs/api-reference/set-mode), which sets the mode (`'light'`, `'dark'` or `'system'`).

The theme can be set to any arbitrary string value, and is persisted to localStorage and applied to the root `html` element via the `data-theme` attribute.

## Usage

```svelte
<script lang="ts">
import { setTheme } from "mode-watcher";
</script>
<button on:click={() => setTheme("dracula")}>Dracula Theme</button>
<button on:click={() => setTheme("retro")}>Retro Theme</button>
```
28 changes: 28 additions & 0 deletions sites/docs/content/api-reference/theme-local-storage-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
title: themeLocalStorageKey
description: The key used to store the theme in local storage.
tagline: API Reference
---

<script>
import { Callout } from '$lib/components'
</script>

The key used to store the _theme_ in local storage.

## Usage

If you wanted to clear the history of the user's theme preference, you could use the `themeLocalStorageKey` like so:

```svelte
<script lang="ts">
import { themeLocalStorageKey } from "mode-watcher";
function clearThemeFromLocalStorage() {
localStorage.removeItem(themeLocalStorageKey);
}
</script>
<p>Clear the user's mode preference history.</p>
<button on:click={clearModeFromLocalStorage}>Clear</button>
```
Loading

0 comments on commit 9c2d9e3

Please sign in to comment.