feat: responsive popovers & menus (#1323)
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { CSSProperties } from 'react'
|
||||
import { PopoverAlignment, PopoverSide } from './Types'
|
||||
import { OppositeSide, checkCollisions, getNonCollidingSide, getNonCollidingAlignment } from './Utils/Collisions'
|
||||
import { getPositionedPopoverRect } from './Utils/Rect'
|
||||
|
||||
const getStylesFromRect = (rect: DOMRect): CSSProperties => {
|
||||
return {
|
||||
willChange: 'transform',
|
||||
transform: `translate(${rect.x}px, ${rect.y}px)`,
|
||||
}
|
||||
}
|
||||
|
||||
type Options = {
|
||||
align: PopoverAlignment
|
||||
anchorRect?: DOMRect
|
||||
documentRect: DOMRect
|
||||
popoverRect?: DOMRect
|
||||
side: PopoverSide
|
||||
}
|
||||
|
||||
export const getPositionedPopoverStyles = ({
|
||||
align,
|
||||
anchorRect,
|
||||
documentRect,
|
||||
popoverRect,
|
||||
side,
|
||||
}: Options): [CSSProperties | null, PopoverSide, PopoverAlignment] => {
|
||||
if (!popoverRect || !anchorRect) {
|
||||
return [null, side, align]
|
||||
}
|
||||
|
||||
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
|
||||
|
||||
if (!matchesMediumBreakpoint) {
|
||||
return [null, side, align]
|
||||
}
|
||||
|
||||
const rectForPreferredSide = getPositionedPopoverRect(popoverRect, anchorRect, side, align)
|
||||
const preferredSideRectCollisions = checkCollisions(rectForPreferredSide, documentRect)
|
||||
|
||||
const oppositeSide = OppositeSide[side]
|
||||
const rectForOppositeSide = getPositionedPopoverRect(popoverRect, anchorRect, oppositeSide, align)
|
||||
const oppositeSideRectCollisions = checkCollisions(rectForOppositeSide, documentRect)
|
||||
|
||||
const finalSide = getNonCollidingSide(side, preferredSideRectCollisions, oppositeSideRectCollisions)
|
||||
const finalAlignment = getNonCollidingAlignment(finalSide, align, preferredSideRectCollisions, {
|
||||
popoverRect,
|
||||
buttonRect: anchorRect,
|
||||
documentRect,
|
||||
})
|
||||
const finalPositionedRect = getPositionedPopoverRect(popoverRect, anchorRect, finalSide, finalAlignment)
|
||||
|
||||
return [getStylesFromRect(finalPositionedRect), finalSide, finalAlignment]
|
||||
}
|
||||
36
packages/web/src/javascripts/Components/Popover/Popover.tsx
Normal file
36
packages/web/src/javascripts/Components/Popover/Popover.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import PositionedPopoverContent from './PositionedPopoverContent'
|
||||
import { PopoverProps } from './Types'
|
||||
|
||||
type Props = PopoverProps & {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const Popover = ({
|
||||
align,
|
||||
anchorElement,
|
||||
anchorPoint,
|
||||
children,
|
||||
className,
|
||||
open,
|
||||
overrideZIndex,
|
||||
side,
|
||||
togglePopover,
|
||||
}: Props) => {
|
||||
return open ? (
|
||||
<>
|
||||
<PositionedPopoverContent
|
||||
align={align}
|
||||
anchorElement={anchorElement}
|
||||
anchorPoint={anchorPoint}
|
||||
className={className}
|
||||
overrideZIndex={overrideZIndex}
|
||||
side={side}
|
||||
togglePopover={togglePopover}
|
||||
>
|
||||
{children}
|
||||
</PositionedPopoverContent>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
export default Popover
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useDocumentRect } from '@/Hooks/useDocumentRect'
|
||||
import { useAutoElementRect } from '@/Hooks/useElementRect'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { useState } from 'react'
|
||||
import Icon from '../Icon/Icon'
|
||||
import Portal from '../Portal/Portal'
|
||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
import { getPositionedPopoverStyles } from './GetPositionedPopoverStyles'
|
||||
import { PopoverContentProps } from './Types'
|
||||
import { getPopoverMaxHeight, getAppRect } from './Utils/Rect'
|
||||
import { usePopoverCloseOnClickOutside } from './Utils/usePopoverCloseOnClickOutside'
|
||||
|
||||
const PositionedPopoverContent = ({
|
||||
align = 'end',
|
||||
anchorElement,
|
||||
anchorPoint,
|
||||
children,
|
||||
className,
|
||||
overrideZIndex,
|
||||
side = 'bottom',
|
||||
togglePopover,
|
||||
}: PopoverContentProps) => {
|
||||
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
|
||||
const popoverRect = useAutoElementRect(popoverElement)
|
||||
const anchorElementRect = useAutoElementRect(anchorElement, {
|
||||
updateOnWindowResize: true,
|
||||
})
|
||||
const anchorPointRect = DOMRect.fromRect({
|
||||
x: anchorPoint?.x,
|
||||
y: anchorPoint?.y,
|
||||
})
|
||||
const anchorRect = anchorPoint ? anchorPointRect : anchorElementRect
|
||||
const documentRect = useDocumentRect()
|
||||
|
||||
const [styles, positionedSide, positionedAlignment] = getPositionedPopoverStyles({
|
||||
align,
|
||||
anchorRect,
|
||||
documentRect,
|
||||
popoverRect: popoverRect ?? popoverElement?.getBoundingClientRect(),
|
||||
side,
|
||||
})
|
||||
|
||||
usePopoverCloseOnClickOutside({
|
||||
popoverElement,
|
||||
anchorElement,
|
||||
togglePopover,
|
||||
})
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute top-0 left-0 flex h-full w-full min-w-80 cursor-auto flex-col overflow-y-auto rounded bg-default shadow-main md:h-auto md:max-w-xs',
|
||||
overrideZIndex ? overrideZIndex : 'z-dropdown-menu',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...styles,
|
||||
maxHeight: getPopoverMaxHeight(getAppRect(documentRect), anchorRect, positionedSide, positionedAlignment),
|
||||
}}
|
||||
ref={(node) => {
|
||||
setPopoverElement(node)
|
||||
}}
|
||||
data-popover
|
||||
>
|
||||
<div className="md:hidden">
|
||||
<div className="flex items-center justify-end px-3">
|
||||
<button className="rounded-full border border-border p-1" onClick={togglePopover}>
|
||||
<Icon type="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<HorizontalSeparator classes="my-2" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export default PositionedPopoverContent
|
||||
49
packages/web/src/javascripts/Components/Popover/Types.ts
Normal file
49
packages/web/src/javascripts/Components/Popover/Types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export type PopoverState = 'closed' | 'positioning' | 'open'
|
||||
|
||||
export type PopoverElement = HTMLDivElement | HTMLMenuElement
|
||||
|
||||
export type PopoverSide = 'top' | 'left' | 'bottom' | 'right'
|
||||
|
||||
export type PopoverAlignment = 'start' | 'center' | 'end'
|
||||
|
||||
export type PopoverOptions = {
|
||||
side: PopoverSide
|
||||
align: PopoverAlignment
|
||||
}
|
||||
|
||||
export type RectCollisions = Record<PopoverSide, boolean>
|
||||
|
||||
type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type PopoverAnchorElementProps = {
|
||||
anchorElement: HTMLElement | null
|
||||
anchorPoint?: never
|
||||
}
|
||||
|
||||
type PopoverAnchorPointProps = {
|
||||
anchorPoint: Point
|
||||
anchorElement?: never
|
||||
}
|
||||
|
||||
type CommonPopoverProps = {
|
||||
align?: PopoverAlignment
|
||||
children: ReactNode
|
||||
side?: PopoverSide
|
||||
overrideZIndex?: string
|
||||
togglePopover: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export type PopoverContentProps = CommonPopoverProps & {
|
||||
anchorElement?: HTMLElement | null
|
||||
anchorPoint?: Point
|
||||
}
|
||||
|
||||
export type PopoverProps =
|
||||
| (CommonPopoverProps & PopoverAnchorElementProps)
|
||||
| (CommonPopoverProps & PopoverAnchorPointProps)
|
||||
@@ -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