Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/next/src/layouts/Root/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
8 changes: 5 additions & 3 deletions packages/next/src/utilities/getRequestLanguage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
33 changes: 33 additions & 0 deletions packages/next/src/utilities/getRequestTheme.ts
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions packages/next/src/withPayload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/fields/RadioGroup/Radio/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/fields/RadioGroup/Radio/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/providers/Root/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -39,6 +40,7 @@ type Props = {
languageCode: string
languageOptions: LanguageOptions
switchLanguageServerAction?: (lang: string) => Promise<void>
theme: Theme
translations: I18nClient['translations']
}

Expand All @@ -51,6 +53,7 @@ export const RootProvider: React.FC<Props> = ({
languageCode,
languageOptions,
switchLanguageServerAction,
theme,
translations,
}) => {
const { ModalContainer, ModalProvider } = facelessUIImport || {
Expand Down Expand Up @@ -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>
Expand Down
85 changes: 53 additions & 32 deletions packages/ui/src/providers/Theme/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<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)
}, [])

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 <Context.Provider value={{ autoMode, setTheme, theme }}>{children}</Context.Provider>
}
Expand Down
6 changes: 0 additions & 6 deletions packages/ui/src/scss/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
59 changes: 59 additions & 0 deletions test/admin/e2e/1/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
],
"paths": {
"@payload-config": [
"./test/access-control/config.ts"
"./test/_community/config.ts"
],
"@payloadcms/live-preview": [
"./packages/live-preview/src"
Expand Down