UNPKG

spatial-navigation-polyfill

Version:
1,165 lines (1,043 loc) 73.5 kB
/* Spatial Navigation Polyfill * * It follows W3C official specification * https://drafts.csswg.org/css-nav-1/ * * Copyright (c) 2018-2019 LG Electronics Inc. * https://github.com/WICG/spatial-navigation/polyfill * * Licensed under the MIT license (MIT) */ (function () { // The polyfill must not be executed, if it's already enabled via browser engine or browser extensions. if ('navigate' in window) { return; } const ARROW_KEY_CODE = {37: 'left', 38: 'up', 39: 'right', 40: 'down'}; const TAB_KEY_CODE = 9; let mapOfBoundRect = null; let startingPoint = null; // Saves spatial navigation starting point let savedSearchOrigin = {element: null, rect: null}; // Saves previous search origin let searchOriginRect = null; // Rect of current search origin /** * Initiate the spatial navigation features of the polyfill. * @function initiateSpatialNavigation */ function initiateSpatialNavigation() { /* * Bind the standards APIs to be exposed to the window object for authors */ window.navigate = navigate; window.Element.prototype.spatialNavigationSearch = spatialNavigationSearch; window.Element.prototype.focusableAreas = focusableAreas; window.Element.prototype.getSpatialNavigationContainer = getSpatialNavigationContainer; /* * CSS.registerProperty() from the Properties and Values API * Reference: https://drafts.css-houdini.org/css-properties-values-api/#the-registerproperty-function */ if (window.CSS && CSS.registerProperty) { if (window.getComputedStyle(document.documentElement).getPropertyValue('--spatial-navigation-contain') === '') { CSS.registerProperty({ name: '--spatial-navigation-contain', syntax: 'auto | contain', inherits: false, initialValue: 'auto' }); } if (window.getComputedStyle(document.documentElement).getPropertyValue('--spatial-navigation-action') === '') { CSS.registerProperty({ name: '--spatial-navigation-action', syntax: 'auto | focus | scroll', inherits: false, initialValue: 'auto' }); } if (window.getComputedStyle(document.documentElement).getPropertyValue('--spatial-navigation-function') === '') { CSS.registerProperty({ name: '--spatial-navigation-function', syntax: 'normal | grid', inherits: false, initialValue: 'normal' }); } } } /** * Add event handlers for the spatial navigation behavior. * This function defines which input methods trigger the spatial navigation behavior. * @function spatialNavigationHandler */ function spatialNavigationHandler() { /* * keydown EventListener : * If arrow key pressed, get the next focusing element and send it to focusing controller */ window.addEventListener('keydown', (e) => { const currentKeyMode = (parent && parent.__spatialNavigation__.keyMode) || window.__spatialNavigation__.keyMode; const eventTarget = document.activeElement; const dir = ARROW_KEY_CODE[e.keyCode]; if (e.keyCode === TAB_KEY_CODE) { startingPoint = null; } if (!currentKeyMode || (currentKeyMode === 'NONE') || ((currentKeyMode === 'SHIFTARROW') && !e.shiftKey) || ((currentKeyMode === 'ARROW') && e.shiftKey)) return; if (!e.defaultPrevented) { let focusNavigableArrowKey = {left: true, up: true, right: true, down: true}; // Edge case (text input, area) : Don't move focus, just navigate cursor in text area if ((eventTarget.nodeName === 'INPUT') || eventTarget.nodeName === 'TEXTAREA') { focusNavigableArrowKey = handlingEditableElement(e); } if (focusNavigableArrowKey[dir]) { e.preventDefault(); mapOfBoundRect = new Map(); navigate(dir); mapOfBoundRect = null; startingPoint = null; } } }); /* * mouseup EventListener : * If the mouse click a point in the page, the point will be the starting point. * NOTE: Let UA set the spatial navigation starting point based on click */ document.addEventListener('mouseup', (e) => { startingPoint = {x: e.clientX, y: e.clientY}; }); /* * focusin EventListener : * When the element get the focus, save it and its DOMRect for resetting the search origin * if it disappears. */ window.addEventListener('focusin', (e) => { if (e.target !== window) { savedSearchOrigin.element = e.target; savedSearchOrigin.rect = e.target.getBoundingClientRect(); } }); } /** * Enable the author to trigger spatial navigation programmatically, as if the user had done so manually. * @see {@link https://drafts.csswg.org/css-nav-1/#dom-window-navigate} * @function navigate * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) */ function navigate(dir) { // spatial navigation steps // 1 const searchOrigin = findSearchOrigin(); let eventTarget = searchOrigin; let elementFromPosition = null; // 2 Optional step, UA defined starting point if (startingPoint) { // if there is a starting point, set eventTarget as the element from position for getting the spatnav container elementFromPosition = document.elementFromPoint(startingPoint.x, startingPoint.y); // Use starting point if the starting point isn't inside the focusable element (but not container) // * Starting point is meaningfull when: // 1) starting point is inside the spatnav container // 2) starting point is inside the non-focusable element if (elementFromPosition === null) { elementFromPosition = document.body; } if (isFocusable(elementFromPosition) && !isContainer(elementFromPosition)) { startingPoint = null; } else if (isContainer(elementFromPosition)) { eventTarget = elementFromPosition; } else { eventTarget = elementFromPosition.getSpatialNavigationContainer(); } } // 4 if (eventTarget === document || eventTarget === document.documentElement) { eventTarget = document.body || document.documentElement; } // 5 // At this point, spatialNavigationSearch can be applied. // If startingPoint is either a scroll container or the document, // find the best candidate within startingPoint let container = null; if ((isContainer(eventTarget) || eventTarget.nodeName === 'BODY') && !(eventTarget.nodeName === 'INPUT')) { if (eventTarget.nodeName === 'IFRAME') { eventTarget = eventTarget.contentDocument.documentElement; } container = eventTarget; let bestInsideCandidate = null; // 5-2 if ((document.activeElement === searchOrigin) || (document.activeElement === document.body) && (searchOrigin === document.documentElement)) { if (getCSSSpatNavAction(eventTarget) === 'scroll') { if (scrollingController(eventTarget, dir)) return; } else if (getCSSSpatNavAction(eventTarget) === 'focus') { bestInsideCandidate = eventTarget.spatialNavigationSearch(dir, {container: eventTarget, candidates: getSpatialNavigationCandidates(eventTarget, {mode: 'all'})}); if (focusingController(bestInsideCandidate, dir)) return; } else if (getCSSSpatNavAction(eventTarget) === 'auto') { bestInsideCandidate = eventTarget.spatialNavigationSearch(dir, {container: eventTarget}); if (focusingController(bestInsideCandidate, dir) || scrollingController(eventTarget, dir)) return; } } else { // when the previous search origin became offscreen container = container.getSpatialNavigationContainer(); } } // 6 // Let container be the nearest ancestor of eventTarget container = eventTarget.getSpatialNavigationContainer(); let parentContainer = (container.parentElement) ? container.getSpatialNavigationContainer() : null; // When the container is the viewport of a browsing context if (!parentContainer && ( window.location !== window.parent.location)) { parentContainer = window.parent.document.documentElement; } if (getCSSSpatNavAction(container) === 'scroll') { if (scrollingController(container, dir)) return; } else if (getCSSSpatNavAction(container) === 'focus') { navigateChain(eventTarget, container, parentContainer, dir, 'all'); } else if (getCSSSpatNavAction(container) === 'auto') { navigateChain(eventTarget, container, parentContainer, dir, 'visible'); } } /** * Move the focus to the best candidate or do nothing. * @function focusingController * @param bestCandidate {Node} - The best candidate of the spatial navigation * @param dir {SpatialNavigationDirection}- The directional information for the spatial navigation (e.g. LRUD) * @returns {boolean} */ function focusingController(bestCandidate, dir) { // 10 & 11 // When bestCandidate is found if (bestCandidate) { // When bestCandidate is a focusable element and not a container : move focus /* * [event] navbeforefocus : Fired before spatial or sequential navigation changes the focus. */ if (!createSpatNavEvents('beforefocus', bestCandidate, null, dir)) return true; const container = bestCandidate.getSpatialNavigationContainer(); if ((container !== window) && (getCSSSpatNavAction(container) === 'focus')) { bestCandidate.focus(); } else { bestCandidate.focus({preventScroll: true}); } startingPoint = null; return true; } // When bestCandidate is not found within the scrollport of a container: Nothing return false; } /** * Directionally scroll the scrollable spatial navigation container if it can be manually scrolled more. * @function scrollingController * @param container {Node} - The spatial navigation container which can scroll * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @returns {boolean} */ function scrollingController(container, dir) { // If there is any scrollable area among parent elements and it can be manually scrolled, scroll the document if (isScrollable(container, dir) && !isScrollBoundary(container, dir)) { moveScroll(container, dir); return true; } // If the spatnav container is document and it can be scrolled, scroll the document if (!container.parentElement && !isHTMLScrollBoundary(container, dir)) { moveScroll(container.ownerDocument.documentElement, dir); return true; } return false; } /** * Find the candidates within a spatial navigation container include delegable container. * This function does not search inside delegable container or focusable container. * In other words, this return candidates set is not included focusable elements inside delegable container or focusable container. * * @function getSpatialNavigationCandidates * @param container {Node} - The spatial navigation container * @param option {FocusableAreasOptions} - 'mode' attribute takes 'visible' or 'all' for searching the boundary of focusable elements. * Default value is 'visible'. * @returns {sequence<Node>} candidate elements within the container */ function getSpatialNavigationCandidates (container, option = {mode: 'visible'}) { let candidates = []; if (container.childElementCount > 0) { if (!container.parentElement) { container = container.getElementsByTagName('body')[0] || document.body; } const children = container.children; for (const elem of children) { if (isDelegableContainer(elem)) { candidates.push(elem); } else if (isFocusable(elem)) { candidates.push(elem); if (!isContainer(elem) && elem.childElementCount) { candidates = candidates.concat(getSpatialNavigationCandidates(elem, {mode: 'all'})); } } else if (elem.childElementCount) { candidates = candidates.concat(getSpatialNavigationCandidates(elem, {mode: 'all'})); } } } return (option.mode === 'all') ? candidates : candidates.filter(isVisible); } /** * Find the candidates among focusable elements within a spatial navigation container from the search origin (currently focused element) * depending on the directional information. * @function getFilteredSpatialNavigationCandidates * @param element {Node} - The currently focused element which is defined as 'search origin' in the spec * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @param candidates {sequence<Node>} - The candidates for spatial navigation without the directional information * @param container {Node} - The spatial navigation container * @returns {Node} The candidates for spatial navigation considering the directional information */ function getFilteredSpatialNavigationCandidates (element, dir, candidates, container) { const targetElement = element; // Removed below line due to a bug. (iframe body rect is sometime weird.) // const targetElement = (element.nodeName === 'IFRAME') ? element.contentDocument.body : element; // If the container is unknown, get the closest container from the element container = container || targetElement.getSpatialNavigationContainer(); // If the candidates is unknown, find candidates // 5-1 candidates = (!candidates || candidates.length <= 0) ? getSpatialNavigationCandidates(container) : candidates; return filteredCandidates(targetElement, candidates, dir, container); } /** * Find the best candidate among the candidates within the container from the search origin (currently focused element) * @see {@link https://drafts.csswg.org/css-nav-1/#dom-element-spatialnavigationsearch} * @function spatialNavigationSearch * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @param candidates {sequence<Node>} - The candidates for spatial navigation * @param container {Node} - The spatial navigation container * @returns {Node} The best candidate which will gain the focus */ function spatialNavigationSearch (dir, args) { const targetElement = this; let internalCandidates = []; let externalCandidates = []; let insideOverlappedCandidates = getOverlappedCandidates(targetElement); let bestTarget; // Set default parameter value if (!args) args = {}; const defaultContainer = targetElement.getSpatialNavigationContainer(); let defaultCandidates = getSpatialNavigationCandidates(defaultContainer); const container = args.container || defaultContainer; if (args.container && (defaultContainer.contains(args.container))) { defaultCandidates = defaultCandidates.concat(getSpatialNavigationCandidates(container)); } const candidates = (args.candidates && args.candidates.length > 0) ? args.candidates.filter((candidate) => container.contains(candidate)) : defaultCandidates.filter((candidate) => container.contains(candidate) && (container !== candidate)); // Find the best candidate // 5 // If startingPoint is either a scroll container or the document, // find the best candidate within startingPoint if (candidates && candidates.length > 0) { // Divide internal or external candidates candidates.forEach(candidate => { if (candidate !== targetElement) { (targetElement.contains(candidate) && targetElement !== candidate ? internalCandidates : externalCandidates).push(candidate); } }); // include overlapped element to the internalCandidates let fullyOverlapped = insideOverlappedCandidates.filter(candidate => !internalCandidates.includes(candidate)); let overlappedContainer = candidates.filter(candidate => (isContainer(candidate) && isEntirelyVisible(targetElement, candidate))); let overlappedByParent = overlappedContainer.map((elm) => elm.focusableAreas()).flat().filter(candidate => candidate !== targetElement); internalCandidates = internalCandidates.concat(fullyOverlapped).filter((candidate) => container.contains(candidate)); externalCandidates = externalCandidates.concat(overlappedByParent).filter((candidate) => container.contains(candidate)); // Filter external Candidates if (externalCandidates.length > 0) { externalCandidates = getFilteredSpatialNavigationCandidates(targetElement, dir, externalCandidates, container); } // If there isn't search origin element but search orgin rect exist (search origin isn't in the layout case) if (searchOriginRect) { bestTarget = selectBestCandidate(targetElement, getFilteredSpatialNavigationCandidates(targetElement, dir, internalCandidates, container), dir); } if ((internalCandidates && internalCandidates.length > 0) && !(targetElement.nodeName === 'INPUT')) { bestTarget = selectBestCandidateFromEdge(targetElement, internalCandidates, dir); } bestTarget = bestTarget || selectBestCandidate(targetElement, externalCandidates, dir); if (bestTarget && isDelegableContainer(bestTarget)) { // if best target is delegable container, then find descendants candidate inside delegable container. const innerTarget = getSpatialNavigationCandidates(bestTarget, {mode: 'all'}); const descendantsBest = innerTarget.length > 0 ? targetElement.spatialNavigationSearch(dir, {candidates: innerTarget, container: bestTarget}) : null; if (descendantsBest) { bestTarget = descendantsBest; } else if (!isFocusable(bestTarget)) { // if there is no target inside bestTarget and delegable container is not focusable, // then try to find another best target without curren best target. candidates.splice(candidates.indexOf(bestTarget), 1); bestTarget = candidates.length ? targetElement.spatialNavigationSearch(dir, {candidates: candidates, container: container}) : null; } } return bestTarget; } return null; } /** * Get the filtered candidate among candidates. * @see {@link https://drafts.csswg.org/css-nav-1/#select-the-best-candidate} * @function filteredCandidates * @param currentElm {Node} - The currently focused element which is defined as 'search origin' in the spec * @param candidates {sequence<Node>} - The candidates for spatial navigation * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @param container {Node} - The spatial navigation container * @returns {sequence<Node>} The filtered candidates which are not the search origin and not in the given spatial navigation direction from the search origin */ // TODO: Need to fix filtering the candidates with more clean code function filteredCandidates(currentElm, candidates, dir, container) { const originalContainer = currentElm.getSpatialNavigationContainer(); let eventTargetRect; // If D(dir) is null, let candidates be the same as visibles if (dir === undefined) return candidates; // Offscreen handling when originalContainer is not <HTML> if (originalContainer.parentElement && container !== originalContainer && !isVisible(currentElm)) { eventTargetRect = getBoundingClientRect(originalContainer); } else { eventTargetRect = searchOriginRect || getBoundingClientRect(currentElm); } /* * Else, let candidates be the subset of the elements in visibles * whose principal box’s geometric center is within the closed half plane * whose boundary goes through the geometric center of starting point and is perpendicular to D. */ if ((isContainer(currentElm) || currentElm.nodeName === 'BODY') && !(currentElm.nodeName === 'INPUT')) { return candidates.filter(candidate => { const candidateRect = getBoundingClientRect(candidate); return container.contains(candidate) && ((currentElm.contains(candidate) && isInside(eventTargetRect, candidateRect) && candidate !== currentElm) || isOutside(candidateRect, eventTargetRect, dir)); }); } else { return candidates.filter(candidate => { const candidateRect = getBoundingClientRect(candidate); const candidateBody = (candidate.nodeName === 'IFRAME') ? candidate.contentDocument.body : null; return container.contains(candidate) && candidate !== currentElm && candidateBody !== currentElm && isOutside(candidateRect, eventTargetRect, dir) && !isInside(eventTargetRect, candidateRect); }); } } /** * Select the best candidate among given candidates. * @see {@link https://drafts.csswg.org/css-nav-1/#select-the-best-candidate} * @function selectBestCandidate * @param currentElm {Node} - The currently focused element which is defined as 'search origin' in the spec * @param candidates {sequence<Node>} - The candidates for spatial navigation * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @returns {Node} The best candidate which will gain the focus */ function selectBestCandidate(currentElm, candidates, dir) { const container = currentElm.getSpatialNavigationContainer(); const spatialNavigationFunction = getComputedStyle(container).getPropertyValue('--spatial-navigation-function'); const currentTargetRect = searchOriginRect || getBoundingClientRect(currentElm); let distanceFunction; let alignedCandidates; switch (spatialNavigationFunction) { case 'grid': alignedCandidates = candidates.filter(elm => isAligned(currentTargetRect, getBoundingClientRect(elm), dir)); if (alignedCandidates.length > 0) { candidates = alignedCandidates; } distanceFunction = getAbsoluteDistance; break; default: distanceFunction = getDistance; break; } return getClosestElement(currentElm, candidates, dir, distanceFunction); } /** * Select the best candidate among candidates by finding the closet candidate from the edge of the currently focused element (search origin). * @see {@link https://drafts.csswg.org/css-nav-1/#select-the-best-candidate (Step 5)} * @function selectBestCandidateFromEdge * @param currentElm {Node} - The currently focused element which is defined as 'search origin' in the spec * @param candidates {sequence<Node>} - The candidates for spatial navigation * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @returns {Node} The best candidate which will gain the focus */ function selectBestCandidateFromEdge(currentElm, candidates, dir) { if (startingPoint) return getClosestElement(currentElm, candidates, dir, getDistanceFromPoint); else return getClosestElement(currentElm, candidates, dir, getInnerDistance); } /** * Select the closest candidate from the currently focused element (search origin) among candidates by using the distance function. * @function getClosestElement * @param currentElm {Node} - The currently focused element which is defined as 'search origin' in the spec * @param candidates {sequence<Node>} - The candidates for spatial navigation * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @param distanceFunction {function} - The distance function which measures the distance from the search origin to each candidate * @returns {Node} The candidate which is the closest one from the search origin */ function getClosestElement(currentElm, candidates, dir, distanceFunction) { let eventTargetRect = null; if (( window.location !== window.parent.location ) && (currentElm.nodeName === 'BODY' || currentElm.nodeName === 'HTML')) { // If the eventTarget is iframe, then get rect of it based on its containing document // Set the iframe's position as (0,0) because the rects of elements inside the iframe don't know the real iframe's position. eventTargetRect = window.frameElement.getBoundingClientRect(); eventTargetRect.x = 0; eventTargetRect.y = 0; } else { eventTargetRect = searchOriginRect || currentElm.getBoundingClientRect(); } let minDistance = Number.POSITIVE_INFINITY; let minDistanceElements = []; if (candidates) { for (let i = 0; i < candidates.length; i++) { const distance = distanceFunction(eventTargetRect, getBoundingClientRect(candidates[i]), dir); // If the same distance, the candidate will be selected in the DOM order if (distance < minDistance) { minDistance = distance; minDistanceElements = [candidates[i]]; } else if (distance === minDistance) { minDistanceElements.push(candidates[i]); } } } if (minDistanceElements.length === 0) return null; return (minDistanceElements.length > 1 && distanceFunction === getAbsoluteDistance) ? getClosestElement(currentElm, minDistanceElements, dir, getEuclideanDistance) : minDistanceElements[0]; } /** * Get container of an element. * @see {@link https://drafts.csswg.org/css-nav-1/#dom-element-getspatialnavigationcontainer} * @module Element * @function getSpatialNavigationContainer * @returns {Node} The spatial navigation container */ function getSpatialNavigationContainer() { let container = this; do { if (!container.parentElement) { if (window.location !== window.parent.location) { container = window.parent.document.documentElement; } else { container = window.document.documentElement; } break; } else { container = container.parentElement; } } while (!isContainer(container)); return container; } /** * Get nearest scroll container of an element. * @function getScrollContainer * @param Element * @returns {Node} The spatial navigation container */ function getScrollContainer(element) { let scrollContainer = element; do { if (!scrollContainer.parentElement) { if (window.location !== window.parent.location) { scrollContainer = window.parent.document.documentElement; } else { scrollContainer = window.document.documentElement; } break; } else { scrollContainer = scrollContainer.parentElement; } } while (!isScrollContainer(scrollContainer) || !isVisible(scrollContainer)); if (scrollContainer === document || scrollContainer === document.documentElement) { scrollContainer = window; } return scrollContainer; } /** * Find focusable elements within the spatial navigation container. * @see {@link https://drafts.csswg.org/css-nav-1/#dom-element-focusableareas} * @function focusableAreas * @param option {FocusableAreasOptions} - 'mode' attribute takes 'visible' or 'all' for searching the boundary of focusable elements. * Default value is 'visible'. * @returns {sequence<Node>} All focusable elements or only visible focusable elements within the container */ function focusableAreas(option = {mode: 'visible'}) { const container = this.parentElement ? this : document.body; const focusables = Array.prototype.filter.call(container.getElementsByTagName('*'), isFocusable); return (option.mode === 'all') ? focusables : focusables.filter(isVisible); } /** * Create the NavigationEvent: navbeforefocus, navnotarget * @see {@link https://drafts.csswg.org/css-nav-1/#events-navigationevent} * @function createSpatNavEvents * @param option {string} - Type of the navigation event (beforefocus, notarget) * @param element {Node} - The target element of the event * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) */ function createSpatNavEvents(eventType, containerElement, currentElement, direction) { if (['beforefocus', 'notarget'].includes(eventType)) { const data = { causedTarget: currentElement, dir: direction }; const triggeredEvent = new CustomEvent('nav' + eventType, {bubbles: true, cancelable: true, detail: data}); return containerElement.dispatchEvent(triggeredEvent); } } /** * Get the value of the CSS custom property of the element * @function readCssVar * @param element {Node} * @param varName {string} - The name of the css custom property without '--' * @returns {string} The value of the css custom property */ function readCssVar(element, varName) { return element.style.getPropertyValue(`--${varName}`).trim(); } /** * Decide whether or not the 'contain' value is given to 'spatial-navigation-contain' css property of an element * @function isCSSSpatNavContain * @param element {Node} * @returns {boolean} */ function isCSSSpatNavContain(element) { return readCssVar(element, 'spatial-navigation-contain') === 'contain'; } /** * Return the value of 'spatial-navigation-action' css property of an element * @function getCSSSpatNavAction * @param element {Node} - would be the spatial navigation container * @returns {string} auto | focus | scroll */ function getCSSSpatNavAction(element) { return readCssVar(element, 'spatial-navigation-action') || 'auto'; } /** * Only move the focus with spatial navigation. Manually scrolling isn't available. * @function navigateChain * @param eventTarget {Node} - currently focused element * @param container {SpatialNavigationContainer} - container * @param parentContainer {SpatialNavigationContainer} - parent container * @param option - visible || all * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) */ function navigateChain(eventTarget, container, parentContainer, dir, option) { let currentOption = {candidates: getSpatialNavigationCandidates(container, {mode: option}), container}; while (parentContainer) { if (focusingController(eventTarget.spatialNavigationSearch(dir, currentOption), dir)) { return; } else { if ((option === 'visible') && scrollingController(container, dir)) return; else { if (!createSpatNavEvents('notarget', container, eventTarget, dir)) return; // find the container if (container === document || container === document.documentElement) { if ( window.location !== window.parent.location ) { // The page is in an iframe. eventTarget needs to be reset because the position of the element in the iframe eventTarget = window.frameElement; container = eventTarget.ownerDocument.documentElement; } } else { container = parentContainer; } currentOption = {candidates: getSpatialNavigationCandidates(container, {mode: option}), container}; let nextContainer = container.getSpatialNavigationContainer(); if (nextContainer !== container) { parentContainer = nextContainer; } else { parentContainer = null; } } } } currentOption = {candidates: getSpatialNavigationCandidates(container, {mode: option}), container}; // Behavior after 'navnotarget' - Getting out from the current spatnav container if ((!parentContainer && container) && focusingController(eventTarget.spatialNavigationSearch(dir, currentOption), dir)) return; if (!createSpatNavEvents('notarget', currentOption.container, eventTarget, dir)) return; if ((getCSSSpatNavAction(container) === 'auto') && (option === 'visible')) { if (scrollingController(container, dir)) return; } } /** * Find search origin * @see {@link https://drafts.csswg.org/css-nav-1/#nav} * @function findSearchOrigin * @returns {Node} The search origin for the spatial navigation */ function findSearchOrigin() { let searchOrigin = document.activeElement; if (!searchOrigin || (searchOrigin === document.body && !document.querySelector(':focus'))) { // When the previous search origin lost its focus by blur: (1) disable attribute (2) visibility: hidden if (savedSearchOrigin.element && (searchOrigin !== savedSearchOrigin.element)) { const elementStyle = window.getComputedStyle(savedSearchOrigin.element, null); const invisibleStyle = ['hidden', 'collapse']; if (savedSearchOrigin.element.disabled || invisibleStyle.includes(elementStyle.getPropertyValue('visibility'))) { searchOrigin = savedSearchOrigin.element; return searchOrigin; } } searchOrigin = document.documentElement; } // When the previous search origin lost its focus by blur: (1) display:none () element size turned into zero if (savedSearchOrigin.element && ((getBoundingClientRect(savedSearchOrigin.element).height === 0) || (getBoundingClientRect(savedSearchOrigin.element).width === 0))) { searchOriginRect = savedSearchOrigin.rect; } if (!isVisibleInScroller(searchOrigin)) { const scroller = getScrollContainer(searchOrigin); if (scroller && ((scroller === window) || (getCSSSpatNavAction(scroller) === 'auto'))) return scroller; } return searchOrigin; } /** * Move the scroll of an element depending on the given spatial navigation directrion * (Assume that User Agent defined distance is '40px') * @see {@link https://drafts.csswg.org/css-nav-1/#directionally-scroll-an-element} * @function moveScroll * @param element {Node} - The scrollable element * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @param offset {Number} - The explicit amount of offset for scrolling. Default value is 0. */ function moveScroll(element, dir, offset = 0) { if (element) { switch (dir) { case 'left': element.scrollLeft -= (40 + offset); break; case 'right': element.scrollLeft += (40 + offset); break; case 'up': element.scrollTop -= (40 + offset); break; case 'down': element.scrollTop += (40 + offset); break; } } } /** * Decide whether an element is container or not. * @function isContainer * @param element {Node} element * @returns {boolean} */ function isContainer(element) { return (!element.parentElement) || (element.nodeName === 'IFRAME') || (isScrollContainer(element)) || (isCSSSpatNavContain(element)); } /** * Decide whether an element is delegable container or not. * NOTE: THIS IS NON-NORMATIVE API. * @function isDelegableContainer * @param element {Node} element * @returns {boolean} */ function isDelegableContainer(element) { return readCssVar(element, 'spatial-navigation-contain') === 'delegable'; } /** * Decide whether an element is a scrollable container or not. * @see {@link https://drafts.csswg.org/css-overflow-3/#scroll-container} * @function isScrollContainer * @param element {Node} * @returns {boolean} */ function isScrollContainer(element) { const elementStyle = window.getComputedStyle(element, null); const overflowX = elementStyle.getPropertyValue('overflow-x'); const overflowY = elementStyle.getPropertyValue('overflow-y'); return ((overflowX !== 'visible' && overflowX !== 'clip' && isOverflow(element, 'left')) || (overflowY !== 'visible' && overflowY !== 'clip' && isOverflow(element, 'down'))) ? true : false; } /** * Decide whether this element is scrollable or not. * NOTE: If the value of 'overflow' is given to either 'visible', 'clip', or 'hidden', the element isn't scrollable. * If the value is 'hidden', the element can be only programmically scrollable. (https://drafts.csswg.org/css-overflow-3/#valdef-overflow-hidden) * @function isScrollable * @param element {Node} * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @returns {boolean} */ function isScrollable(element, dir) { // element, dir if (element && typeof element === 'object') { if (dir && typeof dir === 'string') { // parameter: dir, element if (isOverflow(element, dir)) { // style property const elementStyle = window.getComputedStyle(element, null); const overflowX = elementStyle.getPropertyValue('overflow-x'); const overflowY = elementStyle.getPropertyValue('overflow-y'); switch (dir) { case 'left': /* falls through */ case 'right': return (overflowX !== 'visible' && overflowX !== 'clip' && overflowX !== 'hidden'); case 'up': /* falls through */ case 'down': return (overflowY !== 'visible' && overflowY !== 'clip' && overflowY !== 'hidden'); } } return false; } else { // parameter: element return (element.nodeName === 'HTML' || element.nodeName === 'BODY') || (isScrollContainer(element) && isOverflow(element)); } } } /** * Decide whether an element is overflow or not. * @function isOverflow * @param element {Node} * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @returns {boolean} */ function isOverflow(element, dir) { if (element && typeof element === 'object') { if (dir && typeof dir === 'string') { // parameter: element, dir switch (dir) { case 'left': /* falls through */ case 'right': return (element.scrollWidth > element.clientWidth); case 'up': /* falls through */ case 'down': return (element.scrollHeight > element.clientHeight); } } else { // parameter: element return (element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight); } return false; } } /** * Decide whether the scrollbar of the browsing context reaches to the end or not. * @function isHTMLScrollBoundary * @param element {Node} - The top browsing context * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @returns {boolean} */ function isHTMLScrollBoundary(element, dir) { let result = false; switch (dir) { case 'left': result = element.scrollLeft === 0; break; case 'right': result = (element.scrollWidth - element.scrollLeft - element.clientWidth) === 0; break; case 'up': result = element.scrollTop === 0; break; case 'down': result = (element.scrollHeight - element.scrollTop - element.clientHeight) === 0; break; } return result; } /** * Decide whether the scrollbar of an element reaches to the end or not. * @function isScrollBoundary * @param element {Node} * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @returns {boolean} */ function isScrollBoundary(element, dir) { if (isScrollable(element, dir)) { const winScrollY = element.scrollTop; const winScrollX = element.scrollLeft; const height = element.scrollHeight - element.clientHeight; const width = element.scrollWidth - element.clientWidth; switch (dir) { case 'left': return (winScrollX === 0); case 'right': return (Math.abs(winScrollX - width) <= 1); case 'up': return (winScrollY === 0); case 'down': return (Math.abs(winScrollY - height) <= 1); } } return false; } /** * Decide whether an element is inside the scorller viewport or not * * @function isVisibleInScroller * @param element {Node} * @returns {boolean} */ function isVisibleInScroller(element) { const elementRect = element.getBoundingClientRect(); let nearestScroller = getScrollContainer(element); let scrollerRect = null; if (nearestScroller !== window) { scrollerRect = getBoundingClientRect(nearestScroller); } else { scrollerRect = new DOMRect(0, 0, window.innerWidth, window.innerHeight); } if (isInside(scrollerRect, elementRect, 'left') && isInside(scrollerRect, elementRect, 'down')) return true; else return false; } /** * Decide whether an element is focusable for spatial navigation. * 1. If element is the browsing context (document, iframe), then it's focusable, * 2. If the element is scrollable container (regardless of scrollable axis), then it's focusable, * 3. The value of tabIndex >= 0, then it's focusable, * 4. If the element is disabled, it isn't focusable, * 5. If the element is expressly inert, it isn't focusable, * 6. Whether the element is being rendered or not. * * @function isFocusable * @param element {Node} * @returns {boolean} * * @see {@link https://html.spec.whatwg.org/multipage/interaction.html#focusable-area} */ function isFocusable(element) { if ((element.tabIndex < 0) || isAtagWithoutHref(element) || isActuallyDisabled(element) || isExpresslyInert(element) || !isBeingRendered(element)) return false; else if ((!element.parentElement) || (isScrollable(element) && isOverflow(element)) || (element.tabIndex >= 0)) return true; } /** * Decide whether an element is a tag without href attribute or not. * * @function isAtagWithoutHref * @param element {Node} * @returns {boolean} */ function isAtagWithoutHref(element) { return (element.tagName === 'A' && element.getAttribute('href') === null && element.getAttribute('tabIndex') === null); } /** * Decide whether an element is actually disabled or not. * * @function isActuallyDisabled * @param element {Node} * @returns {boolean} * * @see {@link https://html.spec.whatwg.org/multipage/semantics-other.html#concept-element-disabled} */ function isActuallyDisabled(element) { if (['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTGROUP', 'OPTION', 'FIELDSET'].includes(element.tagName)) return (element.disabled); else return false; } /** * Decide whether the element is expressly inert or not. * @see {@link https://html.spec.whatwg.org/multipage/interaction.html#expressly-inert} * @function isExpresslyInert * @param element {Node} * @returns {boolean} */ function isExpresslyInert(element) { return ((element.inert) && (!element.ownerDocument.documentElement.inert)); } /** * Decide whether the element is being rendered or not. * 1. If an element has the style as "visibility: hidden | collapse" or "display: none", it is not being rendered. * 2. If an element has the style as "opacity: 0", it is not being rendered.(that is, invisible). * 3. If width and height of an element are explicitly set to 0, it is not being rendered. * 4. If a parent element is hidden, an element itself is not being rendered. * (CSS visibility property and display property are inherited.) * @see {@link https://html.spec.whatwg.org/multipage/rendering.html#being-rendered} * @function isBeingRendered * @param element {Node} * @returns {boolean} */ function isBeingRendered(element) { if (!isVisibleStyleProperty(element.parentElement)) return false; if (!isVisibleStyleProperty(element) || (element.style.opacity === '0') || (window.getComputedStyle(element).height === '0px' || window.getComputedStyle(element).width === '0px')) return false; return true; } /** * Decide whether this element is partially or completely visible to user agent. * @function isVisible * @param element {Node} * @returns {boolean} */ function isVisible(element) { return (!element.parentElement) || (isVisibleStyleProperty(element) && hitTest(element)); } /** * Decide whether this element is completely visible in this viewport for the arrow direction. * @function isEntirelyVisible * @param element {Node} * @returns {boolean} */ function isEntirelyVisible(element, container) { const rect = getBoundingClientRect(element); const containerElm = container || element.getSpatialNavigationContainer(); const containerRect = getBoundingClientRect(containerElm); // FIXME: when element is bigger than container? const entirelyVisible = !((rect.left < containerRect.left) || (rect.right > containerRect.right) || (rect.top < containerRect.top) || (rect.bottom > containerRect.bottom)); return entirelyVisible; } /** * Decide the style property of this element is specified whether it's visible or not. * @function isVisibleStyleProperty * @param element {CSSStyleDeclaration} * @returns {boolean} */ function isVisibleStyleProperty(element) { const elementStyle = window.getComputedStyle(element, null); const thisVisibility = elementStyle.getPropertyValue('visibility'); const thisDisplay = elementStyle.getPropertyValue('display'); const invisibleStyle = ['hidden', 'collapse']; return (thisDisplay !== 'none' && !invisibleStyle.includes(thisVisibility)); } /** * Decide whether this element is entirely or partially visible within the viewport. * @function hitTest * @param element {Node} * @returns {boolean} */ function hitTest(element) { const elementRect = getBoundingClientRect(element); if (element.nodeName !== 'IFRAME' && (elementRect.top < 0 || elementRect.left < 0 || elementRect.top > element.ownerDocument.documentElement.clientHeight || elementRect.left >element.ownerDocument.documentElement.clientWidth)) return false; let offsetX = parseInt(element.offsetWidth) / 10; let offsetY = parseInt(element.offsetHeight) / 10; offsetX = isNaN(offsetX) ? 1 : offsetX; offsetY = isNaN(offsetY) ? 1 : offsetY; const hitTestPoint = { // For performance, just using the three point(middle, leftTop, rightBottom) of the element for hit testing middle: [(elementRect.left + elementRect.right) / 2, (elementRect.top + elementRect.bottom) / 2], leftTop: [elementRect.left + offsetX, elementRect.top + offsetY], rightBottom: [elementRect.right - offsetX, elementRect.bottom - offsetY] }; for(const point in hitTestPoint) { const elemFromPoint = element.ownerDocument.elementFromPoint(...hitTestPoint[point]); if (element === elemFromPoint || element.contains(elemFromPoint)) { return true; } } return false; } /** * Decide whether a child element is entirely or partially Included within container visually. * @function isInside * @param containerRect {DOMRect} * @param childRect {DOMRect} * @returns {boolean} */ function isInside(containerRect, childRect) { const rightEdgeCheck = (containerRect.left <= childRect.right && containerRect.right >= childRect.right); const leftEdgeCheck = (containerRect.left <= childRect.left && containerRect.right >= childRect.left); const topEdgeCheck = (containerRect.top <= childRect.top && containerRect.bottom >= childRect.top); const bottomEdgeCheck = (containerRect.top <= childRect.bottom && containerRect.bottom >= childRect.bottom); return (rightEdgeCheck || leftEdgeCheck) && (topEdgeCheck || bottomEdgeCheck); } /** * Decide whether this element is entirely or partially visible within the viewport. * Note: rect1 is outside of rect2 for the dir * @function isOutside * @param rect1 {DOMRect} * @param rect2 {DOMRect} * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) * @returns {boolean} */ function isOutside(rect1,