Skip to content

Commit

Permalink
feat: respond to the change of system theme appearance
Browse files Browse the repository at this point in the history
  • Loading branch information
ambar committed Apr 2, 2024
1 parent d965a06 commit 476c859
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 73 deletions.
6 changes: 6 additions & 0 deletions .changeset/perfect-islands-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rspress/theme-default': minor
'@rspress/core': minor
---

feat: respond to the change of system theme appearance
14 changes: 9 additions & 5 deletions packages/core/src/runtime/clientEntry.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { isProduction } from '@rspress/shared';
import siteData from 'virtual-site-data';
import {
Expand All @@ -12,7 +12,7 @@ import { App, initPageData } from './App';
const enableSSG = siteData.ssg;

// eslint-disable-next-line import/no-commonjs
const { default: Theme } = require('@theme');
const { default: Theme, useThemeState } = require('@theme');

export async function renderInBrowser() {
const container = document.getElementById('root')!;
Expand All @@ -23,10 +23,14 @@ export async function renderInBrowser() {
);
return function RootApp() {
const [data, setData] = useState(initialPageData);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [theme, setTheme] = useThemeState();
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<DataContext.Provider value={{ data, setData }}>
<ThemeContext.Provider
value={useMemo(() => ({ theme, setTheme }), [theme, setTheme])}
>
<DataContext.Provider
value={useMemo(() => ({ data, setData }), [data, setData])}
>
<BrowserRouter>
<App />
</BrowserRouter>
Expand Down
27 changes: 1 addition & 26 deletions packages/theme-default/src/components/SwitchAppearance/index.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,16 @@
import { useContext, useEffect, useState } from 'react';
import { useContext } from 'react';
import { ThemeContext } from '@rspress/runtime';
import SunSvg from '@theme-assets/sun';
import MoonSvg from '@theme-assets/moon';
import {
getToggle,
isDarkMode,
updateUserPreferenceFromStorage,
} from '../../logic/useAppearance';
import { SvgWrapper } from '../SvgWrapper';

export function SwitchAppearance({ onClick }: { onClick?: () => void }) {
const { theme, setTheme } = useContext(ThemeContext);
const toggleAppearance = getToggle();
const updateAppearanceAndTheme = () => {
const isDark = updateUserPreferenceFromStorage();
setTheme(isDark ? 'dark' : 'light');
};

useEffect(() => {
if (isDarkMode()) {
setTheme('dark');
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', updateAppearanceAndTheme);
}
return () => {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', updateAppearanceAndTheme);
}
};
}, []);

return (
<div
onClick={() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
toggleAppearance();
onClick?.();
}}
className="md:mr-2 rspress-nav-appearance"
Expand Down
1 change: 1 addition & 0 deletions packages/theme-default/src/logic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export { setup, bindingAsideScroll, scrollToTarget } from './sideEffects';
export { usePathUtils } from './usePathUtils';
export { useFullTextSearch } from './useFullTextSearch';
export { useRedirect4FirstVisit } from './useRedirect4FirstVisit';
export { useThemeState } from './useAppearance';
export * from './utils';
117 changes: 75 additions & 42 deletions packages/theme-default/src/logic/useAppearance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import siteData from 'virtual-site-data';
import { APPEARANCE_KEY } from '@rspress/shared';
import { useCallback, useEffect, useState } from 'react';
import { useHandler } from './useHandler';
import { useMediaQuery } from './useMediaQuery';
import { useStorageValue } from './useStorageValue';

declare global {
interface Window {
Expand All @@ -8,54 +12,83 @@ declare global {
}
}

let classList: DOMTokenList | undefined;
// Determine if the theme mode of the user's operating system is dark
let userPreference: string;
let query: MediaQueryList;
// Value to be used in the app
type ThemeValue = 'light' | 'dark';
// Value to be stored
type ThemeConfigValue = ThemeValue | 'auto';

const setClass = (dark: boolean): void => {
classList?.[dark ? 'add' : 'remove']('dark');
const sanitize = (value: string): ThemeConfigValue => {
return ['light', 'dark', 'auto'].includes(value)
? (value as ThemeConfigValue)
: 'auto';
};

const updateAppearance = (): void => {
const disableDarkMode = siteData.themeConfig.darkMode === false;
// We set the RSPRESS_THEME as a global variable to determine whether the theme is dark or light.
const defaultTheme = window.RSPRESS_THEME ?? window.MODERN_THEME;
if (defaultTheme) {
setClass(defaultTheme === 'dark');
return;
}
if (disableDarkMode) {
return;
}
updateUserPreferenceFromStorage();
};
const disableDarkMode = siteData.themeConfig.darkMode === false;

export const updateUserPreferenceFromStorage = () => {
const userPreference = localStorage.getItem(APPEARANCE_KEY) || 'auto';
query = window.matchMedia('(prefers-color-scheme: dark)');
const isDark =
userPreference === 'auto' ? query.matches : userPreference === 'dark';
setClass(isDark);
return isDark;
};
/**
* State provider for theme context.
*/
export const useThemeState = () => {
const matchesDark = useMediaQuery('(prefers-color-scheme: dark)');
const [storedTheme, setStoredTheme] = useStorageValue(APPEARANCE_KEY);

if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') {
// When user preference is auto,the modern theme will change with the system user's operating system theme.
// eslint-disable-next-line prefer-destructuring
classList = document.documentElement.classList;
updateAppearance();
}
const getPreferredTheme = useHandler(() => {
if (disableDarkMode) {
return 'light';
}
const sanitized = sanitize(storedTheme);
return sanitized === 'auto' ? (matchesDark ? 'dark' : 'light') : sanitized;
});

export const isDarkMode = () => classList?.contains('dark');
const [theme, setThemeInternal] = useState<ThemeValue>(() => {
if (typeof window === 'undefined') {
return 'light';
}
// We set the RSPRESS_THEME as a global variable to determine whether the theme is dark or light.
const defaultTheme = window.RSPRESS_THEME ?? window.MODERN_THEME;
if (defaultTheme) {
return defaultTheme === 'dark' ? 'dark' : 'light';
}
return getPreferredTheme();
});
const setTheme = useCallback(
(value: ThemeValue, storeValue: ThemeConfigValue = value) => {
if (disableDarkMode) {
return;
}
setThemeInternal(value);
setStoredTheme(storeValue);
setSkipEffect(true);
},
[],
);

useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.colorScheme = theme;
}, [theme]);

// Skip the first effect on mount, only run on updates
const [skipEffect, setSkipEffect] = useState(true);
useEffect(() => {
setSkipEffect(false);
}, [skipEffect]);

export const getToggle = () => {
return () => {
const isDark = isDarkMode();
if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') {
setClass(!isDark);
userPreference = isDark ? 'light' : 'dark';
localStorage.setItem(APPEARANCE_KEY, userPreference);
// Update the theme when the localStorage changes
useEffect(() => {
if (skipEffect) {
return;
}
};
setTheme(getPreferredTheme(), sanitize(storedTheme));
}, [storedTheme]);

// Update the theme when the OS theme changes
useEffect(() => {
if (skipEffect) {
return;
}
setTheme(matchesDark ? 'dark' : 'light', 'auto');
}, [matchesDark]);

return [theme, setTheme] as const;
};
10 changes: 10 additions & 0 deletions packages/theme-default/src/logic/useHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useRef } from 'react';

/**
* Create a memoized handler function with stable reference
*/
export const useHandler = <T extends (...args: any[]) => any>(handler: T) => {
const handlerRef = useRef<T>(handler);
handlerRef.current = handler;
return useRef(((...args: any[]) => handlerRef.current(...args)) as T).current;
};
21 changes: 21 additions & 0 deletions packages/theme-default/src/logic/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';

/**
* Wrapper of CSS Media Query API
*/
export const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(() => {
return typeof window !== 'undefined'
? window.matchMedia(query).matches
: false;
});

useEffect(() => {
const mediaQueryList = window.matchMedia(query);
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
mediaQueryList.addEventListener('change', listener);
return () => mediaQueryList.removeEventListener('change', listener);
}, [query]);

return matches;
};
42 changes: 42 additions & 0 deletions packages/theme-default/src/logic/useStorageValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react';

/**
* Read/update the value in localStorage, and keeping it in sync with other tabs.
*/
export const useStorageValue = (key: string, defaultValue = null) => {
const [value, setValueInternal] = useState(() => {
if (typeof window === 'undefined') {
return defaultValue;
}
return localStorage.getItem(key) ?? defaultValue;
});

const setValue = useCallback(
value => {
setValueInternal(prev => {
const next = typeof value === 'function' ? value(prev) : value;
if (next == null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, next);
}
return next;
});
},
[key],
);

useEffect(() => {
const listener = (e: StorageEvent) => {
if (e.key === key) {
setValueInternal(localStorage.getItem(key) ?? defaultValue);
}
};
window.addEventListener('storage', listener);
return () => {
window.removeEventListener('storage', listener);
};
}, [key, defaultValue]);

return [value, setValue] as const;
};

0 comments on commit 476c859

Please sign in to comment.