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
177 changes: 4 additions & 173 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { decode_pathname, strip_hash, make_trackable, normalize_path } from '../
import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js';
import { parse_routes, parse_server_route } from './parse.js';
import * as storage from './session-storage.js';
import { blur_active_element, is_resetting_focus, reset_focus } from './focus.js';
import { disable_scroll_handling, reset_scroll_and_focus } from './scroll.js';
import {
find_anchor,
resolve_url,
Expand Down Expand Up @@ -170,47 +172,6 @@ function set_history_options(index, options) {
};
}

/** @param {boolean} reset */
function blur_active_element(reset) {
if (
reset &&
document.activeElement instanceof HTMLElement &&
document.activeElement !== document.body
) {
document.activeElement.blur();
}
}

/**
* @param {URL} url
* @param {{ x: number; y: number } | null | undefined} scroll
* @param {boolean} reset
* @param {Element | null} active_element
*/
function reset_scroll_and_focus(url, scroll, reset, active_element) {
/** @type {Element | null} */
let deep_linked = null;

if (autoscroll) {
if (scroll) {
scrollTo(scroll.x, scroll.y);
} else if ((deep_linked = get_hash_element(url))) {
deep_linked.scrollIntoView();
} else {
scrollTo(0, 0);
}
}

const changed_focus =
document.activeElement !== active_element && document.activeElement !== document.body;

if (reset && !changed_focus) {
reset_focus(url, !deep_linked);
}

autoscroll = true;
}

/**
* @param {number} current_history_index
* @param {number} current_navigation_index
Expand Down Expand Up @@ -372,7 +333,6 @@ let current = {
/** this being true means we SSR'd */
let hydrated = false;
let started = false;
let autoscroll = true;
let updating = false;
let is_navigating = false;
/** @type {HistoryMetadata | null} */
Expand Down Expand Up @@ -2623,7 +2583,7 @@ export function disableScrollHandling() {
}

if (updating || !started) {
autoscroll = false;
disable_scroll_handling();
}
}

Expand Down Expand Up @@ -3380,7 +3340,7 @@ function _start_router() {
});

addEventListener('popstate', async (event) => {
if (resetting_focus) return;
if (is_resetting_focus()) return;

const history_metadata = get_history_metadata(event.state);

Expand Down Expand Up @@ -3779,109 +3739,6 @@ function deserialize_uses(uses) {
};
}

/**
* This flag is used to avoid client-side navigation when we're only using
* `location.replace()` to set focus.
*/
let resetting_focus = false;

/**
* @param {URL} url
* @param {boolean} [scroll]
*/
function reset_focus(url, scroll = true) {
const autofocus = document.querySelector('[autofocus]');
if (autofocus) {
// @ts-ignore
autofocus.focus();
} else {
// Reset page selection and focus

// Mimic the browsers' behaviour and set the sequential focus navigation
// starting point to the fragment identifier.
const element = get_hash_element(url);
if (element) {
const { x, y } = scroll_state();

// `element.focus()` doesn't work on Safari and Firefox Ubuntu so we need
// to use this hack with `location.replace()` instead.
setTimeout(() => {
const history_state = history.state;

resetting_focus = true;
location.replace(new URL(`#${element.id}`, location.href));

// Firefox has a bug that sets the history state to `null` so we need to
// restore it after. See https://bugzilla.mozilla.org/show_bug.cgi?id=1199924
// This is also needed to restore the original hash if we're using hash routing
history.replaceState(history_state, '', url);

// 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);
resetting_focus = false;
});
} else {
// If the ID doesn't exist, we try to mimic browsers' behaviour as closely
// as possible by targeting the first scrollable region. 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;
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
// snapshot restoration and afterNavigate callbacks have run
const selection = getSelection();

if (selection && selection.type !== 'None') {
/** @type {Range[]} */
const ranges = [];

for (let i = 0; i < selection.rangeCount; i += 1) {
ranges.push(selection.getRangeAt(i));
}

setTimeout(() => {
if (selection.rangeCount !== ranges.length) return;

for (let i = 0; i < selection.rangeCount; i += 1) {
const a = ranges[i];
const b = selection.getRangeAt(i);

// we need to do a deep comparison rather than just `a !== b` because
// Safari behaves differently to other browsers
if (
a.commonAncestorContainer !== b.commonAncestorContainer ||
a.startContainer !== b.startContainer ||
a.endContainer !== b.endContainer ||
a.startOffset !== b.startOffset ||
a.endOffset !== b.endOffset
) {
return;
}
}

// if the selection hasn't changed (as a result of an element being (auto)focused,
// or a programmatic selection, we reset everything as part of the navigation)
// fixes https://github.com/sveltejs/kit/issues/8439
selection.removeAllRanges();
});
}
}
}

/**
* @template {NavigationType} T
* @param {import('./types.js').NavigationState} current
Expand Down Expand Up @@ -3955,32 +3812,6 @@ function decode_hash(url) {
return new_url;
}

/**
* @param {URL} url
* @returns {string}
*/
function get_id(url) {
let id;

if (app.hash) {
const [, , second] = url.hash.split('#', 3);
id = second ?? '';
} else {
id = url.hash.slice(1);
}

return decodeURIComponent(id);
}

/**
* @param {URL} url
* @returns {Element | null}
*/
function get_hash_element(url) {
const id = get_id(url);
return id ? document.getElementById(id) : null;
}

if (DEV) {
// Nasty hack to silence harmless warnings the user can do nothing about
const console_warn = console.warn;
Expand Down
120 changes: 120 additions & 0 deletions packages/kit/src/runtime/client/focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { hash_routing } from '$app/paths/internal/client';
import { get_hash_element, scroll_state } from './utils.js';

/**
* This flag is used to avoid client-side navigation when we're only using
* `location.replace()` to set focus.
*/
let resetting_focus = false;

export function is_resetting_focus() {
return resetting_focus;
}

/** @param {boolean} reset */
export function blur_active_element(reset) {
if (
reset &&
document.activeElement instanceof HTMLElement &&
document.activeElement !== document.body
) {
document.activeElement.blur();
}
}

/**
* @param {URL} url
* @param {boolean} [scroll]
*/
export function reset_focus(url, scroll = true) {
const autofocus = document.querySelector('[autofocus]');
if (autofocus) {
// @ts-ignore
autofocus.focus();
} else {
// Reset page selection and focus

// Mimic the browsers' behaviour and set the sequential focus navigation
// starting point to the fragment identifier.
const element = get_hash_element(url, hash_routing);
if (element) {
const { x, y } = scroll_state();

// `element.focus()` doesn't work on Safari and Firefox Ubuntu so we need
// to use this hack with `location.replace()` instead.
setTimeout(() => {
const history_state = history.state;

resetting_focus = true;
location.replace(new URL(`#${element.id}`, location.href));

// Firefox has a bug that sets the history state to `null` so we need to
// restore it after. See https://bugzilla.mozilla.org/show_bug.cgi?id=1199924
// This is also needed to restore the original hash if we're using hash routing
history.replaceState(history_state, '', url);

// 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);
resetting_focus = false;
});
} else {
// If the ID doesn't exist, we try to mimic browsers' behaviour as closely
// as possible by targeting the first scrollable region. 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;
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
// snapshot restoration and afterNavigate callbacks have run
const selection = getSelection();

if (selection && selection.type !== 'None') {
/** @type {Range[]} */
const ranges = [];

for (let i = 0; i < selection.rangeCount; i += 1) {
ranges.push(selection.getRangeAt(i));
}

setTimeout(() => {
if (selection.rangeCount !== ranges.length) return;

for (let i = 0; i < selection.rangeCount; i += 1) {
const a = ranges[i];
const b = selection.getRangeAt(i);

// we need to do a deep comparison rather than just `a !== b` because
// Safari behaves differently to other browsers
if (
a.commonAncestorContainer !== b.commonAncestorContainer ||
a.startContainer !== b.startContainer ||
a.endContainer !== b.endContainer ||
a.startOffset !== b.startOffset ||
a.endOffset !== b.endOffset
) {
return;
}
}

// if the selection hasn't changed (as a result of an element being (auto)focused,
// or a programmatic selection, we reset everything as part of the navigation)
// fixes https://github.com/sveltejs/kit/issues/8439
selection.removeAllRanges();
});
}
}
}
29 changes: 29 additions & 0 deletions packages/kit/src/runtime/client/focus.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { reset_focus } from './focus.js';

beforeEach(() => {
window.scrollTo = vi.fn();
document.body.innerHTML = '';
});

afterEach(() => {
vi.useRealTimers();
});

test('reset_focus focuses the body without leaving a tabindex behind', () => {
document.body.innerHTML = '<input>';
/** @type {HTMLInputElement} */ (document.body.firstElementChild).focus();
reset_focus(new URL('/', location.href));
expect(document.activeElement).toBe(document.body);
expect(document.body.hasAttribute('tabindex')).toBe(false);
});

test('reset_focus restores the scroll position after jumping to the hash target', () => {
vi.useFakeTimers();
Object.defineProperty(window, 'pageYOffset', { value: 400, configurable: true });
document.body.innerHTML = '<div id="a"></div>';
reset_focus(new URL('/#a', location.href));
expect(window.scrollTo).not.toHaveBeenCalled();
vi.runAllTimers();
expect(window.scrollTo).toHaveBeenCalledWith(0, 400);
});
Loading
Loading