Skip to content

Commit

Permalink
v0.0.13: System default, color-scheme, bug fixes (pacocoursey#30)
Browse files Browse the repository at this point in the history
* system as defaultTheme if enableSystem is true

* Don't save default into localStorage on first load

* Cleanup

* Support color-scheme property

* Attempt flashing fix

* Fix issues with color-scheme, fix bug with switching theme in forced theme page

* Another fix for setTheme inside of forcedTheme

* v0.0.13-beta.2

* Fix setting system twice

* v0.0.13-beta.3

* Update docs

* Fix typo
  • Loading branch information
pacocoursey authored Mar 19, 2021
1 parent 7c8a326 commit 11a86e0
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 53 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ An abstraction for themes in your Next.js app.

- ✅ Perfect dark mode in 2 lines of code
- ✅ System setting with prefers-color-scheme
- ✅ Themed browser UI with color-scheme
- ✅ No flash on load (both SSR and SSG)
- ✅ Sync theme across tabs and windows
- ✅ Disable flashing when changing themes
Expand Down Expand Up @@ -99,9 +100,10 @@ Let's dig into the details.
All your theme configuration is passed to ThemeProvider.

- `storageKey = 'theme'`: Key used to store theme setting in localStorage
- `defaultTheme = 'light'`: Default theme name
- `defaultTheme = 'system'`: Default theme name (for v0.0.12 and lower the default was `light`). If `enableSystem` is false, the default theme is `light`
- `forcedTheme`: Forced theme name for the current page (does not modify saved theme settings)
- `enableSystem = true`: Whether to switch between `dark` and `light` based on `prefers-color-scheme`
- `enableColorScheme = true`: Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttons
- `disableTransitionOnChange = false`: Optionally disable all CSS transitions when switching themes ([example](#disable-transitions-on-theme-change))
- `themes = ['light', 'dark']`: List of theme names
- `attribute = 'data-theme'`: HTML attribute modified based on the active theme
Expand Down
157 changes: 106 additions & 51 deletions index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, {
useContext,
useEffect,
useState,
useRef,
memo
} from 'react'
import NextHead from 'next/head'
Expand All @@ -23,6 +24,9 @@ const ThemeContext = createContext<UseThemeProps>({
})
export const useTheme = () => useContext(ThemeContext)

const colorSchemes = ['light', 'dark']
const MEDIA = '(prefers-color-scheme: dark)'

interface ValueObject {
[themeName: string]: string
}
Expand All @@ -31,6 +35,7 @@ export interface ThemeProviderProps {
forcedTheme?: string
disableTransitionOnChange?: boolean
enableSystem?: boolean
enableColorScheme?: boolean
storageKey?: string
themes?: string[]
defaultTheme?: string
Expand All @@ -42,79 +47,94 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
forcedTheme,
disableTransitionOnChange = false,
enableSystem = true,
enableColorScheme = true,
storageKey = 'theme',
themes = ['light', 'dark'],
defaultTheme = 'light',
defaultTheme = enableSystem ? 'system' : 'light',
attribute = 'data-theme',
value,
children
}) => {
const [theme, setThemeState] = useState(() => getTheme(storageKey))
const [theme, setThemeState] = useState(() =>
getTheme(storageKey, defaultTheme)
)
const [resolvedTheme, setResolvedTheme] = useState(() => getTheme(storageKey))
const attrs = !value ? themes : Object.values(value)

const changeTheme = useCallback((theme, updateStorage = true) => {
const name = value?.[theme] || theme
const handleMediaQuery = useCallback(
(e?) => {
const systemTheme = getSystemTheme(e)
setResolvedTheme(systemTheme)
if (theme === 'system' && !forcedTheme) changeTheme(systemTheme, false)
},
[theme, forcedTheme]
)

const enable = disableTransitionOnChange ? disableAnimation() : null
// Ref hack to avoid adding handleMediaQuery as a dep
const mediaListener = useRef(handleMediaQuery)
mediaListener.current = handleMediaQuery

if (updateStorage) {
try {
localStorage.setItem(storageKey, theme)
} catch (e) {
// Unsupported
}
}
const changeTheme = useCallback(
(theme, updateStorage = true, updateDOM = true) => {
let name = value?.[theme] || theme

const d = document.documentElement
const enable =
disableTransitionOnChange && updateDOM ? disableAnimation() : null

if (attribute === 'class') {
d.classList.remove(...attrs)
d.classList.add(name)
} else {
d.setAttribute(attribute, name)
}
enable?.()
// All of these deps are stable and should never change
}, []) // eslint-disable-line
if (updateStorage) {
try {
localStorage.setItem(storageKey, theme)
} catch (e) {
// Unsupported
}
}

const handleMediaQuery = useCallback(
(e) => {
const isDark = e.matches
const systemTheme = isDark ? 'dark' : 'light'
setResolvedTheme(systemTheme)
if (theme === 'system' && enableSystem) {
const resolved = getSystemTheme()
name = value?.[resolved] || resolved
}

if (updateDOM) {
const d = document.documentElement

if (theme === 'system') changeTheme(systemTheme, false)
if (attribute === 'class') {
d.classList.remove(...attrs)
d.classList.add(name)
} else {
d.setAttribute(attribute, name)
}
enable?.()
}
},
[theme] // eslint-disable-line
[]
)

useEffect(() => {
if (!enableSystem) {
return
}
const handler = mediaListener.current

// Always listen to System preference
const media = window.matchMedia('(prefers-color-scheme: dark)')
media.addListener(handleMediaQuery)
handleMediaQuery(media)
const media = window.matchMedia(MEDIA)

// Intentionally use deprecated listener methods to support iOS & old browsers
media.addListener(handler)
handler(media)

return () => media.removeListener(handleMediaQuery)
}, [handleMediaQuery]) // eslint-disable-line
return () => media.removeListener(handler)
}, [])

const setTheme = useCallback(
(newTheme) => {
if (forcedTheme) {
return
changeTheme(newTheme, true, false)
} else {
changeTheme(newTheme)
}

changeTheme(newTheme)
setThemeState(newTheme)
},
// All of these deps are stable and should never change
[] // eslint-disable-line
[forcedTheme]
)

// localStorage event handling
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key !== storageKey) {
Expand All @@ -127,8 +147,28 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({

window.addEventListener('storage', handleStorage)
return () => window.removeEventListener('storage', handleStorage)
// All of these deps are stable and should never change
}, []) // eslint-disable-line
}, [setTheme])

// color-scheme handling
useEffect(() => {
if (!enableColorScheme) return

let colorScheme =
// If theme is forced to light or dark, use that
forcedTheme && colorSchemes.includes(forcedTheme)
? forcedTheme
: // If regular theme is light or dark
theme && colorSchemes.includes(theme)
? theme
: // If theme is system, use the resolved version
theme === 'system'
? resolvedTheme || null
: null

// color-scheme tells browser how to render built-in elements like forms, scrollbars, etc.
// if color-scheme is null, this will remove the property
document.documentElement.style.setProperty('color-scheme', colorScheme)
}, [enableColorScheme, theme, resolvedTheme, forcedTheme])

return (
<ThemeContext.Provider
Expand Down Expand Up @@ -202,6 +242,8 @@ const ThemeScript = memo(
return `d.setAttribute('${attribute}', ${val})`
}

const defaultSystem = defaultTheme === 'system'

return (
<NextHead>
{forcedTheme ? (
Expand All @@ -218,15 +260,15 @@ const ThemeScript = memo(
key="next-themes-script"
dangerouslySetInnerHTML={{
// prettier-ignore
__html: `!function(){try {${optimization}var e=localStorage.getItem('${storageKey}');if(!e)return localStorage.setItem('${storageKey}','${defaultTheme}'),${updateDOM(defaultTheme)};if("system"===e){var t="(prefers-color-scheme: dark)",m=window.matchMedia(t);m.media!==t||m.matches?${updateDOM('dark')}:${updateDOM('light')}}else ${value ? `var x=${JSON.stringify(value)};` : ''}${updateDOM(value ? 'x[e]' : 'e', true)}}catch(e){}}()`
__html: `!function(){try {${optimization}var e=localStorage.getItem('${storageKey}');${!defaultSystem ? updateDOM(defaultTheme) + ';' : ''}if("system"===e||(!e&&${defaultSystem})){var t="${MEDIA}",m=window.matchMedia(t);m.media!==t||m.matches?${updateDOM('dark')}:${updateDOM('light')}}else if(e) ${value ? `var x=${JSON.stringify(value)};` : ''}${updateDOM(value ? 'x[e]' : 'e', true)}}catch(e){}}()`
}}
/>
) : (
<script
key="next-themes-script"
dangerouslySetInnerHTML={{
// prettier-ignore
__html: `!function(){try{${optimization}var t=localStorage.getItem("${storageKey}");if(!t)return localStorage.setItem("${storageKey}","${defaultTheme}"),${updateDOM(defaultTheme)};${value ? `var x=${JSON.stringify(value)};` : ''}${updateDOM(value ? 'x[t]' : 't', true)}}catch(t){}}();`
__html: `!function(){try{${optimization}var e=localStorage.getItem("${storageKey}");if(e){${value ? `var x=${JSON.stringify(value)};` : ''}${updateDOM(value ? 'x[e]' : 'e', true)}}else{${updateDOM(defaultTheme)};}}catch(t){}}();`
}}
/>
)}
Expand All @@ -242,15 +284,15 @@ const ThemeScript = memo(
)

// Helpers
const getTheme = (key: string) => {
const getTheme = (key: string, fallback?: string) => {
if (typeof window === 'undefined') return undefined
let theme
try {
theme = localStorage.getItem(key) || undefined
} catch (e) {
// Unsupported
}
return theme
return theme || fallback
}

const disableAnimation = () => {
Expand All @@ -264,8 +306,21 @@ const disableAnimation = () => {

return () => {
// Force restyle
// The CSS property doesn't matter, use "top" because it's short
;(() => window.getComputedStyle(css).top)()
document.head.removeChild(css)
;(() => window.getComputedStyle(document.body))()

// Wait for next tick before removing
setTimeout(() => {
document.head.removeChild(css)
}, 1)
}
}

const getSystemTheme = (e?: MediaQueryList) => {
if (!e) {
e = window.matchMedia(MEDIA)
}

const isDark = e.matches
const systemTheme = isDark ? 'dark' : 'light'
return systemTheme
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "next-themes",
"version": "0.0.11",
"version": "0.0.13-beta.3",
"main": "./dist/index.js",
"module": "./dist/index.modern.js",
"types": "./dist/index.d.ts",
Expand Down

0 comments on commit 11a86e0

Please sign in to comment.