Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-lamps-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: support SvelteKit scroll management in app-owned scroll containers
12 changes: 12 additions & 0 deletions documentation/docs/30-advanced/30-link-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ The attribute can also be used on a `<form method="GET">` — for example a sear

In general, avoid preserving focus on links, since the focused element would be the `<a>` 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
<main data-sveltekit-scroll-container>
<slot />
</main>
```

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:
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_app_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
`;
Expand Down
13 changes: 7 additions & 6 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
get_router_options,
is_external_url,
origin,
scroll_to,
scroll_state,
load_css
} from './utils.js';
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions packages/kit/src/runtime/client/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div class="shell" data-sveltekit-scroll-container="false">
<nav>
<a href="/scroll/custom-container/a">a</a>
<a href="/scroll/custom-container/b">b</a>
</nav>

<main id="scroll-container" data-sveltekit-scroll-container>
<slot />
</main>
</div>

<style>
.shell {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
}

nav {
flex: none;
padding: 0.5rem;
}

main {
flex: 1;
overflow: auto;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<h1>custom container a</h1>
<a href="/scroll/custom-container/b">b</a>
<div class="spacer"></div>
<p id="bottom-a">bottom a</p>

<style>
.spacer {
height: 180vh;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<h1>custom container b</h1>
<a href="/scroll/custom-container/a">a</a>
<div class="spacer"></div>
<p id="bottom-b">bottom b</p>

<style>
.spacer {
height: 180vh;
}
</style>
36 changes: 36 additions & 0 deletions packages/kit/test/apps/basics/test/cross-platform/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('INPUT');
});

test('sets focus for valid hash but invalid selector', async ({ page }) => {

Check warning on line 45 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, windows-latest, chromium, build)

flaky test: sets focus for valid hash but invalid selector

retries: 2
await page.goto('/reset-focus#an:invalid+selector');
await expect(page.locator('button')).toBeFocused();
});
Expand Down Expand Up @@ -148,7 +148,7 @@
});

test.describe('Navigation lifecycle functions', () => {
test('beforeNavigate prevents navigation triggered by link click', async ({ page, baseURL }) => {

Check warning on line 151 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, windows-latest, chromium, build)

flaky test: beforeNavigate prevents navigation triggered by link click

retries: 2
await page.goto('/navigation-lifecycle/before-navigate/prevent-navigation');

await page.click('[href="/navigation-lifecycle/before-navigate/a"]');
Expand All @@ -157,7 +157,7 @@
expect(page.url()).toBe(baseURL + '/navigation-lifecycle/before-navigate/prevent-navigation');
});

test('beforeNavigate prevents navigation to external', async ({ page, baseURL }) => {

Check warning on line 160 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, windows-latest, chromium, build)

flaky test: beforeNavigate prevents navigation to external

retries: 2
await page.goto('/navigation-lifecycle/before-navigate/prevent-navigation');
await page.click('h1'); // The browsers block attempts to prevent navigation on a frame that's never had a user gesture.

Expand Down Expand Up @@ -544,6 +544,42 @@
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();
Expand Down Expand Up @@ -1105,7 +1141,7 @@
expect(await page.textContent('h1')).toBe('b');
});

test('page.url.hash is correctly set on page load', async ({ page }) => {

Check warning on line 1144 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, windows-latest, chromium, build)

flaky test: page.url.hash is correctly set on page load

retries: 2
await page.goto('/routing/hashes/pagestate#target');
expect(await page.textContent('#window-hash')).toBe('#target');
expect(await page.textContent('#page-url-hash')).toBe('#target');
Expand Down Expand Up @@ -1401,7 +1437,7 @@

test.describe('Load', () => {
if (process.env.DEV) {
test('using window.fetch does not cause false-positive warning', async ({ page, baseURL }) => {

Check warning on line 1440 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit (24, ubuntu-latest, chromium, current, dev)

flaky test: using window.fetch does not cause false-positive warning

retries: 2
/** @type {string[]} */
const warnings = [];
page.on('console', (msg) => {
Expand Down
Loading