chore: clean dialog mounting logic (#3559)
* fix: apply scrollbar css on first render * fix: useUnmount portability * chore: clean up dialog ordering * fix: dialog border-radius * chore: cleanup dialog unmount animation
This commit is contained in:
parent
ce6c783174
commit
ee96973212
@ -1,8 +1,8 @@
|
||||
import 'wicg-inert'
|
||||
|
||||
import useUnmount from 'lib/hooks/useUnmount'
|
||||
import { X } from 'lib/icons'
|
||||
import styled, { Color, Layer, ThemeProvider } from 'lib/theme'
|
||||
import { delayUnmountForAnimation } from 'lib/utils/animations'
|
||||
import { createContext, ReactElement, ReactNode, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
@ -97,8 +97,10 @@ export default function Dialog({ color, children, onClose = () => void 0 }: Dial
|
||||
context.setActive(true)
|
||||
return () => context.setActive(false)
|
||||
}, [context])
|
||||
const dialog = useRef<HTMLDivElement>(null)
|
||||
useUnmount(dialog)
|
||||
|
||||
const modal = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => delayUnmountForAnimation(modal), [])
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e: KeyboardEvent) => e.key === 'Escape' && onClose?.()
|
||||
document.addEventListener('keydown', close, true)
|
||||
@ -108,9 +110,11 @@ export default function Dialog({ color, children, onClose = () => void 0 }: Dial
|
||||
context.element &&
|
||||
createPortal(
|
||||
<ThemeProvider>
|
||||
<Modal color={color} ref={dialog}>
|
||||
<OnCloseContext.Provider value={onClose}>{children}</OnCloseContext.Provider>
|
||||
</Modal>
|
||||
<OnCloseContext.Provider value={onClose}>
|
||||
<Modal color={color} ref={modal}>
|
||||
{children}
|
||||
</Modal>
|
||||
</OnCloseContext.Provider>
|
||||
</ThemeProvider>,
|
||||
context.element
|
||||
)
|
||||
|
@ -6,10 +6,10 @@ import { TransactionsUpdater } from 'lib/hooks/transactions'
|
||||
import { Web3Provider } from 'lib/hooks/useActiveWeb3React'
|
||||
import { BlockUpdater } from 'lib/hooks/useBlockNumber'
|
||||
import useEip1193Provider from 'lib/hooks/useEip1193Provider'
|
||||
import { UNMOUNTING } from 'lib/hooks/useUnmount'
|
||||
import { Provider as I18nProvider } from 'lib/i18n'
|
||||
import { MulticallUpdater, store as multicallStore } from 'lib/state/multicall'
|
||||
import styled, { keyframes, Theme, ThemeProvider } from 'lib/theme'
|
||||
import { UNMOUNTING } from 'lib/utils/animations'
|
||||
import { PropsWithChildren, StrictMode, useState } from 'react'
|
||||
import { Provider as ReduxProvider } from 'react-redux'
|
||||
|
||||
@ -48,18 +48,19 @@ const WidgetWrapper = styled.div<{ width?: number | string }>`
|
||||
}
|
||||
`
|
||||
|
||||
const slideDown = keyframes`
|
||||
to {
|
||||
const slideIn = keyframes`
|
||||
from {
|
||||
transform: translateY(calc(100% - 0.25em));
|
||||
}
|
||||
`
|
||||
const slideUp = keyframes`
|
||||
from {
|
||||
const slideOut = keyframes`
|
||||
to {
|
||||
transform: translateY(calc(100% - 0.25em));
|
||||
}
|
||||
`
|
||||
|
||||
const DialogWrapper = styled.div`
|
||||
border-radius: ${({ theme }) => theme.borderRadius}em;
|
||||
height: calc(100% - 0.5em);
|
||||
left: 0;
|
||||
margin: 0.25em;
|
||||
@ -73,11 +74,11 @@ const DialogWrapper = styled.div`
|
||||
}
|
||||
|
||||
${Modal} {
|
||||
animation: ${slideUp} 0.25s ease-in-out;
|
||||
}
|
||||
animation: ${slideIn} 0.25s ease-in;
|
||||
|
||||
${Modal}.${UNMOUNTING} {
|
||||
animation: ${slideDown} 0.25s ease-in-out;
|
||||
&.${UNMOUNTING} {
|
||||
animation: ${slideOut} 0.25s ease-out;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { css } from 'lib/theme'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import useNativeEvent from './useNativeEvent'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const overflowCss = css`
|
||||
overflow-y: scroll;
|
||||
@ -48,21 +46,15 @@ interface ScrollbarOptions {
|
||||
}
|
||||
|
||||
export default function useScrollbar(element: HTMLElement | null, { padded = false }: ScrollbarOptions = {}) {
|
||||
const [overflow, setOverflow] = useState(true)
|
||||
useEffect(() => {
|
||||
setOverflow(hasOverflow(element))
|
||||
}, [element])
|
||||
useNativeEvent(
|
||||
element,
|
||||
'transitionend',
|
||||
useCallback(() => setOverflow(hasOverflow(element)), [element])
|
||||
return useMemo(
|
||||
// NB: The css must be applied on an element's first render. WebKit will not re-apply overflow
|
||||
// properties until any transitions have ended, so waiting a frame for state would cause jank.
|
||||
() => (hasOverflow(element) ? scrollbarCss(padded) : overflowCss),
|
||||
[element, padded]
|
||||
)
|
||||
return useMemo(() => (overflow ? scrollbarCss(padded) : overflowCss), [overflow, padded])
|
||||
|
||||
function hasOverflow(element: HTMLElement | null) {
|
||||
if (!element) {
|
||||
return true
|
||||
}
|
||||
if (!element) return true
|
||||
return element.scrollHeight > element.clientHeight
|
||||
}
|
||||
}
|
||||
|
@ -1,33 +0,0 @@
|
||||
import { RefObject, useEffect } from 'react'
|
||||
|
||||
export const UNMOUNTING = 'unmounting'
|
||||
|
||||
/**
|
||||
* Delays a node's unmounting so that an animation may be applied.
|
||||
* An animation *must* be applied, or the node will not unmount.
|
||||
*/
|
||||
export default function useUnmount(node: RefObject<HTMLElement>) {
|
||||
useEffect(() => {
|
||||
const current = node.current
|
||||
const parent = current?.parentElement
|
||||
const removeChild = parent?.removeChild
|
||||
if (parent && removeChild) {
|
||||
parent.removeChild = function <T extends Node>(child: T) {
|
||||
if ((child as Node) === current) {
|
||||
current.classList.add(UNMOUNTING)
|
||||
current.onanimationend = () => {
|
||||
removeChild.call(parent, child)
|
||||
}
|
||||
return child
|
||||
} else {
|
||||
return removeChild.call(parent, child) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (parent && removeChild) {
|
||||
parent.removeChild = removeChild
|
||||
}
|
||||
}
|
||||
}, [node])
|
||||
}
|
36
src/lib/utils/animations.ts
Normal file
36
src/lib/utils/animations.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { RefObject } from 'react'
|
||||
|
||||
export function isAnimating(node: HTMLElement) {
|
||||
return (node.getAnimations().length ?? 0) > 0
|
||||
}
|
||||
|
||||
export const UNMOUNTING = 'unmounting'
|
||||
|
||||
/**
|
||||
* Delays a node's unmounting until any animations on that node are finished, so that an unmounting
|
||||
* animation may be applied. If there is no animation, this is a no-op.
|
||||
*
|
||||
* CSS should target the UNMOUNTING class to determine when to apply an unmounting animation.
|
||||
*/
|
||||
export function delayUnmountForAnimation(node: RefObject<HTMLElement>) {
|
||||
const current = node.current
|
||||
const parent = current?.parentElement
|
||||
const removeChild = parent?.removeChild
|
||||
if (parent && removeChild) {
|
||||
parent.removeChild = function <T extends Node>(child: T) {
|
||||
if ((child as Node) === current) {
|
||||
current.classList.add(UNMOUNTING)
|
||||
if (isAnimating(current)) {
|
||||
current.addEventListener('animationend', () => {
|
||||
removeChild.call(parent, child)
|
||||
})
|
||||
} else {
|
||||
removeChild.call(parent, child)
|
||||
}
|
||||
return child
|
||||
} else {
|
||||
return removeChild.call(parent, child) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user