From 411ebec55b85dfe45616b6f7a22c872d4dc02792 Mon Sep 17 00:00:00 2001 From: throwaway96 <68320646+throwaway96@users.noreply.github.com> Date: Sun, 24 Mar 2024 17:35:39 -0400 Subject: [PATCH 1/5] normalize line endings to LF Add .gitattributes files to ensure git knows which files are text. Ensure that shell scripts (including .husky/pre-commit) use LF. --- .browserslistrc | 2 +- .editorconfig | 1 - .gitattributes | 23 + .husky/.gitattributes | 1 + src/.eslintignore | 2 +- src/spatial-navigation-polyfill.js | 3512 ++++++++++++++-------------- 6 files changed, 1782 insertions(+), 1759 deletions(-) create mode 100644 .gitattributes create mode 100644 .husky/.gitattributes diff --git a/.browserslistrc b/.browserslistrc index 4518d438..d83a5f78 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1 +1 @@ -chrome 38-79 +chrome 38-79 diff --git a/.editorconfig b/.editorconfig index 69b47751..c042a411 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,6 @@ # top-most EditorConfig file root = true -# Unix-style newlines with a newline ending every file [*] indent_style = space indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a3306784 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +* text=auto + +*.css text +*.html text +*.js text +*.json text +*.md text +*.mjs text +*.svg text +*.yml text + +.browserslistrc text +.editorconfig text +.eslintignore text +.gitattributes text +.gitignore text +.prettierignore text +LICENSE text + +*.sh text eol=lf + +*.jpg binary +*.png binary diff --git a/.husky/.gitattributes b/.husky/.gitattributes new file mode 100644 index 00000000..a6a13212 --- /dev/null +++ b/.husky/.gitattributes @@ -0,0 +1 @@ +pre-commit text eol=lf diff --git a/src/.eslintignore b/src/.eslintignore index 9dfc41f1..a8d669d9 100644 --- a/src/.eslintignore +++ b/src/.eslintignore @@ -1 +1 @@ -*-polyfill.* \ No newline at end of file +*-polyfill.* diff --git a/src/spatial-navigation-polyfill.js b/src/spatial-navigation-polyfill.js index 00244ef1..2cfb8c31 100644 --- a/src/spatial-navigation-polyfill.js +++ b/src/spatial-navigation-polyfill.js @@ -1,1756 +1,1756 @@ -// -// https://raw.githubusercontent.com/WICG/spatial-navigation/183f0146b6741007e46fa64ab0950447defdf8af/polyfill/spatial-navigation-polyfill.js -// License: MIT -// - -/* 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} 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} - 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} - 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} - 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} 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 - 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} - 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} - 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} - 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} 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) { - // 20210606 fix getPropertyValue returning null ~inf - 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, rect2, dir) { - switch (dir) { - case 'left': - return isRightSide(rect2, rect1); - case 'right': - return isRightSide(rect1, rect2); - case 'up': - return isBelow(rect2, rect1); - case 'down': - return isBelow(rect1, rect2); - default: - return false; - } - } - - /* rect1 is right of rect2 */ - function isRightSide(rect1, rect2) { - return rect1.left >= rect2.right || (rect1.left >= rect2.left && rect1.right > rect2.right && rect1.bottom > rect2.top && rect1.top < rect2.bottom); - } - - /* rect1 is below of rect2 */ - function isBelow(rect1, rect2) { - return rect1.top >= rect2.bottom || (rect1.top >= rect2.top && rect1.bottom > rect2.bottom && rect1.left < rect2.right && rect1.right > rect2.left); - } - - /* rect1 is completely aligned or partially aligned for the direction */ - function isAligned(rect1, rect2, dir) { - switch (dir) { - case 'left' : - /* falls through */ - case 'right' : - return rect1.bottom > rect2.top && rect1.top < rect2.bottom; - case 'up' : - /* falls through */ - case 'down' : - return rect1.right > rect2.left && rect1.left < rect2.right; - default: - return false; - } - } - - /** - * Get distance between the search origin and a candidate element along the direction when candidate element is inside the search origin. - * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} - * @function getDistanceFromPoint - * @param point {Point} - The search origin - * @param element {DOMRect} - A candidate element - * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) - * @returns {Number} The euclidian distance between the spatial navigation container and an element inside it - */ - function getDistanceFromPoint(point, element, dir) { - point = startingPoint; - // Get exit point, entry point -> {x: '', y: ''}; - const points = getEntryAndExitPoints(dir, point, element); - - // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate - // that minimize the distance between these two points - const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x); - const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); - - // The result is euclidian distance between P1 and P2. - return Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); - } - - /** - * Get distance between the search origin and a candidate element along the direction when candidate element is inside the search origin. - * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} - * @function getInnerDistance - * @param rect1 {DOMRect} - The search origin - * @param rect2 {DOMRect} - A candidate element - * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) - * @returns {Number} The euclidean distance between the spatial navigation container and an element inside it - */ - function getInnerDistance(rect1, rect2, dir) { - const baseEdgeForEachDirection = {left: 'right', right: 'left', up: 'bottom', down: 'top'}; - const baseEdge = baseEdgeForEachDirection[dir]; - - return Math.abs(rect1[baseEdge] - rect2[baseEdge]); - } - - /** - * Get the distance between the search origin and a candidate element considering the direction. - * @see {@link https://drafts.csswg.org/css-nav-1/#calculating-the-distance} - * @function getDistance - * @param searchOrigin {DOMRect | Point} - The search origin - * @param candidateRect {DOMRect} - A candidate element - * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) - * @returns {Number} The distance scoring between two elements - */ - function getDistance(searchOrigin, candidateRect, dir) { - const kOrthogonalWeightForLeftRight = 30; - const kOrthogonalWeightForUpDown = 2; - - let orthogonalBias = 0; - let alignBias = 0; - const alignWeight = 5.0; - - // Get exit point, entry point -> {x: '', y: ''}; - const points = getEntryAndExitPoints(dir, searchOrigin, candidateRect); - - // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate - // that minimize the distance between these two points - const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x); - const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); - - // A: The euclidean distance between P1 and P2. - const A = Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); - let B, C; - - // B: The absolute distance in the direction which is orthogonal to dir between P1 and P2, or 0 if dir is null. - // C: The intersection edges between a candidate and the starting point. - - // D: The square root of the area of intersection between the border boxes of candidate and starting point - const intersectionRect = getIntersectionRect(searchOrigin, candidateRect); - const D = intersectionRect.area; - - switch (dir) { - case 'left': - /* falls through */ - case 'right' : - // If two elements are aligned, add align bias - // else, add orthogonal bias - if (isAligned(searchOrigin, candidateRect, dir)) - alignBias = Math.min(intersectionRect.height / searchOrigin.height , 1); - else - orthogonalBias = (searchOrigin.height / 2); - - B = (P2 + orthogonalBias) * kOrthogonalWeightForLeftRight; - C = alignWeight * alignBias; - break; - - case 'up' : - /* falls through */ - case 'down' : - // If two elements are aligned, add align bias - // else, add orthogonal bias - if (isAligned(searchOrigin, candidateRect, dir)) - alignBias = Math.min(intersectionRect.width / searchOrigin.width , 1); - else - orthogonalBias = (searchOrigin.width / 2); - - B = (P1 + orthogonalBias) * kOrthogonalWeightForUpDown; - C = alignWeight * alignBias; - break; - - default: - B = 0; - C = 0; - break; - } - - return (A + B - C - D); - } - - /** - * Get the euclidean distance between the search origin and a candidate element considering the direction. - * @function getEuclideanDistance - * @param rect1 {DOMRect} - The search origin - * @param rect2 {DOMRect} - A candidate element - * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) - * @returns {Number} The distance scoring between two elements - */ - function getEuclideanDistance(rect1, rect2, dir) { - // Get exit point, entry point - const points = getEntryAndExitPoints(dir, rect1, rect2); - - // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate - // that minimize the distance between these two points - const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x); - const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); - - // Return the euclidean distance between P1 and P2. - return Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); - } - - /** - * Get the absolute distance between the search origin and a candidate element considering the direction. - * @function getAbsoluteDistance - * @param rect1 {DOMRect} - The search origin - * @param rect2 {DOMRect} - A candidate element - * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) - * @returns {Number} The distance scoring between two elements - */ - function getAbsoluteDistance(rect1, rect2, dir) { - // Get exit point, entry point - const points = getEntryAndExitPoints(dir, rect1, rect2); - - // Return the absolute distance in the dir direction between P1 and P. - return ((dir === 'left') || (dir === 'right')) ? - Math.abs(points.entryPoint.x - points.exitPoint.x) : Math.abs(points.entryPoint.y - points.exitPoint.y); - } - - /** - * Get entry point and exit point of two elements considering the direction. - * @function getEntryAndExitPoints - * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD). Default value for dir is 'down'. - * @param searchOrigin {DOMRect | Point} - The search origin which contains the exit point - * @param candidateRect {DOMRect} - One of candidates which contains the entry point - * @returns {Points} The exit point from the search origin and the entry point from a candidate - */ - function getEntryAndExitPoints(dir = 'down', searchOrigin, candidateRect) { - /** - * User type definition for Point - * @typeof {Object} Points - * @property {Point} Points.entryPoint - * @property {Point} Points.exitPoint - */ - const points = {entryPoint: {x: 0, y: 0}, exitPoint:{x: 0, y: 0}}; - - if (startingPoint) { - points.exitPoint = searchOrigin; - - switch (dir) { - case 'left': - points.entryPoint.x = candidateRect.right; - break; - case 'up': - points.entryPoint.y = candidateRect.bottom; - break; - case 'right': - points.entryPoint.x = candidateRect.left; - break; - case 'down': - points.entryPoint.y = candidateRect.top; - break; - } - - // Set orthogonal direction - switch (dir) { - case 'left': - case 'right': - if (startingPoint.y <= candidateRect.top) { - points.entryPoint.y = candidateRect.top; - } else if (startingPoint.y < candidateRect.bottom) { - points.entryPoint.y = startingPoint.y; - } else { - points.entryPoint.y = candidateRect.bottom; - } - break; - - case 'up': - case 'down': - if (startingPoint.x <= candidateRect.left) { - points.entryPoint.x = candidateRect.left; - } else if (startingPoint.x < candidateRect.right) { - points.entryPoint.x = startingPoint.x; - } else { - points.entryPoint.x = candidateRect.right; - } - break; - } - } - else { - // Set direction - switch (dir) { - case 'left': - points.exitPoint.x = searchOrigin.left; - points.entryPoint.x = (candidateRect.right < searchOrigin.left) ? candidateRect.right : searchOrigin.left; - break; - case 'up': - points.exitPoint.y = searchOrigin.top; - points.entryPoint.y = (candidateRect.bottom < searchOrigin.top) ? candidateRect.bottom : searchOrigin.top; - break; - case 'right': - points.exitPoint.x = searchOrigin.right; - points.entryPoint.x = (candidateRect.left > searchOrigin.right) ? candidateRect.left : searchOrigin.right; - break; - case 'down': - points.exitPoint.y = searchOrigin.bottom; - points.entryPoint.y = (candidateRect.top > searchOrigin.bottom) ? candidateRect.top : searchOrigin.bottom; - break; - } - - // Set orthogonal direction - switch (dir) { - case 'left': - case 'right': - if (isBelow(searchOrigin, candidateRect)) { - points.exitPoint.y = searchOrigin.top; - points.entryPoint.y = (candidateRect.bottom < searchOrigin.top) ? candidateRect.bottom : searchOrigin.top; - } else if (isBelow(candidateRect, searchOrigin)) { - points.exitPoint.y = searchOrigin.bottom; - points.entryPoint.y = (candidateRect.top > searchOrigin.bottom) ? candidateRect.top : searchOrigin.bottom; - } else { - points.exitPoint.y = Math.max(searchOrigin.top, candidateRect.top); - points.entryPoint.y = points.exitPoint.y; - } - break; - - case 'up': - case 'down': - if (isRightSide(searchOrigin, candidateRect)) { - points.exitPoint.x = searchOrigin.left; - points.entryPoint.x = (candidateRect.right < searchOrigin.left) ? candidateRect.right : searchOrigin.left; - } else if (isRightSide(candidateRect, searchOrigin)) { - points.exitPoint.x = searchOrigin.right; - points.entryPoint.x = (candidateRect.left > searchOrigin.right) ? candidateRect.left : searchOrigin.right; - } else { - points.exitPoint.x = Math.max(searchOrigin.left, candidateRect.left); - points.entryPoint.x = points.exitPoint.x; - } - break; - } - } - - return points; - } - - /** - * Find focusable elements within the container - * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} - * @function getIntersectionRect - * @param rect1 {DOMRect} - The search origin which contains the exit point - * @param rect2 {DOMRect} - One of candidates which contains the entry point - * @returns {IntersectionArea} The intersection area between two elements. - * - * @typeof {Object} IntersectionArea - * @property {Number} IntersectionArea.width - * @property {Number} IntersectionArea.height - */ - function getIntersectionRect(rect1, rect2) { - const intersection_rect = {width: 0, height: 0, area: 0}; - - const new_location = [Math.max(rect1.left, rect2.left), Math.max(rect1.top, rect2.top)]; - const new_max_point = [Math.min(rect1.right, rect2.right), Math.min(rect1.bottom, rect2.bottom)]; - - intersection_rect.width = Math.abs(new_location[0] - new_max_point[0]); - intersection_rect.height = Math.abs(new_location[1] - new_max_point[1]); - - if (!(new_location[0] >= new_max_point[0] || new_location[1] >= new_max_point[1])) { - // intersecting-cases - intersection_rect.area = Math.sqrt(intersection_rect.width * intersection_rect.height); - } - - return intersection_rect; - } - - /** - * Handle the spatial navigation behavior for HTMLInputElement, HTMLTextAreaElement - * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input|HTMLInputElement (MDN)} - * @function handlingEditableElement - * @param e {Event} - keydownEvent - * @returns {boolean} - */ - function handlingEditableElement(e) { - const SPINNABLE_INPUT_TYPES = ['email', 'date', 'month', 'number', 'time', 'week'], - TEXT_INPUT_TYPES = ['password', 'text', 'search', 'tel', 'url', null]; - const eventTarget = document.activeElement; - const focusNavigableArrowKey = {left: false, up: false, right: false, down: false}; - - const dir = ARROW_KEY_CODE[e.keyCode]; - if (dir === undefined) { - return focusNavigableArrowKey; - } - - if (SPINNABLE_INPUT_TYPES.includes(eventTarget.getAttribute('type')) && - (dir === 'up' || dir === 'down')) { - focusNavigableArrowKey[dir] = true; - } else if (TEXT_INPUT_TYPES.includes(eventTarget.getAttribute('type')) || eventTarget.nodeName === 'TEXTAREA') { - // 20210606 fix selectionStart unavailable on checkboxes ~inf - const startPosition = eventTarget.selectionStart; - const endPosition = eventTarget.selectionEnd; - if (startPosition === endPosition) { // if there isn't any selected text - if (startPosition === 0) { - focusNavigableArrowKey.left = true; - focusNavigableArrowKey.up = true; - } - if (endPosition === eventTarget.value.length) { - focusNavigableArrowKey.right = true; - focusNavigableArrowKey.down = true; - } - } - } else { // HTMLDataListElement, HTMLSelectElement, HTMLOptGroup - focusNavigableArrowKey[dir] = true; - } - - return focusNavigableArrowKey; - } - - /** - * Get the DOMRect of an element - * @function getBoundingClientRect - * @param {Node} element - * @returns {DOMRect} - */ - function getBoundingClientRect(element) { - // memoization - let rect = mapOfBoundRect && mapOfBoundRect.get(element); - if (!rect) { - const boundingClientRect = element.getBoundingClientRect(); - rect = { - top: Number(boundingClientRect.top.toFixed(2)), - right: Number(boundingClientRect.right.toFixed(2)), - bottom: Number(boundingClientRect.bottom.toFixed(2)), - left: Number(boundingClientRect.left.toFixed(2)), - width: Number(boundingClientRect.width.toFixed(2)), - height: Number(boundingClientRect.height.toFixed(2)) - }; - mapOfBoundRect && mapOfBoundRect.set(element, rect); - } - return rect; - } - - /** - * Get the candidates which is fully inside the target element in visual - * @param {Node} targetElement - * @returns {sequence} overlappedCandidates - */ - function getOverlappedCandidates(targetElement) { - const container = targetElement.getSpatialNavigationContainer(); - const candidates = container.focusableAreas(); - const overlappedCandidates = []; - - candidates.forEach(element => { - if ((targetElement !== element) && isEntirelyVisible(element, targetElement)) { - overlappedCandidates.push(element); - } - }); - - return overlappedCandidates; - } - - /** - * Get the list of the experimental APIs - * @function getExperimentalAPI - */ - function getExperimentalAPI() { - function canScroll(container, dir) { - return (isScrollable(container, dir) && !isScrollBoundary(container, dir)) || - (!container.parentElement && !isHTMLScrollBoundary(container, dir)); - } - - function findTarget(findCandidate, element, dir, option) { - let eventTarget = element; - let bestNextTarget = null; - - // 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 - if ((isContainer(eventTarget) || eventTarget.nodeName === 'BODY') && !(eventTarget.nodeName === 'INPUT')) { - if (eventTarget.nodeName === 'IFRAME') - eventTarget = eventTarget.contentDocument.body; - - const candidates = getSpatialNavigationCandidates(eventTarget, option); - - // 5-2 - if (Array.isArray(candidates) && candidates.length > 0) { - return findCandidate ? getFilteredSpatialNavigationCandidates(eventTarget, dir, candidates) : eventTarget.spatialNavigationSearch(dir, {candidates}); - } - if (canScroll(eventTarget, dir)) { - return findCandidate ? [] : eventTarget; - } - } - - // 6 - // Let container be the nearest ancestor of eventTarget - let 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; - } - - // 7 - while (parentContainer) { - const candidates = filteredCandidates(eventTarget, getSpatialNavigationCandidates(container, option), dir, container); - - if (Array.isArray(candidates) && candidates.length > 0) { - bestNextTarget = eventTarget.spatialNavigationSearch(dir, {candidates, container}); - if (bestNextTarget) { - return findCandidate ? candidates : bestNextTarget; - } - } - - // If there isn't any candidate and the best candidate among candidate: - // 1) Scroll or 2) Find candidates of the ancestor container - // 8 - if - else if (canScroll(container, dir)) { - return findCandidate ? [] : eventTarget; - } else if (container === document || container === document.documentElement) { - container = window.document.documentElement; - - // The page is in an iframe - if ( window.location !== window.parent.location ) { - // eventTarget needs to be reset because the position of the element in the IFRAME - // is unuseful when the focus moves out of the iframe - eventTarget = window.frameElement; - container = window.parent.document.documentElement; - if (container.parentElement) - parentContainer = container.getSpatialNavigationContainer(); - else { - parentContainer = null; - break; - } - } - } else { - // avoiding when spatnav container with tabindex=-1 - if (isFocusable(container)) { - eventTarget = container; - } - - container = parentContainer; - if (container.parentElement) - parentContainer = container.getSpatialNavigationContainer(); - else { - parentContainer = null; - break; - } - } - } - - if (!parentContainer && container) { - // Getting out from the current spatnav container - const candidates = filteredCandidates(eventTarget, getSpatialNavigationCandidates(container, option), dir, container); - - // 9 - if (Array.isArray(candidates) && candidates.length > 0) { - bestNextTarget = eventTarget.spatialNavigationSearch(dir, {candidates, container}); - if (bestNextTarget) { - return findCandidate ? candidates : bestNextTarget; - } - } - } - - if (canScroll(container, dir)) { - bestNextTarget = eventTarget; - return bestNextTarget; - } - } - - return { - isContainer, - isScrollContainer, - isVisibleInScroller, - findCandidates: findTarget.bind(null, true), - findNextTarget: findTarget.bind(null, false), - getDistanceFromTarget: (element, candidateElement, dir) => { - if ((isContainer(element) || element.nodeName === 'BODY') && !(element.nodeName === 'INPUT')) { - if (getSpatialNavigationCandidates(element).includes(candidateElement)) { - return getInnerDistance(getBoundingClientRect(element), getBoundingClientRect(candidateElement), dir); - } - } - return getDistance(getBoundingClientRect(element), getBoundingClientRect(candidateElement), dir); - } - }; - } - - /** - * Makes to use the experimental APIs. - * @function enableExperimentalAPIs - * @param option {boolean} - If it is true, the experimental APIs can be used or it cannot. - */ - function enableExperimentalAPIs (option) { - const currentKeyMode = window.__spatialNavigation__ && window.__spatialNavigation__.keyMode; - window.__spatialNavigation__ = (option === false) ? getInitialAPIs() : Object.assign(getInitialAPIs(), getExperimentalAPI()); - window.__spatialNavigation__.keyMode = currentKeyMode; - Object.seal(window.__spatialNavigation__); - } - - /** - * Set the environment for using the spatial navigation polyfill. - * @function getInitialAPIs - */ - function getInitialAPIs() { - return { - enableExperimentalAPIs, - get keyMode() { return this._keymode ? this._keymode : 'ARROW'; }, - set keyMode(mode) { this._keymode = (['SHIFTARROW', 'ARROW', 'NONE'].includes(mode)) ? mode : 'ARROW'; }, - setStartingPoint: function (x, y) {startingPoint = (x && y) ? {x, y} : null;} - }; - } - - initiateSpatialNavigation(); - enableExperimentalAPIs(false); - - window.addEventListener('load', () => { - spatialNavigationHandler(); - }); -})(); +// +// https://raw.githubusercontent.com/WICG/spatial-navigation/183f0146b6741007e46fa64ab0950447defdf8af/polyfill/spatial-navigation-polyfill.js +// License: MIT +// + +/* 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} 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} - 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} - 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} - 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} 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 + 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} - 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} - 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} - 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} 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) { + // 20210606 fix getPropertyValue returning null ~inf + 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, rect2, dir) { + switch (dir) { + case 'left': + return isRightSide(rect2, rect1); + case 'right': + return isRightSide(rect1, rect2); + case 'up': + return isBelow(rect2, rect1); + case 'down': + return isBelow(rect1, rect2); + default: + return false; + } + } + + /* rect1 is right of rect2 */ + function isRightSide(rect1, rect2) { + return rect1.left >= rect2.right || (rect1.left >= rect2.left && rect1.right > rect2.right && rect1.bottom > rect2.top && rect1.top < rect2.bottom); + } + + /* rect1 is below of rect2 */ + function isBelow(rect1, rect2) { + return rect1.top >= rect2.bottom || (rect1.top >= rect2.top && rect1.bottom > rect2.bottom && rect1.left < rect2.right && rect1.right > rect2.left); + } + + /* rect1 is completely aligned or partially aligned for the direction */ + function isAligned(rect1, rect2, dir) { + switch (dir) { + case 'left' : + /* falls through */ + case 'right' : + return rect1.bottom > rect2.top && rect1.top < rect2.bottom; + case 'up' : + /* falls through */ + case 'down' : + return rect1.right > rect2.left && rect1.left < rect2.right; + default: + return false; + } + } + + /** + * Get distance between the search origin and a candidate element along the direction when candidate element is inside the search origin. + * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} + * @function getDistanceFromPoint + * @param point {Point} - The search origin + * @param element {DOMRect} - A candidate element + * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) + * @returns {Number} The euclidian distance between the spatial navigation container and an element inside it + */ + function getDistanceFromPoint(point, element, dir) { + point = startingPoint; + // Get exit point, entry point -> {x: '', y: ''}; + const points = getEntryAndExitPoints(dir, point, element); + + // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate + // that minimize the distance between these two points + const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x); + const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); + + // The result is euclidian distance between P1 and P2. + return Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); + } + + /** + * Get distance between the search origin and a candidate element along the direction when candidate element is inside the search origin. + * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} + * @function getInnerDistance + * @param rect1 {DOMRect} - The search origin + * @param rect2 {DOMRect} - A candidate element + * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) + * @returns {Number} The euclidean distance between the spatial navigation container and an element inside it + */ + function getInnerDistance(rect1, rect2, dir) { + const baseEdgeForEachDirection = {left: 'right', right: 'left', up: 'bottom', down: 'top'}; + const baseEdge = baseEdgeForEachDirection[dir]; + + return Math.abs(rect1[baseEdge] - rect2[baseEdge]); + } + + /** + * Get the distance between the search origin and a candidate element considering the direction. + * @see {@link https://drafts.csswg.org/css-nav-1/#calculating-the-distance} + * @function getDistance + * @param searchOrigin {DOMRect | Point} - The search origin + * @param candidateRect {DOMRect} - A candidate element + * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) + * @returns {Number} The distance scoring between two elements + */ + function getDistance(searchOrigin, candidateRect, dir) { + const kOrthogonalWeightForLeftRight = 30; + const kOrthogonalWeightForUpDown = 2; + + let orthogonalBias = 0; + let alignBias = 0; + const alignWeight = 5.0; + + // Get exit point, entry point -> {x: '', y: ''}; + const points = getEntryAndExitPoints(dir, searchOrigin, candidateRect); + + // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate + // that minimize the distance between these two points + const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x); + const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); + + // A: The euclidean distance between P1 and P2. + const A = Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); + let B, C; + + // B: The absolute distance in the direction which is orthogonal to dir between P1 and P2, or 0 if dir is null. + // C: The intersection edges between a candidate and the starting point. + + // D: The square root of the area of intersection between the border boxes of candidate and starting point + const intersectionRect = getIntersectionRect(searchOrigin, candidateRect); + const D = intersectionRect.area; + + switch (dir) { + case 'left': + /* falls through */ + case 'right' : + // If two elements are aligned, add align bias + // else, add orthogonal bias + if (isAligned(searchOrigin, candidateRect, dir)) + alignBias = Math.min(intersectionRect.height / searchOrigin.height , 1); + else + orthogonalBias = (searchOrigin.height / 2); + + B = (P2 + orthogonalBias) * kOrthogonalWeightForLeftRight; + C = alignWeight * alignBias; + break; + + case 'up' : + /* falls through */ + case 'down' : + // If two elements are aligned, add align bias + // else, add orthogonal bias + if (isAligned(searchOrigin, candidateRect, dir)) + alignBias = Math.min(intersectionRect.width / searchOrigin.width , 1); + else + orthogonalBias = (searchOrigin.width / 2); + + B = (P1 + orthogonalBias) * kOrthogonalWeightForUpDown; + C = alignWeight * alignBias; + break; + + default: + B = 0; + C = 0; + break; + } + + return (A + B - C - D); + } + + /** + * Get the euclidean distance between the search origin and a candidate element considering the direction. + * @function getEuclideanDistance + * @param rect1 {DOMRect} - The search origin + * @param rect2 {DOMRect} - A candidate element + * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) + * @returns {Number} The distance scoring between two elements + */ + function getEuclideanDistance(rect1, rect2, dir) { + // Get exit point, entry point + const points = getEntryAndExitPoints(dir, rect1, rect2); + + // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate + // that minimize the distance between these two points + const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x); + const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); + + // Return the euclidean distance between P1 and P2. + return Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); + } + + /** + * Get the absolute distance between the search origin and a candidate element considering the direction. + * @function getAbsoluteDistance + * @param rect1 {DOMRect} - The search origin + * @param rect2 {DOMRect} - A candidate element + * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) + * @returns {Number} The distance scoring between two elements + */ + function getAbsoluteDistance(rect1, rect2, dir) { + // Get exit point, entry point + const points = getEntryAndExitPoints(dir, rect1, rect2); + + // Return the absolute distance in the dir direction between P1 and P. + return ((dir === 'left') || (dir === 'right')) ? + Math.abs(points.entryPoint.x - points.exitPoint.x) : Math.abs(points.entryPoint.y - points.exitPoint.y); + } + + /** + * Get entry point and exit point of two elements considering the direction. + * @function getEntryAndExitPoints + * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD). Default value for dir is 'down'. + * @param searchOrigin {DOMRect | Point} - The search origin which contains the exit point + * @param candidateRect {DOMRect} - One of candidates which contains the entry point + * @returns {Points} The exit point from the search origin and the entry point from a candidate + */ + function getEntryAndExitPoints(dir = 'down', searchOrigin, candidateRect) { + /** + * User type definition for Point + * @typeof {Object} Points + * @property {Point} Points.entryPoint + * @property {Point} Points.exitPoint + */ + const points = {entryPoint: {x: 0, y: 0}, exitPoint:{x: 0, y: 0}}; + + if (startingPoint) { + points.exitPoint = searchOrigin; + + switch (dir) { + case 'left': + points.entryPoint.x = candidateRect.right; + break; + case 'up': + points.entryPoint.y = candidateRect.bottom; + break; + case 'right': + points.entryPoint.x = candidateRect.left; + break; + case 'down': + points.entryPoint.y = candidateRect.top; + break; + } + + // Set orthogonal direction + switch (dir) { + case 'left': + case 'right': + if (startingPoint.y <= candidateRect.top) { + points.entryPoint.y = candidateRect.top; + } else if (startingPoint.y < candidateRect.bottom) { + points.entryPoint.y = startingPoint.y; + } else { + points.entryPoint.y = candidateRect.bottom; + } + break; + + case 'up': + case 'down': + if (startingPoint.x <= candidateRect.left) { + points.entryPoint.x = candidateRect.left; + } else if (startingPoint.x < candidateRect.right) { + points.entryPoint.x = startingPoint.x; + } else { + points.entryPoint.x = candidateRect.right; + } + break; + } + } + else { + // Set direction + switch (dir) { + case 'left': + points.exitPoint.x = searchOrigin.left; + points.entryPoint.x = (candidateRect.right < searchOrigin.left) ? candidateRect.right : searchOrigin.left; + break; + case 'up': + points.exitPoint.y = searchOrigin.top; + points.entryPoint.y = (candidateRect.bottom < searchOrigin.top) ? candidateRect.bottom : searchOrigin.top; + break; + case 'right': + points.exitPoint.x = searchOrigin.right; + points.entryPoint.x = (candidateRect.left > searchOrigin.right) ? candidateRect.left : searchOrigin.right; + break; + case 'down': + points.exitPoint.y = searchOrigin.bottom; + points.entryPoint.y = (candidateRect.top > searchOrigin.bottom) ? candidateRect.top : searchOrigin.bottom; + break; + } + + // Set orthogonal direction + switch (dir) { + case 'left': + case 'right': + if (isBelow(searchOrigin, candidateRect)) { + points.exitPoint.y = searchOrigin.top; + points.entryPoint.y = (candidateRect.bottom < searchOrigin.top) ? candidateRect.bottom : searchOrigin.top; + } else if (isBelow(candidateRect, searchOrigin)) { + points.exitPoint.y = searchOrigin.bottom; + points.entryPoint.y = (candidateRect.top > searchOrigin.bottom) ? candidateRect.top : searchOrigin.bottom; + } else { + points.exitPoint.y = Math.max(searchOrigin.top, candidateRect.top); + points.entryPoint.y = points.exitPoint.y; + } + break; + + case 'up': + case 'down': + if (isRightSide(searchOrigin, candidateRect)) { + points.exitPoint.x = searchOrigin.left; + points.entryPoint.x = (candidateRect.right < searchOrigin.left) ? candidateRect.right : searchOrigin.left; + } else if (isRightSide(candidateRect, searchOrigin)) { + points.exitPoint.x = searchOrigin.right; + points.entryPoint.x = (candidateRect.left > searchOrigin.right) ? candidateRect.left : searchOrigin.right; + } else { + points.exitPoint.x = Math.max(searchOrigin.left, candidateRect.left); + points.entryPoint.x = points.exitPoint.x; + } + break; + } + } + + return points; + } + + /** + * Find focusable elements within the container + * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} + * @function getIntersectionRect + * @param rect1 {DOMRect} - The search origin which contains the exit point + * @param rect2 {DOMRect} - One of candidates which contains the entry point + * @returns {IntersectionArea} The intersection area between two elements. + * + * @typeof {Object} IntersectionArea + * @property {Number} IntersectionArea.width + * @property {Number} IntersectionArea.height + */ + function getIntersectionRect(rect1, rect2) { + const intersection_rect = {width: 0, height: 0, area: 0}; + + const new_location = [Math.max(rect1.left, rect2.left), Math.max(rect1.top, rect2.top)]; + const new_max_point = [Math.min(rect1.right, rect2.right), Math.min(rect1.bottom, rect2.bottom)]; + + intersection_rect.width = Math.abs(new_location[0] - new_max_point[0]); + intersection_rect.height = Math.abs(new_location[1] - new_max_point[1]); + + if (!(new_location[0] >= new_max_point[0] || new_location[1] >= new_max_point[1])) { + // intersecting-cases + intersection_rect.area = Math.sqrt(intersection_rect.width * intersection_rect.height); + } + + return intersection_rect; + } + + /** + * Handle the spatial navigation behavior for HTMLInputElement, HTMLTextAreaElement + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input|HTMLInputElement (MDN)} + * @function handlingEditableElement + * @param e {Event} - keydownEvent + * @returns {boolean} + */ + function handlingEditableElement(e) { + const SPINNABLE_INPUT_TYPES = ['email', 'date', 'month', 'number', 'time', 'week'], + TEXT_INPUT_TYPES = ['password', 'text', 'search', 'tel', 'url', null]; + const eventTarget = document.activeElement; + const focusNavigableArrowKey = {left: false, up: false, right: false, down: false}; + + const dir = ARROW_KEY_CODE[e.keyCode]; + if (dir === undefined) { + return focusNavigableArrowKey; + } + + if (SPINNABLE_INPUT_TYPES.includes(eventTarget.getAttribute('type')) && + (dir === 'up' || dir === 'down')) { + focusNavigableArrowKey[dir] = true; + } else if (TEXT_INPUT_TYPES.includes(eventTarget.getAttribute('type')) || eventTarget.nodeName === 'TEXTAREA') { + // 20210606 fix selectionStart unavailable on checkboxes ~inf + const startPosition = eventTarget.selectionStart; + const endPosition = eventTarget.selectionEnd; + if (startPosition === endPosition) { // if there isn't any selected text + if (startPosition === 0) { + focusNavigableArrowKey.left = true; + focusNavigableArrowKey.up = true; + } + if (endPosition === eventTarget.value.length) { + focusNavigableArrowKey.right = true; + focusNavigableArrowKey.down = true; + } + } + } else { // HTMLDataListElement, HTMLSelectElement, HTMLOptGroup + focusNavigableArrowKey[dir] = true; + } + + return focusNavigableArrowKey; + } + + /** + * Get the DOMRect of an element + * @function getBoundingClientRect + * @param {Node} element + * @returns {DOMRect} + */ + function getBoundingClientRect(element) { + // memoization + let rect = mapOfBoundRect && mapOfBoundRect.get(element); + if (!rect) { + const boundingClientRect = element.getBoundingClientRect(); + rect = { + top: Number(boundingClientRect.top.toFixed(2)), + right: Number(boundingClientRect.right.toFixed(2)), + bottom: Number(boundingClientRect.bottom.toFixed(2)), + left: Number(boundingClientRect.left.toFixed(2)), + width: Number(boundingClientRect.width.toFixed(2)), + height: Number(boundingClientRect.height.toFixed(2)) + }; + mapOfBoundRect && mapOfBoundRect.set(element, rect); + } + return rect; + } + + /** + * Get the candidates which is fully inside the target element in visual + * @param {Node} targetElement + * @returns {sequence} overlappedCandidates + */ + function getOverlappedCandidates(targetElement) { + const container = targetElement.getSpatialNavigationContainer(); + const candidates = container.focusableAreas(); + const overlappedCandidates = []; + + candidates.forEach(element => { + if ((targetElement !== element) && isEntirelyVisible(element, targetElement)) { + overlappedCandidates.push(element); + } + }); + + return overlappedCandidates; + } + + /** + * Get the list of the experimental APIs + * @function getExperimentalAPI + */ + function getExperimentalAPI() { + function canScroll(container, dir) { + return (isScrollable(container, dir) && !isScrollBoundary(container, dir)) || + (!container.parentElement && !isHTMLScrollBoundary(container, dir)); + } + + function findTarget(findCandidate, element, dir, option) { + let eventTarget = element; + let bestNextTarget = null; + + // 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 + if ((isContainer(eventTarget) || eventTarget.nodeName === 'BODY') && !(eventTarget.nodeName === 'INPUT')) { + if (eventTarget.nodeName === 'IFRAME') + eventTarget = eventTarget.contentDocument.body; + + const candidates = getSpatialNavigationCandidates(eventTarget, option); + + // 5-2 + if (Array.isArray(candidates) && candidates.length > 0) { + return findCandidate ? getFilteredSpatialNavigationCandidates(eventTarget, dir, candidates) : eventTarget.spatialNavigationSearch(dir, {candidates}); + } + if (canScroll(eventTarget, dir)) { + return findCandidate ? [] : eventTarget; + } + } + + // 6 + // Let container be the nearest ancestor of eventTarget + let 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; + } + + // 7 + while (parentContainer) { + const candidates = filteredCandidates(eventTarget, getSpatialNavigationCandidates(container, option), dir, container); + + if (Array.isArray(candidates) && candidates.length > 0) { + bestNextTarget = eventTarget.spatialNavigationSearch(dir, {candidates, container}); + if (bestNextTarget) { + return findCandidate ? candidates : bestNextTarget; + } + } + + // If there isn't any candidate and the best candidate among candidate: + // 1) Scroll or 2) Find candidates of the ancestor container + // 8 - if + else if (canScroll(container, dir)) { + return findCandidate ? [] : eventTarget; + } else if (container === document || container === document.documentElement) { + container = window.document.documentElement; + + // The page is in an iframe + if ( window.location !== window.parent.location ) { + // eventTarget needs to be reset because the position of the element in the IFRAME + // is unuseful when the focus moves out of the iframe + eventTarget = window.frameElement; + container = window.parent.document.documentElement; + if (container.parentElement) + parentContainer = container.getSpatialNavigationContainer(); + else { + parentContainer = null; + break; + } + } + } else { + // avoiding when spatnav container with tabindex=-1 + if (isFocusable(container)) { + eventTarget = container; + } + + container = parentContainer; + if (container.parentElement) + parentContainer = container.getSpatialNavigationContainer(); + else { + parentContainer = null; + break; + } + } + } + + if (!parentContainer && container) { + // Getting out from the current spatnav container + const candidates = filteredCandidates(eventTarget, getSpatialNavigationCandidates(container, option), dir, container); + + // 9 + if (Array.isArray(candidates) && candidates.length > 0) { + bestNextTarget = eventTarget.spatialNavigationSearch(dir, {candidates, container}); + if (bestNextTarget) { + return findCandidate ? candidates : bestNextTarget; + } + } + } + + if (canScroll(container, dir)) { + bestNextTarget = eventTarget; + return bestNextTarget; + } + } + + return { + isContainer, + isScrollContainer, + isVisibleInScroller, + findCandidates: findTarget.bind(null, true), + findNextTarget: findTarget.bind(null, false), + getDistanceFromTarget: (element, candidateElement, dir) => { + if ((isContainer(element) || element.nodeName === 'BODY') && !(element.nodeName === 'INPUT')) { + if (getSpatialNavigationCandidates(element).includes(candidateElement)) { + return getInnerDistance(getBoundingClientRect(element), getBoundingClientRect(candidateElement), dir); + } + } + return getDistance(getBoundingClientRect(element), getBoundingClientRect(candidateElement), dir); + } + }; + } + + /** + * Makes to use the experimental APIs. + * @function enableExperimentalAPIs + * @param option {boolean} - If it is true, the experimental APIs can be used or it cannot. + */ + function enableExperimentalAPIs (option) { + const currentKeyMode = window.__spatialNavigation__ && window.__spatialNavigation__.keyMode; + window.__spatialNavigation__ = (option === false) ? getInitialAPIs() : Object.assign(getInitialAPIs(), getExperimentalAPI()); + window.__spatialNavigation__.keyMode = currentKeyMode; + Object.seal(window.__spatialNavigation__); + } + + /** + * Set the environment for using the spatial navigation polyfill. + * @function getInitialAPIs + */ + function getInitialAPIs() { + return { + enableExperimentalAPIs, + get keyMode() { return this._keymode ? this._keymode : 'ARROW'; }, + set keyMode(mode) { this._keymode = (['SHIFTARROW', 'ARROW', 'NONE'].includes(mode)) ? mode : 'ARROW'; }, + setStartingPoint: function (x, y) {startingPoint = (x && y) ? {x, y} : null;} + }; + } + + initiateSpatialNavigation(); + enableExperimentalAPIs(false); + + window.addEventListener('load', () => { + spatialNavigationHandler(); + }); +})(); From 2259822f6e1178d561203048dc7de9a71c0dd66d Mon Sep 17 00:00:00 2001 From: throwaway96 <68320646+throwaway96@users.noreply.github.com> Date: Sun, 24 Mar 2024 18:31:51 -0400 Subject: [PATCH 2/5] update .gitignore (ignore .vscode) Ignore .vscode directory and make some other entries more specific. --- .gitignore | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 04db325f..bbc1ff55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.ipk -node_modules -dist +/node_modules/ +/dist/ .DS_Store +/.vscode/ From bd66580475ddbb443e99e5a621a85fcc4a051398 Mon Sep 17 00:00:00 2001 From: throwaway96 <68320646+throwaway96@users.noreply.github.com> Date: Sun, 24 Mar 2024 19:05:42 -0400 Subject: [PATCH 3/5] package.json: add scripts: lint, prettier-check --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 95ddf096..bc5dee32 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "launch": "ares-launch youtube.leanback.v4", "manifest": "node tools/gen-manifest.js youtube.leanback.v4.manifest.json", "version": "node tools/sync-version.js && git add assets/appinfo.json", - "prepare": "husky install" + "prepare": "husky install", + "lint": "eslint src", + "prettier-check": "prettier --check ." }, "repository": "github:webosbrew/youtube-webos", "license": "GPL-3.0-only", From 04dd265a8abe82a53e6d99d7f8dfc5d328241cbb Mon Sep 17 00:00:00 2001 From: throwaway96 <68320646+throwaway96@users.noreply.github.com> Date: Sun, 24 Mar 2024 19:10:18 -0400 Subject: [PATCH 4/5] fix file modes Remove execute permission from several data files. --- assets/icon.png | Bin assets/largeIcon.png | Bin src/index.html | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 assets/icon.png mode change 100755 => 100644 assets/largeIcon.png mode change 100755 => 100644 src/index.html diff --git a/assets/icon.png b/assets/icon.png old mode 100755 new mode 100644 diff --git a/assets/largeIcon.png b/assets/largeIcon.png old mode 100755 new mode 100644 diff --git a/src/index.html b/src/index.html old mode 100755 new mode 100644 From 1beb520505f0a11b15014d00428a1b7c26c741eb Mon Sep 17 00:00:00 2001 From: throwaway96 <68320646+throwaway96@users.noreply.github.com> Date: Sun, 24 Mar 2024 19:55:47 -0400 Subject: [PATCH 5/5] .browserslistrc: update for webOS 7/8/9 Specify the Chromium version used for WAM on each webOS major version. --- .browserslistrc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.browserslistrc b/.browserslistrc index d83a5f78..f923cccd 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1 +1,8 @@ -chrome 38-79 +# WAM uses WebKit on webOS 1 and 2 +chrome 38 # webOS 3.x +chrome 53 # webOS 4.x +chrome 68 # webOS 5 +chrome 79 # webOS 6 +chrome 87 # webOS 7 (22) +chrome 94 # webOS 8 (23) +chrome 108 # webOS 9 (24)