mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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.
197 lines
5.8 KiB
TypeScript
197 lines
5.8 KiB
TypeScript
/*
|
|
Copyright 2023, 2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { type FC, type ReactNode, useCallback } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Root as DialogRoot,
|
|
Portal as DialogPortal,
|
|
Overlay as DialogOverlay,
|
|
Content as DialogContent,
|
|
Title as DialogTitle,
|
|
Close as DialogClose,
|
|
} from "@radix-ui/react-dialog";
|
|
import { Drawer } from "vaul";
|
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
|
import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
import classNames from "classnames";
|
|
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;
|
|
/**
|
|
* Hide the modal header. Used for smaller popups where the context is readily apparent.
|
|
* A title should still be specified for users using assistive technology.
|
|
*/
|
|
hideHeader?: boolean;
|
|
children: ReactNode;
|
|
className?: string;
|
|
/**
|
|
* Class name to be used when in drawer mode (touchscreen).
|
|
*/
|
|
classNameDrawer?: string;
|
|
/**
|
|
* Class name to be used when in modal mode (desktop).
|
|
*/
|
|
classNameModal?: string;
|
|
/**
|
|
* The controlled open state of the modal.
|
|
*/
|
|
// An option to leave the open state uncontrolled is intentionally not
|
|
// provided, since modals are always opened due to external triggers, and it
|
|
// is the author's belief that controlled components lead to more obvious code.
|
|
open: boolean;
|
|
/**
|
|
* Callback for when the user dismisses the modal. If undefined, the modal
|
|
* will be non-dismissable.
|
|
*/
|
|
onDismiss?: () => void;
|
|
/**
|
|
* Whether the modal content has tabs.
|
|
*/
|
|
// TODO: Better tabs support
|
|
tabbed?: boolean;
|
|
}
|
|
|
|
/**
|
|
* A modal, taking the form of a drawer / bottom sheet on touchscreen devices,
|
|
* and a dialog box on desktop.
|
|
*/
|
|
export const Modal: FC<Props> = ({
|
|
title,
|
|
hideHeader,
|
|
children,
|
|
className,
|
|
classNameDrawer,
|
|
classNameModal,
|
|
open,
|
|
onDismiss,
|
|
tabbed,
|
|
...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)");
|
|
const onOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
if (!open) onDismiss?.();
|
|
},
|
|
[onDismiss],
|
|
);
|
|
|
|
if (touchscreen) {
|
|
return (
|
|
<Drawer.Root
|
|
open={open}
|
|
// This autofocus is a custom vault property and not the
|
|
// standard HTML autofocus attribute.
|
|
// It makes the Drawer.Root behave like the `DialogRoot`
|
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
|
autoFocus
|
|
onOpenChange={onOpenChange}
|
|
dismissible={onDismiss !== undefined}
|
|
>
|
|
<Drawer.Portal container={rootElement}>
|
|
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
|
|
<Drawer.Content
|
|
className={classNames(
|
|
className,
|
|
classNameDrawer,
|
|
overlayStyles.overlay,
|
|
styles.modal,
|
|
styles.drawer,
|
|
{ [styles.tabbed]: tabbed },
|
|
)}
|
|
role="dialog"
|
|
// Suppress the warning about there being no description; the modal
|
|
// has an accessible title
|
|
aria-describedby={undefined}
|
|
{...rest}
|
|
>
|
|
<div className={styles.content}>
|
|
<div className={styles.header}>
|
|
<div className={styles.handle} />
|
|
<VisuallyHidden asChild>
|
|
<Drawer.Title>{title}</Drawer.Title>
|
|
</VisuallyHidden>
|
|
</div>
|
|
<div className={styles.body}>{children}</div>
|
|
</div>
|
|
</Drawer.Content>
|
|
</Drawer.Portal>
|
|
</Drawer.Root>
|
|
);
|
|
} else {
|
|
const titleNode = (
|
|
<DialogTitle asChild>
|
|
<Heading as="h2" weight="semibold" size="md">
|
|
{title}
|
|
</Heading>
|
|
</DialogTitle>
|
|
);
|
|
const header = (
|
|
<div className={styles.header}>
|
|
{titleNode}
|
|
{onDismiss !== undefined && (
|
|
<DialogClose
|
|
className={styles.close}
|
|
data-testid="modal_close"
|
|
aria-label={t("action.close")}
|
|
>
|
|
<CloseIcon width={20} height={20} />
|
|
</DialogClose>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
|
<DialogPortal container={rootElement}>
|
|
<DialogOverlay
|
|
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
|
/>
|
|
<DialogContent
|
|
asChild
|
|
// Suppress the warning about there being no description; the modal
|
|
// has an accessible title
|
|
aria-describedby={undefined}
|
|
role="dialog"
|
|
{...rest}
|
|
>
|
|
<Glass
|
|
className={classNames(
|
|
className,
|
|
classNameModal,
|
|
overlayStyles.overlay,
|
|
overlayStyles.animate,
|
|
styles.modal,
|
|
styles.dialog,
|
|
{ [styles.tabbed]: tabbed },
|
|
)}
|
|
>
|
|
<div className={styles.content}>
|
|
{!hideHeader ? header : null}
|
|
{hideHeader ? (
|
|
<VisuallyHidden asChild>{titleNode}</VisuallyHidden>
|
|
) : null}
|
|
<div className={styles.body}>{children}</div>
|
|
</div>
|
|
</Glass>
|
|
</DialogContent>
|
|
</DialogPortal>
|
|
</DialogRoot>
|
|
);
|
|
}
|
|
};
|