Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import isChromatic from "./isChromatic";
import sageStorybookTheme from "./sage-storybook-theme";
import withGlobalStyles from "./with-global-styles";
import withLocaleSelector from "./with-locale-selector";
import withPortalProvider from "./with-portal-provider";
import { withThemeProvider, globalThemeProvider } from "./withThemeProvider";
import withReducedMotion from "./with-reduced-motion";
import withFusionTokens from "./with-fusion-tokens";
Expand Down Expand Up @@ -92,7 +91,6 @@ const decorators = [
withThemeProvider,
withLocaleSelector,
withFusionTokens,
withPortalProvider,
withReducedMotion,
];

Expand Down
13 changes: 0 additions & 13 deletions .storybook/with-portal-provider.tsx

This file was deleted.

102 changes: 53 additions & 49 deletions src/__internal__/modal/modal.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface ModalProps extends TagProps {

const MODAL__ANIMATION_DURATION = 300;

const Modal = ({
const ModalRoot = ({
children,
"data-element": dataElement,
"data-role": dataRole = "modal",
Expand Down Expand Up @@ -124,56 +124,60 @@ const Modal = ({
}

return (
<Portal>
<StyledModal
data-component="modal"
data-element={dataElement}
data-role={dataRole}
data-state={open && isAnimationComplete ? "open" : "closed"}
transitionName="modal"
transitionTime={MODAL__ANIMATION_DURATION}
topModalOverride={topModalOverride}
ref={ref}
{...rest}
>
<TransitionGroup>
{background && (
<CSSTransition
nodeRef={backgroundNodeRef}
key="modal"
appear
classNames="modal-background"
timeout={MODAL__ANIMATION_DURATION}
onEntered={() => setAnimationComplete(true)}
onExiting={() => setAnimationComplete(false)}
>
{background}
</CSSTransition>
)}
</TransitionGroup>
<TransitionGroup>
{content && (
<CSSTransition
nodeRef={contentNodeRef}
appear
classNames="modal"
timeout={MODAL__ANIMATION_DURATION}
<StyledModal
data-component="modal"
data-element={dataElement}
data-role={dataRole}
data-state={open && isAnimationComplete ? "open" : "closed"}
transitionName="modal"
transitionTime={MODAL__ANIMATION_DURATION}
topModalOverride={topModalOverride}
ref={ref}
{...rest}
>
<TransitionGroup>
{background && (
<CSSTransition
nodeRef={backgroundNodeRef}
key="modal"
appear
classNames="modal-background"
timeout={MODAL__ANIMATION_DURATION}
onEntered={() => setAnimationComplete(true)}
onExiting={() => setAnimationComplete(false)}
>
{background}
</CSSTransition>
)}
</TransitionGroup>
<TransitionGroup>
{content && (
<CSSTransition
nodeRef={contentNodeRef}
appear
classNames="modal"
timeout={MODAL__ANIMATION_DURATION}
>
<ModalContext.Provider
value={{
isAnimationComplete,
triggerRefocusFlag,
isInModal: true,
}}
>
<ModalContext.Provider
value={{
isAnimationComplete,
triggerRefocusFlag,
isInModal: true,
}}
>
<div ref={contentNodeRef}>{content}</div>
</ModalContext.Provider>
</CSSTransition>
)}
</TransitionGroup>
</StyledModal>
</Portal>
<div ref={contentNodeRef}>{content}</div>
</ModalContext.Provider>
</CSSTransition>
)}
</TransitionGroup>
</StyledModal>
);
};

const Modal = (props: ModalProps) => (
<Portal>
<ModalRoot {...props} />
</Portal>
);

export default Modal;
74 changes: 29 additions & 45 deletions src/__internal__/popover/popover.component.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import React, {
MutableRefObject,
useContext,
useEffect,
useRef,
RefObject,
} from "react";
import ReactDOM from "react-dom";
import React, { MutableRefObject, useContext, useRef, RefObject } from "react";
import { createPortal } from "react-dom";
import { flip, Placement, Middleware } from "@floating-ui/dom";

import useFloating from "../../hooks/__internal__/useFloating";
import { StyledBackdrop, StyledPopoverContent } from "./popover.style";
import CarbonScopedTokensProvider from "../../style/design-tokens/carbon-scoped-tokens-provider/carbon-scoped-tokens-provider.component";
import ModalContext, { ModalContextProps } from "../modal/modal.context";
import useIsBrowser from "../../hooks/__internal__/useIsBrowser";

export interface PopoverProps {
/**
Expand Down Expand Up @@ -64,28 +59,17 @@ const defaultMiddleware = [
}),
];

const Popover = ({
const PopoverRoot = ({
children,
placement,
disablePortal,
reference,
middleware = defaultMiddleware,
disableBackgroundUI,
isOpen = true,
animationFrame,
popoverStrategy = "absolute",
childRefOverride,
}: PopoverProps) => {
const elementDOM = useRef<HTMLDivElement | null>(null);
const { isInModal } = useContext<ModalContextProps>(ModalContext);
const candidateNode = reference.current?.closest("[role='dialog']");
const mountNode = isInModal && candidateNode ? candidateNode : document.body;

if (!elementDOM.current && !disablePortal) {
elementDOM.current = document.createElement("div");
mountNode.appendChild(elementDOM.current);
}

}: Omit<PopoverProps, "disablePortal">) => {
const childRef =
childRefOverride ||
(React.Children.only(children) as React.FunctionComponentElement<unknown>)
Expand All @@ -110,36 +94,36 @@ const Popover = ({
strategy: popoverStrategy,
});

useEffect(() => {
return () => {
if (!disablePortal && elementDOM.current) {
mountNode.removeChild(elementDOM.current);
elementDOM.current = null;
}
};
}, [disablePortal, mountNode]);
return (
<StyledPopoverContent isOpen={isOpen}>
{disableBackgroundUI ? (
<StyledBackdrop data-role="popup-backdrop">{content}</StyledBackdrop>
) : (
content
)}
</StyledPopoverContent>
);
};

if (!disableBackgroundUI) {
content = (
<StyledPopoverContent isOpen={isOpen}>{content}</StyledPopoverContent>
);
}
const Popover = ({ disablePortal, ...props }: PopoverProps) => {
const { isBrowser } = useIsBrowser();
const { isInModal } = useContext<ModalContextProps>(ModalContext);
const closestDialog =
props.reference.current?.closest<HTMLElement>("[role='dialog']");

if (disableBackgroundUI) {
content = (
<StyledPopoverContent isOpen={isOpen}>
<StyledBackdrop data-role="popup-backdrop">{content}</StyledBackdrop>
</StyledPopoverContent>
);
if (disablePortal) {
return <PopoverRoot {...props} />;
}

if (disablePortal) {
return content;
if (!isBrowser) {
return null;
}

return ReactDOM.createPortal(
<CarbonScopedTokensProvider>{content}</CarbonScopedTokensProvider>,
elementDOM.current as HTMLDivElement,
return createPortal(
<CarbonScopedTokensProvider>
<PopoverRoot {...props} />
</CarbonScopedTokensProvider>,
isInModal && closestDialog ? closestDialog : document.body,
);
};

Expand Down
13 changes: 13 additions & 0 deletions src/__internal__/popover/popover.server.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from "react";
import { renderToString } from "react-dom/server";
import Popover from ".";

test("doesn't render popup in a server environment", () => {
const view = renderToString(
<Popover reference={{ current: null }}>
<>Hello world!</>
</Popover>,
);

expect(view).not.toContain("Hello world!");
});
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import React, { useState, useEffect, useRef, ReactNode } from "react";
import React, { useState, useEffect, ReactNode } from "react";
import TopModalContext from "./top-modal.context";

export const TopModalProvider = ({ children }: { children: ReactNode }) => {
const [topModal, setTopModal] = useState<HTMLElement | null>(null);

// can't add the setter to the global list inside useEffect because that doesn't run until
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): Are we all good to remove this ref and condition for first render? The comment states that we can't add the setter to the global list inside a useEffect and we're now doing that.

Copy link
Contributor Author

@Parsium Parsium Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be okay now, since Modal no longer attempts to access this global on initial render.

Portal now creates its attachment node on a separate render to when the portal content is displayed. Because of this change, Modal has been re-structured (see below) so its internal hooks, like useModalManager, are only called once the modal content is rendered.

// src/__internal__/modal/modal.component.tsx#L179

const Modal = (props: ModalProps) => (
  <Portal>
   {/* 👇 `Portal` only renders this once its attachment node is present */}
    <ModalRoot {...props} />
  </Portal>
);

// after the render. We use a ref to ensure it only runs once.
const isFirstRender = useRef(true);

if (isFirstRender.current) {
useEffect(() => {
if (!window.__CARBON_INTERNALS_MODAL_SETTER_LIST) {
window.__CARBON_INTERNALS_MODAL_SETTER_LIST = [];
}

window.__CARBON_INTERNALS_MODAL_SETTER_LIST.push(setTopModal);

isFirstRender.current = false;
}

useEffect(() => {
return () => {
window.__CARBON_INTERNALS_MODAL_SETTER_LIST =
window.__CARBON_INTERNALS_MODAL_SETTER_LIST?.filter(
Expand Down
Loading
Loading