This commit is contained in:
Jesse Winton
2025-04-04 16:14:08 -04:00
parent 6ff50c2e38
commit 24e87e02c1
4 changed files with 103 additions and 78 deletions

View File

View File

@@ -1,15 +1,14 @@
import type { Theme } from '.';
import { MEDIA } from './constants'; import { MEDIA } from './constants';
export const getTheme = (key: string, fallback?: string): Theme | undefined => { export const getTheme = (key: string, fallback?: string): string | undefined => {
if (typeof window === 'undefined') return undefined; if (typeof window === 'undefined') return undefined;
let theme: Theme | undefined = undefined; let theme: string | undefined = undefined;
try { try {
theme = localStorage.getItem(key) as Theme || undefined; theme = localStorage.getItem(key) as string || undefined;
} catch (e) { } catch (e) {
// Unsupported // Unsupported
} }
return theme || fallback as Theme; return theme || fallback as string;
}; };
export const disableAnimation = () => { export const disableAnimation = () => {
@@ -33,11 +32,11 @@ export const disableAnimation = () => {
}; };
export const getSystemTheme = (e?: MediaQueryList): string => { export const getSystemTheme = (e?: MediaQueryList): string => {
if (!e) { if (!e && typeof window !== 'undefined') {
e = window.matchMedia(MEDIA); e = window.matchMedia(MEDIA);
} }
const isDark = e.matches; const isDark = e?.matches;
const systemTheme = isDark ? 'dark' : 'light'; const systemTheme = isDark ? 'dark' : 'light';
return systemTheme; return systemTheme;
}; };

View File

@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { MEDIA } from './constants'; import { MEDIA } from './constants';
import { type Theme } from '.';
interface Props { interface Props {
forcedTheme?: string; forcedTheme?: string;
storageKey?: string; storageKey?: string;
attribute?: string; attribute?: string;
enableSystem?: boolean; enableSystem?: boolean;
defaultTheme?: Theme; defaultTheme?: string;
value?: { [themeName: string]: string }; value?: { [themeName: string]: string };
attrs: string[]; attrs: string[];
} }
@@ -22,46 +21,64 @@
attrs attrs
}: Props = $props(); }: Props = $props();
// These are minified via Terser and then updated by hand, don't recommend const getThemeUpdate = (name: string, literal?: boolean) => {
const themeName = value?.[name] || name;
const updateDOM = (name: string, literal?: boolean) => { const val = literal ? themeName : `'${themeName}'`;
name = value?.[name] || name;
const val = literal ? name : `'${name}'`;
// Set both attribute and color-scheme
if (attribute === 'class') { if (attribute === 'class') {
return `d.add(${val})${`;document.documentElement.style.setProperty('color-scheme', ${val})`}`; return `d.add(${val});document.documentElement.style.setProperty('color-scheme', ${val})`;
} }
return `d.setAttribute('${attribute}', ${val});document.documentElement.style.setProperty('color-scheme', ${val})`;
return `d.setAttribute('${attribute}', ${val})${`;document.documentElement.style.setProperty('color-scheme', ${val})`}`;
}; };
let defaultSystem = $derived(defaultTheme === 'system'); let defaultSystem = $derived(defaultTheme === 'system');
// Code-golfing the amount of characters in the script let classListPrep = $derived(
let optimization = $derived(
attribute === 'class' attribute === 'class'
? `var d=document.documentElement.classList;${`d.remove(${attrs ? `var d=document.documentElement.classList;d.remove(${attrs
.map((t: string) => `'${t}'`) .map((t: string) => `'${t}'`)
.join(',')})`};` .join(',')});`
: `var d=document.documentElement;` : `var d=document.documentElement;`
); );
// Encapsulate script tag into string to not mess with the compiler // Script implementation varies based on configuration
let themeScript = $derived( let themeScript = $derived(
`<${'script'}>${ `<${'script'}>
forcedTheme (function() {
? `!function(){${optimization}${updateDOM(forcedTheme)}}()` ${classListPrep}
: enableSystem ${
? `!function(){try {${optimization}var e=localStorage.getItem('${storageKey}');${ forcedTheme
!defaultSystem ? updateDOM(defaultTheme) + ';' : '' ? getThemeUpdate(forcedTheme)
}if("system"===e||(!e&&${defaultSystem})){var t="${MEDIA}",m=window.matchMedia(t);if(m.media!==t||m.matches){${updateDOM( : enableSystem
'dark' ? `try {
)}}else{${updateDOM('light')}}}else if(e){ ${ var storedTheme = localStorage.getItem('${storageKey}');
value ? `var x=${JSON.stringify(value)};` : '' ${!defaultSystem ? `${getThemeUpdate(defaultTheme)};` : ''}
}${updateDOM(value ? 'x[e]' : 'e', true)}}}catch(e){}}()`
: `!function(){try{${optimization}var e=localStorage.getItem("${storageKey}");if(e){${ if ("system" === storedTheme || (!storedTheme && ${defaultSystem})) {
value ? `var x=${JSON.stringify(value)};` : '' var mediaQuery = "${MEDIA}";
}${updateDOM(value ? 'x[e]' : 'e', true)}}else{${updateDOM(defaultTheme)};}}catch(t){}}();` var mql = window.matchMedia(mediaQuery);
}</${'script'}>` if (mql.media !== mediaQuery || mql.matches) {
${getThemeUpdate('dark')}
} else {
${getThemeUpdate('light')}
}
} else if (storedTheme) {
${value ? `var themeMapping = ${JSON.stringify(value)};` : ''}
${getThemeUpdate(value ? 'themeMapping[storedTheme]' : 'storedTheme', true)}
}
} catch(e) { console.error("Theme initialization error:", e); }`
: `try {
var storedTheme = localStorage.getItem("${storageKey}");
if (storedTheme) {
${value ? `var themeMapping = ${JSON.stringify(value)};` : ''}
${getThemeUpdate(value ? 'themeMapping[storedTheme]' : 'storedTheme', true)}
} else {
${getThemeUpdate(defaultTheme)};
}
} catch(e) { console.error("Theme initialization error:", e); }`
}
})();
</${'script'}>`
); );
</script> </script>

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { colorSchemes, MEDIA } from './constants'; import { colorSchemes, MEDIA } from './constants';
import { disableAnimation, getSystemTheme, getTheme } from './helpers'; import { disableAnimation, getSystemTheme, getTheme } from './helpers';
import { themeStore, setTheme } from './index'; import { themeStore, setTheme, setResolvedTheme, setSystemTheme, setThemes } from './index';
import ThemeScript from './theme-script.svelte'; import ThemeScript from './theme-script.svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
@@ -32,97 +31,108 @@
value = undefined value = undefined
}: Props = $props(); }: Props = $props();
// Initialize theme state
const initialTheme = getTheme(storageKey, defaultTheme); const initialTheme = getTheme(storageKey, defaultTheme);
const systemTheme = enableSystem ? getSystemTheme() : undefined;
themeStore.set({ themeStore.set({
theme: initialTheme, theme: initialTheme,
forcedTheme, forcedTheme,
resolvedTheme: initialTheme === 'system' ? getTheme(storageKey) : initialTheme, resolvedTheme: initialTheme === 'system' ? systemTheme : initialTheme,
themes: enableSystem ? [...themes, 'system'] : themes, themes: enableSystem ? [...themes, 'system'] : themes,
systemTheme: (enableSystem ? getTheme(storageKey) : undefined) as systemTheme
| 'light'
| 'dark'
| undefined
}); });
let theme = $derived($themeStore.theme); let theme = $derived($themeStore.theme);
let resolvedTheme = $derived($themeStore.resolvedTheme); let resolvedTheme = $derived($themeStore.resolvedTheme);
const attrs = !value ? themes : Object.values(value); const attrs = !value ? themes : Object.values(value);
// Handle system theme changes
const handleMediaQuery = (e?: MediaQueryList) => { const handleMediaQuery = (e?: MediaQueryList) => {
const systemTheme = getSystemTheme(e) as string; const newSystemTheme = getSystemTheme(e);
$themeStore.resolvedTheme = systemTheme; setSystemTheme(newSystemTheme);
$themeStore.systemTheme = systemTheme; setResolvedTheme(newSystemTheme);
if (theme === 'system' && !forcedTheme) changeTheme(systemTheme, false, true); // Only update DOM if currently using system theme
if (theme === 'system' && !forcedTheme) {
changeTheme(newSystemTheme, false, true);
}
}; };
const changeTheme = (theme?: string, updateStorage?: boolean, updateDOM?: boolean) => { // Core theme change function
if (!theme) return; const changeTheme = (newTheme?: string, updateStorage = true, updateDOM = true) => {
let name = value?.[theme] || theme; if (!newTheme) return;
const enable = disableTransitionOnChange && updateDOM ? disableAnimation() : null; // Handle animation disabling if needed
const enableAnimations = disableTransitionOnChange && updateDOM ? disableAnimation() : null;
// Update localStorage if needed
if (updateStorage) { if (updateStorage) {
try { try {
localStorage.setItem(storageKey, theme); localStorage.setItem(storageKey, newTheme);
} catch (e) { } catch (e) {
// Unsupported // Ignore storage errors
} }
} }
if (theme === 'system' && enableSystem) { // Determine the actual theme value to apply
let themeName = value?.[newTheme] || newTheme;
if (newTheme === 'system' && enableSystem) {
const resolved = getSystemTheme(); const resolved = getSystemTheme();
name = value?.[resolved] || resolved; themeName = value?.[resolved] || resolved;
} }
// Update DOM if needed and in browser context
if (updateDOM && browser) { if (updateDOM && browser) {
const d = document.body; const target = document.body;
if (attribute === 'class') { if (attribute === 'class') {
d.classList.remove(...(attrs as string[])); // Remove all possible theme classes then add the current one
d.classList.add(name); target.classList.remove(...attrs);
target.classList.add(themeName);
} else { } else {
d.setAttribute(attribute, name); target.setAttribute(attribute, themeName);
} }
enable?.();
// Re-enable animations if they were disabled
enableAnimations?.();
} }
}; };
const mediaHandler = (...args: any) => handleMediaQuery(...args); // Event handlers
const mediaHandler = (e: MediaQueryList) => handleMediaQuery(e);
const storageHandler = (e: StorageEvent) => { const storageHandler = (e: StorageEvent) => {
if (e.key !== storageKey) return; if (e.key !== storageKey) return;
setTheme((e.newValue as string) || (defaultTheme as string)); setTheme((e.newValue as string) || defaultTheme);
}; };
// Setup and teardown for window events
const onWindow = (window: Window) => { const onWindow = (window: Window) => {
const media = window.matchMedia(MEDIA); const media = window.matchMedia(MEDIA);
// Use modern event listener approach media.addEventListener('change', () => mediaHandler(media));
media.addEventListener('change', mediaHandler);
mediaHandler(media); mediaHandler(media);
window.addEventListener('storage', storageHandler); if (browser) {
window.addEventListener('storage', storageHandler);
}
return { return {
destroy() { destroy() {
window.removeEventListener('storage', storageHandler); window.removeEventListener('storage', storageHandler);
media.removeEventListener('change', mediaHandler); media.removeEventListener('change', () => mediaHandler(media));
} }
}; };
}; };
// Update color-scheme CSS property when theme changes
$effect(() => { $effect(() => {
if (enableColorScheme && browser) { if (enableColorScheme && browser) {
let colorScheme = let colorScheme =
// If theme is forced to light or dark, use that
forcedTheme && colorSchemes.includes(forcedTheme) forcedTheme && colorSchemes.includes(forcedTheme)
? forcedTheme ? forcedTheme
: // If regular theme is light or dark : theme && colorSchemes.includes(theme)
theme && colorSchemes.includes(theme)
? theme ? theme
: // If theme is system, use the resolved version : theme === 'system'
theme === 'system'
? resolvedTheme || null ? resolvedTheme || null
: null; : null;
@@ -130,12 +140,11 @@
} }
}); });
// Apply theme changes when theme store updates
$effect(() => { $effect(() => {
if (forcedTheme) { // If theme is forced, update storage but not DOM (script handles it)
changeTheme(theme, true, false); // Otherwise update both
} else { changeTheme(theme, true, !forcedTheme);
changeTheme(theme, true, true); // Add true for updateDOM parameter
}
}); });
</script> </script>