Skip to content
6 changes: 6 additions & 0 deletions apps/www/src/content/docs/theme/overview/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export type ThemeProviderProps = {
/** Mapping of theme name to HTML attribute value */
value?: { [themeName: string]: string };

/**
* Called when the active theme changes. `resolvedTheme` is the actual applied theme
* (`"light"`/`"dark"` when `theme` is `"system"`). Not fired on initial mount.
*/
onThemeChange?: (theme: string, resolvedTheme: string) => void;

/** React children */
children?: React.ReactNode;
};
Expand Down
55 changes: 42 additions & 13 deletions packages/raystack/components/theme-provider/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react';

Expand Down Expand Up @@ -47,13 +48,14 @@ const Theme = ({
nonce,
style = 'modern',
accentColor = 'indigo',
grayColor = 'gray'
grayColor = 'gray',
onThemeChange
}: ThemeProviderProps) => {
const [theme, setThemeState] = useState(() =>
getTheme(storageKey, defaultTheme)
);
const [resolvedTheme, setResolvedTheme] = useState(() =>
getTheme(storageKey)
const [resolvedTheme, setResolvedTheme] = useState<string | undefined>(
undefined
);
const attrs = !value ? themes : Object.values(value);

Expand Down Expand Up @@ -124,7 +126,7 @@ const Theme = ({
// Unsupported
}
},
[forcedTheme]
[storageKey]
);

const handleMediaQuery = useCallback(
Expand All @@ -136,7 +138,7 @@ const Theme = ({
applyTheme('system');
}
},
[theme, forcedTheme]
[theme, forcedTheme, enableSystem, applyTheme]
);

// Always listen to System preference
Expand Down Expand Up @@ -165,18 +167,41 @@ const Theme = ({
return () => window.removeEventListener('storage', handleStorage);
}, [setTheme]);

// Whenever theme or forcedTheme changes, apply it
// Ref-held callback so consumer render churn doesn't drive effect cadence.
const onThemeChangeRef = useRef(onThemeChange);
onThemeChangeRef.current = onThemeChange;
const lastRef = useRef<{ theme: string; resolved: string } | undefined>(
undefined
);

// Apply on theme/forcedTheme change, then notify on real changes.
useEffect(() => {
// @ts-ignore
applyTheme(forcedTheme ?? theme);
}, [forcedTheme, theme]);
const target = forcedTheme ?? theme;
if (target) applyTheme(target);

if (!theme) return;
const resolved =
forcedTheme ?? (theme === 'system' ? resolvedTheme : theme);
if (!resolved) return;

const prev = lastRef.current;
lastRef.current = { theme, resolved };

if (
prev !== undefined &&
(prev.theme !== theme || prev.resolved !== resolved)
) {
onThemeChangeRef.current?.(theme, resolved);
Comment thread
rohanchkrabrty marked this conversation as resolved.
}
}, [forcedTheme, theme, resolvedTheme, applyTheme]);

const providerValue = useMemo(
() => ({
theme,
setTheme,
forcedTheme,
resolvedTheme: theme === 'system' ? resolvedTheme : theme,
resolvedTheme:
forcedTheme ?? (theme === 'system' ? resolvedTheme : theme),
themes: enableSystem ? [...themes, 'system'] : themes,
systemTheme: (enableSystem ? resolvedTheme : undefined) as
| 'light'
Expand Down Expand Up @@ -277,7 +302,7 @@ const ThemeScript = memo(
setColorScheme = true
) => {
const resolvedName = value ? value[name] : name;
const val = literal ? name + `|| ''` : `'${resolvedName}'`;
const val = literal ? name : `'${resolvedName}'`;
let text = '';

// MUCH faster to set colorScheme alongside HTML attribute/class
Expand All @@ -293,13 +318,17 @@ const ThemeScript = memo(
}

if (attribute === 'class') {
if (literal || resolvedName) {
if (literal) {
text += `if(${val})c.add(${val})`;
Comment thread
rohanchkrabrty marked this conversation as resolved.
} else if (resolvedName) {
text += `c.add(${val})`;
} else {
text += `null`;
}
} else {
if (resolvedName) {
if (literal) {
text += `if(${val})d[s](n,${val})`;
} else if (resolvedName) {
text += `d[s](n,${val})`;
}
}
Expand Down
8 changes: 5 additions & 3 deletions packages/raystack/components/theme-provider/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ export interface UseThemeProps {
setTheme: (theme: string) => void;
/** Active theme name */
theme?: string;
/** If `enableSystem` is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light". Otherwise, identical to `theme` */
/** The actually applied theme. Returns `forcedTheme` when set; otherwise the system preference (`"light"`/`"dark"`) when `theme` is `"system"`; otherwise identical to `theme`. */
resolvedTheme?: string;
/** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */
systemTheme?: "dark" | "light";
systemTheme?: 'dark' | 'light';
}

export interface ThemeProviderProps {
Expand All @@ -33,7 +33,7 @@ export interface ThemeProviderProps {
/** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */
defaultTheme?: string;
/** HTML attribute modified based on the active theme. Accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) */
attribute?: string | "class";
attribute?: string | 'class';
/** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */
value?: ValueObject;
/** Nonce string to pass to the inline script for CSP headers */
Expand All @@ -46,4 +46,6 @@ export interface ThemeProviderProps {
accentColor?: 'indigo' | 'orange' | 'mint';
/** Gray color variant for the theme, options are 'gray', 'mauve', or 'slate' */
grayColor?: 'gray' | 'mauve' | 'slate';
/** Called when the active theme changes. `resolvedTheme` is the actual applied theme (`'light'`/`'dark'` when `theme` is `'system'`). Not fired on initial mount. */
onThemeChange?: (theme: string, resolvedTheme: string) => void;
}
Loading