diff --git a/.changeset/grumpy-chefs-retire.md b/.changeset/grumpy-chefs-retire.md new file mode 100644 index 000000000000..8a834c9a0d82 --- /dev/null +++ b/.changeset/grumpy-chefs-retire.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: correctly set the sequential focus navigation point when using hash routing diff --git a/.changeset/long-cows-pump.md b/.changeset/long-cows-pump.md new file mode 100644 index 000000000000..c5d3c389ee97 --- /dev/null +++ b/.changeset/long-cows-pump.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: regression when navigating with hash routing diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index acf972621e7d..052f36e20835 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1602,11 +1602,7 @@ async function navigate({ const scroll = popped ? popped.scroll : noscroll ? scroll_state() : null; if (autoscroll) { - const deep_linked = - url.hash && - document.getElementById( - decodeURIComponent(app.hash ? (url.hash.split('#')[2] ?? '') : url.hash.slice(1)) - ); + const deep_linked = url.hash && document.getElementById(get_id(url)); if (scroll) { scrollTo(scroll.x, scroll.y); } else if (deep_linked) { @@ -1627,7 +1623,7 @@ async function navigate({ document.activeElement !== document.body; if (!keepfocus && !changed_focus) { - reset_focus(); + reset_focus(url); } autoscroll = true; @@ -2194,7 +2190,7 @@ export async function applyAction(result) { root.$set(navigation_result.props); update(navigation_result.props.page); - void tick().then(reset_focus); + void tick().then(() => reset_focus(url)); } } else if (result.type === 'redirect') { await _goto(result.location, { invalidateAll: true }, 0); @@ -2215,7 +2211,7 @@ export async function applyAction(result) { root.$set({ form: result.data }); if (result.type === 'success') { - reset_focus(); + reset_focus(page.url); } } } @@ -2793,50 +2789,48 @@ function deserialize_uses(uses) { }; } -function reset_focus() { +/** + * @param {URL} url + */ +function reset_focus(url) { const autofocus = document.querySelector('[autofocus]'); if (autofocus) { // @ts-ignore autofocus.focus(); } else { // Reset page selection and focus - if (location.hash && document.querySelector(location.hash)) { - const { x, y } = scroll_state(); - - 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 { - // 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; + const id = get_id(url); + + // 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 + // If there's a fragment identifier, we mimic the browsers' behaviour and set the + // sequential focus navigation starting point to it + const element = document.getElementById(id) ?? document.body; + + const { x, y } = scroll_state(); + + // doing this in a `setTimeout` makes `.focus()` work on Firefox + setTimeout(() => { + const tabindex = element.getAttribute('tabindex'); + element.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 }); + element.focus({ preventScroll: true, focusVisible: false }); - // restore `tabindex` as to prevent `root` from stealing input from elements if (tabindex !== null) { - root.setAttribute('tabindex', tabindex); + element.setAttribute('tabindex', tabindex); } else { - root.removeAttribute('tabindex'); + element.removeAttribute('tabindex'); } - } + + if (id) { + // 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); + } + }); // capture current selection, so we can compare the state after // snapshot restoration and afterNavigate callbacks have run @@ -2960,6 +2954,14 @@ function decode_hash(url) { return new_url; } +/** + * @param {URL} url + * @returns {string} + */ +function get_id(url) { + return decodeURIComponent(app.hash ? (url.hash.split('#')[2] ?? '') : url.hash.slice(1)); +} + if (DEV) { // Nasty hack to silence harmless warnings the user can do nothing about const console_warn = console.warn; diff --git a/packages/kit/test/apps/hash-based-routing/src/routes/focus/+page.svelte b/packages/kit/test/apps/hash-based-routing/src/routes/focus/+page.svelte new file mode 100644 index 000000000000..27694d246b6e --- /dev/null +++ b/packages/kit/test/apps/hash-based-routing/src/routes/focus/+page.svelte @@ -0,0 +1 @@ +click me! diff --git a/packages/kit/test/apps/hash-based-routing/src/routes/focus/a/+page.svelte b/packages/kit/test/apps/hash-based-routing/src/routes/focus/a/+page.svelte new file mode 100644 index 000000000000..573e7efe8e84 --- /dev/null +++ b/packages/kit/test/apps/hash-based-routing/src/routes/focus/a/+page.svelte @@ -0,0 +1,4 @@ + + +
cannot be focused
+ diff --git a/packages/kit/test/apps/hash-based-routing/test/test.js b/packages/kit/test/apps/hash-based-routing/test/test.js index b9ab54dd1249..de423cc9bcb0 100644 --- a/packages/kit/test/apps/hash-based-routing/test/test.js +++ b/packages/kit/test/apps/hash-based-routing/test/test.js @@ -115,4 +115,15 @@ test.describe('hash based navigation', () => { await page.goForward(); expect(page.locator('p')).toHaveText('b'); }); + + test('sequential focus navigation point is set correctly', async ({ page, browserName }) => { + const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + await page.goto('/#/focus'); + await page.locator('a[href="#/focus/a#p"]').click(); + await page.waitForURL('#/focus/a#p'); + expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY'); + await page.keyboard.press(tab); + await expect(page.locator('#button3')).toBeFocused(); + await expect(page.locator('button[id="button3"]')).toBeFocused(); + }); });