Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 3 additions & 3 deletions src/generators/web/ui/components/NavBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Logo from '#config/Logo';
* NavBar component that displays the headings, search, etc.
*/
export default () => {
const [theme, toggleTheme] = useTheme();
const [themePreference, setThemePreference] = useTheme();

return (
<NavBar
Expand All @@ -23,8 +23,8 @@ export default () => {
>
<SearchBox />
<ThemeToggle
onClick={toggleTheme}
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
onChange={setThemePreference}
currentTheme={themePreference}
/>
<a
href={`https://github.com/${STATIC_DATA.repository}`}
Expand Down
123 changes: 93 additions & 30 deletions src/generators/web/ui/hooks/useTheme.mjs
Original file line number Diff line number Diff line change
@@ -1,49 +1,112 @@
import { useState, useEffect, useCallback } from 'react';

const THEME_STORAGE_KEY = 'theme';
const THEME_PREFERENCES = new Set(['system', 'light', 'dark']);

/**
* Sets up theme toggle button and system preference listener
*/
const getSystemTheme = () =>
matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

/**
* Applies the given theme to the `<html>` element's `data-theme` attribute
* and persists the theme preference in `localStorage`.
* Retrieves the stored theme preference from local storage.
*
* @param {string} theme - The theme to apply ('light' or 'dark').
* @returns {'system'|'light'|'dark'|null} The stored theme preference or null if not found.
*/
const applyTheme = theme => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
localStorage.setItem('theme', theme);
const getStoredThemePreference = () => {
try {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
return THEME_PREFERENCES.has(storedTheme) ? storedTheme : null;
} catch {
return null;
}
};

/**
* A React hook for managing the application's light/dark theme.
* Stores the theme preference in local storage.
* If storage is unavailable, it fails silently, allowing the application to continue functioning with an in-memory preference.
*
* @param {'system'|'light'|'dark'} themePreference - The theme preference to store.
*/
const setStoredThemePreference = themePreference => {
try {
localStorage.setItem(THEME_STORAGE_KEY, themePreference);
} catch {
// Ignore storage failures and keep non-persistent in-memory preference.
}
};

/**
* Applies a theme preference to the document.
*
* The persisted preference can be 'system', but the applied document theme is
* always resolved to either 'light' or 'dark'.
*
* @param {'system'|'light'|'dark'} themePreference - Theme preference.
*/
const applyThemePreference = themePreference => {
const resolvedTheme =
themePreference === 'system' ? getSystemTheme() : themePreference;

document.documentElement.setAttribute('data-theme', resolvedTheme);
document.documentElement.style.colorScheme = resolvedTheme;
};

/**
* A React hook for managing the application's theme preference.
*/
export const useTheme = () => {
const [theme, setTheme] = useState('light');
const [themePreference, setThemePreferenceState] = useState('system');

useEffect(() => {
const initial =
// Try to get the theme from localStorage first.
localStorage.getItem('theme') ||
// If not found, check the `data-theme` attribute on the document element
document.documentElement.getAttribute('data-theme') ||
// As a final fallback, check the user's system preference for dark mode.
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');

applyTheme(initial);
setTheme(initial);
// Use persisted preference if available, otherwise default to system.
const initialPreference = getStoredThemePreference() || 'system';

applyThemePreference(initialPreference);
setThemePreferenceState(initialPreference);
}, []);

/**
* Callback function to toggle between 'light' and 'dark' themes.
* Keep the resolved document theme in sync with system changes
* whenever the preference is set to 'system'.
*/
useEffect(() => {
if (themePreference !== 'system') {
return;
}

const mediaQueryList = matchMedia('(prefers-color-scheme: dark)');
/**
*
*/
const handleSystemThemeChange = () => applyThemePreference('system');

if ('addEventListener' in mediaQueryList) {
mediaQueryList.addEventListener('change', handleSystemThemeChange);
return () => {
mediaQueryList.removeEventListener('change', handleSystemThemeChange);
};
}

mediaQueryList.addListener(handleSystemThemeChange);
return () => {
mediaQueryList.removeListener(handleSystemThemeChange);
};
}, [themePreference]);

/**
* Updates the theme preference and applies it immediately.
*/
const toggleTheme = useCallback(() => {
setTheme(prev => {
// Determine the next theme based on the current theme.
const next = prev === 'light' ? 'dark' : 'light';
// Apply the new theme.
applyTheme(next);
// Return the new theme to update the state.
return next;
});
const setThemePreference = useCallback(nextPreference => {
if (!THEME_PREFERENCES.has(nextPreference)) {
return;
}

setThemePreferenceState(nextPreference);
setStoredThemePreference(nextPreference);
applyThemePreference(nextPreference);
}, []);

return [theme, toggleTheme];
return [themePreference, setThemePreference];
};
Loading