Restore horizontal scroll position on history navigation (view transitions) (#8505)

This commit is contained in:
Martin Trapp 2023-09-12 17:37:28 +02:00 committed by GitHub
parent 4105491732
commit 2db9762eb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 74 additions and 11 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Restore horizontal scroll position on history navigation (view transitions)

View file

@ -15,6 +15,7 @@ const { fallback = 'animate' } = Astro.props as Props;
type Direction = 'forward' | 'back'; type Direction = 'forward' | 'back';
type State = { type State = {
index: number; index: number;
scrollX: number;
scrollY: number; scrollY: number;
}; };
type Events = 'astro:page-load' | 'astro:after-swap'; type Events = 'astro:page-load' | 'astro:after-swap';
@ -37,9 +38,9 @@ const { fallback = 'animate' } = Astro.props as Props;
// we reloaded a page with history state // we reloaded a page with history state
// (e.g. history navigation from non-transition page or browser reload) // (e.g. history navigation from non-transition page or browser reload)
currentHistoryIndex = history.state.index; currentHistoryIndex = history.state.index;
scrollTo({ left: 0, top: history.state.scrollY }); scrollTo({ left: history.state.scrollX, top: history.state.scrollY });
} else if (transitionEnabledOnThisPage()) { } else if (transitionEnabledOnThisPage()) {
history.replaceState({ index: currentHistoryIndex, scrollY }, ''); history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, '');
} }
const throttle = (cb: (...args: any[]) => any, delay: number) => { const throttle = (cb: (...args: any[]) => any, delay: number) => {
let wait = false; let wait = false;
@ -208,17 +209,29 @@ const { fallback = 'animate' } = Astro.props as Props;
// Chromium based browsers (Chrome, Edge, Opera, ...) // Chromium based browsers (Chrome, Edge, Opera, ...)
scrollTo({ left: 0, top: 0, behavior: 'instant' }); scrollTo({ left: 0, top: 0, behavior: 'instant' });
let initialScrollX = 0;
let initialScrollY = 0; let initialScrollY = 0;
if (!state && loc.hash) { if (!state && loc.hash) {
const id = decodeURIComponent(loc.hash.slice(1)); const id = decodeURIComponent(loc.hash.slice(1));
const elem = document.getElementById(id); const elem = document.getElementById(id);
// prefer scrollIntoView() over scrollTo() because it takes scroll-padding into account // prefer scrollIntoView() over scrollTo() because it takes scroll-padding into account
elem && (initialScrollY = elem.offsetTop) && elem.scrollIntoView(); if (elem) {
} else if (state && state.scrollY !== 0) { elem.scrollIntoView();
scrollTo(0, state.scrollY); // usings default scrollBehavior initialScrollX = Math.max(
0,
elem.offsetLeft + elem.offsetWidth - document.documentElement.clientWidth
);
initialScrollY = elem.offsetTop;
}
} else if (state) {
scrollTo(state.scrollX, state.scrollY); // usings default scrollBehavior
} }
!state && !state &&
history.pushState({ index: ++currentHistoryIndex, scrollY: initialScrollY }, '', loc.href); history.pushState(
{ index: ++currentHistoryIndex, scrollX: initialScrollX, scrollY: initialScrollY },
'',
loc.href
);
triggerEvent('astro:after-swap'); triggerEvent('astro:after-swap');
}; };
@ -280,7 +293,7 @@ const { fallback = 'animate' } = Astro.props as Props;
} }
// Now we are sure that we will push state, and it is time to create a state if it is still missing. // Now we are sure that we will push state, and it is time to create a state if it is still missing.
!state && history.replaceState({ index: currentHistoryIndex, scrollY }, ''); !state && history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, '');
document.documentElement.dataset.astroTransition = dir; document.documentElement.dataset.astroTransition = dir;
if (supportsViewTransitions) { if (supportsViewTransitions) {
@ -357,8 +370,11 @@ const { fallback = 'animate' } = Astro.props as Props;
ev.preventDefault(); ev.preventDefault();
// push state on the first navigation but not if we were here already // push state on the first navigation but not if we were here already
if (location.hash) { if (location.hash) {
history.replaceState({ index: currentHistoryIndex, scrollY: -(scrollY + 1) }, ''); history.replaceState(
const newState: State = { index: ++currentHistoryIndex, scrollY: 0 }; { index: currentHistoryIndex, scrollX, scrollY: -(scrollY + 1) },
''
);
const newState: State = { index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 };
history.pushState(newState, '', link.href); history.pushState(newState, '', link.href);
} }
scrollTo({ left: 0, top: 0, behavior: 'instant' }); scrollTo({ left: 0, top: 0, behavior: 'instant' });
@ -404,7 +420,7 @@ const { fallback = 'animate' } = Astro.props as Props;
const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back'; const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back';
currentHistoryIndex = nextIndex; currentHistoryIndex = nextIndex;
if (state.scrollY < 0) { if (state.scrollY < 0) {
scrollTo(0, -(state.scrollY + 1)); scrollTo(state.scrollX, -(state.scrollY + 1));
} else { } else {
navigate(direction, new URL(location.href), state); navigate(direction, new URL(location.href), state);
} }
@ -432,7 +448,7 @@ const { fallback = 'animate' } = Astro.props as Props;
// There's not a good way to record scroll position before a back button. // There's not a good way to record scroll position before a back button.
// So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position. // So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position.
const updateState = () => { const updateState = () => {
persistState({ ...history.state, scrollY }); persistState({ ...history.state, scrollX, scrollY });
}; };
if ('onscrollend' in window) addEventListener('scrollend', updateState); if ('onscrollend' in window) addEventListener('scrollend', updateState);

View file

@ -0,0 +1,12 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<a id="click-right" href="#click-one">go right</a>
<article id="widepage">
<div style="width:300vw; text-align: center">
<a id="click-top" href="#click-right">go to top</a> |
<a id="click-one" href="/one">go to 1</a>
</div>
</article>
</Layout>

View file

@ -585,4 +585,34 @@ test.describe('View Transitions', () => {
styles = await page.locator('style').all(); styles = await page.locator('style').all();
expect(styles.length).toEqual(totalExpectedStyles, 'style count has not changed'); expect(styles.length).toEqual(totalExpectedStyles, 'style count has not changed');
}); });
test('Horizontal scroll position restored on back button', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/wide-page'));
let article = page.locator('#widepage');
await expect(article, 'should have script content').toBeVisible('exists');
let locator = page.locator('#click-one');
await expect(locator).not.toBeInViewport();
await page.click('#click-right');
locator = page.locator('#click-one');
await expect(locator).toBeInViewport();
locator = page.locator('#click-top');
await expect(locator).toBeInViewport();
await page.click('#click-one');
let p = page.locator('#one');
await expect(p, 'should have content').toHaveText('Page 1');
await page.goBack();
locator = page.locator('#click-one');
await expect(locator).toBeInViewport();
locator = page.locator('#click-top');
await expect(locator).toBeInViewport();
await page.click('#click-top');
locator = page.locator('#click-one');
await expect(locator).not.toBeInViewport();
});
}); });