Skip to content

Commit

Permalink
refactor: revamped theme provider
Browse files Browse the repository at this point in the history
  • Loading branch information
riipandi committed Sep 18, 2024
1 parent 19fd353 commit 49d4c13
Show file tree
Hide file tree
Showing 4 changed files with 67 additions and 92 deletions.
28 changes: 6 additions & 22 deletions app/components/theme.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,27 @@
import { useState } from 'react'
import { Theme, useTheme } from '#/context/providers/theme-provider'
import { Theme, type ThemeType, useTheme } from '#/context/providers/theme-provider'
import { clx } from '#/utils/ui-helper'

const themes = [
{ id: Theme.LIGHT, name: 'Light' },
{ id: Theme.DARK, name: 'Dark' },
{ id: Theme.SYSTEM, name: 'System' },
] as const

type ThemeOption = (typeof themes)[number]

export default function ThemeSwitcher() {
const [theme, setTheme] = useTheme()
const [selectedTheme, setSelectedTheme] = useState<ThemeOption>(
themes.find((t) => t.id === theme) || themes[0]
)

const handleThemeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newTheme = themes.find((theme) => theme.id === (event.target.value as Theme))
if (newTheme) {
setSelectedTheme(newTheme)
setTheme(newTheme.id)
}
setTheme(event.target.value as ThemeType)
}

return (
<div className="relative">
<select
value={selectedTheme.id}
value={theme || Theme.SYSTEM}
onChange={handleThemeChange}
className={clx(
'w-full appearance-none rounded-lg border px-3 py-1.5 pr-8 text-sm focus:outline-none',
'border-gray-300 bg-white text-gray-900 focus:border-primary-500',
'dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-primary-400'
)}
>
{themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
{Object.entries(Theme).map(([key, value]) => (
<option key={value} value={value}>
{key.charAt(0) + key.slice(1).toLowerCase()}
</option>
))}
</select>
Expand Down
125 changes: 58 additions & 67 deletions app/context/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,116 +1,107 @@
/*!
* Portions of this file are based on code from `mattstobbs/remix-dark-mode`.
* Credits to Matt Stobbs: https://github.com/mattstobbs/remix-dark-mode
*/

import { useFetcher, useRevalidator } from '@remix-run/react'
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import type { EnumValues } from '#/utils/common'

enum Theme {
DARK = 'dark',
LIGHT = 'light',
SYSTEM = 'system',
}
const Theme = {
DARK: 'dark',
LIGHT: 'light',
SYSTEM: 'system',
} as const

type ThemeType = EnumValues<typeof Theme>

type ThemeContextType = [Theme | null, Dispatch<SetStateAction<Theme | null>>]
type ThemeContextType = [ThemeType | null, Dispatch<SetStateAction<ThemeType | null>>]

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

const themes: Theme[] = Object.values(Theme)
const prefersLightMQ = '(prefers-color-scheme: light)'
const prefersDarkMQ = '(prefers-color-scheme: dark)'

const getPreferredTheme = (): Theme => {
const getTheme = (): ThemeType => {
if (typeof window === 'undefined') return Theme.SYSTEM
if (window.matchMedia(prefersLightMQ).matches) return Theme.LIGHT
if (window.matchMedia(prefersDarkMQ).matches) return Theme.DARK
return Theme.SYSTEM
}

const getThemeFromSystem = (): Theme.LIGHT | Theme.DARK => {
if (typeof window === 'undefined') return Theme.LIGHT
return window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK
}

interface ThemeProviderProps {
children: React.ReactNode
specifiedTheme?: EnumValues<typeof Theme>
specifiedTheme?: ThemeType
}

function ThemeProvider({ children, specifiedTheme }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme | null>(() => {
if (specifiedTheme && themes.includes(specifiedTheme)) {
return specifiedTheme
}
return null
})

function useThemeEffect(theme: ThemeType | null) {
const { revalidate } = useRevalidator()
const persistTheme = useFetcher()
const mountRun = useRef(false)

// biome-ignore lint/correctness/useExhaustiveDependencies: need to render once
const setThemeClass = useCallback((newTheme: ThemeType) => {
document.documentElement.classList.remove(Theme.LIGHT, Theme.DARK)
document.documentElement.classList.add(newTheme)
}, [])

// biome-ignore lint/correctness/useExhaustiveDependencies: render once
useEffect(() => {
if (!mountRun.current) {
mountRun.current = true
setTheme(specifiedTheme || getPreferredTheme())
return
}
if (theme) {
persistTheme.submit({ theme }, { action: 'set-theme', method: 'POST' })
const resolvedTheme = theme === Theme.SYSTEM ? getThemeFromSystem() : theme
document.documentElement.classList.remove(Theme.LIGHT, Theme.DARK)
document.documentElement.classList.add(resolvedTheme)
const resolvedTheme = theme === Theme.SYSTEM ? getTheme() : theme
setThemeClass(resolvedTheme)
}
}, [theme, specifiedTheme])
}, [theme])

useEffect(() => {
if (typeof window === 'undefined') return
const mediaQuery = window.matchMedia(prefersLightMQ)
const handleChange = () => {
if (theme === Theme.SYSTEM) {
const newTheme = getThemeFromSystem()
document.documentElement.classList.remove(Theme.LIGHT, Theme.DARK)
document.documentElement.classList.add(newTheme)
setThemeClass(getTheme())
}
revalidate()
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [theme, revalidate])
}, [theme, revalidate, setThemeClass])
}

function ThemeProvider({ children, specifiedTheme }: ThemeProviderProps) {
const [theme, setTheme] = useState<ThemeType | null>(() => {
if (specifiedTheme && Object.values(Theme).includes(specifiedTheme)) {
return specifiedTheme
}
return null
})

const mountRun = useRef(false)

useEffect(() => {
if (!mountRun.current) {
mountRun.current = true
setTheme(specifiedTheme ?? getTheme())
}
}, [specifiedTheme])

useThemeEffect(theme)

return <ThemeContext.Provider value={[theme, setTheme]}>{children}</ThemeContext.Provider>
}

function NonFlashOfWrongThemeEls({ ssrTheme }: { ssrTheme: boolean }) {
const [theme] = useTheme()

// Client-side theme resolution only
const resolvedTheme =
typeof window !== 'undefined' && theme === Theme.SYSTEM ? getThemeFromSystem() : theme

// Handle color scheme for light/dark
const resolvedTheme = theme === Theme.SYSTEM ? getTheme() : theme
const colorScheme =
resolvedTheme === Theme.LIGHT ? 'light' : resolvedTheme === Theme.DARK ? 'dark' : 'light dark'

const setThemeScript = `
(function() {
const theme = ${JSON.stringify(resolvedTheme)};
const cl = document.documentElement.classList;
cl.remove('light', 'dark');
cl.add(theme);
})();
`

return (
<>
<meta name="color-scheme" content={ssrTheme ? 'light' : colorScheme} />
{!ssrTheme && (
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation>
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = ${JSON.stringify(resolvedTheme)};
const cl = document.documentElement.classList;
cl.remove('light', 'dark');
cl.add(theme);
})();
`,
}}
// biome-ignore lint/security/noDangerouslySetInnerHtml: Inline script is necessary for theme initialization
dangerouslySetInnerHTML={{ __html: setThemeScript }}
/>
)}
</>
Expand All @@ -125,8 +116,8 @@ function useTheme(): ThemeContextType {
return context
}

function isTheme(value: unknown): value is Theme {
return typeof value === 'string' && themes.includes(value as Theme)
function isTheme(value: unknown): value is ThemeType {
return typeof value === 'string' && Object.values(Theme).includes(value as ThemeType)
}

export { Theme, isTheme, NonFlashOfWrongThemeEls, ThemeProvider, useTheme }
export { Theme, type ThemeType, isTheme, NonFlashOfWrongThemeEls, ThemeProvider, useTheme }
2 changes: 1 addition & 1 deletion app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@layer base {
html,
body {
@apply size-full min-h-screen antialiased scroll-smooth dark:bg-gray-950;
@apply size-full min-h-screen antialiased scroll-smooth bg-white dark:bg-gray-950;
}
}

Expand Down
4 changes: 2 additions & 2 deletions app/utils/theme.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { createCookieSessionStorage } from '@remix-run/node'
import { Theme, isTheme } from '#/context/providers/theme-provider'
import { Theme, type ThemeType, isTheme } from '#/context/providers/theme-provider'
import { GlobalCookiesOptions } from '#/utils/env.server'

// Expired in 720 hours / 30 days from now
Expand All @@ -31,7 +31,7 @@ async function getThemeSession(request: Request) {
const themeValue = session.get('theme')
return isTheme(themeValue) ? themeValue : Theme.SYSTEM
},
setTheme: (theme: Theme) => session.set('theme', theme),
setTheme: (theme: ThemeType) => session.set('theme', theme),
commit: () => themeStorage.commitSession(session, { expires: cookiesExpiry }),
}
}
Expand Down

0 comments on commit 49d4c13

Please sign in to comment.