feat(next): server-side theme detection (#6452)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
33
packages/next/src/utilities/getRequestTheme.ts
Normal file
33
packages/next/src/utilities/getRequestTheme.ts
Normal 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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
],
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"./test/access-control/config.ts"
|
||||
"./test/_community/config.ts"
|
||||
],
|
||||
"@payloadcms/live-preview": [
|
||||
"./packages/live-preview/src"
|
||||
|
||||
Reference in New Issue
Block a user