style: menu animations (#2401)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type AnimationConfig = {
|
||||
keyframes: Keyframe[]
|
||||
reducedMotionKeyframes?: Keyframe[]
|
||||
options: KeyframeAnimationOptions
|
||||
initialStyle?: Partial<CSSStyleDeclaration>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user