UNPKG

mcrm-svelte-navigator

Version:
186 lines (171 loc) 5.37 kB
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;";