From 2131513788702b74916b2a856f4908c8d8b55d50 Mon Sep 17 00:00:00 2001 From: shrinivas-sn Date: Fri, 23 Oct 2026 23:58:50 +0530 Subject: [PATCH] Show a top gradient on the crossword clue list and update gradients while scrolling The clue list only had a gradient at the bottom, so nothing showed that it could also be scrolled back up. Add the matching gradient at the top, with the same print and left column resets. Both gradients now update on every scroll and resize event instead of after a 100ms debounce. The old effect also created a new debounced function when removing the scroll and resize listeners, so the original listeners were never removed. The logic moves into a useScrollGradients hook that registers one handler and removes that same handler on unmount. Closes #13543 --- .../components/CrosswordComponent.island.tsx | 65 +++---- .../src/lib/useScrollGradients.test.ts | 161 ++++++++++++++++++ .../src/lib/useScrollGradients.ts | 46 +++++ 3 files changed, 229 insertions(+), 43 deletions(-) create mode 100644 dotcom-rendering/src/lib/useScrollGradients.test.ts create mode 100644 dotcom-rendering/src/lib/useScrollGradients.ts 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 }; +};