diff --git a/apps/vr-tests-react-components/src/stories/Dialog/Dialog.stories.tsx b/apps/vr-tests-react-components/src/stories/Dialog/Dialog.stories.tsx index 899db533bdcaf3..bf269121e4c8d1 100644 --- a/apps/vr-tests-react-components/src/stories/Dialog/Dialog.stories.tsx +++ b/apps/vr-tests-react-components/src/stories/Dialog/Dialog.stories.tsx @@ -8,9 +8,10 @@ import { DialogTitle, DialogTrigger, } from '@fluentui/react-dialog'; +import { OverlayDrawer, DrawerBody, DrawerHeader, DrawerHeaderTitle } from '@fluentui/react-drawer'; import { Button } from '@fluentui/react-button'; import { Combobox, Option } from '@fluentui/react-combobox'; -import { Rocket24Regular } from '@fluentui/react-icons'; +import { Rocket24Regular, Dismiss24Regular } from '@fluentui/react-icons'; import type { Meta } from '@storybook/react-webpack5'; import { getStoryVariant, DARK_MODE, HIGH_CONTRAST, RTL } from '../../utilities'; @@ -528,3 +529,149 @@ export const IntegrationComboboxInline = () => { ); }; + +export const DialogInsideDrawerDefault = () => ( + + + } />}> + Drawer with Dialog + + + + + + + + + + Dialog inside Drawer + This dialog has the default transparent backdrop when nested inside a Drawer. + + + + + + + + + + + +); + +DialogInsideDrawerDefault.storyName = 'dialog inside drawer default'; + +export const DialogInsideDrawerDefaultDarkMode = getStoryVariant(DialogInsideDrawerDefault, DARK_MODE); +export const DialogInsideDrawerDefaultHighContrast = getStoryVariant(DialogInsideDrawerDefault, HIGH_CONTRAST); +export const DialogInsideDrawerDefaultRTL = getStoryVariant(DialogInsideDrawerDefault, RTL); + +export const DialogInsideDrawerDimmed = () => ( + + + } />}> + Drawer with Dialog + + + + + + + + + + Dialog inside Drawer + This dialog has a dimmed backdrop even though it is nested inside a Drawer. + + + + + + + + + + + +); + +DialogInsideDrawerDimmed.storyName = 'dialog inside drawer dimmed'; + +export const DialogInsideDrawerDimmedDarkMode = getStoryVariant(DialogInsideDrawerDimmed, DARK_MODE); +export const DialogInsideDrawerDimmedHighContrast = getStoryVariant(DialogInsideDrawerDimmed, HIGH_CONTRAST); +export const DialogInsideDrawerDimmedRTL = getStoryVariant(DialogInsideDrawerDimmed, RTL); + +export const DialogInsideDrawerTransparent = () => ( + + + } />}> + Drawer with Dialog + + + + + + + + + + Dialog inside Drawer + This dialog explicitly sets transparent backdrop appearance. + + + + + + + + + + + +); + +DialogInsideDrawerTransparent.storyName = 'dialog inside drawer transparent'; + +export const DialogInsideDrawerTransparentDarkMode = getStoryVariant(DialogInsideDrawerTransparent, DARK_MODE); +export const DialogInsideDrawerTransparentHighContrast = getStoryVariant(DialogInsideDrawerTransparent, HIGH_CONTRAST); +export const DialogInsideDrawerTransparentRTL = getStoryVariant(DialogInsideDrawerTransparent, RTL); + +export const NestedDialogDimmed = () => ( + + + + + + + Outer Dialog + This is the outer dialog. + + + + + + + + Inner Dialog with dimmed backdrop + + This inner dialog explicitly sets dimmed backdrop to override the default transparent behavior for + nested dialogs. + + + + + + + + + + + + + +); + +NestedDialogDimmed.storyName = 'nested dialog dimmed'; + +export const NestedDialogDimmedDarkMode = getStoryVariant(NestedDialogDimmed, DARK_MODE); +export const NestedDialogDimmedHighContrast = getStoryVariant(NestedDialogDimmed, HIGH_CONTRAST); +export const NestedDialogDimmedRTL = getStoryVariant(NestedDialogDimmed, RTL); diff --git a/change/@fluentui-react-dialog-e96349b1-d4e8-4754-ac99-d97eb5a1e817.json b/change/@fluentui-react-dialog-e96349b1-d4e8-4754-ac99-d97eb5a1e817.json new file mode 100644 index 00000000000000..2d12f4348a734b --- /dev/null +++ b/change/@fluentui-react-dialog-e96349b1-d4e8-4754-ac99-d97eb5a1e817.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add appearance to backdrop slot", + "packageName": "@fluentui/react-dialog", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-dialog/library/etc/react-dialog.api.md b/packages/react-components/react-dialog/library/etc/react-dialog.api.md index 529c5a1b98cc19..3564a6129e20b8 100644 --- a/packages/react-components/react-dialog/library/etc/react-dialog.api.md +++ b/packages/react-components/react-dialog/library/etc/react-dialog.api.md @@ -9,6 +9,7 @@ import { ARIAButtonType } from '@fluentui/react-aria'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import { ContextSelector } from '@fluentui/react-context-selector'; +import type { ExtractSlotProps } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { PortalProps } from '@fluentui/react-portal'; @@ -54,6 +55,11 @@ export type DialogActionsSlots = { // @public export type DialogActionsState = ComponentState & Pick, 'position' | 'fluid'>; +// @public +export type DialogBackdropSlotProps = ExtractSlotProps & { + appearance?: 'dimmed' | 'transparent'; +}>; + // @public export const DialogBody: ForwardRefComponent; @@ -180,7 +186,7 @@ export const DialogSurfaceProvider: React_2.Provider; // @public (undocumented) export type DialogSurfaceSlots = { - backdrop?: Slot<'div'>; + backdrop?: Slot; root: Slot<'div'>; backdropMotion: Slot; }; @@ -190,6 +196,7 @@ export type DialogSurfaceState = ComponentState & Pick { trapFocus: modalType !== 'non-modal', legacyTrapFocus: !inertTrapFocus, }); + const isNestedDialog = useHasParentContext(DialogContext); return { diff --git a/packages/react-components/react-dialog/library/src/components/DialogSurface/DialogSurface.types.ts b/packages/react-components/react-dialog/library/src/components/DialogSurface/DialogSurface.types.ts index 7667018f654a72..66e18a2be3ec93 100644 --- a/packages/react-components/react-dialog/library/src/components/DialogSurface/DialogSurface.types.ts +++ b/packages/react-components/react-dialog/library/src/components/DialogSurface/DialogSurface.types.ts @@ -1,9 +1,23 @@ import type { PresenceMotionSlotProps } from '@fluentui/react-motion'; import type { PortalProps } from '@fluentui/react-portal'; -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { ComponentProps, ComponentState, Slot, ExtractSlotProps } from '@fluentui/react-utilities'; import { DialogContextValue, DialogSurfaceContextValue } from '../../contexts'; +/** + * Custom slot props for the backdrop slot. + */ +export type DialogBackdropSlotProps = ExtractSlotProps< + Slot<'div'> & { + /** + * Controls the backdrop appearance. + * - 'dimmed': Shows a dimmed backdrop (default for standalone dialogs) + * - 'transparent': Shows a transparent backdrop (default for nested dialogs) + */ + appearance?: 'dimmed' | 'transparent'; + } +>; + export type DialogSurfaceSlots = { /** * Dimmed background of dialog. @@ -11,8 +25,16 @@ export type DialogSurfaceSlots = { * This slot expects a `
` element which will replace the default backdrop. * The backdrop should have `aria-hidden="true"`. * + * Accepts an `appearance` prop to control backdrop visibility: + * - `'dimmed'`: Always shows a dimmed backdrop, regardless of nesting. + * - `'transparent'`: Always shows a transparent backdrop. + * + * @example + * ```tsx + * + * ``` */ - backdrop?: Slot<'div'>; + backdrop?: Slot; root: Slot<'div'>; /** * For more information refer to the [Motion docs page](https://react.fluentui.dev/?path=/docs/motion-motion-slot--docs). @@ -44,7 +66,6 @@ export type DialogSurfaceState = ComponentState & Pick & { open?: boolean; unmountOnClose?: boolean; - /** * Transition status for animation. * In test environment, this is always `undefined`. @@ -52,4 +73,5 @@ export type DialogSurfaceState = ComponentState & * @deprecated Will be always `undefined`. */ transitionStatus?: 'entering' | 'entered' | 'idle' | 'exiting' | 'exited' | 'unmounted'; + backdropAppearance?: DialogBackdropSlotProps['appearance']; }; diff --git a/packages/react-components/react-dialog/library/src/components/DialogSurface/index.ts b/packages/react-components/react-dialog/library/src/components/DialogSurface/index.ts index 6de98e07cf8067..78aec16f608ca7 100644 --- a/packages/react-components/react-dialog/library/src/components/DialogSurface/index.ts +++ b/packages/react-components/react-dialog/library/src/components/DialogSurface/index.ts @@ -1,5 +1,6 @@ export { DialogSurface } from './DialogSurface'; export type { + DialogBackdropSlotProps, DialogSurfaceContextValues, DialogSurfaceElement, DialogSurfaceProps, diff --git a/packages/react-components/react-dialog/library/src/components/DialogSurface/useDialogSurface.ts b/packages/react-components/react-dialog/library/src/components/DialogSurface/useDialogSurface.ts index df05129c64297f..008a8f0210a011 100644 --- a/packages/react-components/react-dialog/library/src/components/DialogSurface/useDialogSurface.ts +++ b/packages/react-components/react-dialog/library/src/components/DialogSurface/useDialogSurface.ts @@ -79,8 +79,12 @@ export const useDialogSurface_unstable = ( elementType: 'div', }); + const backdropAppearance = backdrop?.appearance; + if (backdrop) { backdrop.onClick = handledBackdropClick; + // remove backdrop.appearance so it is not passed to the DOM + delete backdrop.appearance; } const { disableBodyScroll, enableBodyScroll } = useDisableBodyScroll(); @@ -109,6 +113,7 @@ export const useDialogSurface_unstable = ( open, backdrop, isNestedDialog, + backdropAppearance, unmountOnClose, mountNode: props.mountNode, root: slot.always( diff --git a/packages/react-components/react-dialog/library/src/components/DialogSurface/useDialogSurfaceStyles.styles.ts b/packages/react-components/react-dialog/library/src/components/DialogSurface/useDialogSurfaceStyles.styles.ts index d3fed44c49bd20..053c7c4c4f2120 100644 --- a/packages/react-components/react-dialog/library/src/components/DialogSurface/useDialogSurfaceStyles.styles.ts +++ b/packages/react-components/react-dialog/library/src/components/DialogSurface/useDialogSurfaceStyles.styles.ts @@ -1,7 +1,6 @@ 'use client'; import { makeResetStyles, makeStyles, mergeClasses } from '@griffel/react'; -import type { SlotClassNames } from '@fluentui/react-utilities'; import { tokens } from '@fluentui/react-theme'; import { createFocusOutlineStyle } from '@fluentui/react-tabster'; import { @@ -12,6 +11,7 @@ import { SURFACE_PADDING, } from '../../contexts'; import type { DialogSurfaceSlots, DialogSurfaceState } from './DialogSurface.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; export const dialogSurfaceClassNames: SlotClassNames> = { root: 'fui-DialogSurface', @@ -82,11 +82,12 @@ const useStyles = makeStyles({ export const useDialogSurfaceStyles_unstable = (state: DialogSurfaceState): DialogSurfaceState => { 'use no memo'; - const { isNestedDialog, root, backdrop, open, unmountOnClose } = state; + const { root, backdrop, open, unmountOnClose, isNestedDialog, backdropAppearance } = state; const rootBaseStyle = useRootBaseStyle(); const backdropBaseStyle = useBackdropBaseStyle(); const styles = useStyles(); + const isBackdropTransparent = backdropAppearance ? backdropAppearance === 'transparent' : isNestedDialog; const mountedAndClosed = !unmountOnClose && !open; @@ -101,10 +102,14 @@ export const useDialogSurfaceStyles_unstable = (state: DialogSurfaceState): Dial backdrop.className = mergeClasses( dialogSurfaceClassNames.backdrop, backdropBaseStyle, - isNestedDialog && styles.nestedDialogBackdrop, mountedAndClosed && styles.dialogHidden, + isBackdropTransparent && styles.nestedDialogBackdrop, backdrop.className, ); + + if (backdrop?.appearance) { + delete backdrop.appearance; + } } return state; diff --git a/packages/react-components/react-dialog/library/src/index.ts b/packages/react-components/react-dialog/library/src/index.ts index b3f35c11aea049..2803b3603d3e1f 100644 --- a/packages/react-components/react-dialog/library/src/index.ts +++ b/packages/react-components/react-dialog/library/src/index.ts @@ -59,6 +59,7 @@ export { renderDialogSurface_unstable, } from './DialogSurface'; export type { + DialogBackdropSlotProps, DialogSurfaceProps, DialogSurfaceSlots, DialogSurfaceState, diff --git a/packages/react-components/react-dialog/stories/src/Dialog/DialogBackdropAppearance.md b/packages/react-components/react-dialog/stories/src/Dialog/DialogBackdropAppearance.md new file mode 100644 index 00000000000000..e163cfe3ff7c9d --- /dev/null +++ b/packages/react-components/react-dialog/stories/src/Dialog/DialogBackdropAppearance.md @@ -0,0 +1,12 @@ +The `backdrop` slot on `DialogSurface` accepts an `appearance` prop that allows you to explicitly control the backdrop appearance of the dialog. + +By default, DialogSurface automatically determines the backdrop appearance based on context: standalone dialogs show a dimmed backdrop, while nested dialogs (inside another Dialog) show a transparent backdrop to avoid stacking multiple dimmed layers. + +Use `backdrop={{ appearance: "dimmed" }}` when rendering a Dialog inside components that internally use Dialog (like `OverlayDrawer`) but the dialog should visually behave as standalone with a dimmed backdrop. + +- **`'dimmed'`**: Always shows a dimmed backdrop, regardless of nesting. +- **`'transparent'`**: Always shows a transparent backdrop. + +```tsx + +``` diff --git a/packages/react-components/react-dialog/stories/src/Dialog/DialogBackdropAppearance.stories.tsx b/packages/react-components/react-dialog/stories/src/Dialog/DialogBackdropAppearance.stories.tsx new file mode 100644 index 00000000000000..12c531d7bfc3df --- /dev/null +++ b/packages/react-components/react-dialog/stories/src/Dialog/DialogBackdropAppearance.stories.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-components'; +import { + Dialog, + DialogTrigger, + DialogSurface, + DialogTitle, + DialogBody, + DialogActions, + DialogContent, + OverlayDrawer, + DrawerBody, + DrawerHeader, + DrawerHeaderTitle, + Button, + Label, + RadioGroup, + Radio, + useId, + tokens, + makeStyles, +} from '@fluentui/react-components'; +import { Dismiss24Regular } from '@fluentui/react-icons'; +import story from './DialogBackdropAppearance.md'; + +const useStyles = makeStyles({ + field: { + display: 'grid', + gridRowGap: tokens.spacingVerticalS, + marginBottom: tokens.spacingVerticalL, + }, +}); + +type BackdropAppearanceOption = 'dimmed' | 'transparent'; + +export const BackdropAppearance = (): JSXElement => { + const styles = useStyles(); + const labelId = useId('backdrop-appearance-label'); + + const [drawerOpen, setDrawerOpen] = React.useState(false); + const [backdropAppearance, setBackdropAppearance] = React.useState(); + const backdropProp = backdropAppearance ? { appearance: backdropAppearance } : undefined; + + return ( + <> + + + setDrawerOpen(open)}> + + } + onClick={() => setDrawerOpen(false)} + /> + } + > + Drawer + + + + +
+ + setBackdropAppearance(data.value as BackdropAppearanceOption)} + aria-labelledby={labelId} + > + + + + +
+ + + + + + + + Dialog + + This Dialog is rendered inside an OverlayDrawer, which internally uses Dialog. By default, nested + dialogs have a transparent backdrop to avoid stacking multiple dimmed layers. Use the{' '} + backdrop prop to override this behavior. + + + + + + + + + +
+
+ + ); +}; + +BackdropAppearance.parameters = { + docs: { + description: { + story, + }, + }, +}; diff --git a/packages/react-components/react-dialog/stories/src/Dialog/index.stories.tsx b/packages/react-components/react-dialog/stories/src/Dialog/index.stories.tsx index defa29b465345c..08b5b71bd7ac70 100644 --- a/packages/react-components/react-dialog/stories/src/Dialog/index.stories.tsx +++ b/packages/react-components/react-dialog/stories/src/Dialog/index.stories.tsx @@ -8,6 +8,7 @@ import ssrMd from './DialogSSR.md'; export { Default } from './DialogDefault.stories'; export { NonModal } from './DialogNonModal.stories'; export { Alert } from './DialogAlert.stories'; +export { BackdropAppearance } from './DialogBackdropAppearance.stories'; export { ScrollingLongContent } from './DialogScrollingLongContent.stories'; export { KeepRenderedInTheDOM } from './DialogKeepRenderedInTheDOM.stories'; export { Actions } from './DialogActions.stories';