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"