Skip to content

Commit 6261a87

Browse files
authored
fix: hash link to new page focuses the correct element (#10856)
* use window.location to scroll to deep linked element * changeset * prettier * fix * fix scroll position being changed * prettier * found a fix that works! * improve some old related tests * forgot to restore scroll pos
1 parent 408e1f5 commit 6261a87

File tree

5 files changed

+67
-17
lines changed

5 files changed

+67
-17
lines changed

.changeset/big-pants-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
fix: correctly set sequential focus navigation starting point after navigation

packages/kit/src/runtime/client/client.js

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2800,22 +2800,42 @@ function reset_focus() {
28002800
autofocus.focus();
28012801
} else {
28022802
// Reset page selection and focus
2803-
// We try to mimic browsers' behaviour as closely as possible by targeting the
2804-
// first scrollable region, but unfortunately it's not a perfect match — e.g.
2805-
// shift-tabbing won't immediately cycle up from the end of the page on Chromium
2806-
// See https://html.spec.whatwg.org/multipage/interaction.html#get-the-focusable-area
2807-
const root = document.body;
2808-
const tabindex = root.getAttribute('tabindex');
2809-
2810-
root.tabIndex = -1;
2811-
// @ts-expect-error
2812-
root.focus({ preventScroll: true, focusVisible: false });
2803+
if (location.hash && document.querySelector(location.hash)) {
2804+
const { x, y } = scroll_state();
28132805

2814-
// restore `tabindex` as to prevent `root` from stealing input from elements
2815-
if (tabindex !== null) {
2816-
root.setAttribute('tabindex', tabindex);
2806+
setTimeout(() => {
2807+
const history_state = history.state;
2808+
// Mimic the browsers' behaviour and set the sequential focus navigation
2809+
// starting point to the fragment identifier
2810+
location.replace(location.hash);
2811+
// but Firefox has a bug that sets the history state as null so we
2812+
// need to restore the history state
2813+
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1199924
2814+
history.replaceState(history_state, '', location.hash);
2815+
2816+
// Scroll management has already happened earlier so we need to restore
2817+
// the scroll position after setting the sequential focus navigation starting point
2818+
scrollTo(x, y);
2819+
});
28172820
} else {
2818-
root.removeAttribute('tabindex');
2821+
// We try to mimic browsers' behaviour as closely as possible by targeting the
2822+
// first scrollable region, but unfortunately it's not a perfect match — e.g.
2823+
// shift-tabbing won't immediately cycle up from the end of the page on Chromium
2824+
// See https://html.spec.whatwg.org/multipage/interaction.html#get-the-focusable-area
2825+
const root = document.body;
2826+
const tabindex = root.getAttribute('tabindex');
2827+
2828+
root.tabIndex = -1;
2829+
// @ts-expect-error options.focusVisible is only supported in Firefox
2830+
// See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#browser_compatibility
2831+
root.focus({ preventScroll: true, focusVisible: false });
2832+
2833+
// restore `tabindex` as to prevent `root` from stealing input from elements
2834+
if (tabindex !== null) {
2835+
root.setAttribute('tabindex', tabindex);
2836+
} else {
2837+
root.removeAttribute('tabindex');
2838+
}
28192839
}
28202840

28212841
// capture current selection, so we can compare the state after
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<a href="/routing/focus/a#p">click me!</a>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<button>button 1</button>
2+
<button>button 2</button>
3+
<p id="p">cannot be focused</p>
4+
<button id="button3">button 3</button>

packages/kit/test/apps/basics/test/cross-platform/client.test.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -806,14 +806,31 @@ test.describe('Routing', () => {
806806
await expect(page.getByRole('textbox')).toBeVisible();
807807
});
808808

809-
test('back button returns to previous route when previous route has been navigated to via hash anchor', async ({
809+
test('sequential focus navigation starting point is set correctly on navigation', async ({
810810
page,
811-
clicknav
811+
browserName
812+
}) => {
813+
const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab';
814+
await page.goto('/routing/focus');
815+
await page.locator('[href="/routing/focus/a#p"]').click();
816+
await page.waitForURL('**/routing/focus/a#p');
817+
expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY');
818+
await page.keyboard.press(tab);
819+
await expect(page.locator('#button3')).toBeFocused();
820+
});
821+
822+
test('back button returns to previous route when previous route was navigated to via hash anchor', async ({
823+
page,
824+
clicknav,
825+
baseURL
812826
}) => {
813827
await page.goto('/routing/hashes/a');
814828

815829
await page.locator('[href="#hash-target"]').click();
830+
expect(page.url()).toBe(`${baseURL}/routing/hashes/a#hash-target`);
831+
816832
await clicknav('[href="/routing/hashes/b"]');
833+
expect(await page.textContent('h1')).toBe('b');
817834

818835
await expect(page.locator('h1')).toHaveText('b');
819836
await page.goBack();
@@ -828,10 +845,13 @@ test.describe('Routing', () => {
828845
await page.goto('/routing/hashes/a');
829846

830847
await clicknav('[href="#hash-target"]');
848+
expect(page.url()).toBe(`${baseURL}/routing/hashes/a#hash-target`);
849+
831850
await clicknav('[href="#replace-state"]');
851+
expect(page.url()).toBe(`${baseURL}/routing/hashes/a#replace-state`);
832852

833853
await page.goBack();
834-
expect(await page.url()).toBe(`${baseURL}/routing/hashes/a`);
854+
expect(page.url()).toBe(`${baseURL}/routing/hashes/a`);
835855
});
836856

837857
test('does not normalize external path', async ({ page, start_server }) => {

0 commit comments

Comments
 (0)