feat: responsive popovers & menus (#1323)

This commit is contained in:
Aman Harwara
2022-07-21 02:20:14 +05:30
committed by GitHub
parent baf7fb0019
commit 2573407851
44 changed files with 1308 additions and 1415 deletions

View File

@@ -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]
}

View 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

View File

@@ -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

View 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)

View File

@@ -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
}

View 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
}

View File

@@ -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])
}