From f125c36c8068a8d244364d05b7767778f2d26685 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 19 Mar 2026 10:53:08 +0100 Subject: [PATCH 1/2] Prevent deep linked content from being hidden behind navigation Apply scroll margin based on widget margin --- entry_types/scrolled/package/src/frontend/Chapter.js | 4 +++- entry_types/scrolled/package/src/frontend/Chapter.module.css | 3 +++ entry_types/scrolled/package/src/frontend/Section.module.css | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/package/src/frontend/Chapter.module.css diff --git a/entry_types/scrolled/package/src/frontend/Chapter.js b/entry_types/scrolled/package/src/frontend/Chapter.js index b7d9dd9e5e..49996ac040 100644 --- a/entry_types/scrolled/package/src/frontend/Chapter.js +++ b/entry_types/scrolled/package/src/frontend/Chapter.js @@ -3,9 +3,11 @@ import React from 'react'; import {Section} from './Section'; import {EventContextDataProvider} from './useEventContextData'; +import styles from './Chapter.module.css'; + export default function Chapter(props) { return ( -
+
{renderSections(props.sections, props.currentSectionIndex, props.setCurrentSection)} diff --git a/entry_types/scrolled/package/src/frontend/Chapter.module.css b/entry_types/scrolled/package/src/frontend/Chapter.module.css new file mode 100644 index 0000000000..f2a8eee755 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/Chapter.module.css @@ -0,0 +1,3 @@ +.wrapper { + scroll-margin-top: var(--widget-margin-top); +} diff --git a/entry_types/scrolled/package/src/frontend/Section.module.css b/entry_types/scrolled/package/src/frontend/Section.module.css index 2d5f47bd5b..e01376c086 100644 --- a/entry_types/scrolled/package/src/frontend/Section.module.css +++ b/entry_types/scrolled/package/src/frontend/Section.module.css @@ -7,6 +7,7 @@ .Section { position: relative; + scroll-margin-top: var(--widget-margin-top); --section-max-width: var(--theme-section-max-width); From e943265637c6890336dd743e693a2bab93e1b865 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 19 Mar 2026 12:22:45 +0100 Subject: [PATCH 2/2] Prevent nav from collapsing after menu click When users click a chapter link in the mobile menu, the navigation bar immediately collapses due to the scroll triggered by jumping to the chapter. Keep the nav expanded make it easier to click through chapters. Also ensure scroll margin matches height of the navigation bar. --- .../DefaultNavigationPresenceProvider-spec.js | 136 ++++++++++++++---- .../defaultNavigation/DefaultNavigation.js | 3 +- .../DefaultNavigationPresenceProvider.js | 13 +- .../defaultNavigation/useTimeoutFlag.js | 20 +++ 4 files changed, 142 insertions(+), 30 deletions(-) create mode 100644 entry_types/scrolled/package/src/widgets/defaultNavigation/useTimeoutFlag.js diff --git a/entry_types/scrolled/package/spec/widgets/defaultNavigation/DefaultNavigationPresenceProvider-spec.js b/entry_types/scrolled/package/spec/widgets/defaultNavigation/DefaultNavigationPresenceProvider-spec.js index fede12920a..ae48ef61b8 100644 --- a/entry_types/scrolled/package/spec/widgets/defaultNavigation/DefaultNavigationPresenceProvider-spec.js +++ b/entry_types/scrolled/package/spec/widgets/defaultNavigation/DefaultNavigationPresenceProvider-spec.js @@ -1,6 +1,9 @@ import React from 'react'; -import {DefaultNavigationPresenceProvider} from 'widgets/defaultNavigation/DefaultNavigationPresenceProvider'; +import { + DefaultNavigationPresenceProvider, + useDefaultNavigationState +} from 'widgets/defaultNavigation/DefaultNavigationPresenceProvider'; import styles from 'widgets/defaultNavigation/presenceClassNames.module.css'; @@ -8,8 +11,14 @@ import {renderInEntry} from 'pageflow-scrolled/testHelpers'; import {act} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; +function LockNavButton() { + const {lockNavExpanded} = useDefaultNavigationState(); + return ; +} + describe('DefaultNavigationPresenceProvider', () => { afterEach(() => jest.restoreAllMocks()); + it('renders wrapper with class setting --widget-margin-top-max by default', () => { const {container} = renderInEntry( @@ -66,41 +75,114 @@ describe('DefaultNavigationPresenceProvider', () => { expect(container.firstChild).toHaveClass(styles.expanded); }); - it('toggles expanded class based on scroll direction', () => { - Object.defineProperty(window, 'scrollY', { - writable: true, - value: 0 + describe('scroll direction', () => { + beforeEach(() => { + jest.useFakeTimers(); + + Object.defineProperty(window, 'scrollY', { + writable: true, + value: 0 + }); + + jest.spyOn(document.body, 'getBoundingClientRect').mockImplementation(() => ({ + top: -window.scrollY, + left: 0, + right: 1024, + bottom: 768 - window.scrollY, + width: 1024, + height: 768 + })); }); - jest.spyOn(document.body, 'getBoundingClientRect').mockImplementation(() => ({ - top: -window.scrollY, - left: 0, - right: 1024, - bottom: 768 - window.scrollY, - width: 1024, - height: 768 - })); + afterEach(() => { + jest.useRealTimers(); + }); - const {container} = renderInEntry( - -
- - ); + it('toggles expanded class based on scroll direction', () => { + const {container} = renderInEntry( + +
+ + ); - expect(container.firstChild).toHaveClass(styles.expanded); + expect(container.firstChild).toHaveClass(styles.expanded); + + act(() => { + window.scrollY = 100; + window.dispatchEvent(new Event('scroll')); + }); + + expect(container.firstChild).not.toHaveClass(styles.expanded); + + act(() => { + window.scrollY = 50; + window.dispatchEvent(new Event('scroll')); + }); + + expect(container.firstChild).toHaveClass(styles.expanded); + }); - act(() => { - window.scrollY = 100; - window.dispatchEvent(new Event('scroll')); + it('stays expanded during scroll lock', () => { + const {container, getByText} = renderInEntry( + + + + ); + + act(() => { + getByText('Lock').click(); + }); + + act(() => { + window.scrollY = 100; + window.dispatchEvent(new Event('scroll')); + }); + + expect(container.firstChild).toHaveClass(styles.expanded); }); - expect(container.firstChild).not.toHaveClass(styles.expanded); + it('resumes collapsing after scroll lock timeout', () => { + const {container, getByText} = renderInEntry( + + + + ); + + act(() => { + getByText('Lock').click(); + }); + + act(() => { + jest.advanceTimersByTime(200); + }); - act(() => { - window.scrollY = 50; - window.dispatchEvent(new Event('scroll')); + act(() => { + window.scrollY = 100; + window.dispatchEvent(new Event('scroll')); + }); + + expect(container.firstChild).not.toHaveClass(styles.expanded); }); - expect(container.firstChild).toHaveClass(styles.expanded); + it('re-expands nav when lockNavExpanded is called while collapsed', () => { + const {container, getByText} = renderInEntry( + + + + ); + + act(() => { + window.scrollY = 100; + window.dispatchEvent(new Event('scroll')); + }); + + expect(container.firstChild).not.toHaveClass(styles.expanded); + + act(() => { + getByText('Lock').click(); + }); + + expect(container.firstChild).toHaveClass(styles.expanded); + }); }); }); diff --git a/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigation.js b/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigation.js index 6f115e35f6..5ffdad1c11 100644 --- a/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigation.js +++ b/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigation.js @@ -34,7 +34,7 @@ export function DefaultNavigation({ logo, omitChapterNavigation }) { - const {navExpanded, setNavExpanded} = useDefaultNavigationState(); + const {navExpanded, setNavExpanded, lockNavExpanded} = useDefaultNavigationState(); const [menuOpen, setMenuOpen] = useState(!!configuration.defaultMobileNavVisible); const [readingProgress, setReadingProgress] = useState(0); @@ -78,6 +78,7 @@ export function DefaultNavigation({ }; function handleMenuClick(chapterLinkId) { + lockNavExpanded(); setMenuOpen(false); }; diff --git a/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigationPresenceProvider.js b/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigationPresenceProvider.js index 34833d4206..db44c2429f 100644 --- a/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigationPresenceProvider.js +++ b/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigationPresenceProvider.js @@ -7,6 +7,7 @@ import { usePhonePlatform } from 'pageflow-scrolled/frontend'; +import {useTimeoutFlag} from './useTimeoutFlag'; import styles from './presenceClassNames.module.css'; const DefaultNavigationContext = createContext({ @@ -21,9 +22,17 @@ export function useDefaultNavigationState() { export function DefaultNavigationPresenceProvider({configuration, children}) { const [navExpanded, setNavExpanded] = useState(true); const isPhonePlatform = usePhonePlatform(); + const [scrollLockRef, activateScrollLock] = useTimeoutFlag(200); + + const lockNavExpanded = useCallback(() => { + activateScrollLock(); + setNavExpanded(true); + }, [activateScrollLock]); useScrollPosition( ({prevPos, currPos}) => { + if (scrollLockRef.current) return; + const expand = currPos.y > prevPos.y || // Mobile Safari reports positive scroll position // during scroll bounce animation when scrolling @@ -51,8 +60,8 @@ export function DefaultNavigationPresenceProvider({configuration, children}) { ); const contextValue = useMemo( - () => ({navExpanded, setNavExpanded}), - [navExpanded] + () => ({navExpanded, setNavExpanded, lockNavExpanded}), + [navExpanded, lockNavExpanded] ); return ( diff --git a/entry_types/scrolled/package/src/widgets/defaultNavigation/useTimeoutFlag.js b/entry_types/scrolled/package/src/widgets/defaultNavigation/useTimeoutFlag.js new file mode 100644 index 0000000000..975163f0f7 --- /dev/null +++ b/entry_types/scrolled/package/src/widgets/defaultNavigation/useTimeoutFlag.js @@ -0,0 +1,20 @@ +import {useCallback, useEffect, useRef} from 'react'; + +export function useTimeoutFlag(duration) { + const flagRef = useRef(false); + const timeoutRef = useRef(null); + + const activate = useCallback(() => { + flagRef.current = true; + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + flagRef.current = false; + }, duration); + }, [duration]); + + useEffect(() => { + return () => clearTimeout(timeoutRef.current); + }, []); + + return [flagRef, activate]; +}