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.
This commit is contained in:
Valere
2026-09-01 18:54:27 +02:00
parent 0a8c24bca8
commit 0dca5835f6
7 changed files with 61 additions and 19 deletions
+4 -4
View File
@@ -34,6 +34,7 @@ import { MediaDevicesContext } from "./MediaDevicesContext";
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
import { AppBar } from "./AppBar"; import { AppBar } from "./AppBar";
import { i18n } from "./utils/i18n"; import { i18n } from "./utils/i18n";
import { useRootElement } from "./RootElementContext";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -44,12 +45,11 @@ interface SimpleProviderProps {
const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => { const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { background } = useUrlParams(); const { background } = useUrlParams();
const rootElement = useRootElement();
useEffect(() => { useEffect(() => {
document rootElement.setAttribute("data-background", background);
.getElementsByTagName("body")[0] }, [pathname, background, rootElement]);
.setAttribute("data-background", background);
}, [pathname, background]);
return children; return children;
}; };
+4 -2
View File
@@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web";
import styles from "./Modal.module.css"; import styles from "./Modal.module.css";
import overlayStyles from "./Overlay.module.css"; import overlayStyles from "./Overlay.module.css";
import { useMediaQuery } from "./useMediaQuery"; import { useMediaQuery } from "./useMediaQuery";
import { useRootElement } from "./RootElementContext";
export interface Props { export interface Props {
title: string; title: string;
@@ -78,6 +79,7 @@ export const Modal: FC<Props> = ({
...rest ...rest
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const rootElement = useRootElement();
// Empirically, Chrome on Android can end up not matching (hover: none), but // Empirically, Chrome on Android can end up not matching (hover: none), but
// still matching (pointer: coarse) :/ // still matching (pointer: coarse) :/
const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)"); const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)");
@@ -100,7 +102,7 @@ export const Modal: FC<Props> = ({
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined} dismissible={onDismiss !== undefined}
> >
<Drawer.Portal> <Drawer.Portal container={rootElement}>
<Drawer.Overlay className={classNames(overlayStyles.bg)} /> <Drawer.Overlay className={classNames(overlayStyles.bg)} />
<Drawer.Content <Drawer.Content
className={classNames( className={classNames(
@@ -155,7 +157,7 @@ export const Modal: FC<Props> = ({
return ( return (
<DialogRoot open={open} onOpenChange={onOpenChange}> <DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal> <DialogPortal container={rootElement}>
<DialogOverlay <DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)} className={classNames(overlayStyles.bg, overlayStyles.animate)}
/> />
+30
View File
@@ -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<HTMLElement | null>(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;
+7 -1
View File
@@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web";
import styles from "./Toast.module.css"; import styles from "./Toast.module.css";
import overlayStyles from "./Overlay.module.css"; import overlayStyles from "./Overlay.module.css";
import { useRootElement } from "./RootElementContext";
interface Props { interface Props {
/** /**
@@ -64,6 +65,7 @@ export const Toast: FC<Props> = ({
Icon, Icon,
modal = true, modal = true,
}) => { }) => {
const rootElement = useRootElement();
const onOpenChange = useCallback( const onOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
if (!open) onDismiss(); if (!open) onDismiss();
@@ -104,7 +106,11 @@ export const Toast: FC<Props> = ({
return ( return (
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}> <DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{modal ? <DialogPortal>{content}</DialogPortal> : content} {modal ? (
<DialogPortal container={rootElement}>{content}</DialogPortal>
) : (
content
)}
</DialogRoot> </DialogRoot>
); );
}; };
+5 -3
View File
@@ -80,6 +80,7 @@ import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx"; import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts"; import { useBehavior } from "../useBehavior.ts";
import { useRootElement } from "../RootElementContext.ts";
/** /**
* If there already are this many participants in the call, we automatically mute * If there already are this many participants in the call, we automatically mute
@@ -123,6 +124,7 @@ export const GroupCallView: FC<Props> = ({
null, null,
); );
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const rootElement = useRootElement();
const muteAllAudio = useBehavior(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
const leaveSoundContext = useLatest( const leaveSoundContext = useLatest(
@@ -150,11 +152,11 @@ export const GroupCallView: FC<Props> = ({
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px // viewport sizes smaller than 122px width. (It is actually this exact number: 122px
// tested on different devices...) // tested on different devices...)
useEffect(() => { useEffect(() => {
document.body.classList.add("no-scroll-body"); rootElement.classList.add("no-scroll-body");
return (): void => { return (): void => {
document.body.classList.remove("no-scroll-body"); rootElement.classList.remove("no-scroll-body");
}; };
}, []); }, [rootElement]);
useEffect(() => { useEffect(() => {
window.rtcSession = rtcSession; window.rtcSession = rtcSession;
+4 -4
View File
@@ -54,6 +54,7 @@ import { Slider } from "../Slider";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus"; import { RingingStatus } from "./RingingStatus";
import { useRootElement } from "../RootElementContext";
interface SpotlightItemBaseProps { interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -414,6 +415,7 @@ export const SpotlightTile: FC<Props> = ({
style, style,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const rootElement = useRootElement();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null); const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const maximised = useBehavior(vm.maximised$); const maximised = useBehavior(vm.maximised$);
@@ -428,24 +430,22 @@ export const SpotlightTile: FC<Props> = ({
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
const isFullscreen = useCallback((): boolean => { const isFullscreen = useCallback((): boolean => {
const rootElement = document.body;
if (rootElement && document.fullscreenElement) return true; if (rootElement && document.fullscreenElement) return true;
return false; return false;
}, []); }, [rootElement]);
const FullScreenIcon = isFullscreen() const FullScreenIcon = isFullscreen()
? FullScreenMinimiseIcon ? FullScreenMinimiseIcon
: FullScreenMaximiseIcon; : FullScreenMaximiseIcon;
const onToggleFullscreen = useCallback(() => { const onToggleFullscreen = useCallback(() => {
const rootElement = document.body;
if (!rootElement) return; if (!rootElement) return;
if (isFullscreen()) { if (isFullscreen()) {
void document?.exitFullscreen(); void document?.exitFullscreen();
} else { } else {
void rootElement.requestFullscreen(); void rootElement.requestFullscreen();
} }
}, [isFullscreen]); }, [isFullscreen, rootElement]);
// To keep track of which item is visible, we need an intersection observer // 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 // hooked up to the root element and the items. Because the items will run
+7 -5
View File
@@ -11,12 +11,14 @@ import { type IThemeChangeActionRequest } from "matrix-widget-api";
import { getUrlParams } from "./UrlParams"; import { getUrlParams } from "./UrlParams";
import { widget } from "./widget"; import { widget } from "./widget";
import { useRootElement } from "./RootElementContext";
export const useTheme = (): void => { export const useTheme = (): void => {
const rootElement = useRootElement();
const [requestedTheme, setRequestedTheme] = useState( const [requestedTheme, setRequestedTheme] = useState(
() => getUrlParams().theme, () => getUrlParams().theme,
); );
const previousTheme = useRef<string | null>(document.body.classList.item(0)); const previousTheme = useRef<string | null>(rootElement.classList.item(0));
useEffect(() => { useEffect(() => {
if (widget) { if (widget) {
@@ -47,15 +49,15 @@ export const useTheme = (): void => {
: ""; : "";
const themeString = "cpd-theme-" + theme + themeHighContrast; const themeString = "cpd-theme-" + theme + themeHighContrast;
if (themeString !== previousTheme.current) { if (themeString !== previousTheme.current) {
document.body.classList.remove( rootElement.classList.remove(
"cpd-theme-light", "cpd-theme-light",
"cpd-theme-dark", "cpd-theme-dark",
"cpd-theme-light-hc", "cpd-theme-light-hc",
"cpd-theme-dark-hc", "cpd-theme-dark-hc",
); );
document.body.classList.add(themeString); rootElement.classList.add(themeString);
previousTheme.current = themeString; previousTheme.current = themeString;
} }
document.body.classList.remove("no-theme"); rootElement.classList.remove("no-theme");
}, [previousTheme, requestedTheme]); }, [previousTheme, requestedTheme, rootElement]);
}; };