Skip to content

Commit

Permalink
Theme store (#174)
Browse files Browse the repository at this point in the history
* Move theme management to a store that lives in the context

* changeset

* Use cross-framework-compatible browser value

* handle post-load changes in prefers-color-scheme

* better browser stub-out
  • Loading branch information
dimfeld authored and techniq committed Feb 6, 2024
1 parent b507d2a commit 21233d6
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 59 deletions.
5 changes: 5 additions & 0 deletions .changeset/modern-pumpkins-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte-ux": patch
---

Add a store to manage the current theme
2 changes: 1 addition & 1 deletion packages/svelte-ux/src/lib/components/MenuItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
setButtonGroup(undefined);
// Clear theme to not expose to Button
settings({ ...getSettings(), theme: {} });
settings({ ...getSettings(), classes: {} });
</script>

<Button
Expand Down
71 changes: 20 additions & 51 deletions packages/svelte-ux/src/lib/components/ThemeButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,58 +12,30 @@
import Tooltip from './Tooltip.svelte';
import { cls } from '../utils/styles';
import { getSettings } from './settings';
export let darkThemes = ['dark'];
export let lightThemes = ['light'];
const { currentTheme, themes: allThemes } = getSettings();
let open = false;
$: themes = colorScheme === 'dark' ? darkThemes : lightThemes;
let theme: (typeof themes)[number] | null = localStorage.theme ?? 'system';
/** The list of dark themes to chose from, if not the list provided to `settings`. */
export let darkThemes = allThemes?.dark ?? ['dark'];
/** The list of light themes to chose from, if not the list provided to `settings`. */
export let lightThemes = allThemes?.light ?? ['light'];
let colorScheme: 'light' | 'dark' =
(theme !== 'system' && darkThemes.includes(theme)) ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
? 'dark'
: 'light';
// TODO: Call inline in `head` to avoid FOUC. Move to <Theme>?
function setTheme(themeName: typeof theme) {
if (themeName === 'system') {
// Remove setting
localStorage.removeItem('theme');
delete document.documentElement.dataset.theme;
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
} else {
// Save theme to local storage, set `<html data-theme="">`, and set `<html class="dark">` if dark mode
localStorage.theme = themeName;
document.documentElement.dataset.theme = theme;
let open = false;
if (darkThemes.includes(themeName)) {
colorScheme = 'dark';
document.documentElement.classList.add('dark');
} else {
colorScheme = 'light';
document.documentElement.classList.remove('dark');
}
}
}
$: setTheme(theme);
$: themes = $currentTheme.dark ? darkThemes : lightThemes;
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.code === 'KeyT') {
if (e.shiftKey) {
// Pick next theme
const currentIndex = themes.indexOf(theme);
theme = themes[(currentIndex + 1) % themes.length];
const currentIndex = themes.indexOf($currentTheme.resolvedTheme);
let newTheme = themes[(currentIndex + 1) % themes.length];
currentTheme.setTheme(newTheme);
} else {
// Toggle light/dark
colorScheme = colorScheme === 'light' ? 'dark' : 'light';
theme = colorScheme;
let newTheme = $currentTheme.dark ? 'light' : 'dark';
currentTheme.setTheme(newTheme);
}
}
}
Expand All @@ -77,7 +49,7 @@
>
Mode

{#if theme !== 'system'}
{#if $currentTheme.theme}
<span transition:fly={{ x: 8 }}>
<Tooltip title="Reset to System" offset={2}>
<Button
Expand All @@ -86,10 +58,7 @@
size="sm"
class="mr-1"
on:click={() => {
colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
theme = 'system';
currentTheme.setTheme('system');
}}
/>
</Tooltip>
Expand All @@ -98,10 +67,10 @@

<Switch
id="switch-color-scheme"
checked={colorScheme === 'light'}
checked={!$currentTheme.dark}
on:change={(e) => {
colorScheme = e.target?.checked ? 'light' : 'dark';
theme = colorScheme;
let newTheme = e.target?.checked ? 'light' : 'dark';
currentTheme.setTheme(newTheme);
}}
class="my-1"
let:checked
Expand All @@ -117,11 +86,11 @@
<div class="grid grid-cols-2 gap-2 p-2 border-b border-surface-content/10">
{#each themes as themeName}
<MenuItem
on:click={() => (theme = themeName)}
on:click={() => currentTheme.setTheme(themeName)}
data-theme={themeName}
class={cls(
'bg-surface-100 text-surface-content font-semibold border shadow',
theme === themeName && 'ring-2 ring-surface-content'
$currentTheme.resolvedTheme === themeName && 'ring-2 ring-surface-content'
)}
>
<div class="grid gap-1">
Expand Down
37 changes: 32 additions & 5 deletions packages/svelte-ux/src/lib/components/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import {
DateToken,
} from '$lib/utils/date';
import type { DictionaryMessages, DictionaryMessagesOptions } from '$lib/utils/dictionary';
import { createThemeStore, type ThemeStore } from '$lib/stores/themeStore';

type ExcludeNone<T> = T extends 'none' ? never : T;
export type Settings = {
export type SettingsInput = {
formats?: {
numbers?: Prettify<
{
Expand All @@ -26,20 +27,46 @@ export type Settings = {
};
dictionary?: DictionaryMessagesOptions;
classes?: ComponentClasses;
/** A list of the available themes */
themes?: {
light?: string[];
dark?: string[];
};
currentTheme?: ThemeStore;
};

export type Settings = SettingsInput & { currentTheme: ThemeStore };

const settingsKey = Symbol();

export function settings(settings: Settings) {
setContext(settingsKey, settings);
export function settings(settings: SettingsInput) {
let lightThemes = settings.themes?.light ?? ['light'];
let darkThemes = settings.themes?.dark ?? ['dark'];

let currentTheme =
// In some cases, `settings` is called again from inside a component. Don't create a new theme store in this case.
settings.currentTheme ??
createThemeStore({
light: lightThemes,
dark: darkThemes,
});

setContext(settingsKey, {
...settings,
themes: {
light: lightThemes,
dark: darkThemes,
},
currentTheme,
});
}

export function getSettings() {
export function getSettings(): Settings {
// in a try/catch to be able to test wo svelte components
try {
return getContext<Settings>(settingsKey) ?? {};
} catch (error) {
return {};
return { currentTheme: createThemeStore({ light: ['light'], dark: ['dark'] }) };
}
}

Expand Down
93 changes: 93 additions & 0 deletions packages/svelte-ux/src/lib/stores/themeStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { writable, type Readable } from 'svelte/store';
import { browser } from '../utils/env';

/** Information about the currently chosen theme. */
export class CurrentTheme {
/** The currently selected theme. If using the "system" theme this will be null. */
theme: string | null;
/** Whether the current theme is a light or dark theme */
dark: boolean;

constructor(theme: string | null, dark: boolean) {
this.theme = theme;
this.dark = dark;
}

/** The theme in use, either the selected theme or the theme chosen based on the "system" setting. */
get resolvedTheme() {
if (this.theme) {
return this.theme;
} else {
return this.dark ? 'dark' : 'light';
}
}
}

export interface ThemeStore extends Readable<CurrentTheme> {
setTheme: (themeName: string) => void;
}

export interface ThemeStoreOptions {
light: string[];
dark: string[];
}

export function createThemeStore(options: ThemeStoreOptions): ThemeStore {
let store = writable<CurrentTheme>(new CurrentTheme(null, false));

if (!browser) {
// Stub out most of the store when running SSR.
return {
subscribe: store.subscribe,
setTheme: (themeName: string) => {
store.set(new CurrentTheme(themeName, options.dark.includes(themeName)));
},
};
}

let darkMatcher = window.matchMedia('(prefers-color-scheme: dark)');

function resolveSystemTheme({ matches }: { matches: boolean }) {
if (matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}

store.set(new CurrentTheme(null, matches));
}

function setTheme(themeName: string) {
if (themeName === 'system') {
// Remove setting
localStorage.removeItem('theme');
delete document.documentElement.dataset.theme;

resolveSystemTheme(darkMatcher);
darkMatcher.addEventListener('change', resolveSystemTheme);
} else {
darkMatcher.removeEventListener('change', resolveSystemTheme);

// Save theme to local storage, set `<html data-theme="">`, and set `<html class="dark">` if dark mode
localStorage.theme = themeName;
document.documentElement.dataset.theme = themeName;

let dark = options.dark.includes(themeName);
if (dark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}

store.set(new CurrentTheme(themeName, dark));
}
}

let savedTheme = localStorage.getItem('theme') || 'system';
setTheme(savedTheme);

return {
subscribe: store.subscribe,
setTheme,
};
}
6 changes: 5 additions & 1 deletion packages/svelte-ux/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@
active: 'text-primary bg-surface-100 border-l-4 border-primary font-medium',
},
},
themes: {
light: lightThemes,
dark: darkThemes,
},
});
let mainEl: HTMLElement;
Expand Down Expand Up @@ -186,7 +190,7 @@
<QuickSearch options={quickSearchOptions} on:change={(e) => goto(e.detail.value)} />

<div class="border-r border-primary-content/20 pr-2">
<ThemeButton {lightThemes} {darkThemes} />
<ThemeButton />
</div>

<Tooltip title="Discord" placement="left" offset={2}>
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte-ux/src/routes/customization/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ On each `ComponentName: ...` you can pass `class` (when value is a `string`) or
import { settings } from 'svelte-ux';

settings({
theme: {
classes: {
Button: 'flex-2', // same as <Button class="flex-2">
TextField: {
container: 'hover:shadow-none group-focus-within:shadow-none', // same as <TextField classes={{ container: '...' }}>
Expand Down

0 comments on commit 21233d6

Please sign in to comment.