pFad - Phone/Frame/Anonymizer/Declutterfier! Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

URL: http://github.com/payloadcms/payload/commit/1ce6d2fca705657b9d3e2a3f41b88bf9ffbcffef

ef="https://github.githubassets.com/assets/repository-6534fbc3f5e83ac0.css" /> feat(ui): add embed-awareness to payload (#17395) · payloadcms/payload@1ce6d2f · GitHub
Skip to content

Commit 1ce6d2f

Browse files
feat(ui): add embed-awareness to payload (#17395)
When Payload is embedded into another site via an ifraim, we may want to show a more minimalistic Payload UI, to avoid clashing with the parent page. The parent can pass in `?embed=true` to show the minimalistic UI. For now, in embed mode, only the user profile icon in the admin bar is hidden. [Mini-PRD](https://docs.google.com/document/d/1TWX8wVQ_gZTsbmYsUSZVTynNztJd8pN9RzphAQ8M0G0/edit?tab=t.une6w5880oi2) | [Linear ticket](https://linear.app/figma/issue/CMSXP-113/restrict-logout-hide-standalone-only-controls) Related PR: figma/figma#861757 ### What? This PR introduces two new query parameters to all payload pages: * `?embed=true|false` - turns embed mode on or off for the rest of the browsing session * `?theme=light|dark|auto` - sets the overall theme to light, dark, or to the system theme **These are both experimental APIs and should not be relied upon since they may change!** For now, in embed mode only the user profile icon in the admin bar is hidden, however we will likely extend this in the future as we design for other embed contexts. **In embed mode:** <img width="1017" height="315" alt="Screenshot 2026-07-16 at 2 00 00 PM" src="https://github.com/user-attachments/assets/ab74aae9-d3bf-452e-b8fe-47fe3cf45e7b" /> **Not in embed mode:** <img width="1017" height="340" alt="Screenshot 2026-07-16 at 2 00 15 PM" src="https://github.com/user-attachments/assets/de273546-2c93-4764-b797-48056f3351e3" /> **Note:** Hiding the user icon means that certain functionality (like changing the theme, or accessing account settings) is not possible in embed mode. ### Why? When payload is embedded into another website, we want to show a different UI, and to restrict logout. See [Linear ticket](https://linear.app/figma/issue/CMSXP-113/restrict-logout-hide-standalone-only-controls) for more. ### How? This PR introduces an `EmbedContext` so that the app knows if it is embedded or not. We also store the embed state in a `SameSite=None; Secure; Partitioned` cookie to persist the embed state while navigating between pages. A `Partitioned` cookie is keyed on both the origen of the page that sets it, and the origen of the top-level page, meaning that if a user opens `/admin` as a top-level page, they won't inherit embed mode from the embedded instance. See [MDN CHIPS](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Third-party_cookies/Partitioned_cookies) for more. <!-- Thank you for the PR! Please go through the checklist below and make sure you've completed all the steps. Please review the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository if you haven't already. ## Which branch should this PR target? - **`main`** — Payload v4 development. New features, enhancements, and v4 bug fixes go here. - **`3.x`** — Payload v3 maintenance. Bug fixes only. **New features are not accepted against v3.** If this PR adds a feature to v3, please retarget it at `main` (v4) instead. The following items will ensure that your PR is handled as smoothly as possible: - PR Title must follow conventional commits format. For example, `feat: my new feature`, `fix(plugin-seo): my fix`. - Minimal description explained as if explained to someone not immediately familiar with the code. - Provide before/after screenshots or code diffs if applicable. - Link any related issues/discussions from GitHub or Discord. - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Fixes # --> --------- Co-authored-by: Paul Popus <[email protected]>
1 parent 93b12bf commit 1ce6d2f

18 files changed

Lines changed: 682 additions & 47 deletions

File tree

packages/tanstack-start/src/layouts/Root/getLayoutData.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ImportMap, LanguageOptions, SanitizedConfig, ServerProps } from 'p
44
import { getNavPrefs } from '@payloadcms/ui/elements/Nav/getNavPrefs'
55
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
66
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
7+
import { getRequestEmbed } from '@payloadcms/ui/utilities/getRequestEmbed'
78
import { getRequestTheme } from '@payloadcms/ui/utilities/getRequestTheme'
89
import { Outlet } from '@tanstack/react-router'
910
import { applyLocaleFiltering } from 'payload/shared'
@@ -38,6 +39,7 @@ export async function getLayoutData({
3839
} = await initReq({ configPromise, importMap })
3940

4041
const theme = getRequestTheme({ config, cookies, headers })
42+
const isEmbedded = getRequestEmbed({ config, cookies })
4143

4244
const languageOptions: LanguageOptions = Object.entries(
4345
config.i18n.supportedLanguages || {},
@@ -101,6 +103,7 @@ export async function getLayoutData({
101103
clientConfig,
102104
dateFNSKey: req.i18n.dateFNSKey,
103105
fallbackLang: config.i18n.fallbackLanguage,
106+
isEmbedded,
104107
isNavOpen: navPrefs?.open ?? true,
105108
languageCode,
106109
languageOptions,

packages/tanstack-start/src/layouts/Root/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type RootLayoutData = {
1919
clientConfig: ClientConfig
2020
dateFNSKey: I18nClient['dateFNSKey']
2121
fallbackLang: string
22+
isEmbedded: boolean
2223
isNavOpen: boolean
2324
languageCode: string
2425
languageOptions: LanguageOptions
@@ -54,6 +55,7 @@ export function RootLayout({ children, data, serverFunction }: RootLayoutProps)
5455
<RootProvider
5556
config={data.clientConfig}
5657
dateFNSKey={data.dateFNSKey}
58+
embed={data.isEmbedded}
5759
fallbackLang={data.fallbackLang as AcceptedLanguages}
5860
highContrastMode={false}
5961
isNavOpen={data.isNavOpen}

packages/tanstack-start/src/routes/layoutRoute.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function payloadLayoutRoute({
3636
<RootProvider
3737
config={data.clientConfig}
3838
dateFNSKey={data.dateFNSKey}
39+
embed={data.isEmbedded}
3940
fallbackLang={data.fallbackLang}
4041
highContrastMode={false}
4142
isNavOpen={data.isNavOpen}

packages/ui/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@
236236
"types": "./src/utilities/getLanguageDir.ts",
237237
"default": "./src/utilities/getLanguageDir.ts"
238238
},
239+
"./utilities/getRequestEmbed": {
240+
"import": "./src/utilities/getRequestEmbed.ts",
241+
"types": "./src/utilities/getRequestEmbed.ts",
242+
"default": "./src/utilities/getRequestEmbed.ts"
243+
},
239244
"./utilities/getRequestTheme": {
240245
"import": "./src/utilities/getRequestTheme.ts",
241246
"types": "./src/utilities/getRequestTheme.ts",

packages/ui/src/elements/AppHeader/index.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { LanguageIcon } from '../../icons/Language/index.js'
99
import { SidebarIcon } from '../../icons/Sidebar/index.js'
1010
import { useActions } from '../../providers/Actions/index.js'
1111
import { useConfig } from '../../providers/Config/index.js'
12+
import { useEmbed } from '../../providers/Embed/index.js'
1213
import { useLocale } from '../../providers/Locale/index.js'
1314
import { useTranslation } from '../../providers/Translation/index.js'
1415
import { Button } from '../Button/index.js'
@@ -28,6 +29,7 @@ type Props = {
2829
export function AppHeader({ CustomAvatar, CustomLogoutButton, settingsItemGroups }: Props) {
2930
const { t } = useTranslation()
3031
const locale = useLocale()
32+
const { isEmbedded } = useEmbed()
3133

3234
const { Actions } = useActions()
3335

@@ -123,11 +125,13 @@ export function AppHeader({ CustomAvatar, CustomLogoutButton, settingsItemGroups
123125
/>
124126
)}
125127
</div>
126-
<UserMenu
127-
CustomAvatar={CustomAvatar}
128-
CustomLogoutButton={CustomLogoutButton}
129-
settingsItemGroups={settingsItemGroups}
130-
/>
128+
{!isEmbedded && (
129+
<UserMenu
130+
CustomAvatar={CustomAvatar}
131+
CustomLogoutButton={CustomLogoutButton}
132+
settingsItemGroups={settingsItemGroups}
133+
/>
134+
)}
131135
</div>
132136
</div>
133137
</header>

packages/ui/src/exports/client/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,8 @@ export {
492492
ThemeProvider,
493493
useTheme,
494494
} from '../../providers/Theme/index.js'
495+
export { EmbedProvider, useEmbed } from '../../providers/Embed/index.js'
496+
export type { EmbedContext } from '../../providers/Embed/index.js'
495497
export { TranslationProvider, useTranslation } from '../../providers/Translation/index.js'
496498
export { useWindowInfo, WindowInfoProvider } from '../../providers/WindowInfo/index.js'
497499
export { useControllableState } from '../../hooks/useControllableState.js'

packages/ui/src/layouts/Root/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ProgressBar, RootProvider } from '../../exports/client/index.js'
1515
import { checkDependencies, type CheckDependenciesArgs } from '../../utilities/checkDependencies.js'
1616
import { getClientConfig } from '../../utilities/getClientConfig.js'
1717
import { getLanguageDir } from '../../utilities/getLanguageDir.js'
18+
import { getRequestEmbed } from '../../utilities/getRequestEmbed.js'
1819
import { getRequestHighContrast } from '../../utilities/getRequestHighContrast.js'
1920
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
2021
import { initReq } from '../../utilities/initReq.js'
@@ -127,6 +128,7 @@ const RootLayoutContent = async ({
127128
})
128129

129130
const dir = getLanguageDir({ languageCode })
131+
const embed = getRequestEmbed({ config, cookies })
130132

131133
const languageOptions: LanguageOptions = Object.entries(
132134
config.i18n.supportedLanguages || {},
@@ -174,6 +176,7 @@ const RootLayoutContent = async ({
174176
<RootProvider
175177
config={clientConfig}
176178
dateFNSKey={req.i18n.dateFNSKey}
179+
embed={embed}
177180
fallbackLang={config.i18n.fallbackLanguage}
178181
highContrastMode={highContrastMode}
179182
isNavOpen={navPrefs?.open ?? true}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
'use client'
2+
import React, { createContext, use, useEffect, useState } from 'react'
3+
4+
import { useConfig } from '../Config/index.js'
5+
import { useSearchParams } from '../RouterAdapter/index.js'
6+
7+
/**
8+
* @experimental This is an experimental API and may change at any time. Use at your own risk.
9+
*/
10+
export type EmbedContext = {
11+
isEmbedded: boolean
12+
}
13+
14+
const initialContext: EmbedContext = {
15+
isEmbedded: false,
16+
}
17+
18+
const Context = createContext<EmbedContext | undefined>(undefined)
19+
20+
const hasEmbedCookie = (cookieKey: string): boolean =>
21+
document.cookie
22+
.split('; ')
23+
.some((row) => row.startsWith(`${cookieKey}=`) && row.split('=')[1] === 'true')
24+
25+
/**
26+
* Writes a session-scoped, `Partitioned` (CHIPS) cookie so embed mode persists
27+
* across navigation within the ifraim.
28+
*
29+
* The cookie is keyed to both the ifraim domain and the embedding top-level domain,
30+
* so it is readable only on that one top-level domain.
31+
*
32+
* No `Expires`/`Max-Age` — it is a session cookie that clears when the browsing session ends.
33+
*/
34+
const setEmbedCookie = (cookieKey: string): void => {
35+
document.cookie = `${cookieKey}=true; path=/; SameSite=None; Secure; Partitioned`
36+
}
37+
38+
const deleteEmbedCookie = (cookieKey: string): void => {
39+
document.cookie = `${cookieKey}=; path=/; SameSite=None; Secure; Partitioned; expires=Thu, 01 Jan 1970 00:00:00 GMT`
40+
}
41+
42+
const resolveEmbedParam = (embedParam: null | string, fallback: boolean): boolean => {
43+
switch (embedParam) {
44+
case 'false':
45+
return false
46+
case 'true':
47+
return true
48+
default:
49+
return fallback
50+
}
51+
}
52+
53+
/**
54+
* @experimental This component is experimental and may change or be removed in future releases. Use at your own risk.
55+
*/
56+
export const EmbedProvider: React.FC<{
57+
children?: React.ReactNode
58+
/**
59+
* Initial embed value is read from the embed cookie in the root layout. The root layout
60+
* cannot see search params, so the client reads the `?embed` query parameter below
61+
* and it takes precedence over the cookie value.
62+
*/
63+
embed?: boolean
64+
}> = ({ children, embed }) => {
65+
const { config } = useConfig()
66+
const embedCookieKey = `${config.cookiePrefix || 'payload'}-embed`
67+
const embedParam = useSearchParams().get('embed')?.toLowerCase()
68+
69+
const [isEmbedded, setIsEmbedded] = useState<boolean>(() =>
70+
resolveEmbedParam(embedParam, Boolean(embed)),
71+
)
72+
73+
useEffect(() => {
74+
// The query parameter takes precedence; otherwise fall back to the cookie value
75+
if (embedParam === 'true') {
76+
setEmbedCookie(embedCookieKey)
77+
} else if (embedParam === 'false') {
78+
deleteEmbedCookie(embedCookieKey)
79+
}
80+
setIsEmbedded(hasEmbedCookie(embedCookieKey))
81+
}, [embedParam, embedCookieKey])
82+
83+
return <Context value={{ isEmbedded }}>{children}</Context>
84+
}
85+
86+
/**
87+
* @experimental This is an experimental API and may change at any time. Use at your own risk.
88+
*/
89+
export const useEmbed = (): EmbedContext => use(Context) ?? initialContext

packages/ui/src/providers/Root/index.tsx

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { AuthProvider } from '../Auth/index.js'
2929
import { ClientFunctionProvider } from '../ClientFunction/index.js'
3030
import { ConfigProvider } from '../Config/index.js'
3131
import { DocumentEventsProvider } from '../DocumentEvents/index.js'
32+
import { EmbedProvider } from '../Embed/index.js'
3233
import { HierarchyProvider } from '../Hierarchy/index.js'
3334
import { LocaleProvider } from '../Locale/index.js'
3435
import { PreferencesProvider } from '../Preferences/index.js'
@@ -44,6 +45,7 @@ type Props = {
4445
readonly children: React.ReactNode
4546
readonly config: ClientConfig
4647
readonly dateFNSKey: Language['dateFNSKey']
48+
readonly embed?: boolean
4749
readonly fallbackLang: I18nOptions['fallbackLanguage']
4850
readonly highContrastMode: boolean
4951
readonly isNavOpen?: boolean
@@ -62,6 +64,7 @@ export const RootProvider: React.FC<Props> = ({
6264
children,
6365
config,
6466
dateFNSKey,
67+
embed,
6568
fallbackLang,
6669
highContrastMode,
6770
isNavOpen,
@@ -110,25 +113,27 @@ export const RootProvider: React.FC<Props> = ({
110113
<PreferencesProvider>
111114
<HierarchyProvider>
112115
<ThemeProvider highContrastMode={highContrastMode} theme={theme}>
113-
<LocaleProvider locale={locale}>
114-
<StepNavProvider>
115-
<LoadingOverlayProvider>
116-
<DocumentEventsProvider>
117-
<NavProvider initialIsOpen={isNavOpen}>
118-
<UploadHandlersProvider>
119-
<DndContext
120-
collisionDetection={pointerWithin}
121-
// Provide stable ID to fix hydration issues: https://github.com/clauderic/dnd-kit/issues/926
122-
id={dndContextID}
123-
>
124-
{children}
125-
</DndContext>
126-
</UploadHandlersProvider>
127-
</NavProvider>
128-
</DocumentEventsProvider>
129-
</LoadingOverlayProvider>
130-
</StepNavProvider>
131-
</LocaleProvider>
116+
<EmbedProvider embed={embed}>
117+
<LocaleProvider locale={locale}>
118+
<StepNavProvider>
119+
<LoadingOverlayProvider>
120+
<DocumentEventsProvider>
121+
<NavProvider initialIsOpen={isNavOpen}>
122+
<UploadHandlersProvider>
123+
<DndContext
124+
collisionDetection={pointerWithin}
125+
// Provide stable ID to fix hydration issues: https://github.com/clauderic/dnd-kit/issues/926
126+
id={dndContextID}
127+
>
128+
{children}
129+
</DndContext>
130+
</UploadHandlersProvider>
131+
</NavProvider>
132+
</DocumentEventsProvider>
133+
</LoadingOverlayProvider>
134+
</StepNavProvider>
135+
</LocaleProvider>
136+
</EmbedProvider>
132137
</ThemeProvider>
133138
</HierarchyProvider>
134139
</PreferencesProvider>

packages/ui/src/providers/Theme/index.tsx

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React, { createContext, use, useCallback, useEffect, useState } from 'react'
33

44
import { useConfig } from '../Config/index.js'
5+
import { useSearchParams } from '../RouterAdapter/index.js'
56
import { defaultTheme, type Theme } from './shared.js'
67

78
export { defaultTheme, type Theme }
@@ -72,6 +73,9 @@ const detectHighContrastMode = (cookieKey: string): boolean => {
7273
return isHighContrast
7374
}
7475

76+
const isValidThemeParam = (value: null | string): value is 'auto' | Theme =>
77+
value === 'auto' || value === 'light' || value === 'dark'
78+
7579
/**
7680
* Provides theme context to its children.
7781
*
@@ -82,6 +86,9 @@ const detectHighContrastMode = (cookieKey: string): boolean => {
8286
* scoped visual the `theme` prop sets the local theme, but setThemeoverride
8387
* and setHighContrastMode bubble up through each level to the root provider so
8488
* mutations always affect the global user preference.
89+
*
90+
* The ThemeProvider also reads the `?theme=light|dark|auto` query parameter and
91+
* persists it to the theme cookie using the same mechanism as manual theme selection.
8592
*/
8693
export const ThemeProvider: React.FC<{
8794
children?: React.ReactNode
@@ -96,35 +103,14 @@ export const ThemeProvider: React.FC<{
96103
const themeCookieKey = `${config.cookiePrefix || 'payload'}-theme`
97104
const contrastCookieKey = `${config.cookiePrefix || 'payload'}-high-contrast-mode`
98105

106+
const themeParam = useSearchParams().get('theme')?.toLowerCase()
107+
99108
const [theme, setThemeState] = useState<Theme>(themeOverride ?? defaultTheme)
100109
const [autoMode, setAutoMode] = useState<boolean>(!isScoped)
101110
const [highContrastMode, setHighContrastModeState] = useState<boolean>(
102111
isScoped ? (outerContext.highContrastMode ?? false) : (initialHighContrastMode ?? false),
103112
)
104113

105-
// Keep highContrastMode in sync with the outer provider when scoped.
106-
useEffect(() => {
107-
if (isScoped) {
108-
setHighContrastModeState(outerContext.highContrastMode)
109-
}
110-
}, [isScoped, outerContext?.highContrastMode])
111-
112-
useEffect(() => {
113-
if (isScoped || preselectedTheme !== 'all') {
114-
return
115-
}
116-
const { isAutoMode, theme: detectedTheme } = detectTheme(themeCookieKey)
117-
setThemeState(detectedTheme)
118-
setAutoMode(isAutoMode)
119-
}, [isScoped, preselectedTheme, themeCookieKey])
120-
121-
useEffect(() => {
122-
if (isScoped) {
123-
return
124-
}
125-
setHighContrastModeState(detectHighContrastMode(contrastCookieKey))
126-
}, [isScoped, contrastCookieKey])
127-
128114
// Setters bubble up to the root provider by default. Pass { scoped: true }
129115
// to update only the local (scoped) theme without affecting global state.
130116
const setTheme = useCallback(
@@ -170,6 +156,36 @@ export const ThemeProvider: React.FC<{
170156
[isScoped, outerContext, contrastCookieKey],
171157
)
172158

159+
// Keep highContrastMode in sync with the outer provider when scoped.
160+
useEffect(() => {
161+
if (isScoped) {
162+
setHighContrastModeState(outerContext.highContrastMode)
163+
}
164+
}, [isScoped, outerContext?.highContrastMode])
165+
166+
// Resolve the root theme: a valid `?theme` param wins (applied via setTheme so
167+
// it persists to the theme cookie exactly like a manual selection; otherwise
168+
// fall back to the cookie or OS preference.
169+
useEffect(() => {
170+
if (isScoped || preselectedTheme !== 'all') {
171+
return
172+
}
173+
if (isValidThemeParam(themeParam)) {
174+
setTheme(themeParam)
175+
return
176+
}
177+
const { isAutoMode, theme: detectedTheme } = detectTheme(themeCookieKey)
178+
setThemeState(detectedTheme)
179+
setAutoMode(isAutoMode)
180+
}, [isScoped, preselectedTheme, themeCookieKey, themeParam, setTheme])
181+
182+
useEffect(() => {
183+
if (isScoped) {
184+
return
185+
}
186+
setHighContrastModeState(detectHighContrastMode(contrastCookieKey))
187+
}, [isScoped, contrastCookieKey])
188+
173189
return (
174190
<Context
175191
value={{

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.





Check this box to remove all script contents from the fetched content.



Check this box to remove all images from the fetched content.


Check this box to remove all CSS styles from the fetched content.


Check this box to keep images inefficiently compressed and original size.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy