diff --git a/src/app/hooks/useScrollToLinkable/index.test.ts b/src/app/hooks/useScrollToLinkable/index.test.ts new file mode 100644 index 00000000000..9eca3418e1f --- /dev/null +++ b/src/app/hooks/useScrollToLinkable/index.test.ts @@ -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(); + }); +}); diff --git a/src/app/hooks/useScrollToLinkable/index.ts b/src/app/hooks/useScrollToLinkable/index.ts new file mode 100644 index 00000000000..02a4838e09f --- /dev/null +++ b/src/app/hooks/useScrollToLinkable/index.ts @@ -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; diff --git a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx index 71d8f0a4669..a707674f6a6 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx @@ -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'); @@ -192,6 +193,7 @@ const LivePage = ({ pageData, assetId }: LivePageProps) => { setIsFirstPostVisible={setIsFirstPostVisible} streamRef={streamRef} applyPendingUpdate={applyPendingUpdate} + {...(postToScrollTo && { postToScrollTo })} /> >; streamRef: ForwardedRef; applyPendingUpdate: () => void; + postToScrollTo?: string | null; }; const Stream = ({ @@ -28,6 +30,7 @@ const Stream = ({ setIsFirstPostVisible, streamRef, applyPendingUpdate, + postToScrollTo, }: Props) => { const { translations: { @@ -35,6 +38,11 @@ const Stream = ({ }, } = use(ServiceContext); + useScrollToLinkable({ + elementId: postToScrollTo, + isReducedMotion: true, + }); + const firstPostRef = useRef(null); const [hasShareApi, setHasShareApi] = useState(false); const [hashValue, setHashValue] = useState(''); diff --git a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx index 4b9e21b5a79..ad90bb3fafe 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx @@ -111,6 +111,7 @@ export const getServerSideProps: GetServerSideProps = async context => { status: data.status, timeOnServer: Date.now(), // TODO: check if needed? variant, + postToScrollTo: assetId || null, }, }; };