style: menu animations (#2401)

This commit is contained in:
Aman Harwara
2023-08-10 22:17:36 +05:30
committed by GitHub
parent c60158c123
commit fd26966a03
7 changed files with 172 additions and 86 deletions

View File

@@ -45,10 +45,8 @@ const Tooltip = ({ text }: { text: string }) => {
title="Info"
anchorElement={anchorElement}
disableMobileFullscreenTakeover
className={classNames(
'w-60 translate-x-2 translate-y-1 select-none rounded border border-border shadow-main',
'z-modal bg-default px-3 py-1.5 text-left',
)}
className="z-modal bg-default px-3 py-1.5 text-left"
containerClassName="w-60"
>
{text}
</Popover>

View File

@@ -10,18 +10,63 @@ const percentOf = (percent: number, value: number) => (percent / 100) * value
export type PopoverCSSProperties = CSSProperties & {
'--translate-x': string
'--translate-y': string
'--transform-origin': string
'--offset': string
}
const getStylesFromRect = (
rect: DOMRect,
options: {
disableMobileFullscreenTakeover?: boolean
maxHeight?: number | 'none'
offset?: number
},
): PopoverCSSProperties => {
const { disableMobileFullscreenTakeover = false, maxHeight = 'none' } = options
const getTransformOrigin = (side: PopoverSide, align: PopoverAlignment) => {
switch (side) {
case 'top':
switch (align) {
case 'start':
return 'bottom left'
case 'center':
return 'bottom center'
case 'end':
return 'bottom right'
}
break
case 'bottom':
switch (align) {
case 'start':
return 'top left'
case 'center':
return 'top center'
case 'end':
return 'top right'
}
break
case 'left':
switch (align) {
case 'start':
return 'top right'
case 'center':
return 'top center'
case 'end':
return 'bottom right'
}
break
case 'right':
switch (align) {
case 'start':
return 'top left'
case 'center':
return 'top center'
case 'end':
return 'bottom left'
}
}
}
const getStylesFromRect = (options: {
rect: DOMRect
side: PopoverSide
align: PopoverAlignment
disableMobileFullscreenTakeover?: boolean
maxHeight?: number | 'none'
offset?: number
}): PopoverCSSProperties => {
const { rect, disableMobileFullscreenTakeover = false, maxHeight = 'none' } = options
const canApplyMaxHeight = maxHeight !== 'none' && (!isMobileScreen() || disableMobileFullscreenTakeover)
const shouldApplyMobileWidth = isMobileScreen() && disableMobileFullscreenTakeover
@@ -32,7 +77,8 @@ const getStylesFromRect = (
'--translate-x': `${shouldApplyMobileWidth ? marginForMobile / 2 : Math.floor(rect.x)}px`,
'--translate-y': `${Math.floor(rect.y)}px`,
'--offset': `${options.offset}px`,
transform: 'translate(var(--translate-x), var(--translate-y))',
transform: 'translate3d(var(--translate-x), var(--translate-y), 0)',
'--transform-origin': getTransformOrigin(options.side, options.align),
visibility: 'visible',
...(canApplyMaxHeight && {
maxHeight: `${maxHeight}px`,
@@ -111,5 +157,12 @@ export const getPositionedPopoverStyles = ({
maxHeight = maxHeightFunction(maxHeight)
}
return getStylesFromRect(finalPositionedRect, { disableMobileFullscreenTakeover, maxHeight, offset })
return getStylesFromRect({
rect: finalPositionedRect,
side: sideWithLessOverflows,
align: finalAlignment,
disableMobileFullscreenTakeover,
maxHeight,
offset,
})
}

View File

@@ -5,6 +5,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import MobilePopoverContent from './MobilePopoverContent'
import PositionedPopoverContent from './PositionedPopoverContent'
import { PopoverProps } from './Types'
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
type PopoverContextData = {
registerChildPopover: (id: string) => void
@@ -27,28 +28,40 @@ const useRegisterPopoverToParent = (popoverId: string) => {
}, [parentPopoverContext, popoverId])
}
type Props = PopoverProps & {
open: boolean
const PositionedPopoverContentWithAnimation = (
props: PopoverProps & {
childPopovers: Set<string>
id: string
},
) => {
const [isMounted, setElement] = useLifecycleAnimation({
open: props.open,
exit: {
keyframes: [
{
opacity: 0,
transform: 'scale(0.95)',
},
],
reducedMotionKeyframes: [
{
opacity: 0,
},
],
options: {
duration: 75,
},
},
})
return isMounted ? (
<PositionedPopoverContent setAnimationElement={setElement} {...props}>
{props.children}
</PositionedPopoverContent>
) : null
}
const Popover = ({
align,
anchorElement,
anchorPoint,
children,
className,
open,
overrideZIndex,
side,
title,
togglePopover,
disableClickOutside,
disableMobileFullscreenTakeover,
maxHeight,
portal,
offset,
hideOnClickInModal,
}: Props) => {
const Popover = (props: PopoverProps) => {
const popoverId = useRef(UuidGenerator.GenerateUuid())
const addAndroidBackHandler = useAndroidBackHandler()
@@ -79,9 +92,9 @@ const Popover = ({
useEffect(() => {
let removeListener: (() => void) | undefined
if (open) {
if (props.open) {
removeListener = addAndroidBackHandler(() => {
togglePopover?.()
props.togglePopover?.()
return true
})
}
@@ -91,50 +104,31 @@ const Popover = ({
removeListener()
}
}
}, [addAndroidBackHandler, open, togglePopover])
}, [addAndroidBackHandler, props, props.open])
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
if (isMobileScreen && !disableMobileFullscreenTakeover) {
if (isMobileScreen && !props.disableMobileFullscreenTakeover) {
return (
<MobilePopoverContent
open={open}
open={props.open}
requestClose={() => {
togglePopover?.()
props.togglePopover?.()
}}
title={title}
className={className}
title={props.title}
className={props.className}
id={popoverId.current}
>
{children}
{props.children}
</MobilePopoverContent>
)
}
return open ? (
return (
<PopoverContext.Provider value={contextValue}>
<PositionedPopoverContent
align={align}
anchorElement={anchorElement}
anchorPoint={anchorPoint}
childPopovers={childPopovers}
className={`popover-content-container ${className ?? ''}`}
disableClickOutside={disableClickOutside}
disableMobileFullscreenTakeover={disableMobileFullscreenTakeover}
id={popoverId.current}
maxHeight={maxHeight}
overrideZIndex={overrideZIndex}
side={side}
title={title}
togglePopover={togglePopover}
portal={portal}
offset={offset}
hideOnClickInModal={hideOnClickInModal}
>
{children}
</PositionedPopoverContent>
<PositionedPopoverContentWithAnimation {...props} childPopovers={childPopovers} id={popoverId.current} />
</PopoverContext.Provider>
) : null
)
}
export default Popover

View File

@@ -30,6 +30,8 @@ const PositionedPopoverContent = ({
portal = true,
offset,
hideOnClickInModal = false,
setAnimationElement,
containerClassName,
}: PopoverContentProps) => {
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
const popoverRect = useAutoElementRect(popoverElement)
@@ -55,6 +57,16 @@ const PositionedPopoverContent = ({
offset,
})
if (!styles) {
document.body.style.overflow = 'hidden'
}
useLayoutEffect(() => {
return () => {
document.body.style.overflow = ''
}
}, [])
let adjustedStyles: PopoverCSSProperties | undefined = undefined
if (!portal && popoverElement && styles) {
@@ -97,13 +109,11 @@ const PositionedPopoverContent = ({
<Portal disabled={!portal}>
<div
className={classNames(
'absolute left-0 top-0 flex w-full min-w-80 cursor-auto flex-col',
'overflow-y-auto rounded border border-[--popover-border-color] bg-[--popover-background-color] [backdrop-filter:var(--popover-backdrop-filter)] shadow-main md:h-auto md:max-w-xs',
'absolute left-0 top-0 flex w-full min-w-80 cursor-auto flex-col md:h-auto md:max-w-xs',
!disableMobileFullscreenTakeover && 'h-full',
overrideZIndex ? overrideZIndex : 'z-dropdown-menu',
!isDesktopScreen && !disableMobileFullscreenTakeover ? 'pb-safe-bottom pt-safe-top' : '',
isDesktopScreen || disableMobileFullscreenTakeover ? 'invisible' : '',
className,
containerClassName,
)}
style={
{
@@ -130,7 +140,18 @@ const PositionedPopoverContent = ({
})
}}
>
{children}
<div
className={classNames(
'overflow-y-auto rounded border border-[--popover-border-color] bg-[--popover-background-color] [backdrop-filter:var(--popover-backdrop-filter)] shadow-main',
!isDesktopScreen && !disableMobileFullscreenTakeover ? 'pb-safe-bottom pt-safe-top' : '',
'transition-[transform,opacity] motion-reduce:transition-opacity duration-75 [transform-origin:var(--transform-origin)]',
styles ? 'scale-100 opacity-100' : 'scale-95 opacity-0',
className,
)}
ref={setAnimationElement}
>
{children}
</div>
</div>
</Portal>
)

View File

@@ -36,6 +36,7 @@ type CommonPopoverProps = {
side?: PopoverSide
overrideZIndex?: string
className?: string
containerClassName?: string
disableClickOutside?: boolean
maxHeight?: (calculatedMaxHeight: number) => number
togglePopover?: () => void
@@ -44,6 +45,7 @@ type CommonPopoverProps = {
portal?: boolean
offset?: number
hideOnClickInModal?: boolean
open: boolean
}
export type PopoverContentProps = CommonPopoverProps & {
@@ -53,6 +55,7 @@ export type PopoverContentProps = CommonPopoverProps & {
togglePopover?: () => void
disableMobileFullscreenTakeover?: boolean
id: string
setAnimationElement: (element: HTMLElement | null) => void
}
export type PopoverProps =

View File

@@ -1,5 +1,6 @@
export type AnimationConfig = {
keyframes: Keyframe[]
reducedMotionKeyframes?: Keyframe[]
options: KeyframeAnimationOptions
initialStyle?: Partial<CSSStyleDeclaration>
}

View File

@@ -4,9 +4,9 @@ import { useStateRef } from './useStateRef'
type Options = {
open: boolean
enter: AnimationConfig
enter?: AnimationConfig
enterCallback?: (element: HTMLElement) => void
exit: AnimationConfig
exit?: AnimationConfig
exitCallback?: (element: HTMLElement) => void
}
@@ -52,37 +52,53 @@ export const useLifecycleAnimation = (
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (prefersReducedMotion) {
setIsMounted(open)
return
}
const enter = enterRef.current
const enterCallback = enterCallbackRef.current
const exit = exitRef.current
const exitCallback = exitCallbackRef.current
if (prefersReducedMotion && !enter?.reducedMotionKeyframes && !exit?.reducedMotionKeyframes) {
setIsMounted(open)
return
}
if (open) {
if (!enter) {
setIsMounted(true)
enterCallback?.(element)
return
}
if (enter.initialStyle) {
Object.assign(element.style, enter.initialStyle)
}
const animation = element.animate(enter.keyframes, {
...enter.options,
fill: 'forwards',
})
const animation = element.animate(
prefersReducedMotion && enter.reducedMotionKeyframes ? enter.reducedMotionKeyframes : enter.keyframes,
{
...enter.options,
fill: 'forwards',
},
)
animation.finished
.then(() => {
enterCallback?.(element)
})
.catch(console.error)
} else {
if (!exit) {
setIsMounted(false)
exitCallback?.(element)
return
}
if (exit.initialStyle) {
Object.assign(element.style, exit.initialStyle)
}
const animation = element.animate(exit.keyframes, {
...exit.options,
fill: 'forwards',
})
const animation = element.animate(
prefersReducedMotion && exit.reducedMotionKeyframes ? exit.reducedMotionKeyframes : exit.keyframes,
{
...exit.options,
fill: 'forwards',
},
)
animation.finished
.then(() => {
setIsMounted(false)