import { Accessibility, dialogBehavior, DialogBehaviorProps } from '@fluentui/accessibility'; import { ComponentWithAs, FocusTrapZoneProps, useAutoControlled, useTelemetry, useAccessibility, useStyles, useFluentContext, useUnhandledProps, getElementType, } from '@fluentui/react-bindings'; import { Unstable_NestingAuto } from '@fluentui/react-component-nesting-registry'; import { EventListener } from '@fluentui/react-component-event-listener'; import { Ref } from '@fluentui/react-component-ref'; import * as customPropTypes from '@fluentui/react-proptypes'; import * as _ from 'lodash'; import * as PropTypes from 'prop-types'; import * as React from 'react'; import { getCode, keyboardKey } from '@fluentui/keyboard-key'; import { lockBodyScroll, unlockBodyScroll } from './utils'; import { UIComponentProps, commonPropTypes, ContentComponentProps, doesNodeContainClick, getOrGenerateIdFromShorthand, createShorthand, createShorthandFactory, } from '../../utils'; import { ComponentEventHandler, ShorthandValue, FluentComponentStaticProps } from '../../types'; import { Button, ButtonProps } from '../Button/Button'; import { ButtonGroup } from '../Button/ButtonGroup'; import { Box, BoxProps } from '../Box/Box'; import { Header, HeaderProps } from '../Header/Header'; import { Portal, TriggerAccessibility } from '../Portal/Portal'; import { Flex } from '../Flex/Flex'; import { DialogFooter, DialogFooterProps } from './DialogFooter'; export interface DialogSlotClassNames { header: string; headerAction: string; content: string; overlay: string; footer: string; } export interface DialogProps extends UIComponentProps, ContentComponentProps> { /** Accessibility behavior if overridden by the user. */ accessibility?: Accessibility; /** A dialog can contain actions. */ actions?: ShorthandValue; /** A dialog can have a backdrop on its overlay. */ backdrop?: boolean; /** A dialog can contain a cancel button. */ cancelButton?: ShorthandValue; /** A dialog can be closed when a user clicks outside of it. */ closeOnOutsideClick?: boolean; /** A dialog can contain a confirm button. */ confirmButton?: ShorthandValue; /** A dialog can be open by default. */ defaultOpen?: boolean; /** A dialog can contain a header. */ header?: ShorthandValue; /** A dialog can contain a button next to the header. */ headerAction?: ShorthandValue; /** A dialog can contain a footer. */ footer?: ShorthandValue; /** * Called after a user clicks the cancel button. * @param event - React's original SyntheticEvent. * @param data - All props. */ onCancel?: ComponentEventHandler; /** * Called after a user clicks the confirm button. * @param event - React's original SyntheticEvent. * @param data - All props. */ onConfirm?: ComponentEventHandler; /** * Called after a user opens the dialog. * @param event - React's original SyntheticEvent. * @param data - All props. */ onOpen?: ComponentEventHandler; /** A dialog's open state can be controlled. */ open?: boolean; /** A dialog can contain a overlay. */ overlay?: ShorthandValue; /** Controls whether or not focus trap should be applied, using boolean or FocusTrapZoneProps type value. */ trapFocus?: boolean | FocusTrapZoneProps; /** Element to be rendered in-place where the dialog is defined. */ trigger?: JSX.Element; } export interface DialogState { contentId?: string; headerId?: string; open?: boolean; } export const dialogClassName = 'ui-dialog'; export const dialogSlotClassNames: DialogSlotClassNames = { header: `${dialogClassName}__header`, headerAction: `${dialogClassName}__headerAction`, content: `${dialogClassName}__content`, overlay: `${dialogClassName}__overlay`, footer: `${dialogClassName}__footer`, }; export type DialogStylesProps = Required>; /** * A Dialog displays important information on top of a page which requires a user's attention, confirmation, or interaction. * Dialogs are purposefully interruptive, so they should be used sparingly. * * @accessibility * Implements [ARIA Dialog (Modal)](https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal) design pattern. * @accessibilityIssues * [NVDA narrates dialog title and button twice](https://github.com/nvaccess/nvda/issues/10003) * [NVDA does not recognize the ARIA 1.1 values of aria-haspopup](https://github.com/nvaccess/nvda/issues/8235) * [Jaws does not announce token values of aria-haspopup](https://github.com/FreedomScientific/VFO-standards-support/issues/33) * [Issue 989517: VoiceOver narrates dialog content and button twice](https://bugs.chromium.org/p/chromium/issues/detail?id=989517) */ export const Dialog: ComponentWithAs<'div', DialogProps> & FluentComponentStaticProps & { Footer: typeof DialogFooter; } = props => { const context = useFluentContext(); const { setStart, setEnd } = useTelemetry(Dialog.displayName, context.telemetry); setStart(); const { accessibility, content, header, actions, cancelButton, closeOnOutsideClick, confirmButton, headerAction, overlay, trapFocus, trigger, footer, backdrop, className, design, styles, variables, } = props; const ElementType = getElementType(props); const unhandledProps = useUnhandledProps(Dialog.handledProps, props); const contentRef = React.useRef(); const overlayRef = React.useRef(); const triggerRef = React.useRef(); const contentId = React.useRef(); contentId.current = getOrGenerateIdFromShorthand('dialog-content-', content, contentId.current); const headerId = React.useRef(); headerId.current = getOrGenerateIdFromShorthand('dialog-header-', header, headerId.current); const getA11yProps = useAccessibility(accessibility, { debugName: Dialog.displayName, actionHandlers: { closeAndFocusTrigger: e => { handleDialogCancel(e); e.stopPropagation(); _.invoke(triggerRef, 'current.focus'); }, close: e => handleDialogCancel(e), }, mapPropsToBehavior: () => ({ headerId: headerId.current, contentId: contentId.current, trapFocus, trigger, }), rtl: context.rtl, }); const { classes, styles: resolvedStyles } = useStyles(Dialog.displayName, { className: dialogClassName, mapPropsToStyles: () => ({ backdrop, }), mapPropsToInlineStyles: () => ({ className, design, styles, variables, }), rtl: context.rtl, }); const [open, setOpen] = useAutoControlled({ defaultValue: props.defaultOpen, value: props.open, initialValue: false, }); React.useEffect(() => { if (open) { lockBodyScroll(context.target); } return () => { if (open) { unlockBodyScroll(context.target); } }; }, [context.target, open]); const handleDialogCancel = (e: Event | React.SyntheticEvent) => { _.invoke(props, 'onCancel', e, { ...props, open: false }); setOpen(false); }; const handleDialogConfirm = (e: React.SyntheticEvent) => { _.invoke(props, 'onConfirm', e, { ...props, open: false }); setOpen(false); }; const handleDialogOpen = (e: React.SyntheticEvent) => { _.invoke(props, 'onOpen', e, { ...props, open: true }); setOpen(true); }; const handleCancelButtonOverrides = (predefinedProps: ButtonProps) => ({ onClick: (e: React.SyntheticEvent, buttonProps: ButtonProps) => { _.invoke(predefinedProps, 'onClick', e, buttonProps); handleDialogCancel(e); }, }); const handleConfirmButtonOverrides = (predefinedProps: ButtonProps) => ({ onClick: (e: React.SyntheticEvent, buttonProps: ButtonProps) => { _.invoke(predefinedProps, 'onClick', e, buttonProps); handleDialogConfirm(e); }, }); const handleOverlayClick = (e: MouseEvent) => { // Dialog has different conditions to close than Popup, so we don't need to iterate across all // refs const isInsideContentClick = doesNodeContainClick(contentRef.current, e, context.target); const isInsideOverlayClick = doesNodeContainClick(overlayRef.current, e, context.target); const shouldClose = !isInsideContentClick && isInsideOverlayClick; if (shouldClose) { handleDialogCancel(e); } }; const handleDocumentKeydown = (getRefs: Function) => (e: KeyboardEvent) => { // if focus was lost from Dialog, for e.g. when click on Dialog's content // and ESC is pressed, the opened Dialog should get closed and the trigger should get focus const lastOverlayRef = getRefs().pop(); const isLastOpenedDialog: boolean = lastOverlayRef && lastOverlayRef.current === overlayRef.current; const targetIsBody = (e.target as HTMLElement).nodeName === 'BODY'; if (targetIsBody && getCode(e) === keyboardKey.Escape && isLastOpenedDialog) { handleDialogCancel(e); _.invoke(triggerRef, 'current.focus'); } }; const cancelElement = createShorthand(Button, cancelButton, { overrideProps: handleCancelButtonOverrides, }); const confirmElement = createShorthand(Button, confirmButton, { defaultProps: () => ({ primary: true, }), overrideProps: handleConfirmButtonOverrides, }); const dialogActions = (cancelElement || confirmElement) && ButtonGroup.create(actions, { defaultProps: () => ({ styles: resolvedStyles.actions, }), overrideProps: { content: ( {cancelElement} {confirmElement} ), }, }); const dialogContent = ( {Header.create(header, { defaultProps: () => getA11yProps('header', { as: 'h2', className: dialogSlotClassNames.header, styles: resolvedStyles.header, }), })} {createShorthand(Button, headerAction, { defaultProps: () => getA11yProps('headerAction', { className: dialogSlotClassNames.headerAction, styles: resolvedStyles.headerAction, text: true, iconOnly: true, }), })} {Box.create(content, { defaultProps: () => getA11yProps('content', { styles: resolvedStyles.content, className: dialogSlotClassNames.content, }), })} {DialogFooter.create(footer, { overrideProps: { content: dialogActions, className: dialogSlotClassNames.footer, styles: resolvedStyles.footer, }, })} ); const triggerAccessibility: TriggerAccessibility = { // refactor this when unstable_behaviorDefinition gets merged attributes: accessibility(props).attributes.trigger, keyHandlers: accessibility(props).keyActions.trigger, }; const element = ( {(getRefs, nestingRef) => ( <> { overlayRef.current = contentNode; nestingRef.current = contentNode; }} > {Box.create(overlay, { defaultProps: () => ({ className: dialogSlotClassNames.overlay, styles: resolvedStyles.overlay, }), overrideProps: { content: dialogContent }, })} {closeOnOutsideClick && ( )} )} ); setEnd(); return element; }; Dialog.displayName = 'Dialog'; Dialog.propTypes = { ...commonPropTypes.createCommon({ children: false, content: 'shorthand', }), actions: customPropTypes.itemShorthand, backdrop: PropTypes.bool, headerAction: customPropTypes.itemShorthand, cancelButton: customPropTypes.itemShorthand, closeOnOutsideClick: PropTypes.bool, confirmButton: customPropTypes.itemShorthand, defaultOpen: PropTypes.bool, header: customPropTypes.itemShorthand, onCancel: PropTypes.func, onConfirm: PropTypes.func, onOpen: PropTypes.func, open: PropTypes.bool, overlay: customPropTypes.itemShorthand, trapFocus: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), trigger: PropTypes.any, }; Dialog.defaultProps = { accessibility: dialogBehavior, actions: {}, backdrop: true, closeOnOutsideClick: true, overlay: {}, footer: {}, trapFocus: true, }; Dialog.handledProps = Object.keys(Dialog.propTypes) as any; Dialog.Footer = DialogFooter; Dialog.create = createShorthandFactory({ Component: Dialog, });