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

View File

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

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { colorSchemes, MEDIA } from './constants';
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 { browser } from '$app/environment';
@@ -32,97 +31,108 @@
value = undefined
}: Props = $props();
// Initialize theme state
const initialTheme = getTheme(storageKey, defaultTheme);
const systemTheme = enableSystem ? getSystemTheme() : undefined;
themeStore.set({
theme: initialTheme,
forcedTheme,
resolvedTheme: initialTheme === 'system' ? getTheme(storageKey) : initialTheme,
resolvedTheme: initialTheme === 'system' ? systemTheme : initialTheme,
themes: enableSystem ? [...themes, 'system'] : themes,
systemTheme: (enableSystem ? getTheme(storageKey) : undefined) as
| 'light'
| 'dark'
| undefined
systemTheme
});
let theme = $derived($themeStore.theme);
let resolvedTheme = $derived($themeStore.resolvedTheme);
const attrs = !value ? themes : Object.values(value);
// Handle system theme changes
const handleMediaQuery = (e?: MediaQueryList) => {
const systemTheme = getSystemTheme(e) as string;
$themeStore.resolvedTheme = systemTheme;
$themeStore.systemTheme = systemTheme;
const newSystemTheme = getSystemTheme(e);
setSystemTheme(newSystemTheme);
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) => {
if (!theme) return;
let name = value?.[theme] || theme;
// Core theme change function
const changeTheme = (newTheme?: string, updateStorage = true, updateDOM = true) => {
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) {
try {
localStorage.setItem(storageKey, theme);
localStorage.setItem(storageKey, newTheme);
} 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();
name = value?.[resolved] || resolved;
themeName = value?.[resolved] || resolved;
}
// Update DOM if needed and in browser context
if (updateDOM && browser) {
const d = document.body;
const target = document.body;
if (attribute === 'class') {
d.classList.remove(...(attrs as string[]));
d.classList.add(name);
// Remove all possible theme classes then add the current one
target.classList.remove(...attrs);
target.classList.add(themeName);
} 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) => {
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 media = window.matchMedia(MEDIA);
// Use modern event listener approach
media.addEventListener('change', mediaHandler);
media.addEventListener('change', () => mediaHandler(media));
mediaHandler(media);
window.addEventListener('storage', storageHandler);
if (browser) {
window.addEventListener('storage', storageHandler);
}
return {
destroy() {
window.removeEventListener('storage', storageHandler);
media.removeEventListener('change', mediaHandler);
media.removeEventListener('change', () => mediaHandler(media));
}
};
};
// Update color-scheme CSS property when theme changes
$effect(() => {
if (enableColorScheme && browser) {
let colorScheme =
// If theme is forced to light or dark, use that
forcedTheme && colorSchemes.includes(forcedTheme)
? forcedTheme
: // If regular theme is light or dark
theme && colorSchemes.includes(theme)
: theme && colorSchemes.includes(theme)
? theme
: // If theme is system, use the resolved version
theme === 'system'
: theme === 'system'
? resolvedTheme || null
: null;
@@ -130,12 +140,11 @@
}
});
// Apply theme changes when theme store updates
$effect(() => {
if (forcedTheme) {
changeTheme(theme, true, false);
} else {
changeTheme(theme, true, true); // Add true for updateDOM parameter
}
// If theme is forced, update storage but not DOM (script handles it)
// Otherwise update both
changeTheme(theme, true, !forcedTheme);
});
</script>