import React, { FC, MouseEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject, useEffect, useRef, WheelEvent } from 'react' import { formatClassList } from '../../utils' type ModalProps = { blurId?: string, children: ReactNode, id: string, showModal: boolean, setModalClosed: () => void, setModalOpen: () => void, title: string, [other:string]: unknown } const DIALOG_BASE: string = ` fixed h-full inset-0 overflow-y-auto sm:w-full w-screen opacity-0 transition duration-300 ease-in-out -z-1 ` // const DIALOG_CLOSED: string = ` // -z-1 // ` const BACKGROUND_BASE: string = ` bg-black cursor-pointer duration-300 ease-in-out fixed inset-0 overflow-hidden transition-all w-full opacity-75 -z-1 ` // const BACKGROUND_OPEN: string = ` // opacity-75 // ` // const BACKGROUND_CLOSED: string = ` // opacity-0 // ` const CONTENT_BASE: string = ` bg-white duration-300 ease-in-out ml-0 lg:ml-1/4 lg:w-1/2 md:ml-2/12 md:rounded-lg md:w-8/12 ml-1/12 px-5 py-5 rounded sm:rounded-md transition-all w-10/12 m-20 ` // opacity-0 // const CONTENT_CLOSED: string = ` // // -m-200 // ` // const CONTENT_OPEN: string = ` // m-20 // ` const CONTENT_HEADER: string = ` -mt-5 -mx-5 bg-bscs-gray-200 flex mb-3 pl-5 pr-5 py-3 rounded-t-lg shadow-sm ` const CONTENT_TITLE: string = ` font-semibold text-bscs-gray-800 text-xl m-0 sm:text-2xl tracking-normal self-center ` const CONTENT_BODY: string = ` tracking-normal leading-tight text-left py-2 sm:py-3 text-bscs-gray-600 ` const CLOSE_BUTTON: string = ` cursor-pointer focus:outline-none focus:shadow-outline ml-auto ` const CLOSE_ICON: string = ` fas fa-times text-bscs-gray-500 text-2xl ` const handleBodyModalClose = (): void => { document.body.style.overflow = '' } const handleBodyModalOpen = (): void => { document.body.style.overflow = 'hidden' } const addFilterBlur = (blurId: string): void => { if (blurId) { const blurElem: HTMLElement | null = document.getElementById(blurId) if (blurElem) { blurElem.style.filter = 'blur(3px)' } } } const removeFilterBlur = (blurId: string) => { if (blurId) { const blurElem: HTMLElement | null = document.getElementById(blurId) if (blurElem) { blurElem.style.filter = '' } } } const openModal = ( setModalOpen: () => void, ref: RefObject | null, id: string, blurId: string ): void => { if (!ref || !ref.current) { return } setModalOpen() // ref.current.classList.remove(formatClassList(DIALOG_CLOSED)) ref.current.classList.remove('-z-1') ref.current.classList.remove('opacity-0') handleBodyModalOpen() addFilterBlur(blurId) // const backgroundElem: Element = ref.current.children[0] // backgroundElem.classList.value = formatClassList( // joinStrings(' ', BACKGROUND_BASE, BACKGROUND_OPEN) // ) // const contentElem: Element = ref.current.children[1] // contentElem.classList.value = formatClassList( // joinStrings(' ', CONTENT_BASE, CONTENT_OPEN) // ) /* COSTLY FPS */ const activeElement: Element | null = document.activeElement if (!activeElement) { return } const selectableElements: NodeListOf = ( ref.current.querySelectorAll('input, textarea, button, select, [role="button"]') ) //Focuses first non-close button focusable element in Modal on open if (selectableElements.length === 1) { (selectableElements[0] as HTMLElement).focus() } if ( selectableElements.length > 1 && !Array.from(selectableElements).includes(activeElement) ) { const nextElement = getNextNonDisabledElement(selectableElements, 0) if (nextElement) { (nextElement as HTMLElement).focus() } } } const closeModal = ( e: MouseEvent | KeyboardEvent, setModalClosed: () => void, ref: RefObject | null, id: string, blurId: string ): void => { e.preventDefault() if (!ref || !ref.current) { return } setModalClosed() handleBodyModalClose() removeFilterBlur(blurId) // waits for animation close before adding z-index to make body clickable again // required for overflow modal scrolling setTimeout(() => { if (ref && ref.current) { //ref.current.classList.add(formatClassList(DIALOG_CLOSED)) ref.current.classList.add('-z-1') } }, 300) //ref.current.classList.add('-z-1') ref.current.classList.add('opacity-0') // const backgroundElem: Element = ref.current.children[0] // backgroundElem.classList.value = formatClassList( // joinStrings(' ', BACKGROUND_BASE, BACKGROUND_CLOSED) // ) // const contentElem: Element = ref.current.children[1] // contentElem.classList.value = formatClassList( // joinStrings(' ', CONTENT_BASE, CONTENT_CLOSED) // ) } const handleEscape = ( e: KeyboardEvent, setModalClosed: () => void, ref: RefObject | null, id: string, blurId: string ): void => { if (e.key === 'Escape') { e.preventDefault() closeModal(e, setModalClosed, ref, id, blurId) } } const getPreviousNonDisabledElement = ( elements: NodeListOf, index: number ): Element | null => { const start: number = index === 0 ? elements.length - 1 : index - 1 for (let i: number = start; i >= 0; i--) { if ((elements[i] as HTMLElement).getAttribute('disabled') === null) { return elements[i] } if (i === 0) { i = elements.length } } return null } const getNextNonDisabledElement = ( elements: NodeListOf, index: number ): Element | null => { const start: number = index === elements.length - 1 ? 0 : index + 1 for (let i: number = start; i < elements.length; i++) { if ((elements[i] as HTMLElement).getAttribute('disabled') === null) { return elements[i] } if (i === elements.length - 1) { i = -1 } } return null } const handlePrevious = (ref: RefObject | null): void => { if (!ref || !ref.current) { return } const selectableElements: NodeListOf = ( ref.current.querySelectorAll('input, textarea, button, select, [role="button"]') ) const activeElement: Element | null = document.activeElement if (!activeElement) { return } for (let i: number = 0; i < selectableElements.length; i++) { if (activeElement === selectableElements[i]) { const previousElement: Element | null = ( getPreviousNonDisabledElement(selectableElements, i) ) if (!previousElement) { return } (previousElement as HTMLElement).focus() } } } const handleNext = (ref: RefObject | null): void => { if (!ref || !ref.current) { return } const selectableElements: NodeListOf = ( ref.current.querySelectorAll('input, textarea, button, select, [role="button"]') ) const activeElement: Element | null = document.activeElement if (!activeElement) { return } for (let i: number = 0; i < selectableElements.length; i++) { if (activeElement === selectableElements[i]) { const nextElement: Element | null = ( getNextNonDisabledElement(selectableElements, i) ) if (!nextElement) { return } (nextElement as HTMLElement).focus() } } } const setNavigationTrap = ( e: ReactKeyboardEvent, ref: RefObject | null ): void => { if (!ref || !ref.current) { return } const nextKeys: string[] = [ 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Tab' ] const previousKeys: string[] = ['ArrowUp', 'ArrowLeft'] const key: string = e.key if ((e.shiftKey && key === 'Tab') || previousKeys.includes(key)) { e.preventDefault(); handlePrevious(ref) return } if (nextKeys.includes(key)) { e.preventDefault(); handleNext(ref) return } } // Necessary because Firefox doesn't propagate wheel event to target parent node const handleWheelEvent = (e: WheelEvent, ref: RefObject) => { if (!ref || !ref.current) { return } ref.current.scrollTop += e.deltaY } const Modal:FC = React.memo(({ blurId="", children, id, showModal, setModalClosed, setModalOpen, title, ...other }: ModalProps) => { const dialog: RefObject | null = useRef(null) const contentClassList: string = formatClassList(CONTENT_BASE) // joinStrings(' ', CONTENT_BASE, CONTENT_CLOSED) // ) const contentBodyClassList: string = formatClassList(CONTENT_BODY) const contentHeaderClassList: string = formatClassList(CONTENT_HEADER) const contentTitleClassList: string = formatClassList(CONTENT_TITLE) const backgroundClassList: string = formatClassList(BACKGROUND_BASE) // joinStrings(' ', BACKGROUND_BASE, BACKGROUND_CLOSED) // ) const closeButtonClassList: string = formatClassList(CLOSE_BUTTON) const closeIconClassList: string = formatClassList(CLOSE_ICON) const dialogClassList: string = formatClassList(DIALOG_BASE) // joinStrings(' ', DIALOG_BASE, DIALOG_CLOSED) // ) useEffect(() => { document.addEventListener( "keydown", (e: KeyboardEvent) => { handleEscape(e, setModalClosed, dialog, id, blurId) }, false ) return () => { document.removeEventListener( "keydown", (e: KeyboardEvent) => { handleEscape(e, setModalClosed, dialog, id, blurId) }, false ) } // eslint-disable-next-line }, []) useEffect(() => { if (showModal) { openModal(setModalOpen, dialog, id, blurId) } }, [blurId, id, setModalOpen, showModal]) return (