Skip to content

Commit

Permalink
feat: dark mode theme
Browse files Browse the repository at this point in the history
  • Loading branch information
riipandi committed Sep 16, 2024
1 parent 5fbdfc0 commit a0e2786
Show file tree
Hide file tree
Showing 10 changed files with 292 additions and 13 deletions.
23 changes: 23 additions & 0 deletions app/components/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Theme, useTheme } from '#/context/providers/theme-provider'

export default function ThemeSwitcher() {
const [, setTheme] = useTheme()
return (
<div className="flex flex-wrap justify-center gap-4 rounded-2xl p-1">
<button
type="button"
className="rounded-xl border-2 border-gray-900 bg-white px-4 py-1 text-gray-900 text-sm"
onClick={() => setTheme(Theme.LIGHT)}
>
Light
</button>
<button
type="button"
className="rounded-xl border-2 border-gray-50 bg-gray-900 px-4 py-1 text-gray-50 text-sm"
onClick={() => setTheme(Theme.DARK)}
>
Dark
</button>
</div>
)
}
137 changes: 137 additions & 0 deletions app/context/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*!
* 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 } from '@remix-run/react'
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import type { Dispatch, ReactNode, SetStateAction } from 'react'

/**
* Enum representing available themes
*/
enum Theme {
DARK = 'dark',
LIGHT = 'light',
}

const themes: Theme[] = Object.values(Theme)

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

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

const prefersLightMQ = '(prefers-color-scheme: light)'

/**
* Get the preferred theme based on system preferences
*/
const getPreferredTheme = (): Theme =>
window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK

/**
* ThemeProvider component to manage theme state
*/
function ThemeProvider({
children,
specifiedTheme,
}: {
children: ReactNode
specifiedTheme: Theme | null
}) {
const [theme, setTheme] = useState<Theme | null>(() => {
if (specifiedTheme && themes.includes(specifiedTheme)) {
return specifiedTheme
}
return typeof window === 'object' ? getPreferredTheme() : null
})

const persistTheme = useFetcher()
const mountRun = useRef(false)

useEffect(() => {
if (!mountRun.current) {
mountRun.current = true
return
}
if (theme) {
persistTheme.submit({ theme }, { action: 'set-theme', method: 'post' })
}
}, [theme, persistTheme])

useEffect(() => {
const mediaQuery = window.matchMedia(prefersLightMQ)
const handleChange = () => setTheme(mediaQuery.matches ? Theme.LIGHT : Theme.DARK)
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])

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

// Client-side theme detection and application
const clientThemeCode = `
;(() => {
const theme = window.matchMedia(${JSON.stringify(prefersLightMQ)}).matches ? 'light' : 'dark';
const cl = document.documentElement.classList;
const themeAlreadyApplied = cl.contains('light') || cl.contains('dark');
if (themeAlreadyApplied) {
// this script shouldn't exist if the theme is already applied!
console.warn("Hi there, could you let Matt know you're seeing this message? Thanks!");
} else {
cl.add(theme);
}
const meta = document.querySelector('meta[name=color-scheme]');
if (meta) {
if (theme === 'dark') {
meta.content = 'dark light';
} else if (theme === 'light') {
meta.content = 'light dark';
}
} else {
console.warn("Hey, could you let Matt know you're seeing this message? Thanks!");
}
})();
`

/**
* Component to prevent flash of wrong theme
*/
function NonFlashOfWrongThemeEls({ ssrTheme }: { ssrTheme: boolean }) {
const [theme] = useTheme()

return (
<>
<meta name="color-scheme" content={theme === Theme.LIGHT ? 'light dark' : 'dark light'} />
{!ssrTheme && (
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for clientThemeCode
dangerouslySetInnerHTML={{ __html: clientThemeCode }}
/>
)}
</>
)
}

/**
* Hook to access the current theme and theme setter
*/
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}

/**
* Type guard to check if a value is a valid Theme
*/
function isTheme(value: unknown): value is Theme {
return typeof value === 'string' && themes.includes(value as Theme)
}

export { isTheme, NonFlashOfWrongThemeEls, Theme, ThemeProvider, useTheme }
33 changes: 26 additions & 7 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import type { LinksFunction, LoaderFunction, MetaDescriptor, MetaFunction } from '@remix-run/node'
import { Links, Meta, Outlet, Scripts, json, useRouteError } from '@remix-run/react'
import { Links, Meta, Outlet, Scripts, json, useLoaderData, useRouteError } from '@remix-run/react'
import { ScrollRestoration, isRouteErrorResponse } from '@remix-run/react'
import { type PropsWithChildren, useEffect } from 'react'
import { useEffect } from 'react'

import InternalError from '#/components/errors/internal-error'
import NotFound from '#/components/errors/not-found'
import { useNonce } from '#/context/providers/nonce-provider'
import {
NonFlashOfWrongThemeEls,
ThemeProvider,
useTheme,
} from '#/context/providers/theme-provider'
import { getThemeSession } from '#/utils/theme.server'
import { clx } from '#/utils/ui-helper'

import styles from './styles.css?url'

export const loader: LoaderFunction = async ({ request, context }) => {
const themeSession = await getThemeSession(request)

return json({
// Dynamic Canonical URL: https://sergiodxa.com/tutorials/add-dynamic-canonical-url-to-remix-routes
meta: [{ tagName: 'link', rel: 'canonical', href: request.url }] satisfies MetaDescriptor[],
nonce: context.nonce as string,
theme: themeSession.getTheme(),
baseUrl: process.env.APP_BASE_URL,
})
}
Expand All @@ -41,7 +50,9 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
]
}

export function Layout({ children }: PropsWithChildren) {
function App() {
const data = useLoaderData<typeof loader>()
const [theme] = useTheme()
const nonce = useNonce()

useEffect(() => {
Expand Down Expand Up @@ -77,18 +88,21 @@ export function Layout({ children }: PropsWithChildren) {
}, [])

return (
<html lang="en">
<html lang="en" className={clx(theme)}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<NonFlashOfWrongThemeEls ssrTheme={Boolean(data.theme)} />
<Meta />
<Links />
</head>
<body className={clx(import.meta.env.DEV && 'debug-breakpoints')} suppressHydrationWarning>
<a href="#main" className="skiplink">
Skip to main content
</a>
<div id="main">{children}</div>
<div id="main">
<Outlet />
</div>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
Expand Down Expand Up @@ -127,6 +141,11 @@ export function ErrorBoundary() {
)
}

export default function App() {
return <Outlet />
export default function AppWithProviders() {
const data = useLoaderData<typeof loader>()
return (
<ThemeProvider specifiedTheme={data.theme}>
<App />
</ThemeProvider>
)
}
8 changes: 4 additions & 4 deletions app/routes/_index.tsx → app/routes/_home+/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default function IndexPage() {

if (!data || data.isDefault) {
return (
<div className="mx-auto flex size-full min-h-screen flex-col items-center justify-center px-4 text-center sm:px-6 lg:px-8">
<>
<h1 className="block font-bold text-7xl text-gray-800 sm:text-7xl dark:text-white">
Welcome to Remix
</h1>
Expand All @@ -115,15 +115,15 @@ export default function IndexPage() {
</Link>
</Button>
</div>
</div>
</>
)
}

// Destructure the data object.
const { domain, sites, currentSite } = data

return (
<div className="mx-auto flex size-full min-h-screen flex-col items-center justify-center px-4 text-center sm:px-6 lg:px-8">
<>
<h1 className="block font-bold text-7xl text-gray-800 sm:text-7xl dark:text-white">
Welcome to Remix
</h1>
Expand Down Expand Up @@ -151,6 +151,6 @@ export default function IndexPage() {
</Button>
))}
</div>
</div>
</>
)
}
20 changes: 20 additions & 0 deletions app/routes/_home+/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { LoaderFunction } from '@remix-run/node'
import { Outlet, json } from '@remix-run/react'
import ThemeSwitcher from '#/components/theme'

export const loader: LoaderFunction = async (_ctx) => {
return json({})
}

export default function HomeLayout() {
return (
<div className="bg-white dark:bg-slate-950">
<div className="absolute top-4 right-4">
<ThemeSwitcher />
</div>
<main className="mx-auto flex size-full min-h-screen flex-col items-center justify-center px-4 text-center sm:px-6 lg:px-8">
<Outlet />
</main>
</div>
)
}
28 changes: 28 additions & 0 deletions app/routes/set-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*!
* 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 { type ActionFunctionArgs, json, redirect } from '@remix-run/node'
import { isTheme } from '#/context/providers/theme-provider'
import { getThemeSession } from '#/utils/theme.server'

export const action = async ({ request }: ActionFunctionArgs) => {
const themeSession = await getThemeSession(request)
const requestText = await request.text()
const form = new URLSearchParams(requestText)
const theme = form.get('theme')

if (!isTheme(theme)) {
return json({
success: false,
message: `theme value of ${theme} is not a valid theme`,
})
}

themeSession.setTheme(theme)

return json({ success: true }, { headers: { 'Set-Cookie': await themeSession.commit() } })
}

export const loader = () => redirect('/', { status: 404 })
50 changes: 50 additions & 0 deletions app/utils/theme.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*!
* 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 { type SessionIdStorageStrategy, createCookieSessionStorage } from '@remix-run/node'
import { Theme, isTheme } from '#/context/providers/theme-provider'

/**
* Determines if the application is running in development mode.
*/
const isDevelopment = process.env.NODE_ENV === 'development'

/**
* Configuration options for the theme cookie.
*/
const cookiesOptions: Omit<SessionIdStorageStrategy['cookie'], 'name'> = {
path: '/',
sameSite: 'lax',
secure: !isDevelopment,
secrets: isDevelopment ? [] : [process.env.APP_SECRET_KEY],
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
httpOnly: true,
}

/**
* Creates a session storage for managing theme preferences.
*/
const themeStorage = createCookieSessionStorage({
cookie: { name: 'remix_theme', ...cookiesOptions },
})

/**
* Retrieves and manages the theme session for a given request.
* @param request - The incoming request object.
* @returns An object with methods to get, set, and commit the theme.
*/
async function getThemeSession(request: Request) {
const session = await themeStorage.getSession(request.headers.get('Cookie'))
return {
getTheme: () => {
const themeValue = session.get('theme')
return isTheme(themeValue) ? themeValue : Theme.DARK
},
setTheme: (theme: Theme) => session.set('theme', theme),
commit: () => themeStorage.commitSession(session, cookiesOptions),
}
}

export { getThemeSession }
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"build:remix": "remix vite:build",
"dev": "pnpm run --reporter-hide-prefix --color --parallel \"/dev:(?!storybook)/\"",
"dev:remix": "remix vite:dev",
"dev:studio": "drizzle-kit studio --config drizzle.config.ts",
"dev:storybook": "storybook dev -p 6006 --no-open",
"preview": "SWCRC=true node --watch --import @swc-node/register/esm-register ./server/server.ts",
"start": "node --no-warnings ./dist/runner/server.js",
Expand Down
2 changes: 1 addition & 1 deletion tests/pages/homepage.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createRemixStub } from '@remix-run/testing'
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it } from 'vitest'
import Index from '#/routes/_index'
import Index from '#/routes/_home+/_index'

describe('Homepage', () => {
it('renders correctly', async () => {
Expand Down
Loading

0 comments on commit a0e2786

Please sign in to comment.