import { createFocusTrap, FocusTrap } from 'focus-trap' import { render, toChildArray, RefObject } from 'preact' import { FC } from 'preact/compat' import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks' import { useGeneratedId } from 'ui/hooks/seamly-hooks' import { childIsVNode, createAriaHider } from 'ui/utils/general-utils' type ModalProps = { onClose: Function fallBackFocusRef: RefObject 'aria-label'?: string 'aria-labelledby'?: string } const Modal: FC = ({ children, onClose, fallBackFocusRef, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, }) => { const modalId = useGeneratedId() const modalContainer = useRef(null) const focusTrap = useRef(null) const [containerIsSet, setContainerIsSet] = useState(false) useEffect(() => { if (containerIsSet && modalContainer.current) { focusTrap.current = createFocusTrap(modalContainer.current, { initialFocus: fallBackFocusRef.current || undefined, }) focusTrap.current.activate() } return () => { if (focusTrap.current) { focusTrap.current.deactivate() } } }, [containerIsSet, fallBackFocusRef]) useEffect(() => { const disposeAriaHider = createAriaHider() return () => { disposeAriaHider() } }, [containerIsSet]) useEffect(() => { if (containerIsSet && modalContainer.current) { modalContainer.current.addEventListener('keydown', (event) => { if ((event.code && event.code === 'Escape') || event.keyCode === 27) { onClose() } }) } }, [containerIsSet, onClose]) useLayoutEffect(() => { const bodyElement = document.getElementsByTagName('body')[0] const container = document.createElement('div') container.setAttribute('id', modalId) container.setAttribute('role', 'dialog') container.setAttribute('data-nosnippet', 'true') container.setAttribute('aria-modal', 'true') if (ariaLabel) { container.setAttribute('aria-label', ariaLabel) } if (ariaLabelledBy) { container.setAttribute('aria-labelledby', ariaLabelledBy) } bodyElement.appendChild(container) modalContainer.current = container setContainerIsSet(true) return () => { if (modalContainer.current) { bodyElement.removeChild(modalContainer.current) modalContainer.current = null } } }, [ariaLabel, ariaLabelledBy, modalId]) // This component can either be provided with a children render // function or another component. // If a render function the function will be called with the onClose // handler function as well as a modalRenderFn that should be used to // render your content. // // {({ onClose, modalRenderFn }) => // modalRenderFn() // } // // If called with a single component, no render function is required // and the onClose function will be automatically added to the // single child component. // // // return typeof children === 'function' ? children({ onClose, modalRenderFn: (els) => modalContainer.current && render(els, modalContainer.current), }) : modalContainer.current && render( toChildArray(children) .filter(childIsVNode) .map((child) => { child.props = { ...child.props, onClose } return child }), modalContainer.current, ) } export default Modal