Skip to content
Merged
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
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