diff --git a/.changeset/big-pants-peel.md b/.changeset/big-pants-peel.md new file mode 100644 index 000000000000..53d0cc35da67 --- /dev/null +++ b/.changeset/big-pants-peel.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: correctly set sequential focus navigation starting point after navigation diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index df35010c65c1..76841d01c7e1 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2561,22 +2561,42 @@ function reset_focus() { autofocus.focus(); } else { // Reset page selection and focus - // We try to mimic browsers' behaviour as closely as possible by targeting the - // first scrollable region, but unfortunately it's not a perfect match — e.g. - // shift-tabbing won't immediately cycle up from the end of the page on Chromium - // See https://html.spec.whatwg.org/multipage/interaction.html#get-the-focusable-area - const root = document.body; - const tabindex = root.getAttribute('tabindex'); - - root.tabIndex = -1; - // @ts-expect-error - root.focus({ preventScroll: true, focusVisible: false }); + if (location.hash && document.querySelector(location.hash)) { + const { x, y } = scroll_state(); - // restore `tabindex` as to prevent `root` from stealing input from elements - if (tabindex !== null) { - root.setAttribute('tabindex', tabindex); + setTimeout(() => { + const history_state = history.state; + // Mimic the browsers' behaviour and set the sequential focus navigation + // starting point to the fragment identifier + location.replace(location.hash); + // but Firefox has a bug that sets the history state as null so we + // need to restore the history state + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1199924 + history.replaceState(history_state, '', location.hash); + + // Scroll management has already happened earlier so we need to restore + // the scroll position after setting the sequential focus navigation starting point + scrollTo(x, y); + }); } else { - root.removeAttribute('tabindex'); + // We try to mimic browsers' behaviour as closely as possible by targeting the + // first scrollable region, but unfortunately it's not a perfect match — e.g. + // shift-tabbing won't immediately cycle up from the end of the page on Chromium + // See https://html.spec.whatwg.org/multipage/interaction.html#get-the-focusable-area + const root = document.body; + const tabindex = root.getAttribute('tabindex'); + + root.tabIndex = -1; + // @ts-expect-error options.focusVisible is only supported in Firefox + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#browser_compatibility + root.focus({ preventScroll: true, focusVisible: false }); + + // restore `tabindex` as to prevent `root` from stealing input from elements + if (tabindex !== null) { + root.setAttribute('tabindex', tabindex); + } else { + root.removeAttribute('tabindex'); + } } // capture current selection, so we can compare the state after diff --git a/packages/kit/test/apps/basics/src/routes/routing/focus/+page.svelte b/packages/kit/test/apps/basics/src/routes/routing/focus/+page.svelte new file mode 100644 index 000000000000..c5007d09e2b7 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/routing/focus/+page.svelte @@ -0,0 +1 @@ +click me! diff --git a/packages/kit/test/apps/basics/src/routes/routing/focus/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/routing/focus/a/+page.svelte new file mode 100644 index 000000000000..573e7efe8e84 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/routing/focus/a/+page.svelte @@ -0,0 +1,4 @@ + + +
cannot be focused
+ 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 71338a8422ce..110f50e6d639 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 @@ -739,14 +739,31 @@ test.describe('Routing', () => { await expect(page.getByRole('textbox')).toBeVisible(); }); - test('back button returns to previous route when previous route has been navigated to via hash anchor', async ({ + test('sequential focus navigation starting point is set correctly on navigation', async ({ page, - clicknav + browserName + }) => { + const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + await page.goto('/routing/focus'); + await page.locator('[href="/routing/focus/a#p"]').click(); + await page.waitForURL('**/routing/focus/a#p'); + expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY'); + await page.keyboard.press(tab); + await expect(page.locator('#button3')).toBeFocused(); + }); + + test('back button returns to previous route when previous route was navigated to via hash anchor', async ({ + page, + clicknav, + baseURL }) => { await page.goto('/routing/hashes/a'); await page.locator('[href="#hash-target"]').click(); + expect(page.url()).toBe(`${baseURL}/routing/hashes/a#hash-target`); + await clicknav('[href="/routing/hashes/b"]'); + expect(await page.textContent('h1')).toBe('b'); await expect(page.locator('h1')).toHaveText('b'); await page.goBack(); @@ -761,10 +778,13 @@ test.describe('Routing', () => { await page.goto('/routing/hashes/a'); await clicknav('[href="#hash-target"]'); + expect(page.url()).toBe(`${baseURL}/routing/hashes/a#hash-target`); + await clicknav('[href="#replace-state"]'); + expect(page.url()).toBe(`${baseURL}/routing/hashes/a#replace-state`); await page.goBack(); - expect(await page.url()).toBe(`${baseURL}/routing/hashes/a`); + expect(page.url()).toBe(`${baseURL}/routing/hashes/a`); }); test('does not normalize external path', async ({ page, start_server }) => {