From ad70e351d4e6bd0d22607862ff0d3100bb65bbe1 Mon Sep 17 00:00:00 2001 From: Jared Vu Date: Thu, 12 Dec 2024 13:32:18 -0800 Subject: [PATCH] style(dialog): animate dialog height change through prop (#1363) --- src/components/Dialog.tsx | 216 +++++++++++----------------- src/hooks/useIsMounted.ts | 19 +++ src/hooks/useResizeObserver.ts | 101 +++++++++++++ src/views/dialogs/DepositDialog.tsx | 1 + 4 files changed, 208 insertions(+), 129 deletions(-) create mode 100644 src/hooks/useIsMounted.ts create mode 100644 src/hooks/useResizeObserver.ts diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index d9d434f71..5f8a73618 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -11,10 +11,11 @@ import { Title, Trigger, } from '@radix-ui/react-dialog'; -import styled, { css, keyframes } from 'styled-components'; +import styled, { css } from 'styled-components'; import tw from 'twin.macro'; import { useDialogArea } from '@/hooks/useDialogArea'; +import { useResizeObserver } from '@/hooks/useResizeObserver'; import breakpoints from '@/styles/breakpoints'; import { layoutMixins } from '@/styles/layoutMixins'; @@ -100,6 +101,12 @@ export const Dialog = ({ className, }: DialogProps) => { const closeButtonRef = useRef(null); + const ref = useRef(null); + + const { height = 0 } = useResizeObserver({ + ref, + box: 'border-box', + }); return ( @@ -117,66 +124,69 @@ export const Dialog = ({ e.preventDefault(); } }} + $height={height} $stacked={stacked} $withAnimation={withAnimation} > - {slotHeaderAbove} - {stacked ? ( - <$StackedHeaderTopRow $withBorder={hasHeaderBorder} $withBlur={hasHeaderBlur}> - {onBack && <$BackButton onClick={onBack} />} - - {slotIcon} - - {!preventClose && withClose && ( - <$Close ref={closeButtonRef} $absolute={stacked}> - - - )} - - {title && <$Title>{title}} - - {description && <$Description>{description}} - - {slotHeaderInner} - - ) : slotHeader ? ( -
- {!preventClose && withClose && ( - <$Close ref={closeButtonRef} $absolute> - - - )} - {slotHeader} -
- ) : ( - <$Header $withBorder={hasHeaderBorder} $withBlur={hasHeaderBlur}> -
- {onBack && } - - {slotIcon && ( -
- {slotIcon} -
+ <$InnerContainer ref={ref} placement={placement}> + {slotHeaderAbove} + {stacked ? ( + <$StackedHeaderTopRow $withBorder={hasHeaderBorder} $withBlur={hasHeaderBlur}> + {onBack && <$BackButton onClick={onBack} />} + + {slotIcon} + + {!preventClose && withClose && ( + <$Close ref={closeButtonRef} $absolute={stacked}> + + )} {title && <$Title>{title}} + {description && <$Description>{description}} + + {slotHeaderInner} + + ) : slotHeader ? ( +
{!preventClose && withClose && ( - <$Close ref={closeButtonRef}> + <$Close ref={closeButtonRef} $absolute> )} + {slotHeader}
+ ) : ( + <$Header $withBorder={hasHeaderBorder} $withBlur={hasHeaderBlur}> +
+ {onBack && } + + {slotIcon && ( +
+ {slotIcon} +
+ )} + + {title && <$Title>{title}} - {description && <$Description>{description}} + {!preventClose && withClose && ( + <$Close ref={closeButtonRef}> + + + )} +
- {slotHeaderInner} - - )} + {description && <$Description>{description}} - <$Content>{children} + {slotHeaderInner} + + )} - {slotFooter && <$Footer $withBorder={hasFooterBorder}>{slotFooter}} + <$Content>{children} + + {slotFooter && <$Footer $withBorder={hasFooterBorder}>{slotFooter}} + @@ -196,6 +206,7 @@ const $Overlay = styled(Overlay)` const $Container = styled(Content)<{ placement: DialogPlacement; + $height?: number; $stacked?: boolean; $withAnimation?: boolean; }>` @@ -255,24 +266,28 @@ const $Container = styled(Content)<{ outline: none; - ${({ placement, $withAnimation }) => + ${({ placement, $height, $withAnimation }) => ({ [DialogPlacement.Default]: css` inset: var(--dialog-inset); margin: auto; max-width: var(--dialog-width); - height: fit-content; max-height: var(--dialog-height); + ${$withAnimation + ? css` + height: ${$height ? `${$height}px` : 'fit-content'}; + transition: height 0.25s ease-in-out; + ` + : css` + height: fit-content; + `} + display: flex; flex-direction: column; border-radius: var(--dialog-radius); - /* clip-path: inset( - calc(-1 * var(--border-width)) round calc(var(--dialog-radius) + var(--border-width)) - ); - overflow-clip-margin: var(--border-width); */ @media ${breakpoints.mobile} { top: calc(var(--dialog-inset) * 2); @@ -284,94 +299,18 @@ const $Container = styled(Content)<{ border-bottom-left-radius: 0; border-bottom-right-radius: 0; - - /* Hack (uneven border-radius causes overflow issues) */ - /* top: auto; - bottom: calc(-1 * var(--dialog-radius)); - padding-bottom: var(--dialog-radius); */ } - - ${$withAnimation && - css` - @media (prefers-reduced-motion: no-preference) { - &[data-state='open'] { - animation: ${keyframes` - from { - opacity: 0; - } - 0.01% { - max-height: 0; - } - `} 0.15s var(--ease-out-expo); - } - - &[data-state='closed'] { - animation: ${keyframes` - to { - opacity: 0; - scale: 0.9; - max-height: 0; - } - `} 0.15s; - } - } - `} `, [DialogPlacement.Sidebar]: css` --dialog-width: var(--sidebar-width); + height: 100%; @media ${breakpoints.notMobile} { max-width: var(--dialog-width); margin-left: auto; } - - ${$withAnimation && - css` - @media (prefers-reduced-motion: no-preference) { - &[data-state='open'] { - animation: ${keyframes` - from { - translate: 100% 0; - opacity: 0; - } - `} 0.15s var(--ease-out-expo); - } - - &[data-state='closed'] { - animation: ${keyframes` - to { - translate: 100% 0; - opacity: 0; - } - `} 0.15s var(--ease-out-expo); - } - } - `} - `, - [DialogPlacement.Inline]: css` - ${$withAnimation && - css` - @media (prefers-reduced-motion: no-preference) { - &[data-state='open'] { - animation: ${keyframes` - from { - scale: 0.99; - opacity: 0; - } - `} 0.15s var(--ease-out-expo); - } - - &[data-state='closed'] { - animation: ${keyframes` - to { - scale: 0.99; - opacity: 0; - } - `} 0.15s var(--ease-out-expo); - } - } - `} `, + [DialogPlacement.Inline]: css``, [DialogPlacement.FullScreen]: css` --dialog-width: 100vw; --dialog-height: 100vh; @@ -388,6 +327,25 @@ const $Container = styled(Content)<{ `} `; +const $InnerContainer = styled.div<{ placement: DialogPlacement }>` + ${({ placement }) => + ({ + [DialogPlacement.Default]: css``, + [DialogPlacement.Sidebar]: css` + ${layoutMixins.flexColumn} + height: 100%; + `, + [DialogPlacement.Inline]: css` + ${layoutMixins.flexColumn} + height: 100%; + `, + [DialogPlacement.FullScreen]: css` + ${layoutMixins.flexColumn} + height: 100%; + `, + })[placement]} +`; + const $Header = styled.header<{ $withBorder: boolean; $withBlur: boolean }>` ${layoutMixins.stickyHeader} diff --git a/src/hooks/useIsMounted.ts b/src/hooks/useIsMounted.ts new file mode 100644 index 000000000..e4443d60a --- /dev/null +++ b/src/hooks/useIsMounted.ts @@ -0,0 +1,19 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * @description Custom hook that determines if the component is currently mounted. + * @url https://usehooks-ts.com/react-hook/use-is-mounted + */ +export function useIsMounted(): () => boolean { + const isMounted = useRef(false); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + + return useCallback(() => isMounted.current, []); +} diff --git a/src/hooks/useResizeObserver.ts b/src/hooks/useResizeObserver.ts new file mode 100644 index 000000000..49a09d957 --- /dev/null +++ b/src/hooks/useResizeObserver.ts @@ -0,0 +1,101 @@ +import { RefObject, useEffect, useRef, useState } from 'react'; + +import { useIsMounted } from './useIsMounted'; + +type Size = { + width: number | undefined; + height: number | undefined; +}; + +type UseResizeObserverOptions = { + ref: RefObject; + onResize?: (size: Size) => void; + box?: 'border-box' | 'content-box' | 'device-pixel-content-box'; +}; + +const initialSize: Size = { + width: undefined, + height: undefined, +}; + +/** + * @description Custom hook that observes the size of an element using the ResizeObserver API. + * @url https://usehooks-ts.com/react-hook/use-resize-observer + */ +export function useResizeObserver( + options: UseResizeObserverOptions +): Size { + const { ref, box = 'content-box' } = options; + const [{ width, height }, setSize] = useState(initialSize); + const isMounted = useIsMounted(); + const previousSize = useRef({ ...initialSize }); + const onResize = useRef<((size: Size) => void) | undefined>(undefined); + onResize.current = options.onResize; + + useEffect(() => { + if (!ref.current) return; + + if (typeof window === 'undefined' || !('ResizeObserver' in window)) return; + + const observer = new ResizeObserver(([entry]) => { + const boxProp = + box === 'border-box' + ? 'borderBoxSize' + : box === 'device-pixel-content-box' + ? 'devicePixelContentBoxSize' + : 'contentBoxSize'; + + const newWidth = extractSize(entry!, boxProp, 'inlineSize'); + const newHeight = extractSize(entry!, boxProp, 'blockSize'); + + const hasChanged = + previousSize.current.width !== newWidth || previousSize.current.height !== newHeight; + + if (hasChanged) { + const newSize: Size = { width: newWidth, height: newHeight }; + previousSize.current.width = newWidth; + previousSize.current.height = newHeight; + + if (onResize.current) { + onResize.current(newSize); + } else { + if (isMounted()) { + setSize(newSize); + } + } + } + }); + + observer.observe(ref.current, { box }); + + // eslint-disable-next-line consistent-return + return () => { + observer.disconnect(); + }; + }, [box, ref, isMounted]); + + return { width, height }; +} + +type BoxSizesKey = keyof Pick< + ResizeObserverEntry, + 'borderBoxSize' | 'contentBoxSize' | 'devicePixelContentBoxSize' +>; + +function extractSize( + entry: ResizeObserverEntry, + box: BoxSizesKey, + sizeType: keyof ResizeObserverSize +): number | undefined { + if (!entry[box]) { + if (box === 'contentBoxSize') { + return entry.contentRect[sizeType === 'inlineSize' ? 'width' : 'height']; + } + return undefined; + } + + return Array.isArray(entry[box]) + ? entry[box][0][sizeType] + : // @ts-ignore Support Firefox's non-standard behavior + (entry[box][sizeType] as number); +} diff --git a/src/views/dialogs/DepositDialog.tsx b/src/views/dialogs/DepositDialog.tsx index 268fd8192..636c4bf4c 100644 --- a/src/views/dialogs/DepositDialog.tsx +++ b/src/views/dialogs/DepositDialog.tsx @@ -56,6 +56,7 @@ export const DepositDialog = ({ return (