mcrm-svelte-navigator
Version:
Simple, accessible routing for Svelte
186 lines (171 loc) • 5.37 kB
JavaScript
import { get } from "svelte/store";
import { tick } from "svelte";
import { warn, ROUTER_ID, ROUTE_ID } from "./warning";
import { addListener } from "./utils";
// We need to keep the focus candidate in a separate file, so svelte does
// not update, when we mutate it.
// Also, we need a single global reference, because taking focus needs to
// work globally, even if we have multiple top level routers
// eslint-disable-next-line import/no-mutable-exports
export let focusCandidate = null;
// eslint-disable-next-line import/no-mutable-exports
export let initialNavigation = true;
/**
* Check if RouterA is above RouterB in the document
* @param {number} routerIdA The first Routers id
* @param {number} routerIdB The second Routers id
*/
function isAbove(routerIdA, routerIdB) {
const routerMarkers = document.querySelectorAll("[data-svnav-router]");
for (let i = 0; i < routerMarkers.length; i++) {
const node = routerMarkers[i];
const currentId = Number(node.dataset.svnavRouter);
if (currentId === routerIdA) return true;
if (currentId === routerIdB) return false;
}
return false;
}
/**
* Check if a Route candidate is the best choice to move focus to,
* and store the best match.
* @param {{
level: number;
routerId: number;
route: {
id: number;
focusElement: import("svelte/store").Readable<Promise<Element>|null>;
}
}} item A Route candidate, that updated and is visible after a navigation
*/
export function pushFocusCandidate(item) {
if (
// Best candidate if it's the only candidate...
!focusCandidate ||
// Route is nested deeper, than previous candidate
// -> Route change was triggered in the deepest affected
// Route, so that's were focus should move to
item.level > focusCandidate.level ||
// If the level is identical, we want to focus the first Route in the document,
// so we pick the first Router lookin from page top to page bottom.
(item.level === focusCandidate.level &&
isAbove(item.routerId, focusCandidate.routerId))
) {
focusCandidate = item;
}
}
/**
* Reset the focus candidate.
*/
export function clearFocusCandidate() {
focusCandidate = null;
}
export function initialNavigationOccurred() {
initialNavigation = false;
}
/*
* `focus` Adapted from https://github.com/oaf-project/oaf-side-effects/blob/master/src/index.ts
*
* https://github.com/oaf-project/oaf-side-effects/blob/master/LICENSE
*/
export function focus(elem) {
if (!elem) return false;
const TABINDEX = "tabindex";
try {
if (!elem.hasAttribute(TABINDEX)) {
elem.setAttribute(TABINDEX, "-1");
let unlisten;
// We remove tabindex after blur to avoid weird browser behavior
// where a mouse click can activate elements with tabindex="-1".
const blurListener = () => {
elem.removeAttribute(TABINDEX);
unlisten();
};
unlisten = addListener(elem, "blur", blurListener);
}
elem.focus();
return document.activeElement === elem;
} catch (e) {
// Apparently trying to focus a disabled element in IE can throw.
// See https://stackoverflow.com/a/1600194/2476884
return false;
}
}
export function isEndMarker(elem, id) {
return Number(elem.dataset.svnavRouteEnd) === id;
}
export function isHeading(elem) {
return /^H[1-6]$/i.test(elem.tagName);
}
function query(selector, parent = document) {
return parent.querySelector(selector);
}
export function queryHeading(id) {
const marker = query(`[data-svnav-route-start="${id}"]`);
let current = marker.nextElementSibling;
while (!isEndMarker(current, id)) {
if (isHeading(current)) {
return current;
}
const heading = query("h1,h2,h3,h4,h5,h6", current);
if (heading) {
return heading;
}
current = current.nextElementSibling;
}
return null;
}
export function handleFocus(route) {
Promise.resolve(get(route.focusElement)).then(elem => {
const focusElement = elem || queryHeading(route.id);
if (!focusElement) {
warn(
ROUTER_ID,
"Could not find an element to focus. " +
"You should always render a header for accessibility reasons, " +
'or set a custom focus element via the "useFocus" hook. ' +
"If you don't want this Route or Router to manage focus, " +
'pass "primary={false}" to it.',
route,
ROUTE_ID,
);
}
const headingFocused = focus(focusElement);
if (headingFocused) return;
focus(document.documentElement);
});
}
export const createTriggerFocus =
(a11yConfig, announcementText, location) =>
(manageFocus, announceNavigation) =>
// Wait until the dom is updated, so we can look for headings
tick().then(() => {
if (!focusCandidate || initialNavigation) {
initialNavigationOccurred();
return;
}
if (manageFocus) {
handleFocus(focusCandidate.route);
}
if (a11yConfig.announcements && announceNavigation) {
const { path, fullPath, meta, params, uri } = focusCandidate.route;
const announcementMessage = a11yConfig.createAnnouncement(
{ path, fullPath, meta, params, uri },
get(location),
);
Promise.resolve(announcementMessage).then(message => {
announcementText.set(message);
});
}
clearFocusCandidate();
});
export const visuallyHiddenStyle =
"position:fixed;" +
"top:-1px;" +
"left:0;" +
"width:1px;" +
"height:1px;" +
"padding:0;" +
"overflow:hidden;" +
"clip:rect(0,0,0,0);" +
"white-space:nowrap;" +
"border:0;";