Skip to content
Draft
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
106 changes: 106 additions & 0 deletions src/app/hooks/useScrollToLinkable/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
act,
renderHook,
} from '#app/components/react-testing-library-with-providers';
import useScrollToLinkable from '.';

const createElementWithId = id => {
const element = document.createElement('article');
element.setAttribute('id', id);
document.body.appendChild(element);

return element;
};

describe('useScrollToLinkable', () => {
const scrollIntoViewMock = jest.fn();
const focusMock = jest.fn();

beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
document.body.innerHTML = '';

window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
window.HTMLElement.prototype.focus = focusMock;
});

afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});

it('should set the element as focusable and focus it after scrolling', () => {
const element = createElementWithId('post-123');

const { result } = renderHook(() =>
useScrollToLinkable({ elementId: 'post-123', isReducedMotion: true }),
);

act(() => {
jest.advanceTimersByTime(1800);
});

expect(element.tabIndex).toBe(-1);
expect(focusMock).toHaveBeenCalledTimes(1);
expect(result.current.hasScrolled.current).toBe(true);
});

it('should scroll to the provided element with smooth behavior by default', () => {
createElementWithId('post-123');

renderHook(() =>
// @ts-expect-error - initentional missing prop for this test
useScrollToLinkable({ elementId: 'post-123' }),
);

expect(scrollIntoViewMock).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(800);
});

expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' });
});

it('should use auto behavior when reduced motion is enabled', () => {
createElementWithId('post-123');

renderHook(() =>
useScrollToLinkable({ elementId: 'post-123', isReducedMotion: true }),
);

act(() => {
jest.advanceTimersByTime(800);
});

expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'auto' });
});

it('should not scroll when elementId is not provided', () => {
// @ts-expect-error - initentional missing elementId for this test
renderHook(() => useScrollToLinkable({}));

act(() => {
jest.runAllTimers();
});

expect(scrollIntoViewMock).not.toHaveBeenCalled();
expect(focusMock).not.toHaveBeenCalled();
});

it('should not scroll when the element is not in the DOM', () => {
renderHook(() =>
useScrollToLinkable({ elementId: 'missing-id', isReducedMotion: true }),
);

act(() => {
jest.runAllTimers();
});

expect(scrollIntoViewMock).not.toHaveBeenCalled();
expect(focusMock).not.toHaveBeenCalled();
});
});
40 changes: 40 additions & 0 deletions src/app/hooks/useScrollToLinkable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react';

const scrollIntoView = (componentToScrollTo, hasScrolled, isReducedMotion) => {
if (componentToScrollTo && !hasScrolled.current) {
componentToScrollTo.scrollIntoView({
behavior: isReducedMotion ? 'auto' : 'smooth',
});
setTimeout(() => {
// eslint-disable-next-line no-param-reassign
componentToScrollTo.tabIndex = '-1';
componentToScrollTo.focus();
// eslint-disable-next-line no-param-reassign
hasScrolled.current = true;
}, 1000);
}
};

const useScrollToLinkable = ({ elementId, isReducedMotion }) => {
const hasScrolled = useRef(null);

useEffect(() => {
hasScrolled.current = null;
}, [elementId]);

useEffect(() => {
let timer;

if (elementId) {
timer = setTimeout(() => {
const component = document.getElementById(elementId);

scrollIntoView(component, hasScrolled, isReducedMotion);
}, 800);
}
return () => clearTimeout(timer);
}, [elementId, hasScrolled, isReducedMotion]);

return { hasScrolled };
};
export default useScrollToLinkable;
4 changes: 3 additions & 1 deletion ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ export type ComponentProps = {

interface LivePageProps extends ComponentProps {
assetId?: string | null;
postToScrollTo?: string | null;
}

const LivePage = ({ pageData, assetId }: LivePageProps) => {
const LivePage = ({ pageData, assetId, postToScrollTo }: LivePageProps) => {
const { lang, translations, defaultImage, brandName } = use(ServiceContext);
const { canonicalNonUkLink } = use(RequestContext);
const { enabled: livePagePollingEnabled } = useToggle('livePagePolling');
Expand Down Expand Up @@ -192,6 +193,7 @@ const LivePage = ({ pageData, assetId }: LivePageProps) => {
setIsFirstPostVisible={setIsFirstPostVisible}
streamRef={streamRef}
applyPendingUpdate={applyPendingUpdate}
{...(postToScrollTo && { postToScrollTo })}
/>
<LatestPostButton
isFirstPostVisible={isFirstPostVisible}
Expand Down
8 changes: 8 additions & 0 deletions ws-nextjs-app/pages/[service]/live/[id]/Stream/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import Heading from '#app/components/Heading';
import Paragraph from '#app/components/Paragraph';
import { ServiceContext } from '#contexts/ServiceContext';
import useScrollToLinkable from '#app/hooks/useScrollToLinkable';
import { StreamResponse } from '../Post/types';
import Post from '../Post';
import styles from './styles';
Expand All @@ -20,6 +21,7 @@ type Props = {
setIsFirstPostVisible: Dispatch<SetStateAction<boolean>>;
streamRef: ForwardedRef<HTMLDivElement>;
applyPendingUpdate: () => void;
postToScrollTo?: string | null;
};

const Stream = ({
Expand All @@ -28,13 +30,19 @@ const Stream = ({
setIsFirstPostVisible,
streamRef,
applyPendingUpdate,
postToScrollTo,
}: Props) => {
const {
translations: {
liveExperiencePage: { liveCoverage = 'Live Coverage' },
},
} = use(ServiceContext);

useScrollToLinkable({
elementId: postToScrollTo,
isReducedMotion: true,
});

const firstPostRef = useRef<HTMLLIElement>(null);
const [hasShareApi, setHasShareApi] = useState(false);
const [hashValue, setHashValue] = useState('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const getServerSideProps: GetServerSideProps = async context => {
status: data.status,
timeOnServer: Date.now(), // TODO: check if needed?
variant,
postToScrollTo: assetId || null,
},
};
};
Expand Down
Loading