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 { 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<SimpleProviderProps> = ({ 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;
};
+4 -2
View File
@@ -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<Props> = ({
...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<Props> = ({
onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined}
>
<Drawer.Portal>
<Drawer.Portal container={rootElement}>
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
<Drawer.Content
className={classNames(
@@ -155,7 +157,7 @@ export const Modal: FC<Props> = ({
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogPortal container={rootElement}>
<DialogOverlay
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 overlayStyles from "./Overlay.module.css";
import { useRootElement } from "./RootElementContext";
interface Props {
/**
@@ -64,6 +65,7 @@ export const Toast: FC<Props> = ({
Icon,
modal = true,
}) => {
const rootElement = useRootElement();
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) onDismiss();
@@ -104,7 +106,11 @@ export const Toast: FC<Props> = ({
return (
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{modal ? <DialogPortal>{content}</DialogPortal> : content}
{modal ? (
<DialogPortal container={rootElement}>{content}</DialogPortal>
) : (
content
)}
</DialogRoot>
);
};
+5 -3
View File
@@ -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<Props> = ({
null,
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const rootElement = useRootElement();
const muteAllAudio = useBehavior(muteAllAudio$);
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
// 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;
+4 -4
View File
@@ -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<HTMLDivElement>;
@@ -414,6 +415,7 @@ export const SpotlightTile: FC<Props> = ({
style,
}) => {
const { t } = useTranslation();
const rootElement = useRootElement();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const maximised = useBehavior(vm.maximised$);
@@ -428,24 +430,22 @@ export const SpotlightTile: FC<Props> = ({
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
+7 -5
View File
@@ -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<string | null>(document.body.classList.item(0));
const previousTheme = useRef<string | null>(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]);
};