feat(next): server-side theme detection (#6452)

This commit is contained in:
Jacob Fletcher
2024-05-22 10:19:38 -04:00
committed by GitHub
parent 3c0853a675
commit 1fe9790d0d
11 changed files with 190 additions and 44 deletions

View File

@@ -15,6 +15,7 @@ import 'react-toastify/dist/ReactToastify.css'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
import { getRequestLanguage } from '../../utilities/getRequestLanguage.js'
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
import { DefaultEditView } from '../../views/Edit/Default/index.js'
import { DefaultListView } from '../../views/List/Default/index.js'
@@ -49,6 +50,12 @@ export const RootLayout = async ({
headers,
})
const theme = getRequestTheme({
config,
cookies,
headers,
})
const payload = await getPayloadHMR({ config })
const i18n: I18nClient = await initI18n({
config: config.i18n,
@@ -94,7 +101,7 @@ export const RootLayout = async ({
})
return (
<html className={merriweather.variable} dir={dir} lang={languageCode}>
<html className={merriweather.variable} data-theme={theme} dir={dir} lang={languageCode}>
<body>
<RootProvider
componentMap={componentMap}
@@ -105,6 +112,7 @@ export const RootLayout = async ({
languageOptions={languageOptions}
// eslint-disable-next-line react/jsx-no-bind
switchLanguageServerAction={switchLanguageServerAction}
theme={theme}
translations={i18n.translations}
>
{wrappedChildren}

View File

@@ -18,17 +18,19 @@ export const getRequestLanguage = ({
}: GetRequestLanguageArgs): AcceptedLanguages => {
const supportedLanguageKeys = <AcceptedLanguages[]>Object.keys(config.i18n.supportedLanguages)
const langCookie = cookies.get(`${config.cookiePrefix || 'payload'}-lng`)
const languageFromCookie: AcceptedLanguages = (
typeof langCookie === 'string' ? langCookie : langCookie?.value
) as AcceptedLanguages
const languageFromHeader = headers.get('Accept-Language')
? extractHeaderLanguage(headers.get('Accept-Language'))
: undefined
if (languageFromCookie && supportedLanguageKeys.includes(languageFromCookie)) {
return languageFromCookie
}
const languageFromHeader = headers.get('Accept-Language')
? extractHeaderLanguage(headers.get('Accept-Language'))
: undefined
if (languageFromHeader && supportedLanguageKeys.includes(languageFromHeader)) {
return languageFromHeader
}

View File

@@ -0,0 +1,33 @@
import type { Theme } from '@payloadcms/ui/providers/Theme'
import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies.js'
import type { SanitizedConfig } from 'payload/config'
import { defaultTheme } from '@payloadcms/ui/providers/Theme'
type GetRequestLanguageArgs = {
config: SanitizedConfig
cookies: Map<string, string> | ReadonlyRequestCookies
headers: Request['headers']
}
const acceptedThemes: Theme[] = ['dark', 'light']
export const getRequestTheme = ({ config, cookies, headers }: GetRequestLanguageArgs): Theme => {
const themeCookie = cookies.get(`${config.cookiePrefix || 'payload'}-theme`)
const themeFromCookie: Theme = (
typeof themeCookie === 'string' ? themeCookie : themeCookie?.value
) as Theme
if (themeFromCookie && acceptedThemes.includes(themeFromCookie)) {
return themeFromCookie
}
const themeFromHeader = headers.get('Sec-CH-Prefers-Color-Scheme') as Theme
if (themeFromHeader && acceptedThemes.includes(themeFromHeader)) {
return themeFromHeader
}
return defaultTheme
}

View File

@@ -17,6 +17,30 @@ export const withPayload = (nextConfig = {}) => {
],
},
},
headers: async () => {
const headersFromConfig = 'headers' in nextConfig ? await nextConfig.headers() : []
return [
...(headersFromConfig || []),
{
source: '/:path*',
headers: [
{
key: 'Accept-CH',
value: 'Sec-CH-Prefers-Color-Scheme',
},
{
key: 'Vary',
value: 'Sec-CH-Prefers-Color-Scheme',
},
{
key: 'Critical-CH',
value: 'Sec-CH-Prefers-Color-Scheme',
},
],
},
]
},
serverExternalPackages: [
...(nextConfig?.serverExternalPackages || []),
'drizzle-kit',

View File

@@ -5,11 +5,12 @@
align-items: center;
cursor: pointer;
margin: base(0.1) 0;
position: relative;
input[type='radio'] {
opacity: 0;
width: 0;
margin: 0;
position: absolute;
}
input[type='radio']:focus + .radio-input__styled-radio {

View File

@@ -37,6 +37,7 @@ export const Radio: React.FC<{
checked={isSelected}
disabled={readOnly}
id={id}
name={path}
onChange={() => (typeof onChange === 'function' ? onChange(option.value) : null)}
type="radio"
/>

View File

@@ -9,6 +9,7 @@ import React, { Fragment } from 'react'
import { Slide, ToastContainer } from 'react-toastify'
import type { ComponentMap } from '../ComponentMap/buildComponentMap/types.js'
import type { Theme } from '../Theme/index.js'
import type { LanguageOptions } from '../Translation/index.js'
import { LoadingOverlayProvider } from '../../elements/LoadingOverlay/index.js'
@@ -39,6 +40,7 @@ type Props = {
languageCode: string
languageOptions: LanguageOptions
switchLanguageServerAction?: (lang: string) => Promise<void>
theme: Theme
translations: I18nClient['translations']
}
@@ -51,6 +53,7 @@ export const RootProvider: React.FC<Props> = ({
languageCode,
languageOptions,
switchLanguageServerAction,
theme,
translations,
}) => {
const { ModalContainer, ModalProvider } = facelessUIImport || {
@@ -88,7 +91,7 @@ export const RootProvider: React.FC<Props> = ({
<ModalProvider classPrefix="payload" transTime={0} zIndex="var(--z-modal)">
<AuthProvider>
<PreferencesProvider>
<ThemeProvider>
<ThemeProvider cookiePrefix={config.cookiePrefix} theme={theme}>
<ParamsProvider>
<LocaleProvider>
<StepNavProvider>

View File

@@ -17,17 +17,28 @@ const initialContext: ThemeContext = {
const Context = createContext(initialContext)
const localStorageKey = 'payload-theme'
function setCookie(cname, cvalue, exdays) {
const d = new Date()
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000)
const expires = 'expires=' + d.toUTCString()
document.cookie = cname + '=' + cvalue + ';' + expires + ';path=/'
}
const getTheme = (): {
const getTheme = (
cookieKey,
): {
theme: Theme
themeFromStorage: null | string
themeFromCookies: null | string
} => {
let theme: Theme
const themeFromStorage = window.localStorage.getItem(localStorageKey)
if (themeFromStorage === 'light' || themeFromStorage === 'dark') {
theme = themeFromStorage
const themeFromCookies = window.document.cookie
.split('; ')
.find((row) => row.startsWith(`${cookieKey}=`))
?.split('=')[1]
if (themeFromCookies === 'light' || themeFromCookies === 'dark') {
theme = themeFromCookies
} else {
theme =
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
@@ -36,31 +47,39 @@ const getTheme = (): {
}
document.documentElement.setAttribute('data-theme', theme)
return { theme, themeFromStorage }
return { theme, themeFromCookies }
}
const defaultTheme = 'light'
export const defaultTheme = 'light'
export const ThemeProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>(defaultTheme)
export const ThemeProvider: React.FC<{
children?: React.ReactNode
cookiePrefix?: string
theme?: Theme
}> = ({ children, cookiePrefix, theme: initialTheme }) => {
const cookieKey = `${cookiePrefix || 'payload'}-theme`
const [theme, setThemeState] = useState<Theme>(initialTheme || defaultTheme)
const [autoMode, setAutoMode] = useState<boolean>()
useEffect(() => {
const { theme, themeFromStorage } = getTheme()
const { theme, themeFromCookies } = getTheme(cookieKey)
setThemeState(theme)
setAutoMode(!themeFromStorage)
}, [])
setAutoMode(!themeFromCookies)
}, [cookieKey])
const setTheme = useCallback((themeToSet: 'auto' | Theme) => {
const setTheme = useCallback(
(themeToSet: 'auto' | Theme) => {
if (themeToSet === 'light' || themeToSet === 'dark') {
setThemeState(themeToSet)
setAutoMode(false)
window.localStorage.setItem(localStorageKey, themeToSet)
setCookie(cookieKey, themeToSet, 365)
document.documentElement.setAttribute('data-theme', themeToSet)
} else if (themeToSet === 'auto') {
const existingThemeFromStorage = window.localStorage.getItem(localStorageKey)
if (existingThemeFromStorage) window.localStorage.removeItem(localStorageKey)
// to delete the cookie, we set an expired date
setCookie(cookieKey, themeToSet, -1)
const themeFromOS =
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
@@ -69,7 +88,9 @@ export const ThemeProvider: React.FC<{ children?: React.ReactNode }> = ({ childr
setAutoMode(true)
setThemeState(themeFromOS)
}
}, [])
},
[cookieKey],
)
return <Context.Provider value={{ autoMode, setTheme, theme }}>{children}</Context.Provider>
}

View File

@@ -67,12 +67,6 @@ html {
@extend %body;
background: var(--theme-bg);
-webkit-font-smoothing: antialiased;
opacity: 0;
&[data-theme='dark'],
&[data-theme='light'] {
opacity: initial;
}
&[data-theme='dark'] {
--theme-bg: var(--theme-elevation-0);

View File

@@ -219,6 +219,65 @@ describe('admin1', () => {
})
})
describe('theme', () => {
test('should render light theme by default', async () => {
await page.goto(postsUrl.admin)
await page.waitForURL(postsUrl.admin)
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await page.goto(`${postsUrl.admin}/account`)
await page.waitForURL(`${postsUrl.admin}/account`)
await expect(page.locator('#field-theme-auto')).toBeChecked()
await expect(page.locator('#field-theme-light')).not.toBeChecked()
await expect(page.locator('#field-theme-dark')).not.toBeChecked()
})
test('should explicitly change to light theme', async () => {
await page.goto(`${postsUrl.admin}/account`)
await page.waitForURL(`${postsUrl.admin}/account`)
await page.locator('label[for="field-theme-light"]').click()
await expect(page.locator('#field-theme-auto')).not.toBeChecked()
await expect(page.locator('#field-theme-light')).toBeChecked()
await expect(page.locator('#field-theme-dark')).not.toBeChecked()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
// reload the page an ensure theme is retained
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
// go back to auto theme
await page.goto(`${postsUrl.admin}/account`)
await page.waitForURL(`${postsUrl.admin}/account`)
await page.locator('label[for="field-theme-auto"]').click()
await expect(page.locator('#field-theme-auto')).toBeChecked()
await expect(page.locator('#field-theme-light')).not.toBeChecked()
await expect(page.locator('#field-theme-dark')).not.toBeChecked()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
})
test('should explicitly change to dark theme', async () => {
await page.goto(`${postsUrl.admin}/account`)
await page.waitForURL(`${postsUrl.admin}/account`)
await page.locator('label[for="field-theme-dark"]').click()
await expect(page.locator('#field-theme-auto')).not.toBeChecked()
await expect(page.locator('#field-theme-light')).not.toBeChecked()
await expect(page.locator('#field-theme-dark')).toBeChecked()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
// reload the page an ensure theme is retained
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
// go back to auto theme
await page.goto(`${postsUrl.admin}/account`)
await page.waitForURL(`${postsUrl.admin}/account`)
await page.locator('label[for="field-theme-auto"]').click()
await expect(page.locator('#field-theme-auto')).toBeChecked()
await expect(page.locator('#field-theme-light')).not.toBeChecked()
await expect(page.locator('#field-theme-dark')).not.toBeChecked()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
})
})
describe('routing', () => {
test('should use custom logout route', async () => {
await page.goto(`${serverURL}${adminRoutes.routes.admin}${adminRoutes.admin.routes.logout}`)

View File

@@ -37,7 +37,7 @@
],
"paths": {
"@payload-config": [
"./test/access-control/config.ts"
"./test/_community/config.ts"
],
"@payloadcms/live-preview": [
"./packages/live-preview/src"