diff --git a/services/web/frontend/js/features/settings/components/root.tsx b/services/web/frontend/js/features/settings/components/root.tsx index 4731ae2dfa..ecd913a8e4 100644 --- a/services/web/frontend/js/features/settings/components/root.tsx +++ b/services/web/frontend/js/features/settings/components/root.tsx @@ -14,10 +14,12 @@ import * as eventTracking from '../../../infrastructure/event-tracking' import { UserProvider } from '../../../shared/context/user-context' import { SSOProvider } from '../context/sso-context' import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n' +import useScrollToIdOnLoad from '../../../shared/hooks/use-scroll-to-id-on-load' import { ExposedSettings } from '../../../../../types/exposed-settings' function SettingsPageRoot() { const { isReady } = useWaitForI18n() + useScrollToIdOnLoad() useEffect(() => { eventTracking.sendMB('settings-view') diff --git a/services/web/frontend/js/shared/hooks/use-scroll-to-id-on-load.ts b/services/web/frontend/js/shared/hooks/use-scroll-to-id-on-load.ts new file mode 100644 index 0000000000..4cd26e6a52 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-scroll-to-id-on-load.ts @@ -0,0 +1,111 @@ +import { useEffect, useState, useRef } from 'react' + +const DEFAULT_TIMEOUT = 3000 +const hash = window.location.hash.substring(1) +const events = ['keydown', 'touchmove', 'wheel'] + +const isKeyboardEvent = (event: Event): event is KeyboardEvent => { + return event.constructor.name === 'KeyboardEvent' +} + +type UseScrollToIdOnLoadProps = { + timeout?: number +} + +function UseScrollToIdOnLoad({ + timeout = DEFAULT_TIMEOUT, +}: UseScrollToIdOnLoadProps = {}) { + const [offsetTop, setOffsetTop] = useState(null) + const requestRef = useRef(null) + const targetRef = useRef(null) + + const cancelAnimationFrame = () => { + if (requestRef.current) { + window.cancelAnimationFrame(requestRef.current) + } + } + + const cancelEventListeners = () => { + events.forEach(eventType => { + window.removeEventListener(eventType, eventListenersCallbackRef.current) + }) + } + + const eventListenersCallback = ( + event: KeyboardEvent | TouchEvent | WheelEvent + ) => { + const keys = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown']) + + if (!isKeyboardEvent(event) || keys.has(event.key)) { + // Remove scroll checks + cancelAnimationFrame() + // Remove event listeners + cancelEventListeners() + } + } + + const eventListenersCallbackRef = useRef(eventListenersCallback) + + // Scroll to the target + useEffect(() => { + if (!offsetTop) { + return + } + + window.scrollTo({ + top: offsetTop, + }) + }, [offsetTop]) + + // Bail out from scrolling automatically in `${timeout}` milliseconds + useEffect(() => { + if (!hash) { + return + } + + setTimeout(() => { + cancelAnimationFrame() + cancelEventListeners() + }, timeout) + }, [timeout]) + + // Scroll to target by recursively looking for the target element + useEffect(() => { + if (!hash) { + return + } + + const offsetTop = () => { + if (targetRef.current) { + setOffsetTop(targetRef.current.offsetTop) + } else { + targetRef.current = document.getElementById(hash) + } + + requestRef.current = window.requestAnimationFrame(offsetTop) + } + + requestRef.current = window.requestAnimationFrame(offsetTop) + + return () => { + cancelAnimationFrame() + } + }, []) + + // Set up the event listeners that will cancel the target element lookup + useEffect(() => { + if (!hash) { + return + } + + events.forEach(eventType => { + window.addEventListener(eventType, eventListenersCallbackRef.current) + }) + + return () => { + cancelEventListeners() + } + }, []) +} + +export default UseScrollToIdOnLoad