diff --git a/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/NestedDialogsExample.tsx b/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/NestedDialogsExample.tsx new file mode 100644 index 0000000000..8e5b901f1e --- /dev/null +++ b/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/NestedDialogsExample.tsx @@ -0,0 +1,43 @@ +"use client"; +import { Button } from "@react-md/core/button/Button"; +import { Dialog } from "@react-md/core/dialog/Dialog"; +import { DialogContent } from "@react-md/core/dialog/DialogContent"; +import { DialogFooter } from "@react-md/core/dialog/DialogFooter"; +import { DialogHeader } from "@react-md/core/dialog/DialogHeader"; +import { DialogTitle } from "@react-md/core/dialog/DialogTitle"; +import { useToggle } from "@react-md/core/useToggle"; +import { type ReactElement } from "react"; + +export default function NestedDialogsExample(): ReactElement { + const { toggle, toggled } = useToggle(); + return ; +} + +interface InfiniteDialogProps { + depth: number; + closeAll(): void; +} + +function InfiniteDialog(props: InfiniteDialogProps): ReactElement { + const { depth, closeAll } = props; + const { enable: show, disable: hide, toggled: visible } = useToggle(); + + return ( + <> + + + + Dialog Depth {depth} + + + + + + + + + + ); +} diff --git a/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/NestedDialogsVisibleExample.tsx b/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/NestedDialogsVisibleExample.tsx new file mode 100644 index 0000000000..5056f69c6c --- /dev/null +++ b/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/NestedDialogsVisibleExample.tsx @@ -0,0 +1,51 @@ +"use client"; +import { Button } from "@react-md/core/button/Button"; +import { Dialog } from "@react-md/core/dialog/Dialog"; +import { DialogContent } from "@react-md/core/dialog/DialogContent"; +import { DialogFooter } from "@react-md/core/dialog/DialogFooter"; +import { DialogHeader } from "@react-md/core/dialog/DialogHeader"; +import { DialogTitle } from "@react-md/core/dialog/DialogTitle"; +import { useToggle } from "@react-md/core/useToggle"; +import { useEffect, type ReactElement } from "react"; + +export default function NestedDialogsVisibleExample(): ReactElement { + const { toggle, toggled } = useToggle(); + return ; +} + +interface InfiniteDialogProps { + depth: number; + closeAll(): void; +} + +function InfiniteDialog(props: InfiniteDialogProps): ReactElement { + const { depth, closeAll } = props; + + const defaultVisible = depth > 0 && depth < 3; + // try setting `useToggle(defaultVisible)` to see the difference + const { enable: show, disable: hide, toggled: visible } = useToggle(); + useEffect(() => { + if (defaultVisible) { + show(); + } + }, [defaultVisible, show]); + + return ( + <> + + + + Dialog Depth {depth} + + + + + + + + + + ); +} diff --git a/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/page.mdx b/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/page.mdx index d4d6ce1279..3db4e03d3b 100644 --- a/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/page.mdx +++ b/apps/docs/src/app/(main)/(markdown)/(demos)/components/dialog/page.mdx @@ -83,3 +83,28 @@ the visibility transitions and can be customized by providing the `timeout` and ```demo source="./CustomTransitionExample.tsx" ``` + +# Nested Dialogs + +Dialogs can be nested without any additional setup since the dialogs are +portalled behind the scenes and only the topmost overlay will be shown at a +time. + +> !Info! If multiple overlays were shown at the same time, the overlay would +> become darker as more dialogs are shown which can cause performance issues on +> mobile devices. + +```demo source="./NestedDialogsExample.tsx" + +``` + +## Nested Dialogs Default Visible + +If nested dialogs should be visible by on initial page load or mount, trigger +the `show` behavior into a `useEffect` instead of setting the initial state to +`true`. If multiple dialogs are rendered at once, the topmost dialog will be +shown instead of the child dialogs due to how React renders. + +```demo source="./NestedDialogsVisibleExample.tsx" + +``` diff --git a/apps/docs/src/components/ReturnToTop.tsx b/apps/docs/src/components/ReturnToTop.tsx index bdc32c5b67..f8718e218d 100644 --- a/apps/docs/src/components/ReturnToTop.tsx +++ b/apps/docs/src/components/ReturnToTop.tsx @@ -3,7 +3,7 @@ import { TooltippedButton } from "@react-md/core/button/TooltippedButton"; import { DEFAULT_DIALOG_CLASSNAMES, DEFAULT_DIALOG_TIMEOUT, -} from "@react-md/core/dialog/Dialog"; +} from "@react-md/core/dialog/styles"; import { useCSSTransition } from "@react-md/core/transition/useCSSTransition"; import { useIntersectionObserver } from "@react-md/core/useIntersectionObserver"; import ArrowUpwardIcon from "@react-md/material-icons/ArrowUpwardIcon"; diff --git a/packages/core/src/dialog/Dialog.tsx b/packages/core/src/dialog/Dialog.tsx index dee478b8e8..727296c771 100644 --- a/packages/core/src/dialog/Dialog.tsx +++ b/packages/core/src/dialog/Dialog.tsx @@ -252,10 +252,10 @@ export const Dialog = forwardRef( exit, onEnter = noop, onEntering = noop, - onEntered, + onEntered = noop, onExit = noop, onExiting = noop, - onExited, + onExited = noop, exitedHidden = true, disableOverlay = false, overlayProps, @@ -273,13 +273,29 @@ export const Dialog = forwardRef( const ssr = useSsr(); const setChildVisible = useNestedDialogContext(); + + // this makes it so that as more non-full page dialogs become visible, the + // overlay does not become darker as more and more overlays are stacked upon + // each other. only the top-most overlay will have and active background + // color. + const [isChildVisible, setIsChildVisible] = useState(false); const { eventHandlers, transitionOptions } = useFocusContainer({ nodeRef: ref, activate: visible, - onEntered, + onEntered(appear) { + onEntered(appear); + // this needs to be called onEnter and onEntered just in case the + // transition is disabled + setChildVisible(type !== "full-page"); + }, onEntering, onExiting, - onExited, + onExited() { + onExited(); + // this needs to be called onExit and onExited just in case the + // transition is disabled + setChildVisible(false); + }, disableTransition, onKeyDown(event) { onKeyDown(event); @@ -306,6 +322,7 @@ export const Dialog = forwardRef( type, fixed, outline: !disableFocusOutline, + disableBoxShadow: isChildVisible, className, }), appear: appear && !disableTransition && !ssr, @@ -326,12 +343,6 @@ export const Dialog = forwardRef( }); useScrollLock(!disableScrollLock && visible); - // this makes it so that as more non-full page dialogs become visible, the - // overlay does not become darker as more and more overlays are stacked upon - // each other. only the top-most overlay will have and active background - // color. - const [isChildVisible, setIsChildVisible] = useState(false); - return ( {!disableOverlay && ( diff --git a/packages/core/src/dialog/__tests__/Dialog.tsx b/packages/core/src/dialog/__tests__/Dialog.tsx index f0d3d3ed4c..119e270bfd 100644 --- a/packages/core/src/dialog/__tests__/Dialog.tsx +++ b/packages/core/src/dialog/__tests__/Dialog.tsx @@ -264,7 +264,7 @@ describe("Dialog", () => { expect(document.body).not.toHaveStyle("overflow: hidden"); }); - it("should enable the noOpacity prop on parent dialog overlay elements so the screen does not get darker as more dialogs are visible", async () => { + it("should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible", async () => { const user = userEvent.setup(); function InfiniteDialog({ depth }: { depth: number }): ReactElement { const { toggled, enable, disable } = useToggle(); @@ -295,17 +295,34 @@ describe("Dialog", () => { await user.click(screen.getByRole("button", { name: "Show" })); const overlay = screen.getByTestId("overlay"); + const dialog = screen.getByRole("dialog", { name: "Dialog" }); + expect(overlay).toHaveClass("rmd-overlay--active"); + expect(dialog).not.toHaveClass("rmd-dialog--no-box-shadow"); expect(overlay.className).toMatchSnapshot(); + expect(dialog.className).toMatchSnapshot(); await user.click(screen.getByRole("button", { name: "Show 1" })); const overlay1 = screen.getByTestId("overlay1"); + const dialog1 = screen.getByRole("dialog", { name: "Dialog 1" }); + expect(overlay).not.toHaveClass("rmd-overlay--active"); + expect(overlay1).toHaveClass("rmd-overlay--active"); + expect(dialog).toHaveClass("rmd-dialog--no-box-shadow"); + expect(dialog1).not.toHaveClass("rmd-dialog--no-box-shadow"); expect(overlay.className).toMatchSnapshot(); expect(overlay1.className).toMatchSnapshot(); + expect(dialog.className).toMatchSnapshot(); + expect(dialog1.className).toMatchSnapshot(); await user.click(screen.getByRole("button", { name: "Show 2" })); + expect(overlay).not.toHaveClass("rmd-overlay--active"); + expect(overlay1).not.toHaveClass("rmd-overlay--active"); + expect(dialog).toHaveClass("rmd-dialog--no-box-shadow"); + expect(dialog1).toHaveClass("rmd-dialog--no-box-shadow"); expect(overlay.className).toMatchSnapshot(); expect(overlay1.className).toMatchSnapshot(); + expect(dialog.className).toMatchSnapshot(); + expect(dialog1.className).toMatchSnapshot(); }); }); diff --git a/packages/core/src/dialog/__tests__/__snapshots__/Dialog.tsx.snap b/packages/core/src/dialog/__tests__/__snapshots__/Dialog.tsx.snap index ae2b28ea65..4b16b35a99 100644 --- a/packages/core/src/dialog/__tests__/__snapshots__/Dialog.tsx.snap +++ b/packages/core/src/dialog/__tests__/__snapshots__/Dialog.tsx.snap @@ -71,14 +71,24 @@ exports[`Dialog should be able to render as a modal with the correct accessibili /> `; -exports[`Dialog should enable the noOpacity prop on parent dialog overlay elements so the screen does not get darker as more dialogs are visible 1`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center rmd-overlay--active"`; +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 1`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center rmd-overlay--active"`; -exports[`Dialog should enable the noOpacity prop on parent dialog overlay elements so the screen does not get darker as more dialogs are visible 2`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center rmd-overlay--active"`; +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 2`] = `"rmd-dialog rmd-dialog--outline rmd-dialog--centered"`; -exports[`Dialog should enable the noOpacity prop on parent dialog overlay elements so the screen does not get darker as more dialogs are visible 3`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center rmd-overlay--active"`; +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 3`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center"`; -exports[`Dialog should enable the noOpacity prop on parent dialog overlay elements so the screen does not get darker as more dialogs are visible 4`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center rmd-overlay--active"`; +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 4`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center rmd-overlay--active"`; -exports[`Dialog should enable the noOpacity prop on parent dialog overlay elements so the screen does not get darker as more dialogs are visible 5`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center rmd-overlay--active"`; +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 5`] = `"rmd-dialog rmd-dialog--outline rmd-dialog--centered rmd-dialog--no-box-shadow"`; + +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 6`] = `"rmd-dialog rmd-dialog--outline rmd-dialog--centered"`; + +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 7`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center"`; + +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 8`] = `"rmd-overlay rmd-overlay--visible rmd-overlay--clickable rmd-box rmd-box--gap rmd-box--wrap rmd-box--align-center rmd-box--justify-center"`; + +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 9`] = `"rmd-dialog rmd-dialog--outline rmd-dialog--centered rmd-dialog--no-box-shadow"`; + +exports[`Dialog should handle nested dialogs by preventing the overlay and box-shadow from getting darker as more dialogs become visible 10`] = `"rmd-dialog rmd-dialog--outline rmd-dialog--centered rmd-dialog--no-box-shadow"`; exports[`dialog class name utility should be callable with no arguments 1`] = `"rmd-dialog rmd-dialog--centered"`; diff --git a/packages/core/src/dialog/_dialog.scss b/packages/core/src/dialog/_dialog.scss index 0f06698d00..ba66207979 100644 --- a/packages/core/src/dialog/_dialog.scss +++ b/packages/core/src/dialog/_dialog.scss @@ -145,6 +145,10 @@ $variables: ( width: 100%; } + &--no-box-shadow { + box-shadow: none; + } + @if not $disable-focus-outline { // Note: Do not use the `interaction-outline` mixin + // `interaction.set-var(interaction.$focus-color)` like normal focus diff --git a/packages/core/src/dialog/styles.ts b/packages/core/src/dialog/styles.ts index 3d82edcb33..6f3ddba705 100644 --- a/packages/core/src/dialog/styles.ts +++ b/packages/core/src/dialog/styles.ts @@ -42,6 +42,14 @@ export interface DialogClassNameOptions { * @defaultValue `type === "full-page"` */ outline?: boolean; + + /** + * This is mostly used for handling nested dialogs and removes any box shadow + * on a dialog that has a child visible. + * + * @defaultValue `false` + */ + disableBoxShadow?: boolean; } /** @since 6.0.0 */ @@ -50,6 +58,7 @@ export function dialog(options: DialogClassNameOptions = {}): string { type = "centered", fixed = false, outline = type === "full-page", + disableBoxShadow, className, } = options; @@ -59,6 +68,7 @@ export function dialog(options: DialogClassNameOptions = {}): string { outline, centered: type === "centered", "full-page": type === "full-page", + "no-box-shadow": type === "centered" && disableBoxShadow, }), className );