Skip to content

Commit 18e261b

Browse files
dimfeldtechniq
authored andcommitted
Theme store (#174)
* 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
1 parent 754b413 commit 18e261b

File tree

7 files changed

+157
-59
lines changed

7 files changed

+157
-59
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte-ux": patch
3+
---
4+
5+
Add a store to manage the current theme

packages/svelte-ux/src/lib/components/MenuItem.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
setButtonGroup(undefined);
2929
3030
// Clear theme to not expose to Button
31-
settings({ ...getSettings(), theme: {} });
31+
settings({ ...getSettings(), classes: {} });
3232
</script>
3333

3434
<Button

packages/svelte-ux/src/lib/components/ThemeButton.svelte

Lines changed: 20 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,58 +12,30 @@
1212
import Tooltip from './Tooltip.svelte';
1313
1414
import { cls } from '../utils/styles';
15+
import { getSettings } from './settings';
1516
16-
export let darkThemes = ['dark'];
17-
export let lightThemes = ['light'];
17+
const { currentTheme, themes: allThemes } = getSettings();
1818
19-
let open = false;
20-
21-
$: themes = colorScheme === 'dark' ? darkThemes : lightThemes;
22-
23-
let theme: (typeof themes)[number] | null = localStorage.theme ?? 'system';
19+
/** The list of dark themes to chose from, if not the list provided to `settings`. */
20+
export let darkThemes = allThemes?.dark ?? ['dark'];
21+
/** The list of light themes to chose from, if not the list provided to `settings`. */
22+
export let lightThemes = allThemes?.light ?? ['light'];
2423
25-
let colorScheme: 'light' | 'dark' =
26-
(theme !== 'system' && darkThemes.includes(theme)) ||
27-
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
28-
? 'dark'
29-
: 'light';
30-
31-
// TODO: Call inline in `head` to avoid FOUC. Move to <Theme>?
32-
function setTheme(themeName: typeof theme) {
33-
if (themeName === 'system') {
34-
// Remove setting
35-
localStorage.removeItem('theme');
36-
delete document.documentElement.dataset.theme;
37-
38-
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
39-
document.documentElement.classList.add('dark');
40-
}
41-
} else {
42-
// Save theme to local storage, set `<html data-theme="">`, and set `<html class="dark">` if dark mode
43-
localStorage.theme = themeName;
44-
document.documentElement.dataset.theme = theme;
24+
let open = false;
4525
46-
if (darkThemes.includes(themeName)) {
47-
colorScheme = 'dark';
48-
document.documentElement.classList.add('dark');
49-
} else {
50-
colorScheme = 'light';
51-
document.documentElement.classList.remove('dark');
52-
}
53-
}
54-
}
55-
$: setTheme(theme);
26+
$: themes = $currentTheme.dark ? darkThemes : lightThemes;
5627
5728
function onKeyDown(e: KeyboardEvent) {
5829
if (e.ctrlKey && e.code === 'KeyT') {
5930
if (e.shiftKey) {
6031
// Pick next theme
61-
const currentIndex = themes.indexOf(theme);
62-
theme = themes[(currentIndex + 1) % themes.length];
32+
const currentIndex = themes.indexOf($currentTheme.resolvedTheme);
33+
let newTheme = themes[(currentIndex + 1) % themes.length];
34+
currentTheme.setTheme(newTheme);
6335
} else {
6436
// Toggle light/dark
65-
colorScheme = colorScheme === 'light' ? 'dark' : 'light';
66-
theme = colorScheme;
37+
let newTheme = $currentTheme.dark ? 'light' : 'dark';
38+
currentTheme.setTheme(newTheme);
6739
}
6840
}
6941
}
@@ -77,7 +49,7 @@
7749
>
7850
Mode
7951

80-
{#if theme !== 'system'}
52+
{#if $currentTheme.theme}
8153
<span transition:fly={{ x: 8 }}>
8254
<Tooltip title="Reset to System" offset={2}>
8355
<Button
@@ -86,10 +58,7 @@
8658
size="sm"
8759
class="mr-1"
8860
on:click={() => {
89-
colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches
90-
? 'dark'
91-
: 'light';
92-
theme = 'system';
61+
currentTheme.setTheme('system');
9362
}}
9463
/>
9564
</Tooltip>
@@ -98,10 +67,10 @@
9867

9968
<Switch
10069
id="switch-color-scheme"
101-
checked={colorScheme === 'light'}
70+
checked={!$currentTheme.dark}
10271
on:change={(e) => {
103-
colorScheme = e.target?.checked ? 'light' : 'dark';
104-
theme = colorScheme;
72+
let newTheme = e.target?.checked ? 'light' : 'dark';
73+
currentTheme.setTheme(newTheme);
10574
}}
10675
class="my-1"
10776
let:checked
@@ -117,11 +86,11 @@
11786
<div class="grid grid-cols-2 gap-2 p-2 border-b border-surface-content/10">
11887
{#each themes as themeName}
11988
<MenuItem
120-
on:click={() => (theme = themeName)}
89+
on:click={() => currentTheme.setTheme(themeName)}
12190
data-theme={themeName}
12291
class={cls(
12392
'bg-surface-100 text-surface-content font-semibold border shadow',
124-
theme === themeName && 'ring-2 ring-surface-content'
93+
$currentTheme.resolvedTheme === themeName && 'ring-2 ring-surface-content'
12594
)}
12695
>
12796
<div class="grid gap-1">

packages/svelte-ux/src/lib/components/settings.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import {
1111
DateToken,
1212
} from '$lib/utils/date';
1313
import type { DictionaryMessages, DictionaryMessagesOptions } from '$lib/utils/dictionary';
14+
import { createThemeStore, type ThemeStore } from '$lib/stores/themeStore';
1415

1516
type ExcludeNone<T> = T extends 'none' ? never : T;
16-
export type Settings = {
17+
export type SettingsInput = {
1718
formats?: {
1819
numbers?: Prettify<
1920
{
@@ -26,20 +27,46 @@ export type Settings = {
2627
};
2728
dictionary?: DictionaryMessagesOptions;
2829
classes?: ComponentClasses;
30+
/** A list of the available themes */
31+
themes?: {
32+
light?: string[];
33+
dark?: string[];
34+
};
35+
currentTheme?: ThemeStore;
2936
};
3037

38+
export type Settings = SettingsInput & { currentTheme: ThemeStore };
39+
3140
const settingsKey = Symbol();
3241

33-
export function settings(settings: Settings) {
34-
setContext(settingsKey, settings);
42+
export function settings(settings: SettingsInput) {
43+
let lightThemes = settings.themes?.light ?? ['light'];
44+
let darkThemes = settings.themes?.dark ?? ['dark'];
45+
46+
let currentTheme =
47+
// In some cases, `settings` is called again from inside a component. Don't create a new theme store in this case.
48+
settings.currentTheme ??
49+
createThemeStore({
50+
light: lightThemes,
51+
dark: darkThemes,
52+
});
53+
54+
setContext(settingsKey, {
55+
...settings,
56+
themes: {
57+
light: lightThemes,
58+
dark: darkThemes,
59+
},
60+
currentTheme,
61+
});
3562
}
3663

37-
export function getSettings() {
64+
export function getSettings(): Settings {
3865
// in a try/catch to be able to test wo svelte components
3966
try {
4067
return getContext<Settings>(settingsKey) ?? {};
4168
} catch (error) {
42-
return {};
69+
return { currentTheme: createThemeStore({ light: ['light'], dark: ['dark'] }) };
4370
}
4471
}
4572

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { writable, type Readable } from 'svelte/store';
2+
import { browser } from '../utils/env';
3+
4+
/** Information about the currently chosen theme. */
5+
export class CurrentTheme {
6+
/** The currently selected theme. If using the "system" theme this will be null. */
7+
theme: string | null;
8+
/** Whether the current theme is a light or dark theme */
9+
dark: boolean;
10+
11+
constructor(theme: string | null, dark: boolean) {
12+
this.theme = theme;
13+
this.dark = dark;
14+
}
15+
16+
/** The theme in use, either the selected theme or the theme chosen based on the "system" setting. */
17+
get resolvedTheme() {
18+
if (this.theme) {
19+
return this.theme;
20+
} else {
21+
return this.dark ? 'dark' : 'light';
22+
}
23+
}
24+
}
25+
26+
export interface ThemeStore extends Readable<CurrentTheme> {
27+
setTheme: (themeName: string) => void;
28+
}
29+
30+
export interface ThemeStoreOptions {
31+
light: string[];
32+
dark: string[];
33+
}
34+
35+
export function createThemeStore(options: ThemeStoreOptions): ThemeStore {
36+
let store = writable<CurrentTheme>(new CurrentTheme(null, false));
37+
38+
if (!browser) {
39+
// Stub out most of the store when running SSR.
40+
return {
41+
subscribe: store.subscribe,
42+
setTheme: (themeName: string) => {
43+
store.set(new CurrentTheme(themeName, options.dark.includes(themeName)));
44+
},
45+
};
46+
}
47+
48+
let darkMatcher = window.matchMedia('(prefers-color-scheme: dark)');
49+
50+
function resolveSystemTheme({ matches }: { matches: boolean }) {
51+
if (matches) {
52+
document.documentElement.classList.add('dark');
53+
} else {
54+
document.documentElement.classList.remove('dark');
55+
}
56+
57+
store.set(new CurrentTheme(null, matches));
58+
}
59+
60+
function setTheme(themeName: string) {
61+
if (themeName === 'system') {
62+
// Remove setting
63+
localStorage.removeItem('theme');
64+
delete document.documentElement.dataset.theme;
65+
66+
resolveSystemTheme(darkMatcher);
67+
darkMatcher.addEventListener('change', resolveSystemTheme);
68+
} else {
69+
darkMatcher.removeEventListener('change', resolveSystemTheme);
70+
71+
// Save theme to local storage, set `<html data-theme="">`, and set `<html class="dark">` if dark mode
72+
localStorage.theme = themeName;
73+
document.documentElement.dataset.theme = themeName;
74+
75+
let dark = options.dark.includes(themeName);
76+
if (dark) {
77+
document.documentElement.classList.add('dark');
78+
} else {
79+
document.documentElement.classList.remove('dark');
80+
}
81+
82+
store.set(new CurrentTheme(themeName, dark));
83+
}
84+
}
85+
86+
let savedTheme = localStorage.getItem('theme') || 'system';
87+
setTheme(savedTheme);
88+
89+
return {
90+
subscribe: store.subscribe,
91+
setTheme,
92+
};
93+
}

packages/svelte-ux/src/routes/+layout.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@
9696
active: 'text-primary bg-surface-100 border-l-4 border-primary font-medium',
9797
},
9898
},
99+
themes: {
100+
light: lightThemes,
101+
dark: darkThemes,
102+
},
99103
});
100104
101105
let mainEl: HTMLElement;
@@ -186,7 +190,7 @@
186190
<QuickSearch options={quickSearchOptions} on:change={(e) => goto(e.detail.value)} />
187191

188192
<div class="border-r border-primary-content/20 pr-2">
189-
<ThemeButton {lightThemes} {darkThemes} />
193+
<ThemeButton />
190194
</div>
191195

192196
<Tooltip title="Discord" placement="left" offset={2}>

packages/svelte-ux/src/routes/customization/+page.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ On each `ComponentName: ...` you can pass `class` (when value is a `string`) or
5555
import { settings } from 'svelte-ux';
5656

5757
settings({
58-
theme: {
58+
classes: {
5959
Button: 'flex-2', // same as <Button class="flex-2">
6060
TextField: {
6161
container: 'hover:shadow-none group-focus-within:shadow-none', // same as <TextField classes={{ container: '...' }}>

0 commit comments

Comments
 (0)