diff --git a/dotcom-rendering/src/components/CrosswordComponent.island.tsx b/dotcom-rendering/src/components/CrosswordComponent.island.tsx index 4c22f95d055..f4f340937fa 100644 --- a/dotcom-rendering/src/components/CrosswordComponent.island.tsx +++ b/dotcom-rendering/src/components/CrosswordComponent.island.tsx @@ -12,11 +12,11 @@ import { textSansItalic12, } from '@guardian/source/foundations'; import { Hide } from '@guardian/source/react-components'; -import libDebounce from 'lodash.debounce'; import type { ReactNode } from 'react'; -import { memo, useEffect, useRef, useState } from 'react'; +import { memo, useRef } from 'react'; import { useCrosswordProgressReporting } from '../lib/puzzleProgress/useCrosswordProgressReporting'; import { removeMediaRulePrefix, useMatchMedia } from '../lib/useMatchMedia'; +import { useScrollGradients } from '../lib/useScrollGradients'; import { palette } from '../palette'; import { AdSlot } from './AdSlot.web'; @@ -64,51 +64,13 @@ const Layout: CrosswordProps['Layout'] = ({ MobileBannerAd, }) => { const cluesRef = useRef(null); - const [showGradient, setShowGradient] = useState(false); + const { showTopGradient, showBottomGradient } = + useScrollGradients(cluesRef); const betweenTabletAndLeftCol = useMatchMedia( removeMediaRulePrefix(between.tablet.and.leftCol), ); - const updateGradientVisibility = () => { - const clueList = cluesRef.current; - if (!clueList) { - return; - } - const scrollPos = clueList.scrollTop; - const maxScroll = clueList.scrollHeight - clueList.clientHeight; - setShowGradient(scrollPos < maxScroll - 16); - }; - - useEffect(() => { - const clueList = cluesRef.current; - if (!clueList) { - return; - } - - updateGradientVisibility(); - - clueList.addEventListener( - 'scroll', - libDebounce(updateGradientVisibility, 100), - ); - window.addEventListener( - 'resize', - libDebounce(updateGradientVisibility, 100), - ); - - return () => { - clueList.removeEventListener( - 'scroll', - libDebounce(updateGradientVisibility, 100), - ); - window.removeEventListener( - 'resize', - libDebounce(updateGradientVisibility, 100), - ); - }; - }, []); - return (
{ + const element = document.createElement('div'); + const metrics = { ...initial }; + Object.defineProperties(element, { + scrollHeight: { get: () => metrics.scrollHeight }, + clientHeight: { get: () => metrics.clientHeight }, + scrollTop: { get: () => metrics.scrollTop }, + }); + const ref = { current: element }; + const hook = renderHook(() => useScrollGradients(ref)); + + const scrollTo = (scrollTop: number) => + act(() => { + metrics.scrollTop = scrollTop; + element.dispatchEvent(new Event('scroll')); + }); + + return { element, metrics, scrollTo, ...hook }; +}; + +describe('useScrollGradients', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('shows only the bottom gradient at the start of a scrollable list', () => { + const { result } = setup({ + scrollHeight: 1000, + clientHeight: 400, + scrollTop: 0, + }); + + expect(result.current).toEqual({ + showTopGradient: false, + showBottomGradient: true, + }); + }); + + it('shows no gradient when the list does not scroll', () => { + const { result } = setup({ + scrollHeight: 400, + clientHeight: 400, + scrollTop: 0, + }); + + expect(result.current).toEqual({ + showTopGradient: false, + showBottomGradient: false, + }); + }); + + it('updates both gradients while scrolling, without waiting for a pause', () => { + const { result, scrollTo } = setup({ + scrollHeight: 1000, + clientHeight: 400, + scrollTop: 0, + }); + + // No timers are advanced below, so a debounced update would not show yet. + scrollTo(200); + expect(result.current).toEqual({ + showTopGradient: true, + showBottomGradient: true, + }); + + scrollTo(600); + expect(result.current).toEqual({ + showTopGradient: true, + showBottomGradient: false, + }); + + scrollTo(0); + expect(result.current).toEqual({ + showTopGradient: false, + showBottomGradient: true, + }); + }); + + it('ignores a scroll of a few pixels from either end', () => { + const { result, scrollTo } = setup({ + scrollHeight: 1000, + clientHeight: 400, + scrollTop: 0, + }); + + scrollTo(10); + expect(result.current.showTopGradient).toBe(false); + + scrollTo(595); + expect(result.current.showBottomGradient).toBe(false); + }); + + it('updates when the window is resized', () => { + const { result, metrics } = setup({ + scrollHeight: 1000, + clientHeight: 400, + scrollTop: 0, + }); + expect(result.current.showBottomGradient).toBe(true); + + act(() => { + metrics.clientHeight = 1000; + window.dispatchEvent(new Event('resize')); + }); + + expect(result.current.showBottomGradient).toBe(false); + }); + + it('registers one handler for the life of the component and removes that same handler on unmount', () => { + const element = document.createElement('div'); + const ref = { current: element }; + const addElementListener = jest.spyOn(element, 'addEventListener'); + const removeElementListener = jest.spyOn( + element, + 'removeEventListener', + ); + const addWindowListener = jest.spyOn(window, 'addEventListener'); + const removeWindowListener = jest.spyOn(window, 'removeEventListener'); + + const { rerender, unmount } = renderHook(() => useScrollGradients(ref)); + rerender(); + + const scrollCalls = addElementListener.mock.calls.filter( + ([type]) => type === 'scroll', + ); + const resizeCalls = addWindowListener.mock.calls.filter( + ([type]) => type === 'resize', + ); + expect(scrollCalls).toHaveLength(1); + expect(resizeCalls).toHaveLength(1); + + unmount(); + + expect(removeElementListener).toHaveBeenCalledWith( + 'scroll', + scrollCalls[0]?.[1], + ); + expect(removeWindowListener).toHaveBeenCalledWith( + 'resize', + resizeCalls[0]?.[1], + ); + }); +}); diff --git a/dotcom-rendering/src/lib/useScrollGradients.ts b/dotcom-rendering/src/lib/useScrollGradients.ts new file mode 100644 index 00000000000..2cd5288568e --- /dev/null +++ b/dotcom-rendering/src/lib/useScrollGradients.ts @@ -0,0 +1,46 @@ +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; + +/** How far from either end the element must be scrolled before its gradient shows */ +const EDGE_THRESHOLD_PX = 16; + +/** + * Tracks whether a scrollable element has more content above and below the + * visible area, so that a fade can hint that it can be scrolled either way. + * + * It updates on every scroll and resize event. Re-rendering only happens when + * one of the flags changes, so there is no need to debounce. + */ +export const useScrollGradients = ( + ref: RefObject, +): { showTopGradient: boolean; showBottomGradient: boolean } => { + const [showTopGradient, setShowTopGradient] = useState(false); + const [showBottomGradient, setShowBottomGradient] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + const update = () => { + const maxScroll = element.scrollHeight - element.clientHeight; + setShowTopGradient(element.scrollTop > EDGE_THRESHOLD_PX); + setShowBottomGradient( + element.scrollTop < maxScroll - EDGE_THRESHOLD_PX, + ); + }; + + update(); + + element.addEventListener('scroll', update); + window.addEventListener('resize', update); + + return () => { + element.removeEventListener('scroll', update); + window.removeEventListener('resize', update); + }; + }, [ref]); + + return { showTopGradient, showBottomGradient }; +};