` element which will replace the default backdrop.
+ * The backdrop should have `aria-hidden="true"`.
+ *
+ * By default if `DialogSurface` is `
` element the backdrop is ignored,
+ * since native `` element supports [::backdrop](https://developer.mozilla.org/en-US/docs/Web/CSS/::backdrop)
*/
- root: Slot<'div', 'main'>;
+ backdrop?: Slot<'div'>;
+ root: NonNullable>;
};
-type DialogTitleProps = ComponentProps;
+type DialogTitleProps = ComponentProps;
```
### DialogTitle
@@ -405,6 +400,27 @@ function AsyncConfirmDialog() {
}
```
+### Opting out of native ``
+
+```tsx
+const dialog =
+
+ Open Dialog
+
+
+ This is as basic as it gets.
+
+
+```
+
+```html
+
+Open Dialog
+
+
+This is as basic as it gets
+```
+
## Migration
_TBA: Link to migration guide doc_
diff --git a/packages/react-components/react-dialog/e2e/Dialog.e2e.tsx b/packages/react-components/react-dialog/e2e/Dialog.e2e.tsx
index b70e511c07d1fa..b7db512ed4829e 100644
--- a/packages/react-components/react-dialog/e2e/Dialog.e2e.tsx
+++ b/packages/react-components/react-dialog/e2e/Dialog.e2e.tsx
@@ -63,7 +63,7 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogSurfaceSelector).should('exist');
});
it('should focus on first focusabled element when opened', () => {
@@ -90,7 +90,7 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogTriggerCloseSelector).should('be.focused');
});
it('should focus on body if no focusabled element in dialog', () => {
@@ -109,7 +109,7 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.focused().should('not.exist');
});
it('should focus back on trigger when dialog closed', () => {
@@ -136,8 +136,8 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
- cy.get(dialogTriggerCloseSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
+ cy.get(dialogTriggerCloseSelector).realClick();
cy.get(dialogTriggerOpenSelector).should('be.focused');
});
it('should allow change of focus on open', () => {
@@ -173,7 +173,7 @@ describe('Dialog', () => {
);
};
mount(
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogTriggerCloseSelector).should('be.focused');
});
describe('modalType = modal', () => {
@@ -199,8 +199,8 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
- cy.focused().type('{esc}');
+ cy.get(dialogTriggerOpenSelector).realClick();
+ cy.focused().realType('{esc}');
cy.get(dialogSurfaceSelector).should('not.exist');
});
it('should lock body scroll when dialog open', () => {
@@ -225,7 +225,7 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get('body').should('have.css', 'overflow', 'hidden');
});
});
@@ -252,8 +252,8 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
- cy.focused().type('{esc}');
+ cy.get(dialogTriggerOpenSelector).realClick();
+ cy.focused().realType('{esc}');
cy.get(dialogSurfaceSelector).should('not.exist');
});
it('should not lock body scroll when dialog open', () => {
@@ -278,7 +278,7 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get('body').should('not.have.css', 'overflow', 'hidden');
});
});
@@ -305,8 +305,8 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
- cy.focused().type('{esc}');
+ cy.get(dialogTriggerOpenSelector).realClick();
+ cy.focused().realType('{esc}');
cy.get(dialogSurfaceSelector).should('exist');
});
it('should lock body scroll when dialog open', () => {
@@ -331,7 +331,7 @@ describe('Dialog', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get('body').should('have.css', 'overflow', 'hidden');
});
});
diff --git a/packages/react-components/react-dialog/e2e/DialogTitle.e2e.tsx b/packages/react-components/react-dialog/e2e/DialogTitle.e2e.tsx
index 670b5771a5fa74..9ba2a5175c2e65 100644
--- a/packages/react-components/react-dialog/e2e/DialogTitle.e2e.tsx
+++ b/packages/react-components/react-dialog/e2e/DialogTitle.e2e.tsx
@@ -34,7 +34,7 @@ describe('DialogTitle', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogActionSelector).should('not.exist');
});
});
@@ -61,7 +61,7 @@ describe('DialogTitle', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogActionSelector).should('exist');
});
});
@@ -88,7 +88,7 @@ describe('DialogTitle', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogActionSelector).should('not.exist');
});
});
diff --git a/packages/react-components/react-dialog/e2e/DialogTrigger.e2e.tsx b/packages/react-components/react-dialog/e2e/DialogTrigger.e2e.tsx
index 1daa0721cb9612..2fe6b03e7441eb 100644
--- a/packages/react-components/react-dialog/e2e/DialogTrigger.e2e.tsx
+++ b/packages/react-components/react-dialog/e2e/DialogTrigger.e2e.tsx
@@ -33,7 +33,7 @@ describe('DialogTrigger', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogSurfaceSelector).should('not.exist');
});
it(`should open dialog when 'aria-disabled' is false`, () => {
@@ -58,7 +58,7 @@ describe('DialogTrigger', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogSurfaceSelector).should('exist');
});
it('should work with any element besides
', () => {
@@ -83,7 +83,7 @@ describe('DialogTrigger', () => {
,
);
- cy.get(dialogTriggerOpenSelector).click();
+ cy.get(dialogTriggerOpenSelector).realClick();
cy.get(dialogSurfaceSelector).should('exist');
});
});
diff --git a/packages/react-components/react-dialog/etc/react-dialog.api.md b/packages/react-components/react-dialog/etc/react-dialog.api.md
index a2d98893499344..ef7fa419ee1bac 100644
--- a/packages/react-components/react-dialog/etc/react-dialog.api.md
+++ b/packages/react-components/react-dialog/etc/react-dialog.api.md
@@ -64,28 +64,35 @@ export type DialogBodyState = ComponentState;
// @public (undocumented)
export type DialogOpenChangeData = {
+ type: 'dialogCancel';
+ open: boolean;
+ event: React_2.SyntheticEvent;
+} | {
type: 'escapeKeyDown';
open: boolean;
- event: React_2.KeyboardEvent;
+ event: React_2.KeyboardEvent;
} | {
type: 'backdropClick';
open: boolean;
- event: React_2.MouseEvent;
+ event: React_2.MouseEvent;
} | {
type: 'triggerClick';
open: boolean;
- event: React_2.MouseEvent;
+ event: React_2.MouseEvent;
};
// @public (undocumented)
-export type DialogOpenChangeEvent = React_2.KeyboardEvent | React_2.MouseEvent;
+export type DialogOpenChangeEvent = DialogOpenChangeData['event'];
+
+// @public
+export type DialogOpenChangeEventHandler = (event: DialogOpenChangeEvent, data: DialogOpenChangeData) => void;
// @public (undocumented)
export type DialogProps = ComponentProps> & {
modalType?: DialogModalType;
open?: boolean;
defaultOpen?: boolean;
- onOpenChange?: (event: DialogOpenChangeEvent, data: DialogOpenChangeData) => void;
+ onOpenChange?: DialogOpenChangeEventHandler;
children: [JSX.Element, JSX.Element] | JSX.Element;
};
@@ -105,12 +112,12 @@ export const DialogSurface: ForwardRefComponent;
export const dialogSurfaceClassNames: SlotClassNames;
// @public
-export type DialogSurfaceProps = ComponentProps;
+export type DialogSurfaceProps = Omit, 'open' | 'onCancel' | 'onClose'>;
// @public (undocumented)
export type DialogSurfaceSlots = {
backdrop?: Slot<'div'>;
- root: NonNullable>;
+ root: NonNullable>;
};
// @public
diff --git a/packages/react-components/react-dialog/src/components/Dialog/Dialog.types.ts b/packages/react-components/react-dialog/src/components/Dialog/Dialog.types.ts
index c6245b25de735b..5cad33153afafc 100644
--- a/packages/react-components/react-dialog/src/components/Dialog/Dialog.types.ts
+++ b/packages/react-components/react-dialog/src/components/Dialog/Dialog.types.ts
@@ -1,18 +1,47 @@
import type * as React from 'react';
import type { ComponentProps, ComponentState } from '@fluentui/react-utilities';
import type { DialogContextValue, DialogSurfaceContextValue } from '../../contexts';
+import type { DialogSurfaceElement } from '../DialogSurface/DialogSurface.types';
export type DialogSlots = {};
-export type DialogOpenChangeEvent = React.KeyboardEvent | React.MouseEvent;
+export type DialogOpenChangeEvent = DialogOpenChangeData['event'];
export type DialogOpenChangeData =
- | { type: 'escapeKeyDown'; open: boolean; event: React.KeyboardEvent }
- | { type: 'backdropClick'; open: boolean; event: React.MouseEvent }
- | { type: 'triggerClick'; open: boolean; event: React.MouseEvent };
+ | {
+ /**
+ * triggered when Escape key is pressed in a native `dialog`
+ */
+ type: 'dialogCancel';
+ open: boolean;
+ event: React.SyntheticEvent;
+ }
+ | {
+ type: 'escapeKeyDown';
+ open: boolean;
+ event: React.KeyboardEvent;
+ }
+ | {
+ type: 'backdropClick';
+ open: boolean;
+ event: React.MouseEvent;
+ }
+ | {
+ type: 'triggerClick';
+ open: boolean;
+ event: React.MouseEvent;
+ };
export type DialogModalType = 'modal' | 'non-modal' | 'alert';
+/**
+ * Callback fired when the component changes value from open state.
+ *
+ * @param event - a React's Synthetic event or a KeyboardEvent in case of `documentEscapeKeyDown`
+ * @param data - A data object with relevant information, such as open value and type
+ */
+export type DialogOpenChangeEventHandler = (event: DialogOpenChangeEvent, data: DialogOpenChangeData) => void;
+
export type DialogContextValues = {
dialog: DialogContextValue;
/**
@@ -52,13 +81,7 @@ export type DialogProps = ComponentProps> & {
* @default false
*/
defaultOpen?: boolean;
- /**
- * Callback fired when the component changes value from open state.
- *
- * @param event - a React's Synthetic event or a KeyboardEvent in case of `documentEscapeKeyDown`
- * @param data - A data object with relevant information, such as open value and type
- */
- onOpenChange?: (event: DialogOpenChangeEvent, data: DialogOpenChangeData) => void;
+ onOpenChange?: DialogOpenChangeEventHandler;
/**
* Can contain two children including {@link DialogTrigger} and {@link DialogSurface}.
* Alternatively can only contain {@link DialogSurface} if using trigger outside dialog, or controlling state.
diff --git a/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts b/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts
index bba332e1953614..723ea7251fe8e0 100644
--- a/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts
+++ b/packages/react-components/react-dialog/src/components/Dialog/useDialog.ts
@@ -1,10 +1,16 @@
import * as React from 'react';
-import { useControllableState, useEventCallback, useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities';
+import {
+ useControllableState,
+ useEventCallback,
+ useId,
+ useIsomorphicLayoutEffect,
+ useMergedRefs,
+} from '@fluentui/react-utilities';
+import { useHasParentContext } from '@fluentui/react-context-selector';
+import { useControlNativeDialogOpenState, useDisableBodyScroll, useFocusFirstElement } from '../../utils';
+import { DialogContext } from '../../contexts';
import type { DialogOpenChangeData, DialogProps, DialogState } from './Dialog.types';
-import { useDisableBodyScroll, useFocusFirstElement } from '../../utils';
-import { useHasParentContext } from '@fluentui/react-context-selector';
-import { DialogContext } from '../../contexts/dialogContext';
/**
* Create the state required to render Dialog.
@@ -36,6 +42,7 @@ export const useDialog_unstable = (props: DialogProps): DialogState => {
});
const focusRef = useFocusFirstElement(open, modalType);
+ const nativeControlRef = useControlNativeDialogOpenState(open, modalType);
const disableBodyScroll = useDisableBodyScroll();
const isBodyScrollLocked = Boolean(open && modalType !== 'non-modal');
@@ -57,7 +64,7 @@ export const useDialog_unstable = (props: DialogProps): DialogState => {
dialogBodyID: useId('dialog-body-'),
dialogTitleID: useId('dialog-title-'),
isNestedDialog: useHasParentContext(DialogContext),
- dialogRef: focusRef,
+ dialogRef: useMergedRefs(focusRef, nativeControlRef),
};
};
diff --git a/packages/react-components/react-dialog/src/components/DialogSurface/DialogSurface.types.ts b/packages/react-components/react-dialog/src/components/DialogSurface/DialogSurface.types.ts
index 0379c76739794b..f92e6aeb188716 100644
--- a/packages/react-components/react-dialog/src/components/DialogSurface/DialogSurface.types.ts
+++ b/packages/react-components/react-dialog/src/components/DialogSurface/DialogSurface.types.ts
@@ -12,7 +12,7 @@ export type DialogSurfaceSlots = {
* since native `` element supports [::backdrop](https://developer.mozilla.org/en-US/docs/Web/CSS/::backdrop)
*/
backdrop?: Slot<'div'>;
- root: NonNullable>;
+ root: NonNullable>;
};
/** @internal */
@@ -23,8 +23,11 @@ export type DialogSurfaceElementIntersection = HTMLDialogElement & HTMLDivElemen
/**
* DialogSurface Props
+ *
+ * Omits basic types from native `dialog` (`open`, `onCancel` and `onClose`)
+ * to ensure `onOpenChange`, `open` and `defaultOpen` from `Dialog` is used instead
*/
-export type DialogSurfaceProps = ComponentProps;
+export type DialogSurfaceProps = Omit, 'open' | 'onCancel' | 'onClose'>;
export type DialogSurfaceContextValues = {
dialogSurface: DialogSurfaceContextValue;
diff --git a/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurface.ts b/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurface.ts
index 5a470fe49860bd..275e9fd7b944cd 100644
--- a/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurface.ts
+++ b/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurface.ts
@@ -13,7 +13,7 @@ import type {
DialogSurfaceState,
} from './DialogSurface.types';
import { useDialogContext_unstable } from '../../contexts';
-import { isEscapeKeyDismiss } from '../../utils/isEscapeKeyDown';
+import { isEscapeKeyDismiss, HTMLDialogElementProps } from '../../utils';
import { useModalAttributes } from '@fluentui/react-tabster';
/**
* Create the state required to render DialogSurface.
@@ -29,8 +29,8 @@ export const useDialogSurface_unstable = (
ref: React.Ref,
): DialogSurfaceState => {
const { backdrop, as } = props;
+ const isNativeDialog = as === 'dialog' || as === undefined;
const modalType = useDialogContext_unstable(ctx => ctx.modalType);
-
const dialogRef = useDialogContext_unstable(ctx => ctx.dialogRef);
const open = useDialogContext_unstable(ctx => ctx.open);
const requestOpenChange = useDialogContext_unstable(ctx => ctx.requestOpenChange);
@@ -38,6 +38,54 @@ export const useDialogSurface_unstable = (
const dialogBodyID = useDialogContext_unstable(ctx => ctx.dialogBodyID);
const isNestedDialog = useDialogContext_unstable(ctx => ctx.isNestedDialog);
+ const handleNativeClick = useEventCallback((event: React.MouseEvent) => {
+ props.onClick?.(event);
+ if (modalType === 'alert' || event.target !== event.currentTarget) {
+ return;
+ }
+ const { clientX, clientY } = event;
+ const { top, left, width, height } = event.currentTarget.getBoundingClientRect();
+ const isBackdropClick = top > clientY || clientY > top + height || left > clientX || clientX > left + width;
+ if (isBackdropClick) {
+ requestOpenChange({
+ event,
+ open: false,
+ type: 'backdropClick',
+ });
+ }
+ });
+
+ const handleNativeCancel = useEventCallback((event: React.SyntheticEvent) => {
+ (props as HTMLDialogElementProps).onCancel?.(event);
+ if (event.currentTarget !== event.target) {
+ return;
+ }
+ if (modalType !== 'alert') {
+ requestOpenChange({
+ event,
+ open: false,
+ type: 'dialogCancel',
+ });
+ }
+ event.preventDefault();
+ });
+
+ const handleNativeClose = useEventCallback((event: React.SyntheticEvent) => {
+ (props as HTMLDialogElementProps).onClose?.(event);
+ // Ensure dialog remains open if force closed by close event
+ if (event.currentTarget.open !== open) {
+ if (open) {
+ if (modalType === 'non-modal') {
+ event.currentTarget.show();
+ } else {
+ event.currentTarget.showModal();
+ }
+ } else {
+ event.currentTarget.close();
+ }
+ }
+ });
+
const handledBackdropClick = useEventCallback((event: React.MouseEvent) => {
if (isResolvedShorthand(props.backdrop)) {
props.backdrop.onClick?.(event);
@@ -72,24 +120,33 @@ export const useDialogSurface_unstable = (
return {
components: {
backdrop: 'div',
- root: 'div',
+ root: 'dialog',
},
backdrop: resolveShorthand(backdrop, {
- required: open && modalType !== 'non-modal',
+ required: !isNativeDialog && open && modalType !== 'non-modal',
defaultProps: {
'aria-hidden': 'true',
onClick: handledBackdropClick,
},
}),
- root: getNativeElementProps(as ?? 'div', {
- role: modalType === 'alert' ? 'alertdialog' : 'dialog',
+ root: getNativeElementProps(as ?? 'dialog', {
+ ...(isNativeDialog
+ ? {
+ role: modalType === 'alert' ? 'alertdialog' : undefined,
+ onClose: handleNativeClose,
+ onClick: handleNativeClick,
+ onCancel: handleNativeCancel,
+ }
+ : {
+ 'aria-modal': modalType !== 'non-modal',
+ role: modalType === 'alert' ? 'alertdialog' : 'dialog',
+ }),
...props,
...modalAttributes,
onKeyDown: handleKeyDown,
'aria-describedby': dialogBodyID,
'aria-labelledby': props['aria-label'] ? undefined : dialogTitleID,
- 'aria-modal': modalType !== 'non-modal',
- ref: useMergedRefs(ref, dialogRef) as React.Ref,
+ ref: useMergedRefs(ref, dialogRef),
}),
};
};
diff --git a/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurfaceStyles.ts b/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurfaceStyles.ts
index 9c4e6a3f44a643..f4d39375eea5ae 100644
--- a/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurfaceStyles.ts
+++ b/packages/react-components/react-dialog/src/components/DialogSurface/useDialogSurfaceStyles.ts
@@ -76,6 +76,9 @@ const useStyles = makeStyles({
...shorthands.margin('auto'),
...shorthands.borderStyle('none'),
...shorthands.overflow('unset'),
+ '&::backdrop': {
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ },
},
backdrop: {
position: 'fixed',
@@ -85,6 +88,11 @@ const useStyles = makeStyles({
nestedDialogBackdrop: {
backgroundColor: 'transparent',
},
+ nestedNativeDialogBackdrop: {
+ '&::backdrop': {
+ backgroundColor: 'transparent',
+ },
+ },
});
/**
@@ -94,7 +102,13 @@ export const useDialogSurfaceStyles_unstable = (state: DialogSurfaceState): Dial
const styles = useStyles();
const isNestedDialog = useDialogContext_unstable(ctx => ctx.isNestedDialog);
- state.root.className = mergeClasses(dialogSurfaceClassNames.root, styles.dialog, styles.root, state.root.className);
+ state.root.className = mergeClasses(
+ dialogSurfaceClassNames.root,
+ styles.dialog,
+ styles.root,
+ isNestedDialog && styles.nestedNativeDialogBackdrop,
+ state.root.className,
+ );
if (state.backdrop) {
state.backdrop.className = mergeClasses(
dialogSurfaceClassNames.backdrop,
diff --git a/packages/react-components/react-dialog/src/contexts/dialogContext.ts b/packages/react-components/react-dialog/src/contexts/dialogContext.ts
index 222dc6ea5df921..919a76f8d2ee80 100644
--- a/packages/react-components/react-dialog/src/contexts/dialogContext.ts
+++ b/packages/react-components/react-dialog/src/contexts/dialogContext.ts
@@ -1,9 +1,8 @@
+import * as React from 'react';
import { createContext, ContextSelector, useContextSelector } from '@fluentui/react-context-selector';
+import { DialogSurfaceElement } from '../DialogSurface';
import type { Context } from '@fluentui/react-context-selector';
-
import type { DialogModalType, DialogOpenChangeData } from '../Dialog';
-import * as React from 'react';
-import { DialogSurfaceElement } from '../DialogSurface';
export type DialogContextValue = {
open: boolean;
diff --git a/packages/react-components/react-dialog/src/index.ts b/packages/react-components/react-dialog/src/index.ts
index 1aae2e3170dc7b..d67145345a72c2 100644
--- a/packages/react-components/react-dialog/src/index.ts
+++ b/packages/react-components/react-dialog/src/index.ts
@@ -1,5 +1,12 @@
export { Dialog, renderDialog_unstable, useDialog_unstable } from './Dialog';
-export type { DialogProps, DialogOpenChangeData, DialogOpenChangeEvent, DialogSlots, DialogState } from './Dialog';
+export type {
+ DialogSlots,
+ DialogProps,
+ DialogState,
+ DialogOpenChangeData,
+ DialogOpenChangeEvent,
+ DialogOpenChangeEventHandler,
+} from './Dialog';
export { DialogTrigger, useDialogTrigger_unstable, renderDialogTrigger_unstable } from './DialogTrigger';
export type {
diff --git a/packages/react-components/react-dialog/src/stories/Dialog/DialogNoFocusableElement.stories.tsx b/packages/react-components/react-dialog/src/stories/Dialog/DialogNoFocusableElement.stories.tsx
index 6a986e612f6ee8..33d241955c9002 100644
--- a/packages/react-components/react-dialog/src/stories/Dialog/DialogNoFocusableElement.stories.tsx
+++ b/packages/react-components/react-dialog/src/stories/Dialog/DialogNoFocusableElement.stories.tsx
@@ -15,7 +15,7 @@ export const NoFocusableElement = () => {
Dialog Title
⛔️ A Dialog without focusable elements is not recommended!
- ⛔️ Escape key doesn't work here
+ ⚠️ Escape key only works with native dialog
✅ Backdrop click still works to ensure this modal can be closed
diff --git a/packages/react-components/react-dialog/src/utils/index.ts b/packages/react-components/react-dialog/src/utils/index.ts
index ed3d5165c8c5a9..d713825c749b09 100644
--- a/packages/react-components/react-dialog/src/utils/index.ts
+++ b/packages/react-components/react-dialog/src/utils/index.ts
@@ -1,3 +1,5 @@
export * from './isEscapeKeyDown';
export * from './useDisableBodyScroll';
export * from './useFocusFirstElement';
+export * from './isHTMLDialogElement';
+export * from './useControlNativeDialogOpenState';
diff --git a/packages/react-components/react-dialog/src/utils/isEscapeKeyDown.ts b/packages/react-components/react-dialog/src/utils/isEscapeKeyDown.ts
index 94b6dda3c482b0..66dc354389f70c 100644
--- a/packages/react-components/react-dialog/src/utils/isEscapeKeyDown.ts
+++ b/packages/react-components/react-dialog/src/utils/isEscapeKeyDown.ts
@@ -1,10 +1,21 @@
import * as React from 'react';
import { Escape } from '@fluentui/keyboard-keys';
import type { DialogModalType } from '../components/Dialog/Dialog.types';
+import { DialogSurfaceElement } from '../DialogSurface';
+import { isHTMLDialogElement } from './isHTMLDialogElement';
/**
* Checks if keydown event is a proper Escape key dismiss
*/
-export function isEscapeKeyDismiss(event: React.KeyboardEvent, type: DialogModalType): boolean {
- return event.key === Escape && type !== 'alert' && !event.isDefaultPrevented();
+export function isEscapeKeyDismiss(
+ event: React.KeyboardEvent,
+ modalType: DialogModalType,
+): boolean {
+ return (
+ event.key === Escape &&
+ // `non-modal` should always have Escape key handling
+ // `modal` should only be handled in the case of non native dialog
+ (modalType === 'non-modal' || (!isHTMLDialogElement(event.currentTarget) && modalType === 'modal')) &&
+ !event.isDefaultPrevented()
+ );
}
diff --git a/packages/react-components/react-dialog/src/utils/isHTMLDialogElement.ts b/packages/react-components/react-dialog/src/utils/isHTMLDialogElement.ts
new file mode 100644
index 00000000000000..537a09b44963e3
--- /dev/null
+++ b/packages/react-components/react-dialog/src/utils/isHTMLDialogElement.ts
@@ -0,0 +1,23 @@
+import * as React from 'react';
+
+export function isHTMLDialogElement(element?: HTMLElement | null): element is HTMLDialogElement {
+ return Boolean(element && 'open' in element && 'show' in element && 'showModal' in element && 'close' in element);
+}
+
+/**
+ * adds additional types which are missing from current version of react
+ * @internal
+ */
+export type HTMLDialogElementProps = JSX.IntrinsicElements['dialog'] & {
+ /**
+ * The close event is fired on a when it has been closed.
+ */
+ onClose?: (event: React.SyntheticEvent) => void;
+ /**
+ * The cancel event fires on a when
+ * the user instructs the browser that they wish to dismiss the current open dialog.
+ * For example, the browser might fire this event when the user presses the Esc
+ * key.
+ */
+ onCancel?: (event: React.SyntheticEvent) => void;
+};
diff --git a/packages/react-components/react-dialog/src/utils/useControlNativeDialogOpenState.ts b/packages/react-components/react-dialog/src/utils/useControlNativeDialogOpenState.ts
new file mode 100644
index 00000000000000..a49fa6b938ae09
--- /dev/null
+++ b/packages/react-components/react-dialog/src/utils/useControlNativeDialogOpenState.ts
@@ -0,0 +1,24 @@
+import * as React from 'react';
+import { isHTMLDialogElement } from './isHTMLDialogElement';
+import type { DialogModalType } from '../Dialog';
+import type { DialogSurfaceElement } from '../DialogSurface';
+
+export function useControlNativeDialogOpenState(open: boolean, modalType: DialogModalType) {
+ const dialogSurfaceRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (isHTMLDialogElement(dialogSurfaceRef.current) && dialogSurfaceRef.current.open !== open) {
+ if (open) {
+ if (modalType === 'non-modal') {
+ dialogSurfaceRef.current.show();
+ } else {
+ dialogSurfaceRef.current.showModal();
+ }
+ } else {
+ dialogSurfaceRef.current.close();
+ }
+ }
+ }, [open, modalType]);
+
+ return dialogSurfaceRef;
+}
diff --git a/packages/react-components/react-dialog/src/utils/useFocusFirstElement.ts b/packages/react-components/react-dialog/src/utils/useFocusFirstElement.ts
index 8fd86eb4fddea0..4bab59d08f110c 100644
--- a/packages/react-components/react-dialog/src/utils/useFocusFirstElement.ts
+++ b/packages/react-components/react-dialog/src/utils/useFocusFirstElement.ts
@@ -3,6 +3,7 @@ import { useFocusFinders } from '@fluentui/react-tabster';
import { useFluent_unstable } from '@fluentui/react-shared-contexts';
import type { DialogSurfaceElement } from '../DialogSurface';
import type { DialogModalType } from '../Dialog';
+import { isHTMLDialogElement } from './isHTMLDialogElement';
/**
* Focus first element on content when dialog is opened,
@@ -21,7 +22,9 @@ export function useFocusFirstElement(open: boolean, modalType: DialogModalType)
const element = dialogRef.current && findFirstFocusable(dialogRef.current);
if (element) {
// this is only required for non native dialogs
- element.focus();
+ if (!isHTMLDialogElement(dialogRef.current)) {
+ element.focus();
+ }
} else if (process.env.NODE_ENV !== 'production') {
triggerRef.current?.blur();
// eslint-disable-next-line no-console