From 9c8f763a77d8737f3626e8ae3f29411b101ea160 Mon Sep 17 00:00:00 2001 From: Oliver Baker Date: Tue, 3 May 2022 11:39:08 -0500 Subject: [PATCH 1/5] fix(spotlight): spotlight now smoothly animates only when switching target and not on scroll/resize By adding a new state hook which keeps track of the previous state of the variable, the spotlight now detects if an update was made due to a scroll change or a target change. Fix #360 --- .../Spotlight/Spotlight.stories.tsx | 32 +++++++++++++- src/components/Spotlight/Spotlight.tsx | 44 ++++++++++++++----- src/utils/hooks.ts | 23 ++++++++++ 3 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 src/utils/hooks.ts diff --git a/src/components/Spotlight/Spotlight.stories.tsx b/src/components/Spotlight/Spotlight.stories.tsx index 7724c84b2..e72eb062c 100644 --- a/src/components/Spotlight/Spotlight.stories.tsx +++ b/src/components/Spotlight/Spotlight.stories.tsx @@ -6,6 +6,10 @@ import styled from 'styled-components'; import Spotlight, { SpotlightProps } from './Spotlight'; import { Button, Card, Text, variants, colors } from '../../index'; +const Container = styled(Card.Container)` + width: 40rem; +`; + const Header = styled(Card.NoPaddingHeader)` display: flex; justify-content: flex-end; @@ -56,6 +60,7 @@ export const AnimatedSpotlight: Story = (args: Partial) => { <> ) => { } > - There are a few items in this card we can talk about + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus porttitor ligula id urna + molestie vulputate a at risus. Maecenas vehicula ligula sed nulla malesuada, at cursus arcu + feugiat. Nulla aliquam odio vitae arcu molestie, eu pretium mi tristique. Pellentesque + scelerisque ultricies libero nec consequat. Fusce euismod diam vel eros efficitur, eget + semper quam ultrices. Donec pharetra lectus felis, ut dapibus libero rhoncus vitae. Cras sed + venenatis tellus. Praesent venenatis erat at tristique mollis. Donec blandit, sem ac dapibus + vulputate, magna nisi eleifend augue, a rutrum nisl enim vitae risus. Proin porta libero ac + ultricies vehicula. Ut sodales pellentesque magna, sed dignissim mauris pulvinar eget. Cras + ornare lobortis blandit. Vestibulum hendrerit, mauris sit amet consectetur accumsan, ante + nulla vehicula lorem, ac lacinia diam odio non odio. Duis volutpat tellus a rutrum varius. + Nullam et nunc quis ipsum fringilla sollicitudin. Aenean mollis dui eget orci feugiat, vitae + dapibus orci vestibulum. Fusce gravida vitae mi a blandit. Nunc eleifend lacinia massa + molestie convallis. Sed sed sodales magna. Etiam lectus risus, semper non convallis vitae, + elementum feugiat sem. Quisque rutrum velit augue, eget molestie lacus aliquet ut. Morbi et + lacus euismod, accumsan leo vitae, consequat arcu. Suspendisse mattis, ligula sit amet + ullamcorper tempus, elit elit feugiat mauris, ut venenatis massa diam quis orci. Nulla ex + enim, pretium nec aliquet sit amet, laoreet ac lorem. Nam quis mi eu tellus accumsan iaculis + vitae non nunc. Sed vel enim tortor. Curabitur quis orci sit amet urna fringilla cursus eu + non est. Ut rutrum gravida ex non vulputate. Proin suscipit diam lorem, sit amet fringilla + est viverra vel. Sed ut congue sem. Donec laoreet venenatis ipsum vel mollis. Nam eget dolor + posuere massa semper elementum ac eu odio. Donec ante justo, lacinia non ex ut, fringilla + porta enim. Maecenas vitae tortor ut ipsum imperdiet vehicula sit amet hendrerit lectus. + Duis diam ipsum, venenatis in condimentum ac, tincidunt et est. Pellentesque venenatis, ex + in dictum rhoncus, massa nisl egestas est, vitae consectetur est dolor eu elit. Suspendisse + scelerisque rhoncus tellus, a sodales ipsum ultrices ut. Quisque ac quam eu tellus laoreet + elementum sed ut urna. {tourStarted && ( diff --git a/src/components/Spotlight/Spotlight.tsx b/src/components/Spotlight/Spotlight.tsx index d18b6fc9f..5eea1fe2b 100644 --- a/src/components/Spotlight/Spotlight.tsx +++ b/src/components/Spotlight/Spotlight.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import styled from 'styled-components'; import { animated, useSpring } from '@react-spring/web'; import { Portal } from 'react-portal'; import { SubcomponentPropsType, StyledSubcomponentType } from '../commonTypes'; +import { useStateWithPrevious } from '../../utils/hooks'; import { useAnalytics } from '../../context'; import { AnimatedDiv } from '../../htmlElements'; @@ -89,11 +90,14 @@ const Spotlight = ({ animationSpringConfig, }: SpotlightProps): JSX.Element | null => { const handleEventWithAnalytics = useAnalytics(); - const [windowDimensions, setWindowDimensions] = useState<{ width: number; height: number }>({ + const [windowDimensions, prevWindowDimensions, setWindowDimensions] = useStateWithPrevious<{ + width: number; + height: number; + }>({ width: window.innerWidth, height: window.innerHeight, }); - const [scrollTop, setScrollTop] = useState(0); + const [scrollTop, prevScrollTop, setScrollTop] = useStateWithPrevious(0); const rect = useMemo>(() => { const defaultVal = { @@ -164,6 +168,11 @@ const Spotlight = ({ `; const finalRectangularPath = `${outerRectPath} ${innerShapePath}`; + const screenLayoutHasChanged = + scrollTop !== prevScrollTop || + windowDimensions.width !== prevWindowDimensions.width || + windowDimensions.height !== prevWindowDimensions.height; + const [ { containerFilter, @@ -214,7 +223,7 @@ const Spotlight = ({ friction: 75, tension: 550, mass: 5, - immediate: !animateTargetChanges, + immediate: !animateTargetChanges || screenLayoutHasChanged, onRest: onAnimationEnd, ...animationSpringConfig, })); @@ -248,7 +257,7 @@ const Spotlight = ({ friction: 75, tension: 550, mass: 5, - immediate: !animateTargetChanges, + immediate: !animateTargetChanges || screenLayoutHasChanged, onRest: onAnimationEnd, ...animationSpringConfig, @@ -261,6 +270,7 @@ const Spotlight = ({ padding, cornerRadius, windowDimensions, + screenLayoutHasChanged, setSpring, finalRectangularPath, circularPath, @@ -275,24 +285,34 @@ const Spotlight = ({ ]); // TODO: use a resize observer to detect when the bounds of the target change - const updateWindowBounds = () => { + const updateWindowBounds = useCallback(() => { setWindowDimensions({ width: window.innerWidth, height: window.innerHeight, }); - }; + }, [setWindowDimensions]); - const updateScrollPosition = (e: Event) => { + const updateScrollPosition = useCallback(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore documentElement does exist on target element - setScrollTop(e?.target?.documentElement.scrollTop); - }; + // setScrollTop(e?.target?.documentElement.scrollTop); + setScrollTop(window.scrollY); + }, [setScrollTop]); + + useEffect(() => { + updateScrollPosition(); // update scroll position every render that the scroll changes so the previous value will update after scrolling events stop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.scrollY]); + useEffect(() => { + updateWindowBounds(); // update window dimensions every render that the window resizes so the previous value will update after resize events stop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.innerWidth, window.innerHeight]); useEffect(() => { window.addEventListener('scroll', updateScrollPosition); return () => window.removeEventListener('scroll', updateScrollPosition); - }, []); + }, [updateScrollPosition]); useEffect(() => { window.addEventListener('resize', updateWindowBounds); @@ -300,7 +320,7 @@ const Spotlight = ({ return () => { window.removeEventListener('resize', updateWindowBounds); }; - }, []); + }, [updateWindowBounds]); const handleClick = (evt: React.MouseEvent) => { handleEventWithAnalytics('Spotlight', onClick, 'onClick', evt, containerProps); diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts new file mode 100644 index 000000000..83222886f --- /dev/null +++ b/src/utils/hooks.ts @@ -0,0 +1,23 @@ +import React, { useEffect, useRef, useState } from 'react'; + +// eslint-disable-next-line import/prefer-default-export +export const useStateWithPrevious = ( + defaultValue: Type, +): [Type, Type, React.Dispatch] => { + const [currentValue, setInternalCurrentValue] = useState(defaultValue); + const currentValueRef = useRef(defaultValue); + const previous = useRef(defaultValue); + + useEffect(() => { + currentValueRef.current = currentValue; + }, [currentValue]); + + const setCurrent = (value: Type): void => { + console.log(currentValue); + previous.current = currentValueRef.current; + currentValueRef.current = value; + setInternalCurrentValue(value); + }; + + return [currentValue, previous.current, setCurrent]; +}; From e203c205872fd4563e6c1ace93b9e725d99b9e7d Mon Sep 17 00:00:00 2001 From: Oliver Baker Date: Tue, 3 May 2022 11:53:16 -0500 Subject: [PATCH 2/5] remove console log --- src/utils/hooks.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 83222886f..51db8a33e 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -13,7 +13,6 @@ export const useStateWithPrevious = ( }, [currentValue]); const setCurrent = (value: Type): void => { - console.log(currentValue); previous.current = currentValueRef.current; currentValueRef.current = value; setInternalCurrentValue(value); From d755b0cc7aa6ea20e684b17cc4c16bfa09644424 Mon Sep 17 00:00:00 2001 From: Oliver Baker Date: Tue, 3 May 2022 11:58:27 -0500 Subject: [PATCH 3/5] chore(exports): add useful util functions to exports --- src/index.ts | 12 ++++++++++++ src/utils/math.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/src/index.ts b/src/index.ts index 9f05d745f..85fdaffea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,8 @@ import TextInput from './components/TextInput'; import Progress from './components/Progress'; import Skeleton from './components/Skeleton'; import { FoundryProvider, FoundryContext, useTheme } from './context'; +import { useStateWithPrevious } from './utils/hooks'; +import { clamp } from './utils/math'; import colors from './enums/colors'; import timings from './enums/timings'; @@ -22,6 +24,11 @@ import fonts from './enums/fonts'; import variants from './enums/variants'; import feedbackTypes from './enums/feedbackTypes'; import checkboxTypes from './enums/checkboxTypes'; +import { + disabledStyles, + getBackgroundColorFromVariant, + getFontColorFromVariant, +} from './utils/color'; export { Button, @@ -46,6 +53,11 @@ export { FoundryProvider, FoundryContext, useTheme, + clamp, + getFontColorFromVariant, + getBackgroundColorFromVariant, + disabledStyles, + useStateWithPrevious, colors, timings, fonts, diff --git a/src/utils/math.ts b/src/utils/math.ts index 1a53a46db..6b9d98090 100644 --- a/src/utils/math.ts +++ b/src/utils/math.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export export const clamp = (val: number, min: number, max: number): number => { return Math.max(Math.min(val, max), min); }; From f15f49f53d6e262f0b63d23111e0df3f45f6bbaa Mon Sep 17 00:00:00 2001 From: Oliver Baker Date: Tue, 3 May 2022 15:13:11 -0500 Subject: [PATCH 4/5] refactor(spotlight): created a useWindowSize hook which efficiently tracks window size changes --- .eslintrc.js | 1 + .../Spotlight/Spotlight.stories.tsx | 34 +------- src/components/Spotlight/Spotlight.tsx | 79 +++++++------------ src/index.ts | 3 +- src/utils/hooks.ts | 53 ++++++++++++- 5 files changed, 84 insertions(+), 86 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 112648826..63ce7f1f4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = { rules: { '@typescript-eslint/ban-types': 1, // StyledComponentBase failed every time '@typescript-eslint/no-empty-function': 0, + '@typescript-eslint/no-inferrable-types': 1, 'arrow-body-style': 0, 'arrow-parens': 0, 'comma-dangle': 1, diff --git a/src/components/Spotlight/Spotlight.stories.tsx b/src/components/Spotlight/Spotlight.stories.tsx index e72eb062c..f8e3b2ae7 100644 --- a/src/components/Spotlight/Spotlight.stories.tsx +++ b/src/components/Spotlight/Spotlight.stories.tsx @@ -6,10 +6,6 @@ import styled from 'styled-components'; import Spotlight, { SpotlightProps } from './Spotlight'; import { Button, Card, Text, variants, colors } from '../../index'; -const Container = styled(Card.Container)` - width: 40rem; -`; - const Header = styled(Card.NoPaddingHeader)` display: flex; justify-content: flex-end; @@ -60,7 +56,6 @@ export const AnimatedSpotlight: Story = (args: Partial) => { <> ) => { containerRef={setButtonRef} color={colors.tertiary} > - Start the tour! + Start tour } > - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus porttitor ligula id urna - molestie vulputate a at risus. Maecenas vehicula ligula sed nulla malesuada, at cursus arcu - feugiat. Nulla aliquam odio vitae arcu molestie, eu pretium mi tristique. Pellentesque - scelerisque ultricies libero nec consequat. Fusce euismod diam vel eros efficitur, eget - semper quam ultrices. Donec pharetra lectus felis, ut dapibus libero rhoncus vitae. Cras sed - venenatis tellus. Praesent venenatis erat at tristique mollis. Donec blandit, sem ac dapibus - vulputate, magna nisi eleifend augue, a rutrum nisl enim vitae risus. Proin porta libero ac - ultricies vehicula. Ut sodales pellentesque magna, sed dignissim mauris pulvinar eget. Cras - ornare lobortis blandit. Vestibulum hendrerit, mauris sit amet consectetur accumsan, ante - nulla vehicula lorem, ac lacinia diam odio non odio. Duis volutpat tellus a rutrum varius. - Nullam et nunc quis ipsum fringilla sollicitudin. Aenean mollis dui eget orci feugiat, vitae - dapibus orci vestibulum. Fusce gravida vitae mi a blandit. Nunc eleifend lacinia massa - molestie convallis. Sed sed sodales magna. Etiam lectus risus, semper non convallis vitae, - elementum feugiat sem. Quisque rutrum velit augue, eget molestie lacus aliquet ut. Morbi et - lacus euismod, accumsan leo vitae, consequat arcu. Suspendisse mattis, ligula sit amet - ullamcorper tempus, elit elit feugiat mauris, ut venenatis massa diam quis orci. Nulla ex - enim, pretium nec aliquet sit amet, laoreet ac lorem. Nam quis mi eu tellus accumsan iaculis - vitae non nunc. Sed vel enim tortor. Curabitur quis orci sit amet urna fringilla cursus eu - non est. Ut rutrum gravida ex non vulputate. Proin suscipit diam lorem, sit amet fringilla - est viverra vel. Sed ut congue sem. Donec laoreet venenatis ipsum vel mollis. Nam eget dolor - posuere massa semper elementum ac eu odio. Donec ante justo, lacinia non ex ut, fringilla - porta enim. Maecenas vitae tortor ut ipsum imperdiet vehicula sit amet hendrerit lectus. - Duis diam ipsum, venenatis in condimentum ac, tincidunt et est. Pellentesque venenatis, ex - in dictum rhoncus, massa nisl egestas est, vitae consectetur est dolor eu elit. Suspendisse - scelerisque rhoncus tellus, a sodales ipsum ultrices ut. Quisque ac quam eu tellus laoreet - elementum sed ut urna. + There are a few items in this card we can talk about! {tourStarted && ( diff --git a/src/components/Spotlight/Spotlight.tsx b/src/components/Spotlight/Spotlight.tsx index 5eea1fe2b..b0b235388 100644 --- a/src/components/Spotlight/Spotlight.tsx +++ b/src/components/Spotlight/Spotlight.tsx @@ -4,7 +4,7 @@ import { animated, useSpring } from '@react-spring/web'; import { Portal } from 'react-portal'; import { SubcomponentPropsType, StyledSubcomponentType } from '../commonTypes'; -import { useStateWithPrevious } from '../../utils/hooks'; +import { useStateWithPrevious, useWindowSize } from '../../utils/hooks'; import { useAnalytics } from '../../context'; import { AnimatedDiv } from '../../htmlElements'; @@ -66,6 +66,7 @@ export type SpotlightProps = { // onAnimationEnd?: ControllerProps['onRest']; onAnimationEnd?: () => void; animationSpringConfig?: Record; + resizeUpdateInterval?: number; }; const Spotlight = ({ @@ -88,27 +89,26 @@ const Spotlight = ({ animateTargetChanges = true, onAnimationEnd, animationSpringConfig, + resizeUpdateInterval = 0, }: SpotlightProps): JSX.Element | null => { const handleEventWithAnalytics = useAnalytics(); - const [windowDimensions, prevWindowDimensions, setWindowDimensions] = useStateWithPrevious<{ - width: number; - height: number; - }>({ - width: window.innerWidth, - height: window.innerHeight, - }); + const { + width: windowWidth, + height: windowHeight, + isResizing, + } = useWindowSize(resizeUpdateInterval); const [scrollTop, prevScrollTop, setScrollTop] = useStateWithPrevious(0); const rect = useMemo>(() => { const defaultVal = { - x: windowDimensions.width / 2, - y: windowDimensions.height / 2, + x: windowWidth / 2, + y: windowHeight / 2, width: 0, height: 0, - bottom: windowDimensions.height / 2, - left: windowDimensions.width / 2, - top: windowDimensions.height / 2, - right: windowDimensions.width / 2, + bottom: windowHeight / 2, + left: windowWidth / 2, + top: windowHeight / 2, + right: windowWidth / 2, }; if (targetElement) { @@ -134,7 +134,7 @@ const Spotlight = ({ } return defaultVal; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetElement, padding, windowDimensions, shape, scrollTop]); + }, [targetElement, padding, windowWidth, windowHeight, shape, scrollTop]); const radii = [Math.min(cornerRadius, rect.width / 2), Math.min(cornerRadius, rect.height / 2)]; if (shape === SpotlightShapes.round) { @@ -145,7 +145,7 @@ const Spotlight = ({ radii[1] = 0; } - const outerRectPath = `M 0 0 h${windowDimensions.width} v${windowDimensions.height} h-${windowDimensions.width}`; + const outerRectPath = `M 0 0 h${windowWidth} v${windowHeight} h-${windowWidth}`; const innerShapePath = ` M ${rect.x} ${rect.y + radii[1]} Q ${rect.x} ${rect.y}, ${rect.x + radii[0]} ${rect.y} @@ -168,10 +168,7 @@ const Spotlight = ({ `; const finalRectangularPath = `${outerRectPath} ${innerShapePath}`; - const screenLayoutHasChanged = - scrollTop !== prevScrollTop || - windowDimensions.width !== prevWindowDimensions.width || - windowDimensions.height !== prevWindowDimensions.height; + const screenLayoutHasChanged = scrollTop !== prevScrollTop || isResizing; const [ { @@ -205,19 +202,19 @@ const Spotlight = ({ annotationTransform: `translate(${rect.x}px, ${rect.y}px) translate(0%, -100%)`, - topBlurWidth: windowDimensions.width, + topBlurWidth: windowWidth, topBlurHeight: rect.y - 1, bottomBlurY: rect.bottom, - bottomBlurWidth: windowDimensions.width, - bottomBlurHeight: windowDimensions.height - rect.bottom - 1, + bottomBlurWidth: windowWidth, + bottomBlurHeight: windowHeight - rect.bottom - 1, leftBlurY: rect.y, leftBlurWidth: rect.x, leftBlurHeight: rect.height + 2, rightBlurY: rect.y, - rightBlurWidth: windowDimensions.width - rect.right, + rightBlurWidth: windowWidth - rect.right, rightBlurHeight: rect.height + 2, friction: 75, @@ -239,19 +236,19 @@ const Spotlight = ({ annotationTransform: `translate(${rect.x}px, ${rect.y}px) translate(0%, -100%)`, - topBlurWidth: windowDimensions.width, + topBlurWidth: windowWidth, topBlurHeight: rect.y - 1, bottomBlurY: rect.bottom, - bottomBlurWidth: windowDimensions.width, - bottomBlurHeight: windowDimensions.height - rect.bottom - 1, + bottomBlurWidth: windowWidth, + bottomBlurHeight: windowHeight - rect.bottom - 1, leftBlurY: rect.y, leftBlurWidth: rect.x, leftBlurHeight: rect.height + 2, rightBlurY: rect.y, - rightBlurWidth: windowDimensions.width - rect.right, + rightBlurWidth: windowWidth - rect.right, rightBlurHeight: rect.height + 2, friction: 75, @@ -269,7 +266,8 @@ const Spotlight = ({ backgroundDarkness, padding, cornerRadius, - windowDimensions, + windowWidth, + windowHeight, screenLayoutHasChanged, setSpring, finalRectangularPath, @@ -284,18 +282,9 @@ const Spotlight = ({ onAnimationEnd, ]); - // TODO: use a resize observer to detect when the bounds of the target change - const updateWindowBounds = useCallback(() => { - setWindowDimensions({ - width: window.innerWidth, - height: window.innerHeight, - }); - }, [setWindowDimensions]); - const updateScrollPosition = useCallback(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore documentElement does exist on target element - // setScrollTop(e?.target?.documentElement.scrollTop); setScrollTop(window.scrollY); }, [setScrollTop]); @@ -303,10 +292,6 @@ const Spotlight = ({ updateScrollPosition(); // update scroll position every render that the scroll changes so the previous value will update after scrolling events stop // eslint-disable-next-line react-hooks/exhaustive-deps }, [window.scrollY]); - useEffect(() => { - updateWindowBounds(); // update window dimensions every render that the window resizes so the previous value will update after resize events stop - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [window.innerWidth, window.innerHeight]); useEffect(() => { window.addEventListener('scroll', updateScrollPosition); @@ -314,14 +299,6 @@ const Spotlight = ({ return () => window.removeEventListener('scroll', updateScrollPosition); }, [updateScrollPosition]); - useEffect(() => { - window.addEventListener('resize', updateWindowBounds); - - return () => { - window.removeEventListener('resize', updateWindowBounds); - }; - }, [updateWindowBounds]); - const handleClick = (evt: React.MouseEvent) => { handleEventWithAnalytics('Spotlight', onClick, 'onClick', evt, containerProps); }; @@ -375,7 +352,7 @@ const Spotlight = ({ diff --git a/src/index.ts b/src/index.ts index 85fdaffea..0f26937ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import TextInput from './components/TextInput'; import Progress from './components/Progress'; import Skeleton from './components/Skeleton'; import { FoundryProvider, FoundryContext, useTheme } from './context'; -import { useStateWithPrevious } from './utils/hooks'; +import { useStateWithPrevious, useWindowSize } from './utils/hooks'; import { clamp } from './utils/math'; import colors from './enums/colors'; @@ -58,6 +58,7 @@ export { getBackgroundColorFromVariant, disabledStyles, useStateWithPrevious, + useWindowSize, colors, timings, fonts, diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 51db8a33e..2c4066575 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -1,6 +1,6 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { debounce, throttle } from 'lodash'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; -// eslint-disable-next-line import/prefer-default-export export const useStateWithPrevious = ( defaultValue: Type, ): [Type, Type, React.Dispatch] => { @@ -20,3 +20,52 @@ export const useStateWithPrevious = ( return [currentValue, previous.current, setCurrent]; }; + +export const useWindowSize = ( + reportInterval: number = 0, // only cause rerenders in the component using the hook every X milliseconds + resizeEndReportDelay: number = 50, // wait this long after the last resize event to update curr/prev values +): { + width: number; + height: number; + previousWidth: number; + previousHeight: number; + isResizing: boolean; +} => { + const [width, previousWidth, setWidth] = useStateWithPrevious(window.innerWidth); + const [height, previousHeight, setHeight] = useStateWithPrevious(window.innerHeight); + const [isResizing, setIsResizing] = useState(false); + + // if the resizing events stop for more than 100ms, set the previous widths/heights to the current + // because this is based on async timing of debounce, + // // the current/previous values will be delivered on the next render of the component using this hook. + const debouncedEndResize = debounce( + () => { + setWidth(window.innerWidth); + setHeight(window.innerHeight); + setIsResizing(false); + }, + resizeEndReportDelay, + { leading: false, trailing: true }, + ); + + const updateWindowBounds = useCallback(() => { + setWidth(window.innerWidth); + setHeight(window.innerHeight); + if (!isResizing) { + setIsResizing(true); + } + debouncedEndResize(); + }, [debouncedEndResize, isResizing, setHeight, setWidth]); + + const throttledResizeHandler = throttle(updateWindowBounds, reportInterval); + + useEffect(() => { + window.addEventListener('resize', throttledResizeHandler); + + return () => { + window.removeEventListener('resize', throttledResizeHandler); + }; + }, [throttledResizeHandler]); + + return { width, height, previousWidth, previousHeight, isResizing }; +}; From bc57ab7caae3f2599e9a33d03f2a4266ca00dfa9 Mon Sep 17 00:00:00 2001 From: Oliver Baker Date: Tue, 3 May 2022 15:45:16 -0500 Subject: [PATCH 5/5] refactor(spotlight): added a useScrollObserver hook to mirror the useWindowSizeObserver hook --- src/components/Spotlight/Spotlight.tsx | 38 +++++------------ src/index.ts | 5 ++- src/utils/hooks.ts | 56 ++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/components/Spotlight/Spotlight.tsx b/src/components/Spotlight/Spotlight.tsx index b0b235388..51fc95cf7 100644 --- a/src/components/Spotlight/Spotlight.tsx +++ b/src/components/Spotlight/Spotlight.tsx @@ -1,10 +1,10 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import styled from 'styled-components'; import { animated, useSpring } from '@react-spring/web'; import { Portal } from 'react-portal'; import { SubcomponentPropsType, StyledSubcomponentType } from '../commonTypes'; -import { useStateWithPrevious, useWindowSize } from '../../utils/hooks'; +import { useScrollObserver, useWindowSizeObserver } from '../../utils/hooks'; import { useAnalytics } from '../../context'; import { AnimatedDiv } from '../../htmlElements'; @@ -67,6 +67,7 @@ export type SpotlightProps = { onAnimationEnd?: () => void; animationSpringConfig?: Record; resizeUpdateInterval?: number; + scrollUpdateInterval?: number; }; const Spotlight = ({ @@ -90,14 +91,15 @@ const Spotlight = ({ onAnimationEnd, animationSpringConfig, resizeUpdateInterval = 0, + scrollUpdateInterval = 0, }: SpotlightProps): JSX.Element | null => { const handleEventWithAnalytics = useAnalytics(); const { width: windowWidth, height: windowHeight, isResizing, - } = useWindowSize(resizeUpdateInterval); - const [scrollTop, prevScrollTop, setScrollTop] = useStateWithPrevious(0); + } = useWindowSizeObserver(resizeUpdateInterval); + const { scrollY, isScrolling } = useScrollObserver(scrollUpdateInterval); const rect = useMemo>(() => { const defaultVal = { @@ -134,7 +136,7 @@ const Spotlight = ({ } return defaultVal; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetElement, padding, windowWidth, windowHeight, shape, scrollTop]); + }, [targetElement, padding, windowWidth, windowHeight, shape, scrollY]); const radii = [Math.min(cornerRadius, rect.width / 2), Math.min(cornerRadius, rect.height / 2)]; if (shape === SpotlightShapes.round) { @@ -168,8 +170,6 @@ const Spotlight = ({ `; const finalRectangularPath = `${outerRectPath} ${innerShapePath}`; - const screenLayoutHasChanged = scrollTop !== prevScrollTop || isResizing; - const [ { containerFilter, @@ -220,7 +220,7 @@ const Spotlight = ({ friction: 75, tension: 550, mass: 5, - immediate: !animateTargetChanges || screenLayoutHasChanged, + immediate: !animateTargetChanges || isScrolling || isResizing, onRest: onAnimationEnd, ...animationSpringConfig, })); @@ -254,7 +254,7 @@ const Spotlight = ({ friction: 75, tension: 550, mass: 5, - immediate: !animateTargetChanges || screenLayoutHasChanged, + immediate: !animateTargetChanges || isScrolling || isResizing, onRest: onAnimationEnd, ...animationSpringConfig, @@ -268,7 +268,8 @@ const Spotlight = ({ cornerRadius, windowWidth, windowHeight, - screenLayoutHasChanged, + isScrolling, + isResizing, setSpring, finalRectangularPath, circularPath, @@ -282,23 +283,6 @@ const Spotlight = ({ onAnimationEnd, ]); - const updateScrollPosition = useCallback(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore documentElement does exist on target element - setScrollTop(window.scrollY); - }, [setScrollTop]); - - useEffect(() => { - updateScrollPosition(); // update scroll position every render that the scroll changes so the previous value will update after scrolling events stop - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [window.scrollY]); - - useEffect(() => { - window.addEventListener('scroll', updateScrollPosition); - - return () => window.removeEventListener('scroll', updateScrollPosition); - }, [updateScrollPosition]); - const handleClick = (evt: React.MouseEvent) => { handleEventWithAnalytics('Spotlight', onClick, 'onClick', evt, containerProps); }; diff --git a/src/index.ts b/src/index.ts index 0f26937ff..d5194f759 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import TextInput from './components/TextInput'; import Progress from './components/Progress'; import Skeleton from './components/Skeleton'; import { FoundryProvider, FoundryContext, useTheme } from './context'; -import { useStateWithPrevious, useWindowSize } from './utils/hooks'; +import { useStateWithPrevious, useWindowSizeObserver, useScrollObserver } from './utils/hooks'; import { clamp } from './utils/math'; import colors from './enums/colors'; @@ -58,7 +58,8 @@ export { getBackgroundColorFromVariant, disabledStyles, useStateWithPrevious, - useWindowSize, + useWindowSizeObserver, + useScrollObserver, colors, timings, fonts, diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 2c4066575..422d9fa4b 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -21,9 +21,10 @@ export const useStateWithPrevious = ( return [currentValue, previous.current, setCurrent]; }; -export const useWindowSize = ( - reportInterval: number = 0, // only cause rerenders in the component using the hook every X milliseconds - resizeEndReportDelay: number = 50, // wait this long after the last resize event to update curr/prev values +// TODO: Generalize observer to observe any attribute of any element on when an event happens, with previous values, "onComplete" callback func, and an "isComplete" flag +export const useWindowSizeObserver = ( + reportInterval = 0, // only cause rerenders in the component using the hook every X milliseconds + resizeEndReportDelay = 50, // wait this long after the last resize event to update curr/prev values ): { width: number; height: number; @@ -69,3 +70,52 @@ export const useWindowSize = ( return { width, height, previousWidth, previousHeight, isResizing }; }; + +export const useScrollObserver = ( + reportInterval = 0, // only cause rerenders in the component using the hook every X milliseconds + scrollEndReportDelay = 50, // wait this long after the last resize event to update curr/prev values +): { + scrollX: number; + scrollY: number; + previousScrollX: number; + previousScrollY: number; + isScrolling: boolean; +} => { + const [scrollX, previousScrollX, setScrollX] = useStateWithPrevious(window.scrollX); + const [scrollY, previousScrollY, setScrollY] = useStateWithPrevious(window.scrollY); + const [isScrolling, setIsScrolling] = useState(false); + + // if the scrolling events stop for more than 100ms, set the previous X/Y to the current + // because this is based on async timing of debounce, + // // the current/previous values will be delivered on the next render of the component using this hook. + const debouncedEndScroll = debounce( + () => { + setScrollX(window.scrollX); + setScrollY(window.scrollY); + setIsScrolling(false); + }, + scrollEndReportDelay, + { leading: false, trailing: true }, + ); + + const updateWindowBounds = useCallback(() => { + setScrollX(window.scrollX); + setScrollY(window.scrollY); + if (!isScrolling) { + setIsScrolling(true); + } + debouncedEndScroll(); + }, [debouncedEndScroll, isScrolling, setScrollY, setScrollX]); + + const throttledScrollHandler = throttle(updateWindowBounds, reportInterval); + + useEffect(() => { + window.addEventListener('scroll', throttledScrollHandler); + + return () => { + window.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + return { scrollX, scrollY, previousScrollX, previousScrollY, isScrolling }; +};