Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 22 additions & 43 deletions dotcom-rendering/src/components/CrosswordComponent.island.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -64,51 +64,13 @@ const Layout: CrosswordProps['Layout'] = ({
MobileBannerAd,
}) => {
const cluesRef = useRef<HTMLDivElement>(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 (
<div
css={css`
Expand Down Expand Up @@ -178,8 +140,23 @@ const Layout: CrosswordProps['Layout'] = ({
display: flex;
${from.tablet} {
max-height: ${gridWidth}px;
::before {
display: ${showTopGradient ? 'block' : 'none'};
position: absolute;
content: '';
top: 0;
left: 0;
width: 100%;
height: 64px;
pointer-events: none;
background-image: linear-gradient(
180deg,
${palette('--article-background')},
transparent
);
}
::after {
display: ${showGradient ? 'block' : 'none'};
display: ${showBottomGradient ? 'block' : 'none'};
position: absolute;
content: '';
bottom: 0;
Expand All @@ -195,12 +172,14 @@ const Layout: CrosswordProps['Layout'] = ({
}
${from.leftCol} {
max-height: none;
::before,
::after {
background-image: none;
}
}
@media print {
max-height: none;
::before,
::after {
background-image: none;
}
Expand Down
161 changes: 161 additions & 0 deletions dotcom-rendering/src/lib/useScrollGradients.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { act, renderHook } from '@testing-library/react';
import { useScrollGradients } from './useScrollGradients';

type Metrics = {
scrollHeight: number;
clientHeight: number;
scrollTop: number;
};

/**
* jsdom does no layout, so the scroll metrics are stubbed on a plain element
* and can be changed between events.
*/
const setup = (initial: Metrics) => {
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],
);
});
});
46 changes: 46 additions & 0 deletions dotcom-rendering/src/lib/useScrollGradients.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>,
): { 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 };
};