Skip to content

Commit

Permalink
refactor(application-components): use radix-ui components for modals …
Browse files Browse the repository at this point in the history
…and dialogs (#3710)

* refactor: use radix-ui components for modals and dialogs

* refactor: revert unneded changes

* refactor: important
  • Loading branch information
kark authored Feb 4, 2025
1 parent 87055f1 commit fa5488a
Show file tree
Hide file tree
Showing 13 changed files with 1,100 additions and 899 deletions.
7 changes: 7 additions & 0 deletions .changeset/thin-kiwis-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@commercetools-frontend/application-components': minor
'@commercetools-website/components-playground': minor
'@commercetools-frontend/application-shell': minor
---

Replace the usage of the `react-modal` library with `@radix-ui/react-dialog` for managing modals and dialogs.
4 changes: 2 additions & 2 deletions packages/application-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@flopflip/react-broadcast": "14.0.2",
"@radix-ui/react-dialog": "1.1.4",
"@react-hook/latest": "1.0.3",
"@react-hook/resize-observer": "1.2.6",
"@types/history": "^4.7.11",
Expand All @@ -69,8 +70,7 @@
"history": "4.10.1",
"lodash": "4.17.21",
"prop-types": "15.8.1",
"raf-schd": "^4.0.3",
"react-modal": "3.16.1"
"raf-schd": "^4.0.3"
},
"devDependencies": {
"@apollo/client": "3.7.14",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ describe('CustomViewLoader', () => {
<CustomViewLoader customView={TEST_CUSTOM_VIEW} onClose={onCloseMock} />
);

const overlay = baseElement.querySelector('[data-role="modal-overlay"]');
const overlay = baseElement.querySelector(
'[data-role="modal-overlay-clickable"]'
);
fireEvent.click(overlay!);

await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SyntheticEvent, ReactNode } from 'react';
import type { ReactNode, SyntheticEvent } from 'react';
import DialogContainer from '../internals/dialog-container';
import DialogContent from '../internals/dialog-content';
import DialogHeader, { TextTitle } from '../internals/dialog-header';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import type { ReactNode, SyntheticEvent } from 'react';
import { css, ClassNames } from '@emotion/react';
import {
useState,
useEffect,
type ReactNode,
type SyntheticEvent,
} from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import Modal, { type Props as ModalProps } from 'react-modal';
import {
Root as DialogRoot,
Portal as DialogPortal,
type DialogContentProps,
} from '@radix-ui/react-dialog';
import { PORTALS_CONTAINER_ID } from '@commercetools-frontend/constants';
import Card from '@commercetools-uikit/card';
import { designTokens as uiKitDesignTokens } from '@commercetools-uikit/design-system';
import { useWarning } from '@commercetools-uikit/utils';
import { getOverlayStyles, getModalContentStyles } from './dialog.styles';
import {
DialogOverlay,
DialogContent,
ClickableDialogContent,
} from './dialog.styles';

// When running tests, we don't render the AppShell. Instead we mock the
// application context to make the data available to the application under
Expand All @@ -28,17 +41,6 @@ const getDefaultParentSelector = () =>
`#${PORTALS_CONTAINER_ID}`
) as HTMLElement);

const getOverlayElement: ModalProps['overlayElement'] = (
props,
contentElement
) => (
// Assign the `data-role` to the overlay container, which is used as
// the CSS selector in the `<PortalsContainer>`.
<div {...props} data-role="dialog-overlay">
{contentElement}
</div>
);

type Props = {
isOpen: boolean;
onClose?: (event: SyntheticEvent) => void;
Expand All @@ -53,6 +55,7 @@ type Props = {
type GridAreaProps = {
name: string;
};

const GridArea = styled.div<GridAreaProps>`
grid-area: ${(props) => props.name};
`;
Expand All @@ -67,74 +70,90 @@ const DialogContainer = ({
(typeof props.title !== 'string' && Boolean(props['aria-label'])),
'app-kit/DialogHeader: "aria-label" prop is required when the "title" prop is not a string.'
);
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(
null
);

return (
<ClassNames>
{({ css: makeClassName }) => (
<Modal
isOpen={props.isOpen}
onRequestClose={props.onClose}
shouldCloseOnOverlayClick={Boolean(props.onClose)}
shouldCloseOnEsc={Boolean(props.onClose)}
overlayElement={getOverlayElement}
overlayClassName={makeClassName(getOverlayStyles({ size, ...props }))}
className={makeClassName(getModalContentStyles({ size, ...props }))}
contentLabel={
typeof props.title === 'string' ? props.title : props['aria-label']
}
parentSelector={getParentSelector}
ariaHideApp={false}
>
<GridArea name="top" />
<GridArea name="left" />
<GridArea name="right" />
<GridArea name="bottom" />
<GridArea
name="main"
css={css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
overflow: hidden;
`}
>
<Card
// 1. For the min-height: https://stackoverflow.com/questions/28636832/firefox-overflow-y-not-working-with-nested-flexbox/28639686#28639686
// 2. For the actual "> div" container with the content, we need to use normal pointer events so that clicking on it does not close the dialog.
css={css`
min-height: 0;
padding: ${uiKitDesignTokens.spacing20}
${uiKitDesignTokens.spacing30};
useEffect(() => {
const container = getParentSelector();
if (container) {
setPortalContainer(container);
}
}, []);

> div {
display: flex;
flex-direction: column;
height: 100%;
pointer-events: auto;
min-height: 0;
}
`}
const dialogAccessibleLabel =
typeof props.title === 'string' ? props.title : props['aria-label'];

return (
<DialogRoot open={props.isOpen} modal={false}>
<DialogPortal container={portalContainer}>
<DialogOverlay data-role="dialog-overlay" zIndex={props.zIndex}>
<DialogContent>
<ClickableDialogContent
size={size}
onEscapeKeyDown={
props.onClose as DialogContentProps['onEscapeKeyDown']
}
onPointerDownOutside={
props.onClose as DialogContentProps['onPointerDownOutside']
}
aria-describedby={undefined}
aria-labelledby=""
aria-label={dialogAccessibleLabel}
>
<div
<GridArea name="top" />
<GridArea name="left" />
<GridArea name="right" />
<GridArea name="bottom" />
<GridArea
name="main"
css={css`
display: flex;
flex-direction: column;
align-items: stretch;
align-items: center;
justify-content: center;
height: 100%;
min-height: 0;
overflow: hidden;
`}
>
{props.children}
</div>
</Card>
</GridArea>
</Modal>
)}
</ClassNames>
<Card
// 1. For the min-height: https://stackoverflow.com/questions/28636832/firefox-overflow-y-not-working-with-nested-flexbox/28639686#28639686
// 2. For the actual "> div" container with the content, we need to use normal pointer events so that clicking on it does not close the dialog.
css={css`
min-height: 0;
padding: ${uiKitDesignTokens.spacing20}
${uiKitDesignTokens.spacing30};
> div {
display: flex;
flex-direction: column;
height: 100%;
pointer-events: auto;
min-height: 0;
}
`}
>
<div
css={css`
display: flex;
flex-direction: column;
align-items: stretch;
height: 100%;
min-height: 0;
`}
>
{props.children}
</div>
</Card>
</GridArea>
</ClickableDialogContent>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
);
};

DialogContainer.displayName = 'DialogContainer';

export default DialogContainer;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode, SyntheticEvent } from 'react';
import { css } from '@emotion/react';
import { Title as DialogTitle } from '@radix-ui/react-dialog';
import { designTokens as uiKitDesignTokens } from '@commercetools-uikit/design-system';
import { CloseIcon } from '@commercetools-uikit/icons';
import SecondaryIconButton from '@commercetools-uikit/secondary-icon-button';
Expand All @@ -21,13 +22,28 @@ export const TextTitle = (props: TTextTitleProps) => (
</Text.Headline>
);

const HiddenEmptyDialogTitle = () => (
<div aria-hidden={true} style={{ display: 'none' }}>
<DialogTitle />
</div>
);

type TitleProps = Pick<Props, 'title'>;
const Title = (props: TitleProps) => {
if (typeof props.title === 'string') {
return <TextTitle title={props.title} />;
} else {
return <>{props.title}</>;
}
return (
<>
{typeof props.title === 'string' ? (
<TextTitle title={props.title} />
) : (
props.title
)}
{/* FIXME: Temporary workaround for https://github.com/radix-ui/primitives/issues/2986
Radix UI's DialogContent requires rendering a DialogTitle, which renders as <h2>.
To meet this requirement and avoid rendering two heading elements with the title in the DOM (<TextTitle> renders as <h3>), we are hiding the DialogTitle.
*/}
<HiddenEmptyDialogTitle />
</>
);
};

const DialogHeader = (props: Props) => (
Expand Down Expand Up @@ -56,6 +72,7 @@ const DialogHeader = (props: Props) => (
</Spacings.Inline>
</div>
);

DialogHeader.displayName = 'DialogHeader';

export default DialogHeader;
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { css, type SerializedStyles } from '@emotion/react';
import styled from '@emotion/styled';
import { Content } from '@radix-ui/react-dialog';
import { designTokens as uiKitDesignTokens } from '@commercetools-uikit/design-system';

type StyleProps = {
Expand Down Expand Up @@ -75,23 +77,40 @@ export const getModalContentStyles = (props: StyleProps): SerializedStyles => {
height: 100%;
width: 100%;
outline: none;
position: relative;
pointer-events: none;
z-index: ${typeof props.zIndex === 'number'
? // Use `!important` to overwrite the default value assigned by the Stacking Layer System.
// We're assigning value 1 unit higher than the overlay to ensure the content is on top.
// It's safe to do that since the modal is topmost in the stacking layer.
`${props.zIndex + 1} !important`
: 'auto'};
${gridStyle};
`;
return baseStyles;
};

export const getOverlayStyles = (props: StyleProps): SerializedStyles => css`
export const ClickableDialogContent = styled(Content)<StyleProps>`
${(props) => getModalContentStyles(props)}
`;

export const DialogOverlay = styled.div<Pick<StyleProps, 'zIndex'>>`
display: flex;
position: absolute;
z-index: ${typeof props.zIndex === 'number'
? // Use `!important` to overwrite the default value assigned by the Stacking Layer System.
`${props.zIndex} !important`
: 'auto'};
z-index: ${({ zIndex }) =>
// Use `!important` to overwrite the default value assigned by the Stacking Layer System.
typeof zIndex === 'number' ? `${zIndex} !important` : 'auto'};
top: 0;
width: 100%;
height: 100%;
background-color: rgba(32, 62, 72, 0.5);
opacity: 1;
`;

export const DialogContent = styled.div`
position: absolute;
width: 100%;
height: 100%;
top: 0;
`;
Loading

0 comments on commit fa5488a

Please sign in to comment.