-
-
Notifications
You must be signed in to change notification settings - Fork 307
feat: handle theme with localStorage #456
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b60838c
b1ba62d
29b36d6
06085db
483bcb2
744f2ed
e6ffe3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import { ScriptOnce } from '@tanstack/react-router' | ||
| import { clientOnly, createIsomorphicFn } from '@tanstack/react-start' | ||
| import * as React from 'react' | ||
| import { createContext, ReactNode, useEffect, useState } from 'react' | ||
| import { z } from 'zod' | ||
|
|
||
| const themeModeSchema = z.enum(['light', 'dark', 'auto']) | ||
| const resolvedThemeSchema = z.enum(['light', 'dark']) | ||
| const themeKey = 'theme' | ||
|
|
||
| type ThemeMode = z.infer<typeof themeModeSchema> | ||
| type ResolvedTheme = z.infer<typeof resolvedThemeSchema> | ||
|
|
||
| const getStoredThemeMode = createIsomorphicFn() | ||
| .server((): ThemeMode => 'auto') | ||
| .client((): ThemeMode => { | ||
| try { | ||
| const storedTheme = localStorage.getItem(themeKey) | ||
| return themeModeSchema.parse(storedTheme) | ||
| } catch { | ||
| return 'auto' | ||
| } | ||
| }) | ||
|
|
||
| const setStoredThemeMode = clientOnly((theme: ThemeMode) => { | ||
| try { | ||
| const parsedTheme = themeModeSchema.parse(theme) | ||
| localStorage.setItem(themeKey, parsedTheme) | ||
| } catch {} | ||
| }) | ||
|
|
||
| const getSystemTheme = createIsomorphicFn() | ||
| .server((): ResolvedTheme => 'light') | ||
| .client((): ResolvedTheme => { | ||
| return window.matchMedia('(prefers-color-scheme: dark)').matches | ||
| ? 'dark' | ||
| : 'light' | ||
| }) | ||
|
|
||
| const updateThemeClass = clientOnly((themeMode: ThemeMode) => { | ||
| const root = document.documentElement | ||
| root.classList.remove('light', 'dark', 'auto') | ||
| const newTheme = themeMode === 'auto' ? getSystemTheme() : themeMode | ||
| root.classList.add(newTheme) | ||
|
|
||
| if (themeMode === 'auto') { | ||
| root.classList.add('auto') | ||
| } | ||
| }) | ||
|
|
||
| const setupPreferredListener = clientOnly(() => { | ||
| const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') | ||
| const handler = () => updateThemeClass('auto') | ||
| mediaQuery.addEventListener('change', handler) | ||
| return () => mediaQuery.removeEventListener('change', handler) | ||
| }) | ||
|
|
||
| const getNextTheme = clientOnly((current: ThemeMode): ThemeMode => { | ||
| const themes: ThemeMode[] = | ||
| getSystemTheme() === 'dark' | ||
| ? ['auto', 'light', 'dark'] | ||
| : ['auto', 'dark', 'light'] | ||
| return themes[(themes.indexOf(current) + 1) % themes.length] | ||
| }) | ||
|
|
||
| const themeDetectorScript = (function () { | ||
| function themeFn() { | ||
| try { | ||
| const storedTheme = localStorage.getItem('theme') || 'auto' | ||
| const validTheme = ['light', 'dark', 'auto'].includes(storedTheme) | ||
| ? storedTheme | ||
| : 'auto' | ||
|
|
||
| if (validTheme === 'auto') { | ||
| const autoTheme = window.matchMedia('(prefers-color-scheme: dark)') | ||
| .matches | ||
| ? 'dark' | ||
| : 'light' | ||
| document.documentElement.classList.add(autoTheme, 'auto') | ||
| } else { | ||
| document.documentElement.classList.add(validTheme) | ||
| } | ||
| } catch (e) { | ||
| const autoTheme = window.matchMedia('(prefers-color-scheme: dark)') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we call this again? when does the above throw?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is just in case accessing localStorage fails |
||
| .matches | ||
| ? 'dark' | ||
| : 'light' | ||
| document.documentElement.classList.add(autoTheme, 'auto') | ||
| } | ||
| } | ||
| return `(${themeFn.toString()})();` | ||
| })() | ||
|
|
||
| type ThemeContextProps = { | ||
| themeMode: ThemeMode | ||
| resolvedTheme: ResolvedTheme | ||
| setTheme: (theme: ThemeMode) => void | ||
| toggleMode: () => void | ||
| } | ||
| const ThemeContext = createContext<ThemeContextProps | undefined>(undefined) | ||
|
|
||
| type ThemeProviderProps = { | ||
| children: ReactNode | ||
| } | ||
| export function ThemeProvider({ children }: ThemeProviderProps) { | ||
| const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredThemeMode) | ||
|
|
||
| useEffect(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we really need that effect? cant we just execute it whenever setTheme is called?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can move |
||
| if (themeMode !== 'auto') return | ||
| return setupPreferredListener() | ||
| }, [themeMode]) | ||
|
|
||
| const resolvedTheme = themeMode === 'auto' ? getSystemTheme() : themeMode | ||
|
|
||
| const setTheme = (newTheme: ThemeMode) => { | ||
| setThemeMode(newTheme) | ||
| setStoredThemeMode(newTheme) | ||
| updateThemeClass(newTheme) | ||
| } | ||
|
|
||
| const toggleMode = () => { | ||
| setTheme(getNextTheme(themeMode)) | ||
| } | ||
|
|
||
| return ( | ||
| <ThemeContext.Provider | ||
| value={{ themeMode, resolvedTheme, setTheme, toggleMode }} | ||
| > | ||
| <ScriptOnce children={themeDetectorScript} /> | ||
| {children} | ||
| </ThemeContext.Provider> | ||
| ) | ||
| } | ||
|
|
||
| export const useTheme = () => { | ||
| const context = React.useContext(ThemeContext) | ||
| if (!context) { | ||
| throw new Error('useTheme must be used within a ThemeProvider') | ||
| } | ||
| return context | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we have a subscription to theme change on the client? e.g. if i switch from dark to light mode on the client in my browser or OS settings, does this update the theme here as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it's the
setupPreferredListenerfunction setting it up. While onautoif you change the theme for example on your OS setting it immediately updates on tanstack.com