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