From 11a86e01bd55d3a08beab7262d7c65680ca4cfc2 Mon Sep 17 00:00:00 2001 From: Paco <34928425+pacocoursey@users.noreply.github.com> Date: Fri, 19 Mar 2021 12:32:26 -0600 Subject: [PATCH] v0.0.13: System default, color-scheme, bug fixes (#30) * 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 --- README.md | 4 +- index.tsx | 157 ++++++++++++++++++++++++++++++++++----------------- package.json | 2 +- 3 files changed, 110 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 79d551d..b46ccc9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/index.tsx b/index.tsx index 0e11687..bb5f84a 100644 --- a/index.tsx +++ b/index.tsx @@ -4,6 +4,7 @@ import React, { useContext, useEffect, useState, + useRef, memo } from 'react' import NextHead from 'next/head' @@ -23,6 +24,9 @@ const ThemeContext = createContext({ }) export const useTheme = () => useContext(ThemeContext) +const colorSchemes = ['light', 'dark'] +const MEDIA = '(prefers-color-scheme: dark)' + interface ValueObject { [themeName: string]: string } @@ -31,6 +35,7 @@ export interface ThemeProviderProps { forcedTheme?: string disableTransitionOnChange?: boolean enableSystem?: boolean + enableColorScheme?: boolean storageKey?: string themes?: string[] defaultTheme?: string @@ -42,79 +47,94 @@ export const ThemeProvider: React.FC = ({ 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) { @@ -127,8 +147,28 @@ export const ThemeProvider: React.FC = ({ 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 ( {forcedTheme ? ( @@ -218,7 +260,7 @@ 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){}}()` }} /> ) : ( @@ -226,7 +268,7 @@ const ThemeScript = memo( 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){}}();` }} /> )} @@ -242,7 +284,7 @@ const ThemeScript = memo( ) // Helpers -const getTheme = (key: string) => { +const getTheme = (key: string, fallback?: string) => { if (typeof window === 'undefined') return undefined let theme try { @@ -250,7 +292,7 @@ const getTheme = (key: string) => { } catch (e) { // Unsupported } - return theme + return theme || fallback } const disableAnimation = () => { @@ -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 +} diff --git a/package.json b/package.json index c656960..ddcd52c 100644 --- a/package.json +++ b/package.json @@ -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",