import React, { useId, useRef } from 'react' import { createPortal } from 'react-dom' import { Transition } from '@headlessui/react' import classnames from 'classnames' import { FocusOn } from 'react-focus-on' import { useIsClientReady } from '../../utils/useIsClientReady' import { ModalContext } from './context/ModalContext' import styles from './GenericModal.module.scss' export type GenericModalProps = { id?: string isOpen: boolean children: React.ReactNode focusLockDisabled?: boolean onEscapeKeyup?: (event: KeyboardEvent) => void onOutsideModalClick?: (event: React.MouseEvent) => void /** A callback that is triggered after the modal is opened. */ onAfterEnter?: () => void /** A callback that is triggered after the modal is closed. */ onAfterLeave?: () => void className?: string } export const GenericModal = ({ id: propsId, children, isOpen = true, focusLockDisabled, onEscapeKeyup, onOutsideModalClick, onAfterEnter, onAfterLeave: propsOnAfterLeave, className, }: GenericModalProps): JSX.Element => { const reactId = useId() const id = propsId ?? reactId const labelledByID = useId() const describedByID = useId() const isClientReady = useIsClientReady() const scrollLayerRef = useRef(null) const modalLayerRef = useRef(null) const scrollModalToTop = (): void => { // If we have a really long modal, the autofocus could land on an element down below // causing the modal to scroll down and skipping over the content near the modal's top. // Ensure that when the modal opens, we are at the top of its content. requestAnimationFrame(() => { const scrollElement = scrollLayerRef.current // This little verbose of a check but this ensures that the element is attached to the DOM as it animates in. This additional check aims to avoid race conditions if (!scrollElement?.isConnected) return scrollElement.scrollTop = 0 }) } const a11yWarn = (): void => { if (!isClientReady) return // Ensure that consumers have provided an element that labels the modal // to meet ARIA accessibility guidelines. if (!document.getElementById(labelledByID)) { // eslint-disable-next-line no-console console.warn( 'When using the Modal component, you must provide a label for the modal. Make sure you have a component with content that labels the modal.', ) } } const focusOnAccessibleLabel = (): void => { if (!isClientReady) return const modalElement = modalLayerRef.current if (!modalElement?.isConnected) return // Check if focus already exists within the modal if (modalElement.contains(document.activeElement)) { return } const labelElement: HTMLElement | null = document.getElementById(labelledByID) if (labelElement?.isConnected) { labelElement.focus() } } const onEscapeKeyHandler = (e: Event): void => { if (e instanceof KeyboardEvent) { onEscapeKeyup?.(e) } } const onAfterEnterHandler = (): void => { scrollModalToTop() const modalElement = modalLayerRef.current if (modalElement) { onAfterEnter?.() focusOnAccessibleLabel() a11yWarn() } } const outsideModalClickHandler = (e: React.MouseEvent): void => { if (e.target === scrollLayerRef.current || e.target === modalLayerRef.current) { onOutsideModalClick?.(e) } } const onAfterLeaveHandler = (): void => { propsOnAfterLeave?.() } // Don't render portal during SSR if (!isClientReady) { return <> } return createPortal(
{/* Disabling these because we don't want this to be keyboard focusable. Users can use Esc to achieve this with a keyboard. */} {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
{children}
, document.body, ) } GenericModal.displayName = 'GenericModal'