1/* Spatial Navigation Polyfill 2 * 3 * It follows W3C official specification 4 * https://drafts.csswg.org/css-nav-1/ 5 * 6 * Copyright (c) 2018-2019 LG Electronics Inc. 7 * https://github.com/WICG/spatial-navigation/polyfill 8 * 9 * Licensed under the MIT license (MIT) 10 */ 11 12(function () { 13 14 // The polyfill must not be executed, if it's already enabled via browser engine or browser extensions. 15 if ('navigate' in window) { 16 return; 17 } 18 19 const ARROW_KEY_CODE = {37: 'left', 38: 'up', 39: 'right', 40: 'down'}; 20 const TAB_KEY_CODE = 9; 21 let mapOfBoundRect = null; 22 let startingPoint = null; // Saves spatial navigation starting point 23 let savedSearchOrigin = {element: null, rect: null}; // Saves previous search origin 24 let searchOriginRect = null; // Rect of current search origin 25 26 /** 27 * Initiate the spatial navigation features of the polyfill. 28 * @function initiateSpatialNavigation 29 */ 30 function initiateSpatialNavigation() { 31 /* 32 * Bind the standards APIs to be exposed to the window object for authors 33 */ 34 window.navigate = navigate; 35 window.Element.prototype.spatialNavigationSearch = spatialNavigationSearch; 36 window.Element.prototype.focusableAreas = focusableAreas; 37 window.Element.prototype.getSpatialNavigationContainer = getSpatialNavigationContainer; 38 39 /* 40 * CSS.registerProperty() from the Properties and Values API 41 * Reference: https://drafts.css-houdini.org/css-properties-values-api/#the-registerproperty-function 42 */ 43 if (window.CSS && CSS.registerProperty) { 44 if (window.getComputedStyle(document.documentElement).getPropertyValue('--spatial-navigation-contain') === '') { 45 CSS.registerProperty({ 46 name: '--spatial-navigation-contain', 47 syntax: 'auto | contain', 48 inherits: false, 49 initialValue: 'auto' 50 }); 51 } 52 53 if (window.getComputedStyle(document.documentElement).getPropertyValue('--spatial-navigation-action') === '') { 54 CSS.registerProperty({ 55 name: '--spatial-navigation-action', 56 syntax: 'auto | focus | scroll', 57 inherits: false, 58 initialValue: 'auto' 59 }); 60 } 61 62 if (window.getComputedStyle(document.documentElement).getPropertyValue('--spatial-navigation-function') === '') { 63 CSS.registerProperty({ 64 name: '--spatial-navigation-function', 65 syntax: 'normal | grid', 66 inherits: false, 67 initialValue: 'normal' 68 }); 69 } 70 } 71 } 72 73 /** 74 * Add event handlers for the spatial navigation behavior. 75 * This function defines which input methods trigger the spatial navigation behavior. 76 * @function spatialNavigationHandler 77 */ 78 function spatialNavigationHandler() { 79 /* 80 * keydown EventListener : 81 * If arrow key pressed, get the next focusing element and send it to focusing controller 82 */ 83 window.addEventListener('keydown', (e) => { 84 const currentKeyMode = (parent && parent.__spatialNavigation__.keyMode) || window.__spatialNavigation__.keyMode; 85 const eventTarget = document.activeElement; 86 const dir = ARROW_KEY_CODE[e.keyCode]; 87 88 if (e.keyCode === TAB_KEY_CODE) { 89 startingPoint = null; 90 } 91 92 if (!currentKeyMode || 93 (currentKeyMode === 'NONE') || 94 ((currentKeyMode === 'SHIFTARROW') && !e.shiftKey) || 95 ((currentKeyMode === 'ARROW') && e.shiftKey)) 96 return; 97 98 if (!e.defaultPrevented) { 99 let focusNavigableArrowKey = {left: true, up: true, right: true, down: true}; 100 101 // Edge case (text input, area) : Don't move focus, just navigate cursor in text area 102 if ((eventTarget.nodeName === 'INPUT') || eventTarget.nodeName === 'TEXTAREA') { 103 focusNavigableArrowKey = handlingEditableElement(e); 104 } 105 106 if (focusNavigableArrowKey[dir]) { 107 e.preventDefault(); 108 mapOfBoundRect = new Map(); 109 110 navigate(dir); 111 112 mapOfBoundRect = null; 113 startingPoint = null; 114 } 115 } 116 });
117 118 /* 119 * mouseup EventListener : 120 * If the mouse click a point in the page, the point will be the starting point. 121 * NOTE: Let UA set the spatial navigation starting point based on click 122 */ 123 document.addEventListener('mouseup', (e) => { 124 startingPoint = {x: e.clientX, y: e.clientY}; 125 }); 126 127 /* 128 * focusin EventListener : 129 * When the element get the focus, save it and its DOMRect for resetting the search origin 130 * if it disappears. 131 */ 132 window.addEventListener('focusin', (e) => { 133 if (e.target !== window) { 134 savedSearchOrigin.element = e.target; 135 savedSearchOrigin.rect = e.target.getBoundingClientRect(); 136 } 137 }); 138 } 139 140 /** 141 * Enable the author to trigger spatial navigation programmatically, as if the user had done so manually. 142 * @see {@link https://drafts.csswg.org/css-nav-1/#dom-window-navigate} 143 * @function navigate 144 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 145 */ 146 function navigate(dir) { 147 // spatial navigation steps 148 149 // 1 150 const searchOrigin = findSearchOrigin(); 151 let eventTarget = searchOrigin; 152 153 let elementFromPosition = null; 154 155 // 2 Optional step, UA defined starting point 156 if (startingPoint) { 157 // if there is a starting point, set eventTarget as the element from position for getting the spatnav container 158 elementFromPosition = document.elementFromPoint(startingPoint.x, startingPoint.y); 159 160 // Use starting point if the starting point isn't inside the focusable element (but not container) 161 // * Starting point is meaningfull when: 162 // 1) starting point is inside the spatnav container 163 // 2) starting point is inside the non-focusable element 164 if (elementFromPosition === null) { 165 elementFromPosition = document.body; 166 } 167 if (isFocusable(elementFromPosition) && !isContainer(elementFromPosition)) { 168 startingPoint = null; 169 } else if (isContainer(elementFromPosition)) { 170 eventTarget = elementFromPosition; 171 } else { 172 eventTarget = elementFromPosition.getSpatialNavigationContainer(); 173 } 174 } 175 176 // 4 177 if (eventTarget === document || eventTarget === document.documentElement) { 178 eventTarget = document.body || document.documentElement; 179 } 180 181 // 5 182 // At this point, spatialNavigationSearch can be applied. 183 // If startingPoint is either a scroll container or the document, 184 // find the best candidate within startingPoint 185 let container = null; 186 if ((isContainer(eventTarget) || eventTarget.nodeName === 'BODY') && !(eventTarget.nodeName === 'INPUT')) { 187 if (eventTarget.nodeName === 'IFRAME') { 188 eventTarget = eventTarget.contentDocument.documentElement; 189 } 190 container = eventTarget; 191 let bestInsideCandidate = null; 192 193 // 5-2 194 if ((document.activeElement === searchOrigin) || 195 (document.activeElement === document.body) && (searchOrigin === document.documentElement)) { 196 if (getCSSSpatNavAction(eventTarget) === 'scroll') { 197 if (scrollingController(eventTarget, dir)) return; 198 } else if (getCSSSpatNavAction(eventTarget) === 'focus') { 199 bestInsideCandidate = eventTarget.spatialNavigationSearch(dir, {container: eventTarget, candidates: getSpatialNavigationCandidates(eventTarget, {mode: 'all'})}); 200 if (focusingController(bestInsideCandidate, dir)) return; 201 } else if (getCSSSpatNavAction(eventTarget) === 'auto') { 202 bestInsideCandidate = eventTarget.spatialNavigationSearch(dir, {container: eventTarget}); 203 if (focusingController(bestInsideCandidate, dir) || scrollingController(eventTarget, dir)) return; 204 } 205 } else { 206 // when the previous search origin became offscreen 207 container = container.getSpatialNavigationContainer(); 208 } 209 } 210 211 // 6 212 // Let container be the nearest ancestor of eventTarget 213 container = eventTarget.getSpatialNavigationContainer();
214 let parentContainer = (container.parentElement) ? container.getSpatialNavigationContainer() : null; 215 216 // When the container is the viewport of a browsing context 217 if (!parentContainer && ( window.location !== window.parent.location)) { 218 parentContainer = window.parent.document.documentElement; 219 } 220 221 if (getCSSSpatNavAction(container) === 'scroll') { 222 if (scrollingController(container, dir)) return; 223 } else if (getCSSSpatNavAction(container) === 'focus') { 224 navigateChain(eventTarget, container, parentContainer, dir, 'all'); 225 } else if (getCSSSpatNavAction(container) === 'auto') { 226 navigateChain(eventTarget, container, parentContainer, dir, 'visible'); 227 } 228 } 229 230 /** 231 * Move the focus to the best candidate or do nothing. 232 * @function focusingController 233 * @param bestCandidate {Node} - The best candidate of the spatial navigation 234 * @param dir {SpatialNavigationDirection}- The directional information for the spatial navigation (e.g. LRUD) 235 * @returns {boolean} 236 */ 237 function focusingController(bestCandidate, dir) { 238 // 10 & 11 239 // When bestCandidate is found 240 if (bestCandidate) { 241 // When bestCandidate is a focusable element and not a container : move focus 242 /* 243 * [event] navbeforefocus : Fired before spatial or sequential navigation changes the focus. 244 */ 245 if (!createSpatNavEvents('beforefocus', bestCandidate, null, dir)) 246 return true; 247 248 const container = bestCandidate.getSpatialNavigationContainer(); 249 250 if ((container !== window) && (getCSSSpatNavAction(container) === 'focus')) { 251 bestCandidate.focus(); 252 } else { 253 bestCandidate.focus({preventScroll: true}); 254 } 255 256 startingPoint = null; 257 return true; 258 } 259 260 // When bestCandidate is not found within the scrollport of a container: Nothing 261 return false; 262 } 263 264 /** 265 * Directionally scroll the scrollable spatial navigation container if it can be manually scrolled more. 266 * @function scrollingController 267 * @param container {Node} - The spatial navigation container which can scroll 268 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 269 * @returns {boolean} 270 */ 271 function scrollingController(container, dir) { 272 273 // If there is any scrollable area among parent elements and it can be manually scrolled, scroll the document 274 if (isScrollable(container, dir) && !isScrollBoundary(container, dir)) { 275 moveScroll(container, dir); 276 return true; 277 } 278 279 // If the spatnav container is document and it can be scrolled, scroll the document 280 if (!container.parentElement && !isHTMLScrollBoundary(container, dir)) { 281 moveScroll(container.ownerDocument.documentElement, dir); 282 return true; 283 } 284 return false; 285 } 286 287 /** 288 * Find the candidates within a spatial navigation container include delegable container. 289 * This function does not search inside delegable container or focusable container. 290 * In other words, this return candidates set is not included focusable elements inside delegable container or focusable container. 291 * 292 * @function getSpatialNavigationCandidates 293 * @param container {Node} - The spatial navigation container 294 * @param option {FocusableAreasOptions} - 'mode' attribute takes 'visible' or 'all' for searching the boundary of focusable elements. 295 * Default value is 'visible'. 296 * @returns {sequence<Node>} candidate elements within the container 297 */ 298 function getSpatialNavigationCandidates (container, option = {mode: 'visible'}) { 299 let candidates = []; 300 301 if (container.childElementCount > 0) { 302 if (!container.parentElement) { 303 container = container.getElementsByTagName('body')[0] || document.body; 304 } 305 const children = container.children; 306 for (const elem of children) { 307 if (isDelegableContainer(elem)) { 308 candidates.push(elem); 309 } else if (isFocusable(elem)) { 310 candidates.push(elem); 311 312 if (!isContainer(elem) && elem.childElementCount) { 313 candidates = candidates.concat(getSpatialNavigationCandidates(elem, {mode: 'all'})); 314 } 315 } else if (elem.childElementCount) { 316 candidates = candidates.concat(getSpatialNavigationCandidates(elem, {mode: 'all'})); 317 } 318 } 319 } 320 return (option.mode === 'all') ? candidates : candidates.filter(isVisible); 321 } 322 323 /** 324 * Find the candidates among focusable elements within a spatial navigation container from the search origin (currently focused element) 325 * depending on the directional information. 326 * @function getFilteredSpatialNavigationCandidates 327 * @param element {Node} - The currently focused element which is defined as 'search origin' in the spec 328 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 329 * @param candidates {sequence<Node>} - The candidates for spatial navigation without the directional information 330 * @param container {Node} - The spatial navigation container 331 * @returns {Node} The candidates for spatial navigation considering the directional information 332 */ 333 function getFilteredSpatialNavigationCandidates (element, dir, candidates, container) { 334 const targetElement = element; 335 // Removed below line due to a bug. (iframe body rect is sometime weird.) 336 // const targetElement = (element.nodeName === 'IFRAME') ? element.contentDocument.body : element; 337 // If the container is unknown, get the closest container from the element 338 container = container || targetElement.getSpatialNavigationContainer(); 339 340 // If the candidates is unknown, find candidates 341 // 5-1 342 candidates = (!candidates || candidates.length <= 0) ? getSpatialNavigationCandidates(container) : candidates; 343 return filteredCandidates(targetElement, candidates, dir, container); 344 } 345 346 /** 347 * Find the best candidate among the candidates within the container from the search origin (currently focused element) 348 * @see {@link https://drafts.csswg.org/css-nav-1/#dom-element-spatialnavigationsearch} 349 * @function spatialNavigationSearch 350 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 351 * @param candidates {sequence<Node>} - The candidates for spatial navigation 352 * @param container {Node} - The spatial navigation container 353 * @returns {Node} The best candidate which will gain the focus 354 */ 355 function spatialNavigationSearch (dir, args) { 356 const targetElement = this; 357 let internalCandidates = []; 358 let externalCandidates = []; 359 let insideOverlappedCandidates = getOverlappedCandidates(targetElement); 360 let bestTarget; 361 362 // Set default parameter value 363 if (!args) 364 args = {}; 365 366 const defaultContainer = targetElement.getSpatialNavigationContainer(); 367 let defaultCandidates = getSpatialNavigationCandidates(defaultContainer); 368 const container = args.container || defaultContainer; 369 if (args.container && (defaultContainer.contains(args.container))) { 370 defaultCandidates = defaultCandidates.concat(getSpatialNavigationCandidates(container));
371 } 372 const candidates = (args.candidates && args.candidates.length > 0) ? 373 args.candidates.filter((candidate) => container.contains(candidate)) : 374 defaultCandidates.filter((candidate) => container.contains(candidate) && (container !== candidate)); 375 376 // Find the best candidate 377 // 5 378 // If startingPoint is either a scroll container or the document, 379 // find the best candidate within startingPoint 380 if (candidates && candidates.length > 0) { 381 382 // Divide internal or external candidates 383 candidates.forEach(candidate => { 384 if (candidate !== targetElement) { 385 (targetElement.contains(candidate) && targetElement !== candidate ? internalCandidates : externalCandidates).push(candidate); 386 } 387 }); 388 389 // include overlapped element to the internalCandidates 390 let fullyOverlapped = insideOverlappedCandidates.filter(candidate => !internalCandidates.includes(candidate)); 391 let overlappedContainer = candidates.filter(candidate => (isContainer(candidate) && isEntirelyVisible(targetElement, candidate))); 392 let overlappedByParent = overlappedContainer.map((elm) => elm.focusableAreas()).flat().filter(candidate => candidate !== targetElement); 393 394 internalCandidates = internalCandidates.concat(fullyOverlapped).filter((candidate) => container.contains(candidate)); 395 externalCandidates = externalCandidates.concat(overlappedByParent).filter((candidate) => container.contains(candidate)); 396 397 // Filter external Candidates 398 if (externalCandidates.length > 0) { 399 externalCandidates = getFilteredSpatialNavigationCandidates(targetElement, dir, externalCandidates, container); 400 } 401 402 // If there isn't search origin element but search orgin rect exist (search origin isn't in the layout case) 403 if (searchOriginRect) { 404 bestTarget = selectBestCandidate(targetElement, getFilteredSpatialNavigationCandidates(targetElement, dir, internalCandidates, container), dir); 405 } 406 407 if ((internalCandidates && internalCandidates.length > 0) && !(targetElement.nodeName === 'INPUT')) { 408 bestTarget = selectBestCandidateFromEdge(targetElement, internalCandidates, dir); 409 } 410 411 bestTarget = bestTarget || selectBestCandidate(targetElement, externalCandidates, dir); 412 413 if (bestTarget && isDelegableContainer(bestTarget)) { 414 // if best target is delegable container, then find descendants candidate inside delegable container. 415 const innerTarget = getSpatialNavigationCandidates(bestTarget, {mode: 'all'}); 416 const descendantsBest = innerTarget.length > 0 ? targetElement.spatialNavigationSearch(dir, {candidates: innerTarget, container: bestTarget}) : null; 417 if (descendantsBest) { 418 bestTarget = descendantsBest; 419 } else if (!isFocusable(bestTarget)) { 420 // if there is no target inside bestTarget and delegable container is not focusable, 421 // then try to find another best target without curren best target. 422 candidates.splice(candidates.indexOf(bestTarget), 1); 423 bestTarget = candidates.length ? targetElement.spatialNavigationSearch(dir, {candidates: candidates, container: container}) : null; 424 } 425 } 426 return bestTarget; 427 } 428 429 return null; 430 } 431 432 /** 433 * Get the filtered candidate among candidates. 434 * @see {@link https://drafts.csswg.org/css-nav-1/#select-the-best-candidate} 435 * @function filteredCandidates 436 * @param currentElm {Node} - The currently focused element which is defined as 'search origin' in the spec 437 * @param candidates {sequence<Node>} - The candidates for spatial navigation 438 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 439 * @param container {Node} - The spatial navigation container 440 * @returns {sequence<Node>} The filtered candidates which are not the search origin and not in the given spatial navigation direction from the search origin 441 */ 442 // TODO: Need to fix filtering the candidates with more clean code 443 function filteredCandidates(currentElm, candidates, dir, container) { 444 const originalContainer = currentElm.getSpatialNavigationContainer(); 445 let eventTargetRect; 446 447 // If D(dir) is null, let candidates be the same as visibles 448 if (dir === undefined) 449 return candidates; 450 451 // Offscreen handling when originalContainer is not <HTML> 452 if (originalContainer.parentElement && container !== originalContainer && !isVisible(currentElm)) { 453 eventTargetRect = getBoundingClientRect(originalContainer); 454 } else { 455 eventTargetRect = searchOriginRect || getBoundingClientRect(currentElm); 456 } 457 458 /* 459 * Else, let candidates be the subset of the elements in visibles 460 * whose principal boxâs geometric center is within the closed half plane 461 * whose boundary goes through the geometric center of starting point and is perpendicular to D. 462 */ 463 if ((isContainer(currentElm) || currentElm.nodeName === 'BODY') && !(currentElm.nodeName === 'INPUT')) { 464 return candidates.filter(candidate => { 465 const candidateRect = getBoundingClientRect(candidate); 466 return container.contains(candidate) && 467 ((currentElm.contains(candidate) && isInside(eventTargetRect, candidateRect) && candidate !== currentElm) || 468 isOutside(candidateRect, eventTargetRect, dir)); 469 }); 470 } else { 471 return candidates.filter(candidate => { 472 const candidateRect = getBoundingClientRect(candidate); 473 const candidateBody = (candidate.nodeName === 'IFRAME') ? candidate.contentDocument.body : null; 474 return container.contains(candidate) && 475 candidate !== currentElm && candidateBody !== currentElm && 476 isOutside(candidateRect, eventTargetRect, dir) && 477 !isInside(eventTargetRect, candidateRect); 478 }); 479 } 480 } 481 482 /** 483 * Select the best candidate among given candidates. 484 * @see {@link https://drafts.csswg.org/css-nav-1/#select-the-best-candidate} 485 * @function selectBestCandidate 486 * @param currentElm {Node} - The currently focused element which is defined as 'search origin' in the spec 487 * @param candidates {sequence<Node>} - The candidates for spatial navigation 488 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 489 * @returns {Node} The best candidate which will gain the focus 490 */ 491 function selectBestCandidate(currentElm, candidates, dir) { 492 const container = currentElm.getSpatialNavigationContainer(); 493 const spatialNavigationFunction = getComputedStyle(container).getPropertyValue('--spatial-navigation-function'); 494 const currentTargetRect = searchOriginRect || getBoundingClientRect(currentElm); 495 let distanceFunction; 496 let alignedCandidates; 497
498 switch (spatialNavigationFunction) { 499 case 'grid': 500 alignedCandidates = candidates.filter(elm => isAligned(currentTargetRect, getBoundingClientRect(elm), dir)); 501 if (alignedCandidates.length > 0) { 502 candidates = alignedCandidates; 503 } 504 distanceFunction = getAbsoluteDistance; 505 break; 506 default: 507 distanceFunction = getDistance; 508 break; 509 } 510 return getClosestElement(currentElm, candidates, dir, distanceFunction); 511 } 512 513 /** 514 * Select the best candidate among candidates by finding the closet candidate from the edge of the currently focused element (search origin). 515 * @see {@link https://drafts.csswg.org/css-nav-1/#select-the-best-candidate (Step 5)} 516 * @function selectBestCandidateFromEdge 517 * @param currentElm {Node} - The currently focused element which is defined as 'search origin' in the spec 518 * @param candidates {sequence<Node>} - The candidates for spatial navigation 519 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 520 * @returns {Node} The best candidate which will gain the focus 521 */ 522 function selectBestCandidateFromEdge(currentElm, candidates, dir) { 523 if (startingPoint) 524 return getClosestElement(currentElm, candidates, dir, getDistanceFromPoint); 525 else 526 return getClosestElement(currentElm, candidates, dir, getInnerDistance); 527 } 528 529 /** 530 * Select the closest candidate from the currently focused element (search origin) among candidates by using the distance function. 531 * @function getClosestElement 532 * @param currentElm {Node} - The currently focused element which is defined as 'search origin' in the spec 533 * @param candidates {sequence<Node>} - The candidates for spatial navigation 534 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 535 * @param distanceFunction {function} - The distance function which measures the distance from the search origin to each candidate 536 * @returns {Node} The candidate which is the closest one from the search origin 537 */ 538 function getClosestElement(currentElm, candidates, dir, distanceFunction) { 539 let eventTargetRect = null; 540 if (( window.location !== window.parent.location ) && (currentElm.nodeName === 'BODY' || currentElm.nodeName === 'HTML')) { 541 // If the eventTarget is iframe, then get rect of it based on its containing document 542 // Set the iframe's position as (0,0) because the rects of elements inside the iframe don't know the real iframe's position. 543 eventTargetRect = window.frameElement.getBoundingClientRect(); 544 eventTargetRect.x = 0; 545 eventTargetRect.y = 0; 546 } else { 547 eventTargetRect = searchOriginRect || currentElm.getBoundingClientRect(); 548 } 549 550 let minDistance = Number.POSITIVE_INFINITY; 551 let minDistanceElements = []; 552 553 if (candidates) { 554 for (let i = 0; i < candidates.length; i++) { 555 const distance = distanceFunction(eventTargetRect, getBoundingClientRect(candidates[i]), dir); 556 557 // If the same distance, the candidate will be selected in the DOM order 558 if (distance < minDistance) { 559 minDistance = distance; 560 minDistanceElements = [candidates[i]]; 561 } else if (distance === minDistance) { 562 minDistanceElements.push(candidates[i]); 563 } 564 } 565 } 566 if (minDistanceElements.length === 0) 567 return null; 568 569 return (minDistanceElements.length > 1 && distanceFunction === getAbsoluteDistance) ? 570 getClosestElement(currentElm, minDistanceElements, dir, getEuclideanDistance) : minDistanceElements[0]; 571 } 572 573 /** 574 * Get container of an element. 575 * @see {@link https://drafts.csswg.org/css-nav-1/#dom-element-getspatialnavigationcontainer} 576 * @module Element 577 * @function getSpatialNavigationContainer 578 * @returns {Node} The spatial navigation container 579 */ 580 function getSpatialNavigationContainer() { 581 let container = this; 582 583 do { 584 if (!container.parentElement) { 585 if (window.location !== window.parent.location) { 586 container = window.parent.document.documentElement; 587 } else { 588 container = window.document.documentElement; 589 } 590 break; 591 } else { 592 container = container.parentElement; 593 } 594 } while (!isContainer(container));
595 return container; 596 } 597 598 /** 599 * Get nearest scroll container of an element. 600 * @function getScrollContainer 601 * @param Element 602 * @returns {Node} The spatial navigation container 603 */ 604 function getScrollContainer(element) { 605 let scrollContainer = element; 606 607 do { 608 if (!scrollContainer.parentElement) { 609 if (window.location !== window.parent.location) { 610 scrollContainer = window.parent.document.documentElement; 611 } else { 612 scrollContainer = window.document.documentElement; 613 } 614 break; 615 } else { 616 scrollContainer = scrollContainer.parentElement; 617 } 618 } while (!isScrollContainer(scrollContainer) || !isVisible(scrollContainer)); 619 620 if (scrollContainer === document || scrollContainer === document.documentElement) { 621 scrollContainer = window; 622 } 623 624 return scrollContainer; 625 } 626 627 /** 628 * Find focusable elements within the spatial navigation container. 629 * @see {@link https://drafts.csswg.org/css-nav-1/#dom-element-focusableareas} 630 * @function focusableAreas 631 * @param option {FocusableAreasOptions} - 'mode' attribute takes 'visible' or 'all' for searching the boundary of focusable elements. 632 * Default value is 'visible'. 633 * @returns {sequence<Node>} All focusable elements or only visible focusable elements within the container 634 */ 635 function focusableAreas(option = {mode: 'visible'}) { 636 const container = this.parentElement ? this : document.body; 637 const focusables = Array.prototype.filter.call(container.getElementsByTagName('*'), isFocusable);
638 return (option.mode === 'all') ? focusables : focusables.filter(isVisible); 639 } 640 641 /** 642 * Create the NavigationEvent: navbeforefocus, navnotarget 643 * @see {@link https://drafts.csswg.org/css-nav-1/#events-navigationevent} 644 * @function createSpatNavEvents 645 * @param option {string} - Type of the navigation event (beforefocus, notarget) 646 * @param element {Node} - The target element of the event 647 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 648 */ 649 function createSpatNavEvents(eventType, containerElement, currentElement, direction) { 650 if (['beforefocus', 'notarget'].includes(eventType)) { 651 const data = { 652 causedTarget: currentElement, 653 dir: direction 654 }; 655 const triggeredEvent = new CustomEvent('nav' + eventType, {bubbles: true, cancelable: true, detail: data}); 656 return containerElement.dispatchEvent(triggeredEvent); 657 } 658 } 659 660 /** 661 * Get the value of the CSS custom property of the element 662 * @function readCssVar 663 * @param element {Node} 664 * @param varName {string} - The name of the css custom property without '--' 665 * @returns {string} The value of the css custom property 666 */ 667 function readCssVar(element, varName) { 668 return element.style.getPropertyValue(`--${varName}`).trim(); 669 } 670 671 /** 672 * Decide whether or not the 'contain' value is given to 'spatial-navigation-contain' css property of an element 673 * @function isCSSSpatNavContain 674 * @param element {Node} 675 * @returns {boolean} 676 */ 677 function isCSSSpatNavContain(element) { 678 return readCssVar(element, 'spatial-navigation-contain') === 'contain'; 679 } 680 681 /** 682 * Return the value of 'spatial-navigation-action' css property of an element 683 * @function getCSSSpatNavAction 684 * @param element {Node} - would be the spatial navigation container 685 * @returns {string} auto | focus | scroll 686 */ 687 function getCSSSpatNavAction(element) { 688 return readCssVar(element, 'spatial-navigation-action') || 'auto'; 689 } 690 691 /** 692 * Only move the focus with spatial navigation. Manually scrolling isn't available. 693 * @function navigateChain 694 * @param eventTarget {Node} - currently focused element 695 * @param container {SpatialNavigationContainer} - container 696 * @param parentContainer {SpatialNavigationContainer} - parent container 697 * @param option - visible || all 698 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 699 */ 700 function navigateChain(eventTarget, container, parentContainer, dir, option) { 701 let currentOption = {candidates: getSpatialNavigationCandidates(container, {mode: option}), container}; 702 703 while (parentContainer) { 704 if (focusingController(eventTarget.spatialNavigationSearch(dir, currentOption), dir)) { 705 return; 706 } else { 707 if ((option === 'visible') && scrollingController(container, dir)) return; 708 else { 709 if (!createSpatNavEvents('notarget', container, eventTarget, dir)) return; 710 711 // find the container 712 if (container === document || container === document.documentElement) { 713 if ( window.location !== window.parent.location ) { 714 // The page is in an iframe. eventTarget needs to be reset because the position of the element in the iframe 715 eventTarget = window.frameElement; 716 container = eventTarget.ownerDocument.documentElement; 717 } 718 } else { 719 container = parentContainer; 720 } 721 currentOption = {candidates: getSpatialNavigationCandidates(container, {mode: option}), container}; 722 let nextContainer = container.getSpatialNavigationContainer(); 723 724 if (nextContainer !== container) { 725 parentContainer = nextContainer; 726 } else { 727 parentContainer = null; 728 } 729 } 730 } 731 } 732 733 currentOption = {candidates: getSpatialNavigationCandidates(container, {mode: option}), container}; 734 735 // Behavior after 'navnotarget' - Getting out from the current spatnav container 736 if ((!parentContainer && container) && focusingController(eventTarget.spatialNavigationSearch(dir, currentOption), dir)) return; 737 738 if (!createSpatNavEvents('notarget', currentOption.container, eventTarget, dir)) return; 739 740 if ((getCSSSpatNavAction(container) === 'auto') && (option === 'visible')) { 741 if (scrollingController(container, dir)) return; 742 } 743 } 744 745 /** 746 * Find search origin 747 * @see {@link https://drafts.csswg.org/css-nav-1/#nav} 748 * @function findSearchOrigin 749 * @returns {Node} The search origin for the spatial navigation 750 */ 751 function findSearchOrigin() { 752 let searchOrigin = document.activeElement; 753 754 if (!searchOrigin || (searchOrigin === document.body && !document.querySelector(':focus'))) { 755 // When the previous search origin lost its focus by blur: (1) disable attribute (2) visibility: hidden 756 if (savedSearchOrigin.element && (searchOrigin !== savedSearchOrigin.element)) { 757 const elementStyle = window.getComputedStyle(savedSearchOrigin.element, null); 758 const invisibleStyle = ['hidden', 'collapse']; 759 760 if (savedSearchOrigin.element.disabled || invisibleStyle.includes(elementStyle.getPropertyValue('visibility'))) { 761 searchOrigin = savedSearchOrigin.element; 762 return searchOrigin; 763 } 764 } 765 searchOrigin = document.documentElement; 766 } 767 // When the previous search origin lost its focus by blur: (1) display:none () element size turned into zero 768 if (savedSearchOrigin.element && 769 ((getBoundingClientRect(savedSearchOrigin.element).height === 0) || (getBoundingClientRect(savedSearchOrigin.element).width === 0))) { 770 searchOriginRect = savedSearchOrigin.rect; 771 } 772 773 if (!isVisibleInScroller(searchOrigin)) {
774 const scroller = getScrollContainer(searchOrigin); 775 if (scroller && ((scroller === window) || (getCSSSpatNavAction(scroller) === 'auto'))) 776 return scroller; 777 } 778 return searchOrigin; 779 } 780 781 /** 782 * Move the scroll of an element depending on the given spatial navigation directrion 783 * (Assume that User Agent defined distance is '40px') 784 * @see {@link https://drafts.csswg.org/css-nav-1/#directionally-scroll-an-element} 785 * @function moveScroll 786 * @param element {Node} - The scrollable element 787 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 788 * @param offset {Number} - The explicit amount of offset for scrolling. Default value is 0. 789 */ 790 function moveScroll(element, dir, offset = 0) { 791 if (element) { 792 switch (dir) { 793 case 'left': element.scrollLeft -= (40 + offset); break; 794 case 'right': element.scrollLeft += (40 + offset); break; 795 case 'up': element.scrollTop -= (40 + offset); break; 796 case 'down': element.scrollTop += (40 + offset); break; 797 } 798 } 799 } 800 801 /** 802 * Decide whether an element is container or not. 803 * @function isContainer 804 * @param element {Node} element 805 * @returns {boolean} 806 */ 807 function isContainer(element) { 808 return (!element.parentElement) || 809 (element.nodeName === 'IFRAME') || 810 (isScrollContainer(element)) || 811 (isCSSSpatNavContain(element)); 812 } 813 814 /** 815 * Decide whether an element is delegable container or not. 816 * NOTE: THIS IS NON-NORMATIVE API. 817 * @function isDelegableContainer 818 * @param element {Node} element 819 * @returns {boolean} 820 */ 821 function isDelegableContainer(element) { 822 return readCssVar(element, 'spatial-navigation-contain') === 'delegable'; 823 } 824 825 /** 826 * Decide whether an element is a scrollable container or not. 827 * @see {@link https://drafts.csswg.org/css-overflow-3/#scroll-container} 828 * @function isScrollContainer 829 * @param element {Node} 830 * @returns {boolean} 831 */ 832 function isScrollContainer(element) { 833 const elementStyle = window.getComputedStyle(element, null); 834 const overflowX = elementStyle.getPropertyValue('overflow-x'); 835 const overflowY = elementStyle.getPropertyValue('overflow-y'); 836 837 return ((overflowX !== 'visible' && overflowX !== 'clip' && isOverflow(element, 'left')) || 838 (overflowY !== 'visible' && overflowY !== 'clip' && isOverflow(element, 'down'))) ? 839 true : false; 840 } 841 842 /** 843 * Decide whether this element is scrollable or not. 844 * NOTE: If the value of 'overflow' is given to either 'visible', 'clip', or 'hidden', the element isn't scrollable. 845 * If the value is 'hidden', the element can be only programmically scrollable. (https://drafts.csswg.org/css-overflow-3/#valdef-overflow-hidden) 846 * @function isScrollable 847 * @param element {Node} 848 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 849 * @returns {boolean} 850 */ 851 function isScrollable(element, dir) { // element, dir 852 if (element && typeof element === 'object') { 853 if (dir && typeof dir === 'string') { // parameter: dir, element 854 if (isOverflow(element, dir)) { 855 // style property 856 const elementStyle = window.getComputedStyle(element, null); 857 const overflowX = elementStyle.getPropertyValue('overflow-x'); 858 const overflowY = elementStyle.getPropertyValue('overflow-y'); 859 860 switch (dir) { 861 case 'left': 862 /* falls through */ 863 case 'right': 864 return (overflowX !== 'visible' && overflowX !== 'clip' && overflowX !== 'hidden'); 865 case 'up': 866 /* falls through */ 867 case 'down': 868 return (overflowY !== 'visible' && overflowY !== 'clip' && overflowY !== 'hidden'); 869 } 870 } 871 return false; 872 } else { // parameter: element 873 return (element.nodeName === 'HTML' || element.nodeName === 'BODY') || 874 (isScrollContainer(element) && isOverflow(element)); 875 } 876 } 877 } 878 879 /** 880 * Decide whether an element is overflow or not. 881 * @function isOverflow 882 * @param element {Node} 883 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 884 * @returns {boolean} 885 */ 886 function isOverflow(element, dir) { 887 if (element && typeof element === 'object') { 888 if (dir && typeof dir === 'string') { // parameter: element, dir 889 switch (dir) { 890 case 'left': 891 /* falls through */ 892 case 'right': 893 return (element.scrollWidth > element.clientWidth);
894 case 'up': 895 /* falls through */ 896 case 'down': 897 return (element.scrollHeight > element.clientHeight); 898 } 899 } else { // parameter: element 900 return (element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight); 901 } 902 return false; 903 } 904 } 905 906 /** 907 * Decide whether the scrollbar of the browsing context reaches to the end or not. 908 * @function isHTMLScrollBoundary 909 * @param element {Node} - The top browsing context 910 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 911 * @returns {boolean} 912 */ 913 function isHTMLScrollBoundary(element, dir) { 914 let result = false; 915 switch (dir) { 916 case 'left': 917 result = element.scrollLeft === 0; 918 break; 919 case 'right': 920 result = (element.scrollWidth - element.scrollLeft - element.clientWidth) === 0; 921 break; 922 case 'up': 923 result = element.scrollTop === 0; 924 break; 925 case 'down': 926 result = (element.scrollHeight - element.scrollTop - element.clientHeight) === 0; 927 break; 928 } 929 return result; 930 } 931 932 /** 933 * Decide whether the scrollbar of an element reaches to the end or not. 934 * @function isScrollBoundary 935 * @param element {Node} 936 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 937 * @returns {boolean} 938 */ 939 function isScrollBoundary(element, dir) { 940 if (isScrollable(element, dir)) { 941 const winScrollY = element.scrollTop; 942 const winScrollX = element.scrollLeft; 943 944 const height = element.scrollHeight - element.clientHeight; 945 const width = element.scrollWidth - element.clientWidth; 946 947 switch (dir) { 948 case 'left': return (winScrollX === 0); 949 case 'right': return (Math.abs(winScrollX - width) <= 1); 950 case 'up': return (winScrollY === 0); 951 case 'down': return (Math.abs(winScrollY - height) <= 1); 952 } 953 } 954 return false; 955 } 956 957 /** 958 * Decide whether an element is inside the scorller viewport or not 959 * 960 * @function isVisibleInScroller 961 * @param element {Node} 962 * @returns {boolean} 963 */ 964 function isVisibleInScroller(element) { 965 const elementRect = element.getBoundingClientRect(); 966 let nearestScroller = getScrollContainer(element); 967 968 let scrollerRect = null; 969 if (nearestScroller !== window) { 970 scrollerRect = getBoundingClientRect(nearestScroller); 971 } else { 972 scrollerRect = new DOMRect(0, 0, window.innerWidth, window.innerHeight); 973 } 974 975 if (isInside(scrollerRect, elementRect, 'left') && isInside(scrollerRect, elementRect, 'down')) 976 return true; 977 else 978 return false; 979 } 980 981 /** 982 * Decide whether an element is focusable for spatial navigation. 983 * 1. If element is the browsing context (document, iframe), then it's focusable, 984 * 2. If the element is scrollable container (regardless of scrollable axis), then it's focusable, 985 * 3. The value of tabIndex >= 0, then it's focusable, 986 * 4. If the element is disabled, it isn't focusable, 987 * 5. If the element is expressly inert, it isn't focusable, 988 * 6. Whether the element is being rendered or not. 989 * 990 * @function isFocusable 991 * @param element {Node} 992 * @returns {boolean} 993 * 994 * @see {@link https://html.spec.whatwg.org/multipage/interaction.html#focusable-area} 995 */ 996 function isFocusable(element) { 997 if ((element.tabIndex < 0) || isAtagWithoutHref(element) || isActuallyDisabled(element) || isExpresslyInert(element) || !isBeingRendered(element)) 998 return false; 999 else if ((!element.parentElement) || (isScrollable(element) && isOverflow(element)) || (element.tabIndex >= 0)) 1000 return true; 1001 } 1002 1003 /** 1004 * Decide whether an element is a tag without href attribute or not. 1005 * 1006 * @function isAtagWithoutHref 1007 * @param element {Node} 1008 * @returns {boolean} 1009 */ 1010 function isAtagWithoutHref(element) { 1011 return (element.tagName === 'A' && element.getAttribute('href') === null && element.getAttribute('tabIndex') === null); 1012 } 1013 1014 /** 1015 * Decide whether an element is actually disabled or not. 1016 * 1017 * @function isActuallyDisabled 1018 * @param element {Node} 1019 * @returns {boolean} 1020 * 1021 * @see {@link https://html.spec.whatwg.org/multipage/semantics-other.html#concept-element-disabled} 1022 */ 1023 function isActuallyDisabled(element) { 1024 if (['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTGROUP', 'OPTION', 'FIELDSET'].includes(element.tagName)) 1025 return (element.disabled); 1026 else 1027 return false; 1028 } 1029 1030 /** 1031 * Decide whether the element is expressly inert or not. 1032 * @see {@link https://html.spec.whatwg.org/multipage/interaction.html#expressly-inert} 1033 * @function isExpresslyInert 1034 * @param element {Node} 1035 * @returns {boolean} 1036 */ 1037 function isExpresslyInert(element) { 1038 return ((element.inert) && (!element.ownerDocument.documentElement.inert)); 1039 } 1040 1041 /** 1042 * Decide whether the element is being rendered or not. 1043 * 1. If an element has the style as "visibility: hidden | collapse" or "display: none", it is not being rendered. 1044 * 2. If an element has the style as "opacity: 0", it is not being rendered.(that is, invisible). 1045 * 3. If width and height of an element are explicitly set to 0, it is not being rendered. 1046 * 4. If a parent element is hidden, an element itself is not being rendered. 1047 * (CSS visibility property and display property are inherited.) 1048 * @see {@link https://html.spec.whatwg.org/multipage/rendering.html#being-rendered} 1049 * @function isBeingRendered 1050 * @param element {Node} 1051 * @returns {boolean} 1052 */ 1053 function isBeingRendered(element) { 1054 if (!isVisibleStyleProperty(element.parentElement)) 1055 return false; 1056 if (!isVisibleStyleProperty(element) || (element.style.opacity === '0') || 1057 (window.getComputedStyle(element).height === '0px' || window.getComputedStyle(element).width === '0px')) 1058 return false; 1059 return true; 1060 } 1061 1062 /** 1063 * Decide whether this element is partially or completely visible to user agent. 1064 * @function isVisible 1065 * @param element {Node} 1066 * @returns {boolean} 1067 */ 1068 function isVisible(element) { 1069 return (!element.parentElement) || (isVisibleStyleProperty(element) && hitTest(element)); 1070 } 1071 1072 /** 1073 * Decide whether this element is completely visible in this viewport for the arrow direction. 1074 * @function isEntirelyVisible 1075 * @param element {Node} 1076 * @returns {boolean} 1077 */ 1078 function isEntirelyVisible(element, container) { 1079 const rect = getBoundingClientRect(element); 1080 const containerElm = container || element.getSpatialNavigationContainer(); 1081 const containerRect = getBoundingClientRect(containerElm); 1082 1083 // FIXME: when element is bigger than container? 1084 const entirelyVisible = !((rect.left < containerRect.left) || 1085 (rect.right > containerRect.right) || 1086 (rect.top < containerRect.top) || 1087 (rect.bottom > containerRect.bottom)); 1088 1089 return entirelyVisible; 1090 } 1091 1092 /** 1093 * Decide the style property of this element is specified whether it's visible or not. 1094 * @function isVisibleStyleProperty 1095 * @param element {CSSStyleDeclaration} 1096 * @returns {boolean} 1097 */ 1098 function isVisibleStyleProperty(element) { 1099 const elementStyle = window.getComputedStyle(element, null); 1100 const thisVisibility = elementStyle.getPropertyValue('visibility'); 1101 const thisDisplay = elementStyle.getPropertyValue('display'); 1102 const invisibleStyle = ['hidden', 'collapse']; 1103 1104 return (thisDisplay !== 'none' && !invisibleStyle.includes(thisVisibility)); 1105 } 1106 1107 /** 1108 * Decide whether this element is entirely or partially visible within the viewport. 1109 * @function hitTest 1110 * @param element {Node} 1111 * @returns {boolean} 1112 */ 1113 function hitTest(element) { 1114 const elementRect = getBoundingClientRect(element); 1115 if (element.nodeName !== 'IFRAME' && (elementRect.top < 0 || elementRect.left < 0 || 1116 elementRect.top > element.ownerDocument.documentElement.clientHeight || elementRect.left >element.ownerDocument.documentElement.clientWidth)) 1117 return false; 1118 1119 let offsetX = parseInt(element.offsetWidth) / 10; 1120 let offsetY = parseInt(element.offsetHeight) / 10; 1121 1122 offsetX = isNaN(offsetX) ? 1 : offsetX; 1123 offsetY = isNaN(offsetY) ? 1 : offsetY; 1124 1125 const hitTestPoint = { 1126 // For performance, just using the three point(middle, leftTop, rightBottom) of the element for hit testing 1127 middle: [(elementRect.left + elementRect.right) / 2, (elementRect.top + elementRect.bottom) / 2], 1128 leftTop: [elementRect.left + offsetX, elementRect.top + offsetY], 1129 rightBottom: [elementRect.right - offsetX, elementRect.bottom - offsetY] 1130 }; 1131 1132 for(const point in hitTestPoint) { 1133 const elemFromPoint = element.ownerDocument.elementFromPoint(...hitTestPoint[point]); 1134 if (element === elemFromPoint || element.contains(elemFromPoint)) { 1135 return true; 1136 } 1137 } 1138 return false; 1139 } 1140 1141 /** 1142 * Decide whether a child element is entirely or partially Included within container visually. 1143 * @function isInside 1144 * @param containerRect {DOMRect} 1145 * @param childRect {DOMRect} 1146 * @returns {boolean} 1147 */ 1148 function isInside(containerRect, childRect) { 1149 const rightEdgeCheck = (containerRect.left <= childRect.right && containerRect.right >= childRect.right); 1150 const leftEdgeCheck = (containerRect.left <= childRect.left && containerRect.right >= childRect.left);
1151 const topEdgeCheck = (containerRect.top <= childRect.top && containerRect.bottom >= childRect.top); 1152 const bottomEdgeCheck = (containerRect.top <= childRect.bottom && containerRect.bottom >= childRect.bottom); 1153 return (rightEdgeCheck || leftEdgeCheck) && (topEdgeCheck || bottomEdgeCheck); 1154 } 1155 1156 /** 1157 * Decide whether this element is entirely or partially visible within the viewport. 1158 * Note: rect1 is outside of rect2 for the dir 1159 * @function isOutside 1160 * @param rect1 {DOMRect} 1161 * @param rect2 {DOMRect} 1162 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 1163 * @returns {boolean} 1164 */ 1165 function isOutside(rect1, rect2, dir) { 1166 switch (dir) { 1167 case 'left': 1168 return isRightSide(rect2, rect1); 1169 case 'right': 1170 return isRightSide(rect1, rect2); 1171 case 'up': 1172 return isBelow(rect2, rect1); 1173 case 'down': 1174 return isBelow(rect1, rect2); 1175 default: 1176 return false; 1177 } 1178 } 1179 1180 /* rect1 is right of rect2 */ 1181 function isRightSide(rect1, rect2) { 1182 return rect1.left >= rect2.right || (rect1.left >= rect2.left && rect1.right > rect2.right && rect1.bottom > rect2.top && rect1.top < rect2.bottom); 1183 } 1184 1185 /* rect1 is below of rect2 */ 1186 function isBelow(rect1, rect2) { 1187 return rect1.top >
1187= rect2.bottom || (rect1.top >= rect2.top && rect1.bottom > rect2.bottom && rect1.left < rect2.right && rect1.right > rect2.left); 1188 } 1189 1190 /* rect1 is completely aligned or partially aligned for the direction */ 1191 function isAligned(rect1, rect2, dir) { 1192 switch (dir) { 1193 case 'left' : 1194 /* falls through */ 1195 case 'right' : 1196 return rect1.bottom > rect2.top && rect1.top < rect2.bottom; 1197 case 'up' : 1198 /* falls through */ 1199 case 'down' : 1200 return rect1.right > rect2.left && rect1.left < rect2.right; 1201 default: 1202 return false; 1203 } 1204 } 1205 1206 /** 1207 * Get distance between the search origin and a candidate element along the direction when candidate element is inside the search origin. 1208 * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} 1209 * @function getDistanceFromPoint 1210 * @param point {Point} - The search origin 1211 * @param element {DOMRect} - A candidate element 1212 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 1213 * @returns {Number} The euclidian distance between the spatial navigation container and an element inside it 1214 */ 1215 function getDistanceFromPoint(point, element, dir) { 1216 point = startingPoint; 1217 // Get exit point, entry point -> {x: '', y: ''}; 1218 const points = getEntryAndExitPoints(dir, point, element); 1219 1220 // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate 1221 // that minimize the distance between these two points 1222 const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x); 1223 const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); 1224 1225 // The result is euclidian distance between P1 and P2. 1226 return Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); 1227 } 1228 1229 /** 1230 * Get distance between the search origin and a candidate element along the direction when candidate element is inside the search origin. 1231 * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} 1232 * @function getInnerDistance 1233 * @param rect1 {DOMRect} - The search origin 1234 * @param rect2 {DOMRect} - A candidate element 1235 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 1236 * @returns {Number} The euclidean distance between the spatial navigation container and an element inside it 1237 */ 1238 function getInnerDistance(rect1, rect2, dir) { 1239 const baseEdgeForEachDirection = {left: 'right', right: 'left', up: 'bottom', down: 'top'}; 1240 const baseEdge = baseEdgeForEachDirection[dir]; 1241 1242 return Math.abs(rect1[baseEdge] - rect2[baseEdge]); 1243 } 1244 1245 /** 1246 * Get the distance between the search origin and a candidate element considering the direction. 1247 * @see {@link https://drafts.csswg.org/css-nav-1/#calculating-the-distance} 1248 * @function getDistance 1249 * @param searchOrigin {DOMRect | Point} - The search origin 1250 * @param candidateRect {DOMRect} - A candidate element 1251 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 1252 * @returns {Number} The distance scoring between two elements 1253 */ 1254 function getDistance(searchOrigin, candidateRect, dir) { 1255 const kOrthogonalWeightForLeftRight = 30; 1256 const kOrthogonalWeightForUpDown = 2; 1257 1258 let orthogonalBias = 0; 1259 let alignBias = 0; 1260 const alignWeight = 5.0; 1261 1262 // Get exit point, entry point -> {x: '', y: ''}; 1263 const points = getEntryAndExitPoints(dir, searchOrigin, candidateRect); 1264 1265 // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate 1266 // that minimize the distance between these two points 1267 const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x); 1268 const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); 1269 1270 // A: The euclidean distance between P1 and P2. 1271 const A = Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); 1272 let B, C; 1273 1274 // B: The absolute distance in the direction which is orthogonal to dir between P1 and P2, or 0 if dir is null. 1275 // C: The intersection edges between a candidate and the starting point. 1276 1277 // D: The square root of the area of intersection between the border boxes of candidate and starting point 1278 const intersectionRect = getIntersectionRect(searchOrigin, candidateRect); 1279 const D = intersectionRect.area; 1280 1281 switch (dir) { 1282 case 'left': 1283 /* falls through */ 1284 case 'right' : 1285 // If two elements are aligned, add align bias 1286 // else, add orthogonal bias 1287 if (isAligned(searchOrigin, candidateRect, dir)) 1288 alignBias = Math.min(intersectionRect.height / searchOrigin.height , 1); 1289 else 1290 orthogonalBias = (searchOrigin.height / 2); 1291 1292 B = (P2 + orthogonalBias) * kOrthogonalWeightForLeftRight; 1293 C = alignWeight * alignBias; 1294 break; 1295 1296 case 'up' : 1297 /* falls through */ 1298 case 'down' : 1299 // If two elements are aligned, add align bias 1300 // else, add orthogonal bias 1301 if (isAligned(searchOrigin, candidateRect, dir)) 1302 alignBias = Math.min(intersectionRect.width / searchOrigin.width , 1); 1303 else 1304 orthogonalBias = (searchOrigin.width / 2); 1305 1306 B = (P1 + orthogonalBias) * kOrthogonalWeightForUpDown; 1307 C = alignWeight * alignBias; 1308 break; 1309 1310 default: 1311 B = 0; 1312 C = 0; 1313 break; 1314 } 1315 1316 return (A + B - C - D); 1317 } 1318 1319 /** 1320 * Get the euclidean distance between the search origin and a candidate element considering the direction. 1321 * @function getEuclideanDistance 1322 * @param rect1 {DOMRect} - The search origin 1323 * @param rect2 {DOMRect} - A candidate element 1324 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 1325 * @returns {Number} The distance scoring between two elements 1326 */ 1327 function getEuclideanDistance(rect1, rect2, dir) { 1328 // Get exit point, entry point 1329 const points = getEntryAndExitPoints(dir, rect1, rect2); 1330 1331 // Find the points P1 inside the border box of starting point and P2 inside the border box of candidate 1332 // that minimize the distance between these two points 1333 const P1 = Math.abs(points.entryPoint.x - points.exitPoint.x);
1334 const P2 = Math.abs(points.entryPoint.y - points.exitPoint.y); 1335 1336 // Return the euclidean distance between P1 and P2. 1337 return Math.sqrt(Math.pow(P1, 2) + Math.pow(P2, 2)); 1338 } 1339 1340 /** 1341 * Get the absolute distance between the search origin and a candidate element considering the direction. 1342 * @function getAbsoluteDistance 1343 * @param rect1 {DOMRect} - The search origin 1344 * @param rect2 {DOMRect} - A candidate element 1345 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD) 1346 * @returns {Number} The distance scoring between two elements 1347 */ 1348 function getAbsoluteDistance(rect1, rect2, dir) { 1349 // Get exit point, entry point 1350 const points = getEntryAndExitPoints(dir, rect1, rect2); 1351 1352 // Return the absolute distance in the dir direction between P1 and P. 1353 return ((dir === 'left') || (dir === 'right')) ? 1354 Math.abs(points.entryPoint.x - points.exitPoint.x) : Math.abs(points.entryPoint.y - points.exitPoint.y); 1355 } 1356 1357 /** 1358 * Get entry point and exit point of two elements considering the direction. 1359 * @function getEntryAndExitPoints 1360 * @param dir {SpatialNavigationDirection} - The directional information for the spatial navigation (e.g. LRUD). Default value for dir is 'down'. 1361 * @param searchOrigin {DOMRect | Point} - The search origin which contains the exit point 1362 * @param candidateRect {DOMRect} - One of candidates which contains the entry point 1363 * @returns {Points} The exit point from the search origin and the entry point from a candidate 1364 */ 1365 function getEntryAndExitPoints(dir = 'down', searchOrigin, candidateRect) { 1366 /** 1367 * User type definition for Point 1368 * @typeof {Object} Points 1369 * @property {Point} Points.entryPoint 1370 * @property {Point} Points.exitPoint 1371 */ 1372 const points = {entryPoint: {x: 0, y: 0}, exitPoint:{x: 0, y: 0}}; 1373 1374 if (startingPoint) { 1375 points.exitPoint = searchOrigin; 1376 1377 switch (dir) { 1378 case 'left': 1379 points.entryPoint.x = candidateRect.right; 1380 break; 1381 case 'up': 1382 points.entryPoint.y = candidateRect.bottom; 1383 break; 1384 case 'right': 1385 points.entryPoint.x = candidateRect.left; 1386 break; 1387 case 'down': 1388 points.entryPoint.y = candidateRect.top; 1389 break; 1390 } 1391 1392 // Set orthogonal direction 1393 switch (dir) { 1394 case 'left': 1395 case 'right': 1396 if (startingPoint.y <= candidateRect.top) { 1397 points.entryPoint.y = candidateRect.top; 1398 } else if (startingPoint.y < candidateRect.bottom) { 1399 points.entryPoint.y = startingPoint.y; 1400 } else { 1401 points.entryPoint.y = candidateRect.bottom; 1402 } 1403 break; 1404 1405 case 'up': 1406 case 'down': 1407 if (startingPoint.x <= candidateRect.left) { 1408 points.entryPoint.x = candidateRect.left; 1409 } else if (startingPoint.x < candidateRect.right) { 1410 points.entryPoint.x = startingPoint.x; 1411 } else { 1412 points.entryPoint.x = candidateRect.right; 1413 } 1414 break; 1415 } 1416 } 1417 else { 1418 // Set direction 1419 switch (dir) { 1420 case 'left': 1421 points.exitPoint.x = searchOrigin.left; 1422 points.entryPoint.x = (candidateRect.right < searchOrigin.left) ? candidateRect.right : searchOrigin.left; 1423 break; 1424 case 'up': 1425 points.exitPoint.y = searchOrigin.top; 1426 points.entryPoint.y = (candidateRect.bottom < searchOrigin.top) ? candidateRect.bottom : searchOrigin.top; 1427 break; 1428 case 'right': 1429 points.exitPoint.x = searchOrigin.right; 1430 points.entryPoint.x = (candidateRect.left > searchOrigin.right) ? candidateRect.left : searchOrigin.right; 1431 break; 1432 case 'down': 1433 points.exitPoint.y = searchOrigin.bottom; 1434 points.entryPoint.y = (candidateRect.top > searchOrigin.bottom) ? candidateRect.top : searchOrigin.bottom; 1435 break; 1436 } 1437 1438 // Set orthogonal direction 1439 switch (dir) { 1440 case 'left': 1441 case 'right': 1442 if (isBelow(searchOrigin, candidateRect)) { 1443 points.exitPoint.y = searchOrigin.top; 1444 points.entryPoint.y = (candidateRect.bottom < searchOrigin.top) ? candidateRect.bottom : searchOrigin.top; 1445 } else if (isBelow(candidateRect, searchOrigin)) { 1446 points.exitPoint.y = searchOrigin.bottom; 1447 points.entryPoint.y = (candidateRect.top > searchOrigin.bottom) ? candidateRect.top : searchOrigin.bottom; 1448 } else { 1449 points.exitPoint.y = Math.max(searchOrigin.top, candidateRect.top); 1450 points.entryPoint.y = points.exitPoint.y; 1451 } 1452 break; 1453 1454 case 'up': 1455 case 'down': 1456 if (isRightSide(searchOrigin, candidateRect)) { 1457 points.exitPoint.x = searchOrigin.left; 1458 points.entryPoint.x = (candidateRect.right < searchOrigin.left) ? candidateRect.right : searchOrigin.left; 1459 } else if (isRightSide(candidateRect, searchOrigin)) { 1460 points.exitPoint.x = searchOrigin.right; 1461 points.entryPoint.x = (candidateRect.left > searchOrigin.right) ? candidateRect.left : searchOrigin.right; 1462 } else { 1463 points.exitPoint.x = Math.max(searchOrigin.left, candidateRect.left); 1464 points.entryPoint.x = points.exitPoint.x; 1465 } 1466 break; 1467 } 1468 } 1469 1470 return points; 1471 } 1472 1473 /** 1474 * Find focusable elements within the container 1475 * @see {@link https://drafts.csswg.org/css-nav-1/#find-the-shortest-distance} 1476 * @function getIntersectionRect 1477 * @param rect1 {DOMRect} - The search origin which contains the exit point 1478 * @param rect2 {DOMRect} - One of candidates which contains the entry point 1479 * @returns {IntersectionArea} The intersection area between two elements. 1480 * 1481 * @typeof {Object} IntersectionArea 1482 * @property {Number} IntersectionArea.width 1483 * @property {Number} IntersectionArea.height 1484 */ 1485 function getIntersectionRect(rect1, rect2) { 1486 const intersection_rect = {width: 0, height: 0, area: 0}; 1487 1488 const new_location = [Math.max(rect1.left, rect2.left), Math.max(rect1.top, rect2.top)]; 1489 const new_max_point = [Math.min(rect1.right, rect2.right), Math.min(rect1.bottom, rect2.bottom)]; 1490 1491 intersection_rect.width = Math.abs(new_location[0] - new_max_point[0]); 1492 intersection_rect.height = Math.abs(new_location[1] - new_max_point[1]); 1493 1494 if (!(new_location[0] >= new_max_point[0] || new_location[1] >= new_max_point[1])) { 1495 // intersecting-cases 1496 intersection_rect.area = Math.sqrt(intersection_rect.width * intersection_rect.height); 1497 } 1498 1499 return intersection_rect; 1500 } 1501 1502 /** 1503 * Handle the spatial navigation behavior for HTMLInputElement, HTMLTextAreaElement 1504 * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input|HTMLInputElement (MDN)} 1505 * @function handlingEditableElement 1506 * @param e {Event} - keydownEvent 1507 * @returns {boolean} 1508 */ 1509 function handlingEditableElement(e) { 1510 const SPINNABLE_INPUT_TYPES = ['email', 'date', 'month', 'number', 'time', 'week'], 1511 TEXT_INPUT_TYPES = ['password', 'text', 'search', 'tel', 'url', null]; 1512 const eventTarget = document.activeElement; 1513 const startPosition = eventTarget.selectionStart; 1514 const endPosition = eventTarget.selectionEnd;
1515 const focusNavigableArrowKey = {left: false, up: false, right: false, down: false}; 1516 1517 const dir = ARROW_KEY_CODE[e.keyCode]; 1518 if (dir === undefined) { 1519 return focusNavigableArrowKey; 1520 } 1521 1522 if (SPINNABLE_INPUT_TYPES.includes(eventTarget.getAttribute('type')) && 1523 (dir === 'up' || dir === 'down')) { 1524 focusNavigableArrowKey[dir] = true; 1525 } else if (TEXT_INPUT_TYPES.includes(eventTarget.getAttribute('type')) || eventTarget.nodeName === 'TEXTAREA') { 1526 if (startPosition === endPosition) { // if there isn't any selected text 1527 if (startPosition === 0) { 1528 focusNavigableArrowKey.left = true; 1529 focusNavigableArrowKey.up = true; 1530 } 1531 if (endPosition === eventTarget.value.length) { 1532 focusNavigableArrowKey.right = true; 1533 focusNavigableArrowKey.down = true; 1534 } 1535 } 1536 } else { // HTMLDataListElement, HTMLSelectElement, HTMLOptGroup 1537 focusNavigableArrowKey[dir] = true; 1538 } 1539 1540 return focusNavigableArrowKey; 1541 } 1542 1543 /** 1544 * Get the DOMRect of an element 1545 * @function getBoundingClientRect 1546 * @param {Node} element 1547 * @returns {DOMRect} 1548 */ 1549 function getBoundingClientRect(element) { 1550 // memoization 1551 let rect = mapOfBoundRect && mapOfBoundRect.get(element); 1552 if (!rect) { 1553 const boundingClientRect = element.getBoundingClientRect(); 1554 rect = { 1555 top: Number(boundingClientRect.top.toFixed(2)), 1556 right: Number(boundingClientRect.right.toFixed(2)), 1557 bottom: Number(boundingClientRect.bottom.toFixed(2)), 1558 left: Number(boundingClientRect.left.toFixed(2)), 1559 width: Number(boundingClientRect.width.toFixed(2)), 1560 height: Number(boundingClientRect.height.toFixed(2)) 1561 }; 1562 mapOfBoundRect && mapOfBoundRect.set(element, rect); 1563 } 1564 return rect; 1565 } 1566 1567 /** 1568 * Get the candidates which is fully inside the target element in visual 1569 * @param {Node} targetElement 1570 * @returns {sequence<Node>} overlappedCandidates 1571 */ 1572 function getOverlappedCandidates(targetElement) { 1573 const container = targetElement.getSpatialNavigationContainer(); 1574 const candidates = container.focusableAreas(); 1575 const overlappedCandidates = []; 1576 1577 candidates.forEach(element => { 1578 if ((targetElement !== element) && isEntirelyVisible(element, targetElement)) { 1579 overlappedCandidates.push(element); 1580 } 1581 }); 1582 1583 return overlappedCandidates; 1584 } 1585 1586 /** 1587 * Get the list of the experimental APIs 1588 * @function getExperimentalAPI 1589 */ 1590 function getExperimentalAPI() { 1591 function canScroll(container, dir) { 1592 return (isScrollable(container, dir) && !isScrollBoundary(container, dir)) || 1593 (!container.parentElement && !isHTMLScrollBoundary(container, dir)); 1594 } 1595 1596 function findTarget(findCandidate, element, dir, option) { 1597 let eventTarget = element; 1598 let bestNextTarget = null; 1599 1600 // 4 1601 if (eventTarget === document || eventTarget === document.documentElement) { 1602 eventTarget = document.body || document.documentElement; 1603 } 1604 1605 // 5 1606 // At this point, spatialNavigationSearch can be applied. 1607 // If startingPoint is either a scroll container or the document, 1608 // find the best candidate within startingPoint 1609 if ((isContainer(eventTarget) || eventTarget.nodeName === 'BODY') && !(eventTarget.nodeName === 'INPUT')) { 1610 if (eventTarget.nodeName === 'IFRAME') 1611 eventTarget = eventTarget.contentDocument.body; 1612 1613 const candidates = getSpatialNavigationCandidates(eventTarget, option); 1614 1615 // 5-2 1616 if (Array.isArray(candidates) && candidates.length > 0) { 1617 return findCandidate ? getFilteredSpatialNavigationCandidates(eventTarget, dir, candidates) : eventTarget.spatialNavigationSearch(dir, {candidates}); 1618 } 1619 if (canScroll(eventTarget, dir)) { 1620 return findCandidate ? [] : eventTarget; 1621 } 1622 } 1623 1624 // 6 1625 // Let container be the nearest ancestor of eventTarget 1626 let container = eventTarget.getSpatialNavigationContainer();
1627 let parentContainer = (container.parentElement) ? container.getSpatialNavigationContainer() : null; 1628 1629 // When the container is the viewport of a browsing context 1630 if (!parentContainer && ( window.location !== window.parent.location)) { 1631 parentContainer = window.parent.document.documentElement; 1632 } 1633 1634 // 7 1635 while (parentContainer) { 1636 const candidates = filteredCandidates(eventTarget, getSpatialNavigationCandidates(container, option), dir, container); 1637 1638 if (Array.isArray(candidates) && candidates.length > 0) { 1639 bestNextTarget = eventTarget.spatialNavigationSearch(dir, {candidates, container}); 1640 if (bestNextTarget) { 1641 return findCandidate ? candidates : bestNextTarget; 1642 } 1643 } 1644 1645 // If there isn't any candidate and the best candidate among candidate: 1646 // 1) Scroll or 2) Find candidates of the ancestor container 1647 // 8 - if 1648 else if (canScroll(container, dir)) { 1649 return findCandidate ? [] : eventTarget; 1650 } else if (container === document || container === document.documentElement) { 1651 container = window.document.documentElement; 1652 1653 // The page is in an iframe 1654 if ( window.location !== window.parent.location ) { 1655 // eventTarget needs to be reset because the position of the element in the IFRAME 1656 // is unuseful when the focus moves out of the iframe 1657 eventTarget = window.frameElement; 1658 container = window.parent.document.documentElement; 1659 if (container.parentElement) 1660 parentContainer = container.getSpatialNavigationContainer(); 1661 else { 1662 parentContainer = null; 1663 break; 1664 } 1665 } 1666 } else { 1667 // avoiding when spatnav container with tabindex=-1 1668 if (isFocusable(container)) { 1669 eventTarget = container; 1670 } 1671 1672 container = parentContainer; 1673 if (container.parentElement) 1674 parentContainer = container.getSpatialNavigationContainer(); 1675 else { 1676 parentContainer = null; 1677 break; 1678 } 1679 } 1680 } 1681 1682 if (!parentContainer && container) { 1683 // Getting out from the current spatnav container 1684 const candidates = filteredCandidates(eventTarget, getSpatialNavigationCandidates(container, option), dir, container); 1685 1686 // 9 1687 if (Array.isArray(candidates) && candidates.length > 0) { 1688 bestNextTarget = eventTarget.spatialNavigationSearch(dir, {candidates, container}); 1689 if (bestNextTarget) { 1690 return findCandidate ? candidates : bestNextTarget; 1691 } 1692 } 1693 } 1694 1695 if (canScroll(container, dir)) { 1696 bestNextTarget = eventTarget; 1697 return bestNextTarget; 1698 } 1699 } 1700 1701 return { 1702 isContainer, 1703 isScrollContainer, 1704 isVisibleInScroller, 1705 findCandidates: findTarget.bind(null, true), 1706 findNextTarget: findTarget.bind(null, false), 1707 getDistanceFromTarget: (element, candidateElement, dir) => { 1708 if ((isContainer(element) || element.nodeName === 'BODY') && !(element.nodeName === 'INPUT')) { 1709 if (getSpatialNavigationCandidates(element).includes(candidateElement)) { 1710 return getInnerDistance(getBoundingClientRect(element), getBoundingClientRect(candidateElement), dir); 1711 } 1712 } 1713 return getDistance(getBoundingClientRect(element), getBoundingClientRect(candidateElement), dir); 1714 } 1715 }; 1716 } 1717 1718 /** 1719 * Makes to use the experimental APIs. 1720 * @function enableExperimentalAPIs 1721 * @param option {boolean} - If it is true, the experimental APIs can be used or it cannot. 1722 */ 1723 function enableExperimentalAPIs (option) { 1724 const currentKeyMode = window.__spatialNavigation__ && window.__spatialNavigation__.keyMode; 1725 window.__spatialNavigation__ = (option === false) ? getInitialAPIs() : Object.assign(getInitialAPIs(), getExperimentalAPI()); 1726 window.__spatialNavigation__.keyMode = currentKeyMode; 1727 Object.seal(window.__spatialNavigation__); 1728 } 1729 1730 /** 1731 * Set the environment for using the spatial navigation polyfill. 1732 * @function getInitialAPIs 1733 */ 1734 function getInitialAPIs() { 1735 return { 1736 enableExperimentalAPIs, 1737 get keyMode() { return this._keymode ? this._keymode : 'ARROW'; }, 1738 set keyMode(mode) { this._keymode = (['SHIFTARROW', 'ARROW', 'NONE'].includes(mode)) ? mode : 'ARROW'; }, 1739 setStartingPoint: function (x, y) {startingPoint = (x && y) ? {x, y} : null;} 1740 }; 1741 } 1742 1743 initiateSpatialNavigation(); 1744 enableExperimentalAPIs(false); 1745 1746 window.addEventListener('load', () => { 1747 spatialNavigationHandler(); 1748 }); 1749})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.