Skip to content
Open
Show file tree
Hide file tree
Changes from all 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;
}
};
Comment on lines +17 to 24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just assume that the theme preference is correct, no need for the ternary or catch.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are proposing to just return localStorage.getItem ?


/**
* 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);
};
Comment on lines +85 to +95
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addEventListener is standard, is it not? We don't need a backup check

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature is well established and works across many devices and browser versions. It’s been available across browsers since July 2015.

https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList

so we can remove the back-up change

Copy link
Member

@MattIPv4 MattIPv4 Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be careful about Safari support here, esp. on iOS: https://caniuse.com/mdn-api_mediaquerylist_change_event. Support only landed in late 2020.

}, [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;
}
Comment on lines +102 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this ever happen?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no we can remove it


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

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