-
Notifications
You must be signed in to change notification settings - Fork 14
fix: sidebar menu collapse/expand behaviour #435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
niallobrien
wants to merge
7
commits into
main
Choose a base branch
from
fix/web-365-sidebar-navigation-bug-in-docs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
09c0244
feat: normalize menu path matching and expand logic
niallobrien 1d5e89d
Merge remote-tracking branch 'origin/main' into fix/web-365-sidebar-n…
niallobrien f066253
feat: add robust hash-based scroll and sidenav interaction
niallobrien 152829f
refactor: improve active menu ancestor resolution and hash scroll
niallobrien 39f85e0
chore: add copyright headers and clean up imports
niallobrien 73d51ff
fix: correct import order and adjust JSX formatting in Sidenav component
niallobrien 6397b2d
fix: specify window object for animation frame and timeout functions …
niallobrien File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| /** @jest-environment jsdom */ | ||
|
|
||
| import { scheduleDeferredScrollToTop, scheduleHashScroll, scheduleScrollToTop } from './hashScroll'; | ||
|
|
||
| describe('scheduleHashScroll', () => { | ||
| beforeEach(() => { | ||
| jest.useFakeTimers(); | ||
| document.body.innerHTML = ''; | ||
| jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { | ||
| callback(0); | ||
| return 0; | ||
| }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| jest.useRealTimers(); | ||
| }); | ||
|
|
||
| test('skips post-layout retry when the target is missing', () => { | ||
| const scrollIntoView = jest.fn(); | ||
| const target = document.createElement('h2'); | ||
| target.id = 'developer-tools'; | ||
| target.scrollIntoView = scrollIntoView; | ||
| document.body.appendChild(target); | ||
|
|
||
| scheduleHashScroll('#missing'); | ||
| jest.runAllTicks(); | ||
| jest.advanceTimersByTime(350); | ||
|
|
||
| expect(scrollIntoView).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('scheduleScrollToTop scrolls to the document top with a post-layout retry', () => { | ||
| const scrollTo = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); | ||
|
|
||
| scheduleScrollToTop(); | ||
| jest.runAllTicks(); | ||
| expect(scrollTo).toHaveBeenCalled(); | ||
|
|
||
| const callsAfterFrames = scrollTo.mock.calls.length; | ||
| jest.advanceTimersByTime(350); | ||
| expect(scrollTo.mock.calls.length).toBeGreaterThan(callsAfterFrames); | ||
|
|
||
| scrollTo.mockRestore(); | ||
| }); | ||
|
|
||
| test('scheduleDeferredScrollToTop respects hash presence after the frame', () => { | ||
| const scrollTo = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); | ||
|
|
||
| scheduleDeferredScrollToTop(() => true); | ||
| jest.runAllTicks(); | ||
| expect(scrollTo).not.toHaveBeenCalled(); | ||
|
|
||
| scheduleDeferredScrollToTop(() => false); | ||
| jest.runAllTicks(); | ||
| expect(scrollTo).toHaveBeenCalled(); | ||
|
|
||
| scrollTo.mockRestore(); | ||
| }); | ||
|
|
||
| test('cancels a scheduled post-layout retry', () => { | ||
| const scrollIntoView = jest.fn(); | ||
| const target = document.createElement('h2'); | ||
| target.id = 'developer-tools'; | ||
| target.scrollIntoView = scrollIntoView; | ||
| document.body.appendChild(target); | ||
|
|
||
| const cancel = scheduleHashScroll('#developer-tools'); | ||
| jest.runAllTicks(); | ||
|
|
||
| const callsAfterFrames = scrollIntoView.mock.calls.length; | ||
| expect(callsAfterFrames).toBeGreaterThanOrEqual(1); | ||
|
|
||
| cancel(); | ||
| jest.advanceTimersByTime(350); | ||
|
|
||
| expect(scrollIntoView).toHaveBeenCalledTimes(callsAfterFrames); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| // Copyright 2026 Cloudsmith Ltd | ||
|
|
||
| const FRAME_RETRIES = 5; | ||
| /** Match Sidenav `openCloseTransition.duration` (0.35s). */ | ||
| const POST_LAYOUT_MS = 350; | ||
|
|
||
| const withInstantScroll = (scroll: () => void) => { | ||
| const { documentElement } = document; | ||
| const previousScrollBehavior = documentElement.style.scrollBehavior; | ||
| documentElement.style.scrollBehavior = 'auto'; | ||
|
|
||
| scroll(); | ||
|
|
||
| documentElement.style.scrollBehavior = previousScrollBehavior; | ||
| }; | ||
|
|
||
| export const scrollToDocumentTop = () => { | ||
| withInstantScroll(() => { | ||
| window.scrollTo(0, 0); | ||
| }); | ||
| }; | ||
|
|
||
| export const scrollToHashTarget = (hash = window.location.hash) => { | ||
| const normalizedHash = hash.replace(/^#/, ''); | ||
| if (!normalizedHash) return false; | ||
|
|
||
| let targetId = normalizedHash; | ||
| try { | ||
| targetId = decodeURIComponent(normalizedHash); | ||
| } catch { | ||
| targetId = normalizedHash; | ||
| } | ||
|
|
||
| const target = document.getElementById(targetId); | ||
| if (!target) return false; | ||
|
|
||
| withInstantScroll(() => { | ||
| target.scrollIntoView({ block: 'start' }); | ||
| }); | ||
|
|
||
| return true; | ||
| }; | ||
|
|
||
| export type CancelHashScroll = () => void; | ||
|
|
||
| type PostLayoutRetry = 'always' | 'onAttemptSuccess'; | ||
|
|
||
| /** | ||
| * Runs `attempt` across animation frames and optionally after sidenav layout animations. | ||
| * Returns a cancel function; call it when the hash or route changes before pending work runs. | ||
| */ | ||
| const scheduleScrollRetries = (attempt: () => boolean, postLayout: PostLayoutRetry): CancelHashScroll => { | ||
| let cancelled = false; | ||
| let rafId = 0; | ||
| let timeoutId = 0; | ||
|
|
||
| const cancel = () => { | ||
| cancelled = true; | ||
| if (rafId) { | ||
| window.cancelAnimationFrame(rafId); | ||
| rafId = 0; | ||
| } | ||
| if (timeoutId) { | ||
| window.clearTimeout(timeoutId); | ||
| timeoutId = 0; | ||
| } | ||
|
niallobrien marked this conversation as resolved.
|
||
| }; | ||
|
|
||
| const runAttempt = () => { | ||
| if (cancelled) { | ||
| return false; | ||
| } | ||
|
|
||
| return attempt(); | ||
| }; | ||
|
|
||
| const schedulePostLayoutRetry = () => { | ||
| if (cancelled || timeoutId) { | ||
| return; | ||
| } | ||
|
|
||
| timeoutId = window.setTimeout(() => { | ||
| timeoutId = 0; | ||
| runAttempt(); | ||
| }, POST_LAYOUT_MS); | ||
| }; | ||
|
|
||
| queueMicrotask(() => { | ||
| if (cancelled) { | ||
| return; | ||
| } | ||
|
|
||
| const retryFrames = (remaining: number) => { | ||
| if (cancelled) { | ||
| return; | ||
| } | ||
|
|
||
| const succeeded = runAttempt(); | ||
|
|
||
| if (postLayout === 'onAttemptSuccess' && succeeded) { | ||
| schedulePostLayoutRetry(); | ||
| return; | ||
| } | ||
|
|
||
| if (remaining > 0) { | ||
| rafId = window.requestAnimationFrame(() => retryFrames(remaining - 1)); | ||
| return; | ||
|
niallobrien marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if (postLayout === 'always') { | ||
| schedulePostLayoutRetry(); | ||
| } | ||
| }; | ||
|
|
||
| retryFrames(FRAME_RETRIES); | ||
| }); | ||
|
|
||
| return cancel; | ||
| }; | ||
|
|
||
| /** | ||
| * Scrolls to the document top, retrying across frames and after sidenav layout animations. | ||
| * Use when the hash is cleared on the current page (e.g. switching hash-anchor menu sections). | ||
| */ | ||
| export const scheduleScrollToTop = (): CancelHashScroll => { | ||
| return scheduleScrollRetries(() => { | ||
| scrollToDocumentTop(); | ||
| return true; | ||
| }, 'always'); | ||
| }; | ||
|
|
||
| /** | ||
| * Scrolls to the hash target, retrying across frames and after sidenav layout animations. | ||
| * Sidenav expand/collapse can reflow the page after the first scroll attempt. | ||
| * | ||
| * A post-layout retry runs only when a frame retry finds the target (to correct scroll | ||
| * position after the branch animation). If the target is never found, no delayed retry runs. | ||
| */ | ||
| export const scheduleHashScroll = (hash = window.location.hash): CancelHashScroll => { | ||
| if (!hash) { | ||
| return () => {}; | ||
| } | ||
|
|
||
| return scheduleScrollRetries(() => scrollToHashTarget(hash), 'onAttemptSuccess'); | ||
| }; | ||
|
|
||
| /** | ||
| * Defers scroll-to-top by one frame so a hash applied in the same navigation tick | ||
| * (e.g. `router.push('/page#section')`) is not overridden. | ||
| */ | ||
| export const scheduleDeferredScrollToTop = ( | ||
| hasWindowHash: () => boolean = () => Boolean(window.location.hash), | ||
| ): CancelHashScroll => { | ||
| let cancelled = false; | ||
| let cancelScroll: CancelHashScroll | undefined; | ||
| const rafId = window.requestAnimationFrame(() => { | ||
| if (cancelled || hasWindowHash()) { | ||
| return; | ||
| } | ||
|
|
||
| cancelScroll = scheduleScrollToTop(); | ||
| }); | ||
|
|
||
| return () => { | ||
| cancelled = true; | ||
| window.cancelAnimationFrame(rafId); | ||
| cancelScroll?.(); | ||
|
niallobrien marked this conversation as resolved.
|
||
| }; | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.