feat: responsive popovers & menus (#1323)
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { PopoverSide, PopoverAlignment, RectCollisions } from '../Types'
|
||||
import { getAppRect, getPositionedPopoverRect } from './Rect'
|
||||
|
||||
export const OppositeSide: Record<PopoverSide, PopoverSide> = {
|
||||
top: 'bottom',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
right: 'left',
|
||||
}
|
||||
|
||||
export const checkCollisions = (popoverRect: DOMRect, containerRect: DOMRect): RectCollisions => {
|
||||
const appRect = getAppRect(containerRect)
|
||||
|
||||
return {
|
||||
top: popoverRect.top < appRect.top,
|
||||
left: popoverRect.left < appRect.left,
|
||||
bottom: popoverRect.bottom > appRect.bottom,
|
||||
right: popoverRect.right > appRect.right,
|
||||
}
|
||||
}
|
||||
|
||||
export const getNonCollidingSide = (
|
||||
preferredSide: PopoverSide,
|
||||
preferredSideCollisions: RectCollisions,
|
||||
oppositeSideCollisions: RectCollisions,
|
||||
): PopoverSide => {
|
||||
const oppositeSide = OppositeSide[preferredSide]
|
||||
|
||||
return preferredSideCollisions[preferredSide] && !oppositeSideCollisions[oppositeSide] ? oppositeSide : preferredSide
|
||||
}
|
||||
|
||||
const OppositeAlignment: Record<Exclude<PopoverAlignment, 'center'>, PopoverAlignment> = {
|
||||
start: 'end',
|
||||
end: 'start',
|
||||
}
|
||||
|
||||
export const getNonCollidingAlignment = (
|
||||
finalSide: PopoverSide,
|
||||
preferredAlignment: PopoverAlignment,
|
||||
collisions: RectCollisions,
|
||||
{
|
||||
popoverRect,
|
||||
buttonRect,
|
||||
documentRect,
|
||||
}: {
|
||||
popoverRect: DOMRect
|
||||
buttonRect: DOMRect
|
||||
documentRect: DOMRect
|
||||
},
|
||||
): PopoverAlignment => {
|
||||
const isHorizontalSide = finalSide === 'top' || finalSide === 'bottom'
|
||||
const boundToCheckForStart = isHorizontalSide ? 'right' : 'bottom'
|
||||
const boundToCheckForEnd = isHorizontalSide ? 'left' : 'top'
|
||||
|
||||
const prefersAligningAtStart = preferredAlignment === 'start'
|
||||
const prefersAligningAtCenter = preferredAlignment === 'center'
|
||||
const prefersAligningAtEnd = preferredAlignment === 'end'
|
||||
|
||||
if (prefersAligningAtCenter) {
|
||||
if (collisions[boundToCheckForStart]) {
|
||||
return 'end'
|
||||
}
|
||||
if (collisions[boundToCheckForEnd]) {
|
||||
return 'start'
|
||||
}
|
||||
} else {
|
||||
const oppositeAlignmentCollisions = checkCollisions(
|
||||
getPositionedPopoverRect(popoverRect, buttonRect, finalSide, OppositeAlignment[preferredAlignment]),
|
||||
documentRect,
|
||||
)
|
||||
|
||||
if (
|
||||
prefersAligningAtStart &&
|
||||
collisions[boundToCheckForStart] &&
|
||||
!oppositeAlignmentCollisions[boundToCheckForEnd]
|
||||
) {
|
||||
return 'end'
|
||||
}
|
||||
|
||||
if (prefersAligningAtEnd && collisions[boundToCheckForEnd] && !oppositeAlignmentCollisions[boundToCheckForStart]) {
|
||||
return 'start'
|
||||
}
|
||||
}
|
||||
|
||||
return preferredAlignment
|
||||
}
|
||||
120
packages/web/src/javascripts/Components/Popover/Utils/Rect.ts
Normal file
120
packages/web/src/javascripts/Components/Popover/Utils/Rect.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { PopoverSide, PopoverAlignment } from '../Types'
|
||||
|
||||
export const getPopoverMaxHeight = (
|
||||
appRect: DOMRect,
|
||||
buttonRect: DOMRect | undefined,
|
||||
side: PopoverSide,
|
||||
alignment: PopoverAlignment,
|
||||
): number | 'none' => {
|
||||
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
|
||||
|
||||
if (!matchesMediumBreakpoint) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const MarginFromAppBorderInPX = 10
|
||||
|
||||
let constraint = 0
|
||||
|
||||
if (buttonRect) {
|
||||
switch (side) {
|
||||
case 'top':
|
||||
constraint = appRect.height - buttonRect.top
|
||||
break
|
||||
case 'bottom':
|
||||
constraint = buttonRect.bottom
|
||||
break
|
||||
case 'left':
|
||||
case 'right':
|
||||
switch (alignment) {
|
||||
case 'start':
|
||||
constraint = buttonRect.top
|
||||
break
|
||||
case 'end':
|
||||
constraint = appRect.height - buttonRect.bottom
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return appRect.height - constraint - MarginFromAppBorderInPX
|
||||
}
|
||||
|
||||
export const getMaxHeightAdjustedRect = (rect: DOMRect, maxHeight: number) => {
|
||||
return DOMRect.fromRect({
|
||||
width: rect.width,
|
||||
height: rect.height < maxHeight ? rect.height : maxHeight,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
})
|
||||
}
|
||||
|
||||
export const getAppRect = (updatedDocumentRect?: DOMRect) => {
|
||||
const footerRect = document.querySelector('footer')?.getBoundingClientRect()
|
||||
const documentRect = updatedDocumentRect ? updatedDocumentRect : document.documentElement.getBoundingClientRect()
|
||||
|
||||
const appRect = footerRect
|
||||
? DOMRect.fromRect({
|
||||
width: documentRect.width,
|
||||
height: documentRect.height - footerRect.height,
|
||||
})
|
||||
: documentRect
|
||||
|
||||
return appRect
|
||||
}
|
||||
|
||||
export const getPositionedPopoverRect = (
|
||||
popoverRect: DOMRect,
|
||||
buttonRect: DOMRect,
|
||||
side: PopoverSide,
|
||||
align: PopoverAlignment,
|
||||
): DOMRect => {
|
||||
const { width, height } = popoverRect
|
||||
|
||||
const positionPopoverRect = DOMRect.fromRect(popoverRect)
|
||||
|
||||
switch (side) {
|
||||
case 'top': {
|
||||
positionPopoverRect.y = buttonRect.top - height
|
||||
break
|
||||
}
|
||||
case 'bottom':
|
||||
positionPopoverRect.y = buttonRect.bottom
|
||||
break
|
||||
case 'left':
|
||||
positionPopoverRect.x = buttonRect.left - width
|
||||
break
|
||||
case 'right':
|
||||
positionPopoverRect.x = buttonRect.right
|
||||
break
|
||||
}
|
||||
|
||||
if (side === 'top' || side === 'bottom') {
|
||||
switch (align) {
|
||||
case 'start':
|
||||
positionPopoverRect.x = buttonRect.left
|
||||
break
|
||||
case 'center':
|
||||
positionPopoverRect.x = buttonRect.left - width / 2 + buttonRect.width / 2
|
||||
break
|
||||
case 'end':
|
||||
positionPopoverRect.x = buttonRect.right - width
|
||||
break
|
||||
}
|
||||
} else {
|
||||
switch (align) {
|
||||
case 'start':
|
||||
positionPopoverRect.y = buttonRect.top
|
||||
break
|
||||
case 'center':
|
||||
positionPopoverRect.y = buttonRect.top - height / 2 + buttonRect.height / 2
|
||||
break
|
||||
case 'end':
|
||||
positionPopoverRect.y = buttonRect.bottom - height
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return positionPopoverRect
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Options = {
|
||||
popoverElement: HTMLElement | null
|
||||
anchorElement: HTMLElement | null | undefined
|
||||
togglePopover: () => void
|
||||
}
|
||||
|
||||
export const usePopoverCloseOnClickOutside = ({ popoverElement, anchorElement, togglePopover }: Options) => {
|
||||
useEffect(() => {
|
||||
const closeIfClickedOutside = (event: MouseEvent) => {
|
||||
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
|
||||
|
||||
if (!matchesMediumBreakpoint) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target as Element
|
||||
|
||||
const isDescendantOfMenu = popoverElement?.contains(target)
|
||||
const isAnchorElement = anchorElement ? anchorElement === event.target || anchorElement.contains(target) : false
|
||||
const isDescendantOfPopover = target.closest('[data-popover]')
|
||||
|
||||
if (!isDescendantOfMenu && !isAnchorElement && !isDescendantOfPopover) {
|
||||
togglePopover()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', closeIfClickedOutside, { capture: true })
|
||||
return () => {
|
||||
document.removeEventListener('click', closeIfClickedOutside, {
|
||||
capture: true,
|
||||
})
|
||||
}
|
||||
}, [anchorElement, popoverElement, togglePopover])
|
||||
}
|
||||
Reference in New Issue
Block a user