2021-02-02 07:28:25 +03:00
import { h , Fragment } from 'preact' ;
import { createPortal } from 'preact/compat' ;
2021-02-04 02:43:24 +03:00
import { useCallback , useEffect , useLayoutEffect , useRef , useState } from 'preact/hooks' ;
2021-02-23 20:10:55 +03:00
const WINDOW _PADDING = 20 ;
2021-02-02 07:28:25 +03:00
2021-02-07 07:59:11 +03:00
export default function RelativeModal ( {
className ,
role = 'dialog' ,
children ,
onDismiss ,
portalRootID ,
relativeTo ,
widthRelative = false ,
} ) {
2021-02-23 20:10:55 +03:00
const [ position , setPosition ] = useState ( { top : - 9999 , left : - 9999 } ) ;
2021-02-02 07:28:25 +03:00
const [ show , setShow ] = useState ( false ) ;
const portalRoot = portalRootID && document . getElementById ( portalRootID ) ;
const ref = useRef ( null ) ;
const handleDismiss = useCallback (
( event ) => {
onDismiss && onDismiss ( event ) ;
} ,
[ onDismiss ]
) ;
const handleKeydown = useCallback (
( event ) => {
2022-02-02 16:26:45 +03:00
const focusable = ref . current && ref . current . querySelectorAll ( '[tabindex]' ) ;
2021-02-02 07:28:25 +03:00
if ( event . key === 'Tab' && focusable . length ) {
if ( event . shiftKey && document . activeElement === focusable [ 0 ] ) {
focusable [ focusable . length - 1 ] . focus ( ) ;
event . preventDefault ( ) ;
} else if ( document . activeElement === focusable [ focusable . length - 1 ] ) {
focusable [ 0 ] . focus ( ) ;
event . preventDefault ( ) ;
}
return ;
}
if ( event . key === 'Escape' ) {
setShow ( false ) ;
2021-02-14 20:31:57 +03:00
handleDismiss ( ) ;
2021-02-02 07:28:25 +03:00
return ;
}
} ,
2021-02-14 20:31:57 +03:00
[ ref , handleDismiss ]
2021-02-02 07:28:25 +03:00
) ;
2021-02-04 02:43:24 +03:00
useLayoutEffect ( ( ) => {
2021-02-02 07:28:25 +03:00
if ( ref && ref . current && relativeTo && relativeTo . current ) {
const windowWidth = window . innerWidth ;
const windowHeight = window . innerHeight ;
const { width : menuWidth , height : menuHeight } = ref . current . getBoundingClientRect ( ) ;
2021-02-23 20:10:55 +03:00
const {
x : relativeToX ,
y : relativeToY ,
width : relativeToWidth ,
2023-04-14 15:14:28 +03:00
height : relativeToHeight ,
2021-02-23 20:10:55 +03:00
} = relativeTo . current . getBoundingClientRect ( ) ;
2021-02-07 07:59:11 +03:00
2021-02-23 20:10:55 +03:00
const _width = widthRelative ? relativeToWidth : menuWidth ;
const width = _width * 1.1 ;
const left = relativeToX + window . scrollX ;
const top = relativeToY + window . scrollY ;
let newTop = top ;
let newLeft = left ;
2021-02-07 07:59:11 +03:00
2021-02-02 07:28:25 +03:00
// too far left
2022-02-02 16:26:45 +03:00
if ( left < WINDOW _PADDING ) {
2021-02-23 20:10:55 +03:00
newLeft = WINDOW _PADDING ;
2021-02-02 07:28:25 +03:00
}
2022-02-02 16:26:45 +03:00
// too far right
else if ( newLeft + width + WINDOW _PADDING >= windowWidth - WINDOW _PADDING ) {
newLeft = windowWidth - width - WINDOW _PADDING ;
}
2023-04-14 15:14:28 +03:00
// This condition checks if the menu overflows the bottom of the page and
// if there's enough space to position the menu above the clicked icon.
// If both conditions are met, the menu will be positioned above the clicked icon
if (
top + menuHeight > windowHeight - WINDOW _PADDING + window . scrollY &&
top - menuHeight - relativeToHeight >= WINDOW _PADDING
) {
2023-02-16 16:47:18 +03:00
newTop = top - menuHeight ;
2021-02-02 07:28:25 +03:00
}
2021-02-06 02:46:17 +03:00
2021-02-23 20:10:55 +03:00
if ( top <= WINDOW _PADDING + window . scrollY ) {
newTop = WINDOW _PADDING ;
2021-02-06 02:46:17 +03:00
}
2023-04-14 15:14:28 +03:00
// This calculation checks if there's enough space below the clicked icon for the menu to fit.
// If there is, it sets the maxHeight to null(meaning no height constraint). If not, it calculates the maxHeight based on the remaining space in the window
const maxHeight =
windowHeight - WINDOW _PADDING * 2 - top > menuHeight
? null
: windowHeight - WINDOW _PADDING * 2 - top + window . scrollY ;
2021-02-23 20:10:55 +03:00
const newPosition = { left : newLeft , top : newTop , maxHeight } ;
2021-02-07 07:59:11 +03:00
if ( widthRelative ) {
2021-02-23 20:10:55 +03:00
newPosition . width = relativeToWidth ;
2021-02-07 07:59:11 +03:00
}
setPosition ( newPosition ) ;
2021-02-02 07:28:25 +03:00
const focusable = ref . current . querySelector ( '[tabindex]' ) ;
focusable && focusable . focus ( ) ;
}
2021-02-09 22:35:33 +03:00
} , [ relativeTo , ref , widthRelative ] ) ;
2021-02-02 07:28:25 +03:00
useEffect ( ( ) => {
2021-02-07 07:59:11 +03:00
if ( position . top >= 0 ) {
2021-02-23 20:10:55 +03:00
window . requestAnimationFrame ( ( ) => {
setShow ( true ) ;
} ) ;
2021-02-02 07:28:25 +03:00
} else {
setShow ( false ) ;
}
2021-02-09 22:35:33 +03:00
} , [ show , position , ref ] ) ;
2021-02-02 07:28:25 +03:00
const menu = (
2021-02-04 21:27:22 +03:00
< Fragment >
2022-02-26 22:11:00 +03:00
< div data - testid = "scrim" key = "scrim" className = "fixed inset-0 z-10" onClick = { handleDismiss } / >
2021-02-02 07:28:25 +03:00
< div
2021-02-06 02:52:47 +03:00
key = "menu"
2023-04-14 15:14:28 +03:00
className = { ` z-10 bg-white dark:bg-gray-700 dark:text-white absolute shadow-lg rounded w-auto h-auto transition-transform duration-75 transform scale-90 opacity-0 overflow-x-hidden overflow-y-auto ${
2021-02-02 07:28:25 +03:00
show ? 'scale-100 opacity-100' : ''
} $ { className } ` }
2021-02-09 22:35:33 +03:00
onKeyDown = { handleKeydown }
2021-02-02 07:28:25 +03:00
role = { role }
ref = { ref }
2021-02-23 20:10:55 +03:00
style = { position }
2021-02-02 07:28:25 +03:00
>
{ children }
< / div >
2021-02-04 21:27:22 +03:00
< / Fragment >
2021-02-02 07:28:25 +03:00
) ;
return portalRoot ? createPortal ( menu , portalRoot ) : menu ;
}