style: menu animations (#2401)
This commit is contained in:
@@ -45,10 +45,8 @@ const Tooltip = ({ text }: { text: string }) => {
|
|||||||
title="Info"
|
title="Info"
|
||||||
anchorElement={anchorElement}
|
anchorElement={anchorElement}
|
||||||
disableMobileFullscreenTakeover
|
disableMobileFullscreenTakeover
|
||||||
className={classNames(
|
className="z-modal bg-default px-3 py-1.5 text-left"
|
||||||
'w-60 translate-x-2 translate-y-1 select-none rounded border border-border shadow-main',
|
containerClassName="w-60"
|
||||||
'z-modal bg-default px-3 py-1.5 text-left',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -10,18 +10,63 @@ const percentOf = (percent: number, value: number) => (percent / 100) * value
|
|||||||
export type PopoverCSSProperties = CSSProperties & {
|
export type PopoverCSSProperties = CSSProperties & {
|
||||||
'--translate-x': string
|
'--translate-x': string
|
||||||
'--translate-y': string
|
'--translate-y': string
|
||||||
|
'--transform-origin': string
|
||||||
'--offset': string
|
'--offset': string
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStylesFromRect = (
|
const getTransformOrigin = (side: PopoverSide, align: PopoverAlignment) => {
|
||||||
rect: DOMRect,
|
switch (side) {
|
||||||
options: {
|
case 'top':
|
||||||
disableMobileFullscreenTakeover?: boolean
|
switch (align) {
|
||||||
maxHeight?: number | 'none'
|
case 'start':
|
||||||
offset?: number
|
return 'bottom left'
|
||||||
},
|
case 'center':
|
||||||
): PopoverCSSProperties => {
|
return 'bottom center'
|
||||||
const { disableMobileFullscreenTakeover = false, maxHeight = 'none' } = options
|
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 canApplyMaxHeight = maxHeight !== 'none' && (!isMobileScreen() || disableMobileFullscreenTakeover)
|
||||||
const shouldApplyMobileWidth = isMobileScreen() && disableMobileFullscreenTakeover
|
const shouldApplyMobileWidth = isMobileScreen() && disableMobileFullscreenTakeover
|
||||||
@@ -32,7 +77,8 @@ const getStylesFromRect = (
|
|||||||
'--translate-x': `${shouldApplyMobileWidth ? marginForMobile / 2 : Math.floor(rect.x)}px`,
|
'--translate-x': `${shouldApplyMobileWidth ? marginForMobile / 2 : Math.floor(rect.x)}px`,
|
||||||
'--translate-y': `${Math.floor(rect.y)}px`,
|
'--translate-y': `${Math.floor(rect.y)}px`,
|
||||||
'--offset': `${options.offset}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',
|
visibility: 'visible',
|
||||||
...(canApplyMaxHeight && {
|
...(canApplyMaxHeight && {
|
||||||
maxHeight: `${maxHeight}px`,
|
maxHeight: `${maxHeight}px`,
|
||||||
@@ -111,5 +157,12 @@ export const getPositionedPopoverStyles = ({
|
|||||||
maxHeight = maxHeightFunction(maxHeight)
|
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 MobilePopoverContent from './MobilePopoverContent'
|
||||||
import PositionedPopoverContent from './PositionedPopoverContent'
|
import PositionedPopoverContent from './PositionedPopoverContent'
|
||||||
import { PopoverProps } from './Types'
|
import { PopoverProps } from './Types'
|
||||||
|
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
|
||||||
|
|
||||||
type PopoverContextData = {
|
type PopoverContextData = {
|
||||||
registerChildPopover: (id: string) => void
|
registerChildPopover: (id: string) => void
|
||||||
@@ -27,28 +28,40 @@ const useRegisterPopoverToParent = (popoverId: string) => {
|
|||||||
}, [parentPopoverContext, popoverId])
|
}, [parentPopoverContext, popoverId])
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = PopoverProps & {
|
const PositionedPopoverContentWithAnimation = (
|
||||||
open: boolean
|
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 = ({
|
const Popover = (props: PopoverProps) => {
|
||||||
align,
|
|
||||||
anchorElement,
|
|
||||||
anchorPoint,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
open,
|
|
||||||
overrideZIndex,
|
|
||||||
side,
|
|
||||||
title,
|
|
||||||
togglePopover,
|
|
||||||
disableClickOutside,
|
|
||||||
disableMobileFullscreenTakeover,
|
|
||||||
maxHeight,
|
|
||||||
portal,
|
|
||||||
offset,
|
|
||||||
hideOnClickInModal,
|
|
||||||
}: Props) => {
|
|
||||||
const popoverId = useRef(UuidGenerator.GenerateUuid())
|
const popoverId = useRef(UuidGenerator.GenerateUuid())
|
||||||
|
|
||||||
const addAndroidBackHandler = useAndroidBackHandler()
|
const addAndroidBackHandler = useAndroidBackHandler()
|
||||||
@@ -79,9 +92,9 @@ const Popover = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let removeListener: (() => void) | undefined
|
let removeListener: (() => void) | undefined
|
||||||
|
|
||||||
if (open) {
|
if (props.open) {
|
||||||
removeListener = addAndroidBackHandler(() => {
|
removeListener = addAndroidBackHandler(() => {
|
||||||
togglePopover?.()
|
props.togglePopover?.()
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -91,50 +104,31 @@ const Popover = ({
|
|||||||
removeListener()
|
removeListener()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [addAndroidBackHandler, open, togglePopover])
|
}, [addAndroidBackHandler, props, props.open])
|
||||||
|
|
||||||
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
|
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
|
||||||
|
|
||||||
if (isMobileScreen && !disableMobileFullscreenTakeover) {
|
if (isMobileScreen && !props.disableMobileFullscreenTakeover) {
|
||||||
return (
|
return (
|
||||||
<MobilePopoverContent
|
<MobilePopoverContent
|
||||||
open={open}
|
open={props.open}
|
||||||
requestClose={() => {
|
requestClose={() => {
|
||||||
togglePopover?.()
|
props.togglePopover?.()
|
||||||
}}
|
}}
|
||||||
title={title}
|
title={props.title}
|
||||||
className={className}
|
className={props.className}
|
||||||
id={popoverId.current}
|
id={popoverId.current}
|
||||||
>
|
>
|
||||||
{children}
|
{props.children}
|
||||||
</MobilePopoverContent>
|
</MobilePopoverContent>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return open ? (
|
return (
|
||||||
<PopoverContext.Provider value={contextValue}>
|
<PopoverContext.Provider value={contextValue}>
|
||||||
<PositionedPopoverContent
|
<PositionedPopoverContentWithAnimation {...props} childPopovers={childPopovers} id={popoverId.current} />
|
||||||
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>
|
|
||||||
</PopoverContext.Provider>
|
</PopoverContext.Provider>
|
||||||
) : null
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Popover
|
export default Popover
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const PositionedPopoverContent = ({
|
|||||||
portal = true,
|
portal = true,
|
||||||
offset,
|
offset,
|
||||||
hideOnClickInModal = false,
|
hideOnClickInModal = false,
|
||||||
|
setAnimationElement,
|
||||||
|
containerClassName,
|
||||||
}: PopoverContentProps) => {
|
}: PopoverContentProps) => {
|
||||||
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
|
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
|
||||||
const popoverRect = useAutoElementRect(popoverElement)
|
const popoverRect = useAutoElementRect(popoverElement)
|
||||||
@@ -55,6 +57,16 @@ const PositionedPopoverContent = ({
|
|||||||
offset,
|
offset,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!styles) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
let adjustedStyles: PopoverCSSProperties | undefined = undefined
|
let adjustedStyles: PopoverCSSProperties | undefined = undefined
|
||||||
|
|
||||||
if (!portal && popoverElement && styles) {
|
if (!portal && popoverElement && styles) {
|
||||||
@@ -97,13 +109,11 @@ const PositionedPopoverContent = ({
|
|||||||
<Portal disabled={!portal}>
|
<Portal disabled={!portal}>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'absolute left-0 top-0 flex w-full min-w-80 cursor-auto flex-col',
|
'absolute left-0 top-0 flex w-full min-w-80 cursor-auto flex-col md:h-auto md:max-w-xs',
|
||||||
'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',
|
|
||||||
!disableMobileFullscreenTakeover && 'h-full',
|
!disableMobileFullscreenTakeover && 'h-full',
|
||||||
overrideZIndex ? overrideZIndex : 'z-dropdown-menu',
|
overrideZIndex ? overrideZIndex : 'z-dropdown-menu',
|
||||||
!isDesktopScreen && !disableMobileFullscreenTakeover ? 'pb-safe-bottom pt-safe-top' : '',
|
|
||||||
isDesktopScreen || disableMobileFullscreenTakeover ? 'invisible' : '',
|
isDesktopScreen || disableMobileFullscreenTakeover ? 'invisible' : '',
|
||||||
className,
|
containerClassName,
|
||||||
)}
|
)}
|
||||||
style={
|
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>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type CommonPopoverProps = {
|
|||||||
side?: PopoverSide
|
side?: PopoverSide
|
||||||
overrideZIndex?: string
|
overrideZIndex?: string
|
||||||
className?: string
|
className?: string
|
||||||
|
containerClassName?: string
|
||||||
disableClickOutside?: boolean
|
disableClickOutside?: boolean
|
||||||
maxHeight?: (calculatedMaxHeight: number) => number
|
maxHeight?: (calculatedMaxHeight: number) => number
|
||||||
togglePopover?: () => void
|
togglePopover?: () => void
|
||||||
@@ -44,6 +45,7 @@ type CommonPopoverProps = {
|
|||||||
portal?: boolean
|
portal?: boolean
|
||||||
offset?: number
|
offset?: number
|
||||||
hideOnClickInModal?: boolean
|
hideOnClickInModal?: boolean
|
||||||
|
open: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PopoverContentProps = CommonPopoverProps & {
|
export type PopoverContentProps = CommonPopoverProps & {
|
||||||
@@ -53,6 +55,7 @@ export type PopoverContentProps = CommonPopoverProps & {
|
|||||||
togglePopover?: () => void
|
togglePopover?: () => void
|
||||||
disableMobileFullscreenTakeover?: boolean
|
disableMobileFullscreenTakeover?: boolean
|
||||||
id: string
|
id: string
|
||||||
|
setAnimationElement: (element: HTMLElement | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PopoverProps =
|
export type PopoverProps =
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type AnimationConfig = {
|
export type AnimationConfig = {
|
||||||
keyframes: Keyframe[]
|
keyframes: Keyframe[]
|
||||||
|
reducedMotionKeyframes?: Keyframe[]
|
||||||
options: KeyframeAnimationOptions
|
options: KeyframeAnimationOptions
|
||||||
initialStyle?: Partial<CSSStyleDeclaration>
|
initialStyle?: Partial<CSSStyleDeclaration>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useStateRef } from './useStateRef'
|
|||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
open: boolean
|
open: boolean
|
||||||
enter: AnimationConfig
|
enter?: AnimationConfig
|
||||||
enterCallback?: (element: HTMLElement) => void
|
enterCallback?: (element: HTMLElement) => void
|
||||||
exit: AnimationConfig
|
exit?: AnimationConfig
|
||||||
exitCallback?: (element: HTMLElement) => void
|
exitCallback?: (element: HTMLElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,37 +52,53 @@ export const useLifecycleAnimation = (
|
|||||||
|
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
if (prefersReducedMotion) {
|
|
||||||
setIsMounted(open)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const enter = enterRef.current
|
const enter = enterRef.current
|
||||||
const enterCallback = enterCallbackRef.current
|
const enterCallback = enterCallbackRef.current
|
||||||
const exit = exitRef.current
|
const exit = exitRef.current
|
||||||
const exitCallback = exitCallbackRef.current
|
const exitCallback = exitCallbackRef.current
|
||||||
|
|
||||||
|
if (prefersReducedMotion && !enter?.reducedMotionKeyframes && !exit?.reducedMotionKeyframes) {
|
||||||
|
setIsMounted(open)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
|
if (!enter) {
|
||||||
|
setIsMounted(true)
|
||||||
|
enterCallback?.(element)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (enter.initialStyle) {
|
if (enter.initialStyle) {
|
||||||
Object.assign(element.style, enter.initialStyle)
|
Object.assign(element.style, enter.initialStyle)
|
||||||
}
|
}
|
||||||
const animation = element.animate(enter.keyframes, {
|
const animation = element.animate(
|
||||||
...enter.options,
|
prefersReducedMotion && enter.reducedMotionKeyframes ? enter.reducedMotionKeyframes : enter.keyframes,
|
||||||
fill: 'forwards',
|
{
|
||||||
})
|
...enter.options,
|
||||||
|
fill: 'forwards',
|
||||||
|
},
|
||||||
|
)
|
||||||
animation.finished
|
animation.finished
|
||||||
.then(() => {
|
.then(() => {
|
||||||
enterCallback?.(element)
|
enterCallback?.(element)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
} else {
|
} else {
|
||||||
|
if (!exit) {
|
||||||
|
setIsMounted(false)
|
||||||
|
exitCallback?.(element)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (exit.initialStyle) {
|
if (exit.initialStyle) {
|
||||||
Object.assign(element.style, exit.initialStyle)
|
Object.assign(element.style, exit.initialStyle)
|
||||||
}
|
}
|
||||||
const animation = element.animate(exit.keyframes, {
|
const animation = element.animate(
|
||||||
...exit.options,
|
prefersReducedMotion && exit.reducedMotionKeyframes ? exit.reducedMotionKeyframes : exit.keyframes,
|
||||||
fill: 'forwards',
|
{
|
||||||
})
|
...exit.options,
|
||||||
|
fill: 'forwards',
|
||||||
|
},
|
||||||
|
)
|
||||||
animation.finished
|
animation.finished
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIsMounted(false)
|
setIsMounted(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user