From 0dca5835f6087f0b2df0ab989ac4c96adb83b759 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 1 Sep 2026 18:54:27 +0200 Subject: [PATCH] Route Element Call's DOM decoration through an injectable root element Element Call writes its theme classes, background and layout attributes straight onto document.body, and portals its modals there too. That is only correct while it owns the page; embedded in a host application it has to confine itself to the container it was mounted into. Add a RootElementContext, defaulting to document.body so that the standalone and widget builds are unaffected, and point the theme classes, data-background, no-scroll-body and the fullscreen target at it. Give the Modal and Toast portals an explicit container as well. Radix and vaul both default to document.body, so without this every modal, drawer and toast would render outside the container and lose the theme and platform attributes set on it. No functional change: the root element is document.body until an embedder provides otherwise. --- src/App.tsx | 8 ++++---- src/Modal.tsx | 6 ++++-- src/RootElementContext.ts | 30 ++++++++++++++++++++++++++++++ src/Toast.tsx | 8 +++++++- src/room/GroupCallView.tsx | 8 +++++--- src/tile/SpotlightTile.tsx | 8 ++++---- src/useTheme.ts | 12 +++++++----- 7 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 src/RootElementContext.ts diff --git a/src/App.tsx b/src/App.tsx index 36afc4c2f..55703f4c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { MediaDevicesContext } from "./MediaDevicesContext"; import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; +import { useRootElement } from "./RootElementContext"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -44,12 +45,11 @@ interface SimpleProviderProps { const BackgroundProvider: FC = ({ children }) => { const { pathname } = useLocation(); const { background } = useUrlParams(); + const rootElement = useRootElement(); useEffect(() => { - document - .getElementsByTagName("body")[0] - .setAttribute("data-background", background); - }, [pathname, background]); + rootElement.setAttribute("data-background", background); + }, [pathname, background, rootElement]); return children; }; diff --git a/src/Modal.tsx b/src/Modal.tsx index e6ffdf450..46316d370 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web"; import styles from "./Modal.module.css"; import overlayStyles from "./Overlay.module.css"; import { useMediaQuery } from "./useMediaQuery"; +import { useRootElement } from "./RootElementContext"; export interface Props { title: string; @@ -78,6 +79,7 @@ export const Modal: FC = ({ ...rest }) => { const { t } = useTranslation(); + const rootElement = useRootElement(); // Empirically, Chrome on Android can end up not matching (hover: none), but // still matching (pointer: coarse) :/ const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)"); @@ -100,7 +102,7 @@ export const Modal: FC = ({ onOpenChange={onOpenChange} dismissible={onDismiss !== undefined} > - + = ({ return ( - + diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts new file mode 100644 index 000000000..b97b17796 --- /dev/null +++ b/src/RootElementContext.ts @@ -0,0 +1,30 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { createContext, use } from "react"; + +/** + * The element that Element Call treats as the root of its own interface. + * + * Element Call decorates this element with the theme, layout and background + * attributes its stylesheets key off, and portals its modals into it. When + * Element Call owns the page this is simply the document body; when it is + * embedded in a host application it is the container the host mounted it into, + * so that Element Call does not reach outside its own subtree. + */ +const RootElementContext = createContext(null); + +export const RootElementProvider = RootElementContext.Provider; + +/** + * The element Element Call should decorate and portal into. + * + * Defaults to the document body, so that the standalone and widget builds work + * without a provider. + */ +export const useRootElement = (): HTMLElement => + use(RootElementContext) ?? document.body; diff --git a/src/Toast.tsx b/src/Toast.tsx index 83e220bc1..0a2a2f1b8 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web"; import styles from "./Toast.module.css"; import overlayStyles from "./Overlay.module.css"; +import { useRootElement } from "./RootElementContext"; interface Props { /** @@ -64,6 +65,7 @@ export const Toast: FC = ({ Icon, modal = true, }) => { + const rootElement = useRootElement(); const onOpenChange = useCallback( (open: boolean) => { if (!open) onDismiss(); @@ -104,7 +106,11 @@ export const Toast: FC = ({ return ( - {modal ? {content} : content} + {modal ? ( + {content} + ) : ( + content + )} ); }; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index fbd589e78..dfeb0866a 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -80,6 +80,7 @@ import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; +import { useRootElement } from "../RootElementContext.ts"; /** * If there already are this many participants in the call, we automatically mute @@ -123,6 +124,7 @@ export const GroupCallView: FC = ({ null, ); const memberships = useMatrixRTCSessionMemberships(rtcSession); + const rootElement = useRootElement(); const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( @@ -150,11 +152,11 @@ export const GroupCallView: FC = ({ // viewport sizes smaller than 122px width. (It is actually this exact number: 122px // tested on different devices...) useEffect(() => { - document.body.classList.add("no-scroll-body"); + rootElement.classList.add("no-scroll-body"); return (): void => { - document.body.classList.remove("no-scroll-body"); + rootElement.classList.remove("no-scroll-body"); }; - }, []); + }, [rootElement]); useEffect(() => { window.rtcSession = rtcSession; diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 036e044fe..97e1f4a62 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -54,6 +54,7 @@ import { Slider } from "../Slider"; import { platform } from "../Platform"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { RingingStatus } from "./RingingStatus"; +import { useRootElement } from "../RootElementContext"; interface SpotlightItemBaseProps { ref?: Ref; @@ -414,6 +415,7 @@ export const SpotlightTile: FC = ({ style, }) => { const { t } = useTranslation(); + const rootElement = useRootElement(); const [ourRef, root$] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); const maximised = useBehavior(vm.maximised$); @@ -428,24 +430,22 @@ export const SpotlightTile: FC = ({ const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; const isFullscreen = useCallback((): boolean => { - const rootElement = document.body; if (rootElement && document.fullscreenElement) return true; return false; - }, []); + }, [rootElement]); const FullScreenIcon = isFullscreen() ? FullScreenMinimiseIcon : FullScreenMaximiseIcon; const onToggleFullscreen = useCallback(() => { - const rootElement = document.body; if (!rootElement) return; if (isFullscreen()) { void document?.exitFullscreen(); } else { void rootElement.requestFullscreen(); } - }, [isFullscreen]); + }, [isFullscreen, rootElement]); // To keep track of which item is visible, we need an intersection observer // hooked up to the root element and the items. Because the items will run diff --git a/src/useTheme.ts b/src/useTheme.ts index e992aee7b..5bac28982 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -11,12 +11,14 @@ import { type IThemeChangeActionRequest } from "matrix-widget-api"; import { getUrlParams } from "./UrlParams"; import { widget } from "./widget"; +import { useRootElement } from "./RootElementContext"; export const useTheme = (): void => { + const rootElement = useRootElement(); const [requestedTheme, setRequestedTheme] = useState( () => getUrlParams().theme, ); - const previousTheme = useRef(document.body.classList.item(0)); + const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { if (widget) { @@ -47,15 +49,15 @@ export const useTheme = (): void => { : ""; const themeString = "cpd-theme-" + theme + themeHighContrast; if (themeString !== previousTheme.current) { - document.body.classList.remove( + rootElement.classList.remove( "cpd-theme-light", "cpd-theme-dark", "cpd-theme-light-hc", "cpd-theme-dark-hc", ); - document.body.classList.add(themeString); + rootElement.classList.add(themeString); previousTheme.current = themeString; } - document.body.classList.remove("no-theme"); - }, [previousTheme, requestedTheme]); + rootElement.classList.remove("no-theme"); + }, [previousTheme, requestedTheme, rootElement]); };