diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx
index b4b03c13055..54c8857ef74 100644
--- a/packages/next/src/layouts/Root/index.tsx
+++ b/packages/next/src/layouts/Root/index.tsx
@@ -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 (
-
+
{wrappedChildren}
diff --git a/packages/next/src/utilities/getRequestLanguage.ts b/packages/next/src/utilities/getRequestLanguage.ts
index 2d070e2105f..065709316ba 100644
--- a/packages/next/src/utilities/getRequestLanguage.ts
+++ b/packages/next/src/utilities/getRequestLanguage.ts
@@ -18,17 +18,19 @@ export const getRequestLanguage = ({
}: GetRequestLanguageArgs): AcceptedLanguages => {
const supportedLanguageKeys = 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
}
diff --git a/packages/next/src/utilities/getRequestTheme.ts b/packages/next/src/utilities/getRequestTheme.ts
new file mode 100644
index 00000000000..ceb1ad0dcf8
--- /dev/null
+++ b/packages/next/src/utilities/getRequestTheme.ts
@@ -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 | 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
+}
diff --git a/packages/next/src/withPayload.js b/packages/next/src/withPayload.js
index 1894fd2d92f..760874cb4db 100644
--- a/packages/next/src/withPayload.js
+++ b/packages/next/src/withPayload.js
@@ -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',
diff --git a/packages/ui/src/fields/RadioGroup/Radio/index.scss b/packages/ui/src/fields/RadioGroup/Radio/index.scss
index 07934e68621..2256e344897 100644
--- a/packages/ui/src/fields/RadioGroup/Radio/index.scss
+++ b/packages/ui/src/fields/RadioGroup/Radio/index.scss
@@ -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 {
diff --git a/packages/ui/src/fields/RadioGroup/Radio/index.tsx b/packages/ui/src/fields/RadioGroup/Radio/index.tsx
index 5e9210abc9e..d34e3f6e9f8 100644
--- a/packages/ui/src/fields/RadioGroup/Radio/index.tsx
+++ b/packages/ui/src/fields/RadioGroup/Radio/index.tsx
@@ -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"
/>
diff --git a/packages/ui/src/providers/Root/index.tsx b/packages/ui/src/providers/Root/index.tsx
index 1fd62d702ed..16cdcb8fd07 100644
--- a/packages/ui/src/providers/Root/index.tsx
+++ b/packages/ui/src/providers/Root/index.tsx
@@ -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
+ theme: Theme
translations: I18nClient['translations']
}
@@ -51,6 +53,7 @@ export const RootProvider: React.FC = ({
languageCode,
languageOptions,
switchLanguageServerAction,
+ theme,
translations,
}) => {
const { ModalContainer, ModalProvider } = facelessUIImport || {
@@ -88,7 +91,7 @@ export const RootProvider: React.FC = ({
-
+
diff --git a/packages/ui/src/providers/Theme/index.tsx b/packages/ui/src/providers/Theme/index.tsx
index e0c91d3b92a..ac4ebaeb5a3 100644
--- a/packages/ui/src/providers/Theme/index.tsx
+++ b/packages/ui/src/providers/Theme/index.tsx
@@ -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,40 +47,50 @@ 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(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(initialTheme || defaultTheme)
const [autoMode, setAutoMode] = useState()
useEffect(() => {
- const { theme, themeFromStorage } = getTheme()
+ const { theme, themeFromCookies } = getTheme(cookieKey)
setThemeState(theme)
- setAutoMode(!themeFromStorage)
- }, [])
-
- const setTheme = useCallback((themeToSet: 'auto' | Theme) => {
- if (themeToSet === 'light' || themeToSet === 'dark') {
- setThemeState(themeToSet)
- setAutoMode(false)
- window.localStorage.setItem(localStorageKey, themeToSet)
- document.documentElement.setAttribute('data-theme', themeToSet)
- } else if (themeToSet === 'auto') {
- const existingThemeFromStorage = window.localStorage.getItem(localStorageKey)
- if (existingThemeFromStorage) window.localStorage.removeItem(localStorageKey)
- const themeFromOS =
- window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
- ? 'dark'
- : 'light'
- document.documentElement.setAttribute('data-theme', themeFromOS)
- setAutoMode(true)
- setThemeState(themeFromOS)
- }
- }, [])
+ setAutoMode(!themeFromCookies)
+ }, [cookieKey])
+
+ const setTheme = useCallback(
+ (themeToSet: 'auto' | Theme) => {
+ if (themeToSet === 'light' || themeToSet === 'dark') {
+ setThemeState(themeToSet)
+ setAutoMode(false)
+ setCookie(cookieKey, themeToSet, 365)
+ document.documentElement.setAttribute('data-theme', themeToSet)
+ } else if (themeToSet === 'auto') {
+ // 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'
+ : 'light'
+ document.documentElement.setAttribute('data-theme', themeFromOS)
+ setAutoMode(true)
+ setThemeState(themeFromOS)
+ }
+ },
+ [cookieKey],
+ )
return {children}
}
diff --git a/packages/ui/src/scss/app.scss b/packages/ui/src/scss/app.scss
index d2424f66e08..7bf7bfbc768 100644
--- a/packages/ui/src/scss/app.scss
+++ b/packages/ui/src/scss/app.scss
@@ -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);
diff --git a/test/admin/e2e/1/e2e.spec.ts b/test/admin/e2e/1/e2e.spec.ts
index a8070b0af93..63e439edc82 100644
--- a/test/admin/e2e/1/e2e.spec.ts
+++ b/test/admin/e2e/1/e2e.spec.ts
@@ -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}`)
diff --git a/tsconfig.json b/tsconfig.json
index 609ea599b29..d55001935b9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -37,7 +37,7 @@
],
"paths": {
"@payload-config": [
- "./test/access-control/config.ts"
+ "./test/_community/config.ts"
],
"@payloadcms/live-preview": [
"./packages/live-preview/src"