diff --git a/.changeset/smart-lamps-dance.md b/.changeset/smart-lamps-dance.md new file mode 100644 index 000000000000..b6a11bf7defa --- /dev/null +++ b/.changeset/smart-lamps-dance.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: support SvelteKit scroll management in app-owned scroll containers diff --git a/documentation/docs/30-advanced/30-link-options.md b/documentation/docs/30-advanced/30-link-options.md index d683e1ec4f62..56e6a0d36827 100644 --- a/documentation/docs/30-advanced/30-link-options.md +++ b/documentation/docs/30-advanced/30-link-options.md @@ -100,6 +100,18 @@ The attribute can also be used on a `
` — for example a sear In general, avoid preserving focus on links, since the focused element would be the `` tag (and not a previously focused element) and screen reader and other assistive technology users often expect focus to be moved after a navigation. You should also only use this attribute on elements that still exist after navigation. If the element no longer exists, the user's focus will be lost, making for a confusing experience for assistive technology users. +## data-sveltekit-scroll-container + +SvelteKit applies its scroll management to the window by default. If your app keeps the document fixed and scrolls inside an app-owned container instead, mark the scrollable element that should own route scroll state with `data-sveltekit-scroll-container`: + +```svelte +
+ +
+``` + +SvelteKit will read and restore that element's `scrollLeft` and `scrollTop` when navigating between routes. Unlike the link options above, this attribute applies to the scrollable app shell element itself. Use a single scroll container for the app shell; if multiple elements have the attribute, the first one in the document whose value is not `"false"` is used. + ## Disabling options To disable any of these options inside an element where they have been enabled, use the `"false"` value: diff --git a/packages/kit/src/core/sync/write_app_types.js b/packages/kit/src/core/sync/write_app_types.js index c77c514abb1b..2695c208d2cb 100644 --- a/packages/kit/src/core/sync/write_app_types.js +++ b/packages/kit/src/core/sync/write_app_types.js @@ -94,6 +94,7 @@ declare module "svelte/elements" { 'data-sveltekit-reload'?: true | false | '' | undefined | null; 'data-sveltekit-replacestate'?: true | false | '' | undefined | null; 'data-sveltekit-reset'?: true | false | '' | undefined | null; + 'data-sveltekit-scroll-container'?: true | false | '' | undefined | null; } } `; diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 4fdba71201ef..1f3ad66fcf6b 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -20,6 +20,7 @@ import { get_router_options, is_external_url, origin, + scroll_to, scroll_state, load_css } from './utils.js'; @@ -193,11 +194,11 @@ function reset_scroll_and_focus(url, scroll, reset, active_element) { if (autoscroll) { if (scroll) { - scrollTo(scroll.x, scroll.y); + scroll_to(scroll.x, scroll.y); } else if ((deep_linked = get_hash_element(url))) { deep_linked.scrollIntoView(); } else { - scrollTo(0, 0); + scroll_to(0, 0); } } @@ -569,7 +570,7 @@ async function _start(_app, _target, data) { function restore_scroll() { if (scroll) { history.scrollRestoration = 'manual'; - scrollTo(scroll.x, scroll.y); + scroll_to(scroll.x, scroll.y); } } @@ -3303,7 +3304,7 @@ function _start_router() { // /#top and click on a link that goes to /#top. In those cases just go to // the top of the page, and avoid a history change. if (hash === '' || (hash === 'top' && a.ownerDocument.getElementById('top') === null)) { - scrollTo({ top: 0 }); + scroll_to({ top: 0 }); } else { const element = a.ownerDocument.getElementById(decodeURIComponent(hash)); if (element) { @@ -3462,7 +3463,7 @@ function _start_router() { capture_navigation_snapshot(current_history_index); current_history_index = history_index; current_reset_index = reset_index; - if (reset && scroll) scrollTo(scroll.x, scroll.y); + if (reset && scroll) scroll_to(scroll.x, scroll.y); restore_navigation_snapshot(current_history_index, current_registrations()); return; } @@ -3830,7 +3831,7 @@ function reset_focus(url, scroll = true) { // If scroll management has already happened earlier, we need to restore // the scroll position after setting the sequential focus navigation starting point - if (scroll) scrollTo(x, y); + if (scroll) scroll_to(x, y); resetting_focus = false; }); } else { diff --git a/packages/kit/src/runtime/client/utils.js b/packages/kit/src/runtime/client/utils.js index 0e280d36505d..810ab63fec58 100644 --- a/packages/kit/src/runtime/client/utils.js +++ b/packages/kit/src/runtime/client/utils.js @@ -17,13 +17,41 @@ export function resolve_url(url) { return new URL(url, baseURI); } +function get_scroll_container() { + return /** @type {HTMLElement | null} */ ( + document.querySelector( + '[data-sveltekit-scroll-container]:not([data-sveltekit-scroll-container="false"])' + ) + ); +} + export function scroll_state() { + const scroll_container = get_scroll_container(); + + if (scroll_container) { + return { + x: scroll_container.scrollLeft, + y: scroll_container.scrollTop + }; + } + return { x: pageXOffset, y: pageYOffset }; } +/** + * @param {number | ScrollToOptions} x + * @param {number} [y] + */ +export function scroll_to(x, y) { + const scroll_container = get_scroll_container(); + const scroll = scroll_container ?? window; + if (typeof x === 'number') scroll.scrollTo(x, y ?? 0); + else scroll.scrollTo(x); +} + const warned = new WeakSet(); /** @typedef {keyof typeof valid_link_options} LinkOptionName */ diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte new file mode 100644 index 000000000000..d1fc9a5a036b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte @@ -0,0 +1,29 @@ +
+ + +
+ +
+
+ + diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte new file mode 100644 index 000000000000..d9717dad6a80 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte @@ -0,0 +1,10 @@ +

custom container a

+b +
+

bottom a

+ + diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte new file mode 100644 index 000000000000..ffd88679e0ac --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte @@ -0,0 +1,10 @@ +

custom container b

+a +
+

bottom b

+ + diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js index 923240d4a6f2..c83235246ce9 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js @@ -544,6 +544,42 @@ test.describe('Scrolling', () => { expect(await page.evaluate(() => scrollY)).toBe(0); }); + test('no-anchor url will scroll app-owned container to top when navigated from scrolled page', async ({ + page, + clicknav + }) => { + await page.goto('/scroll/custom-container/a'); + const scroll_container = page.locator('#scroll-container'); + + await scroll_container.evaluate((node) => node.scrollTo(0, node.scrollHeight)); + await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + + await clicknav('nav [href="/scroll/custom-container/b"]'); + await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBe(0); + }); + + test('app-owned container scroll is restored after hitting the back button', async ({ + page, + clicknav + }) => { + await page.goto('/scroll/custom-container/a'); + const scroll_container = page.locator('#scroll-container'); + + await scroll_container.evaluate((node) => node.scrollTo(0, 300)); + await expect + .poll(() => scroll_container.evaluate((node) => node.scrollTop)) + .toBeGreaterThan(250); + + await clicknav('nav [href="/scroll/custom-container/b"]'); + await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBe(0); + + await page.goBack(); + await page.waitForURL('/scroll/custom-container/a'); + await expect + .poll(() => scroll_container.evaluate((node) => node.scrollTop)) + .toBeGreaterThan(250); + }); + test('scroll is restored after hitting the back button', async ({ clicknav, page }) => { await page.goto('/anchor'); await page.locator('#scroll-anchor').click();