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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,17 +17,28 @@ const initialContext: ThemeContext = {
const Context = createContext(initialContext) 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 theme: Theme
themeFromStorage: null | string themeFromCookies: null | string
} => { } => {
let theme: Theme let theme: Theme
const themeFromStorage = window.localStorage.getItem(localStorageKey)
if (themeFromStorage === 'light' || themeFromStorage === 'dark') { const themeFromCookies = window.document.cookie
theme = themeFromStorage .split('; ')
.find((row) => row.startsWith(`${cookieKey}=`))
?.split('=')[1]
if (themeFromCookies === 'light' || themeFromCookies === 'dark') {
theme = themeFromCookies
} else { } else {
theme = theme =
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
@@ -36,40 +47,50 @@ const getTheme = (): {
} }
document.documentElement.setAttribute('data-theme', theme) 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 }) => { export const ThemeProvider: React.FC<{
const [theme, setThemeState] = useState<Theme>(defaultTheme) 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>() const [autoMode, setAutoMode] = useState<boolean>()
useEffect(() => { useEffect(() => {
const { theme, themeFromStorage } = getTheme() const { theme, themeFromCookies } = getTheme(cookieKey)
setThemeState(theme) setThemeState(theme)
setAutoMode(!themeFromStorage) setAutoMode(!themeFromCookies)
}, []) }, [cookieKey])
const setTheme = useCallback((themeToSet: 'auto' | Theme) => { const setTheme = useCallback(
if (themeToSet === 'light' || themeToSet === 'dark') { (themeToSet: 'auto' | Theme) => {
setThemeState(themeToSet) if (themeToSet === 'light' || themeToSet === 'dark') {
setAutoMode(false) setThemeState(themeToSet)
window.localStorage.setItem(localStorageKey, themeToSet) setAutoMode(false)
document.documentElement.setAttribute('data-theme', themeToSet) setCookie(cookieKey, themeToSet, 365)
} else if (themeToSet === 'auto') { document.documentElement.setAttribute('data-theme', themeToSet)
const existingThemeFromStorage = window.localStorage.getItem(localStorageKey) } else if (themeToSet === 'auto') {
if (existingThemeFromStorage) window.localStorage.removeItem(localStorageKey) // to delete the cookie, we set an expired date
const themeFromOS = setCookie(cookieKey, themeToSet, -1)
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches const themeFromOS =
? 'dark' window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
: 'light' ? 'dark'
document.documentElement.setAttribute('data-theme', themeFromOS) : 'light'
setAutoMode(true) document.documentElement.setAttribute('data-theme', themeFromOS)
setThemeState(themeFromOS) setAutoMode(true)
} setThemeState(themeFromOS)
}, []) }
},
[cookieKey],
)
return <Context.Provider value={{ autoMode, setTheme, theme }}>{children}</Context.Provider> return <Context.Provider value={{ autoMode, setTheme, theme }}>{children}</Context.Provider>
} }

View File

@@ -67,12 +67,6 @@ html {
@extend %body; @extend %body;
background: var(--theme-bg); background: var(--theme-bg);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
opacity: 0;
&[data-theme='dark'],
&[data-theme='light'] {
opacity: initial;
}
&[data-theme='dark'] { &[data-theme='dark'] {
--theme-bg: var(--theme-elevation-0); --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', () => { describe('routing', () => {
test('should use custom logout route', async () => { test('should use custom logout route', async () => {
await page.goto(`${serverURL}${adminRoutes.routes.admin}${adminRoutes.admin.routes.logout}`) await page.goto(`${serverURL}${adminRoutes.routes.admin}${adminRoutes.admin.routes.logout}`)

View File

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