UNPKG

@empathyco/x-components

Version:
416 lines (413 loc) • 16.8 kB
import { FOCUSABLE_SELECTORS } from '../utils/focus.js'; import { getActiveElement } from '../utils/html.js'; /** * Implementation of {@link SpatialNavigation} using directional focus. * * @public */ class DirectionalFocusNavigationService { /** * Constructor for the {@link DirectionalFocusNavigationService}. * * @param container - The element that contains the navigable elements. * @param focusableSelectors - A comma separated string with the focusable selectors to look up. */ constructor( /** * The {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement | HTMLElement} that * contains the navigable elements. */ container, /** * Comma separated focusable selectors to look up. */ focusableSelectors = FOCUSABLE_SELECTORS) { this.container = container; this.focusableSelectors = focusableSelectors; /** * Weight of the projected intersection area weight in the * {@link DirectionalFocusNavigationService.getDistanceScore | getDistanceScore} formula. */ this.intersectionAreaWeight = 100; /** * Weight of the absolute distance on the orthogonal axis between to elements when navigating * left or right. Used to calculate the displacement in * {@link DirectionalFocusNavigationService.getDisplacementAndAlignment | * getDisplacementAndAlignment}. */ this.orthogonalWeightHorizontal = 30; /** * Weight of the absolute distance on the orthogonal axis between to elements when navigating * up or down. Used to calculate the displacement in * {@link DirectionalFocusNavigationService.getDisplacementAndAlignment | * getDisplacementAndAlignment}. */ this.orthogonalWeightVertical = 2; /** * Weight of the degree of alignment between two elements when calculating the alignment in * {@link DirectionalFocusNavigationService.getDisplacementAndAlignment | * getDisplacementAndAlignment}. */ this.alignWeight = 5; /** * Set of functions to filter out candidates based on the navigation's direction. */ this.filterFunction = { ArrowUp: (candidateRect) => this.isBelow(this.originRect, candidateRect), ArrowRight: (candidateRect) => this.isRightSide(candidateRect, this.originRect), ArrowDown: (candidateRect) => this.isBelow(candidateRect, this.originRect), ArrowLeft: (candidateRect) => this.isRightSide(this.originRect, candidateRect), }; } /** * Get the element that would be the next one to be navigated to based on the direction of the * arrow key pressed. If there are no possible candidates the element to focus would be the one on * currently on focus or the first one in the container. * * @param arrowKey - The arrow key that was pressed. * * @returns The element to navigate to. */ navigateTo(arrowKey) { const rawCandidates = this.getFocusableElements(); this.direction = arrowKey; this.updateOrigin(); return this.getBestCandidate(rawCandidates); } /** * Gets focusable elements within the container. * * @returns List of focusable elements. * @internal */ getFocusableElements() { return Array.from(this.container.querySelectorAll(this.focusableSelectors)); } /** * Updates the origin with the current document active element. * * @remarks * This also covers cases when the user might have iterated through the DOM using the TAB or * SHIFT+TAB keys. */ updateOrigin() { const newOrigin = getActiveElement(); if (newOrigin) { this.origin = newOrigin; this.originRect = newOrigin.getBoundingClientRect(); } } /** * Finds the closest candidate to the origin from a list of candidates. * * @remarks * If there are no candidates the origin will be retrieved as best candidate. * * @param rawCandidates - List of all candidates. * * @returns The closest candidate to the origin or origin if there's none. * @internal */ getBestCandidate(rawCandidates) { const candidates = this.filterCandidates(rawCandidates); let bestCandidate = this.origin; candidates.reduce((bestCurrentScore, candidate) => { const bestScore = Math.min(bestCurrentScore, this.getDistanceScore(candidate)); if (bestScore !== bestCurrentScore) { bestCandidate = candidate; } return bestScore; }, Number.MAX_SAFE_INTEGER); return bestCandidate; } /** * Filters out candidates that can't be candidates based on the direction of the navigation and * if they are visible and enabled. * * @param rawCandidates - List of all candidates. * * @returns List of filtered candidates. * @internal */ filterCandidates(rawCandidates) { return rawCandidates.filter(candidate => this.isValidCandidate(candidate)); } /** * Checks if the provided candidate is not the origin, is visible, enabled and in the correct * direction to be a valid candidate. * * @param candidate - The candidate element. * @returns If the candidate is valid for the navigation. * @internal */ isValidCandidate(candidate) { return (candidate !== this.origin && this.isCandidateVisible(candidate) && this.hasFocusCompatibleAttributes(candidate) && this.isInNavigateDirection(candidate)); } /** * Checks if the provided candidate is visible. * * @param candidate - The candidate element. * @returns If the candidate is visible. * @internal */ isCandidateVisible(candidate) { const candidateStyle = window.getComputedStyle(candidate, null); return !!(candidate.offsetWidth && candidate.offsetHeight && candidateStyle.visibility === 'visible'); } /** * Checks if the provided candidate is disabled and if the tabindex allows the element to be * focused. * * @param candidate - The candidate element. * @returns If candidate's attributes allow it to be focused. * @internal */ hasFocusCompatibleAttributes(candidate) { return !candidate.getAttribute('disabled') && candidate.getAttribute('tabindex') !== '-1'; } /** * Checks if the provided candidate is in the direction the navigation is going. * * @param candidate - The candidate element. * @returns If the candidate is in the correct direction. * @internal */ isInNavigateDirection(candidate) { return this.filterFunction[this.direction](candidate.getBoundingClientRect()); } /** * Calculates the candidate's score for it to be the next element to navigateTo to based on a * formula that takes into account euclidean distance, displacement, alignment and * intersection area relative to the origin element. * * @param candidate - The candidate element. * * @returns The candidate score for best candidate. * @internal */ getDistanceScore(candidate) { const candidateRect = candidate.getBoundingClientRect(); const { 0: candidatePoint, 1: originPoint } = this.getComparisionPoints(candidateRect); const absoluteDistances = { x: Math.abs(candidatePoint.x - originPoint.x), y: Math.abs(candidatePoint.y - originPoint.y), }; const euclideanDistance = Math.sqrt(absoluteDistances.x ** 2 + absoluteDistances.y ** 2); const intersection = this.getIntersection(this.originRect, candidateRect); const { displacement, alignment } = this.getDisplacementAndAlignment(candidateRect, intersection, absoluteDistances); const projectedArea = Math.sqrt(intersection.area) / this.intersectionAreaWeight; return euclideanDistance + displacement - alignment - projectedArea; } /** * Gets the closest point to origin within the candidate and to the candidate within the origin * based on the navigation direction. * * @param candidateRect - The DOMRect of the candidate. * * @returns The candidate's closest Points to the origin. * @internal */ getComparisionPoints(candidateRect) { const points = [ { x: 0, y: 0 }, { x: 0, y: 0 }, ]; return { ...this.setParallelPointValues(points, candidateRect), ...this.setOrthogonalPointValues(points, candidateRect), }; } /** * Set parallel values between candidate and origin based on the navigation direction and * returns them. * * @param points - Current values for the candidate and origin's points. * @param candidateRect - The DOMRect of the candidate. * @returns Candidate and origin points with parallel values set. * @internal */ setParallelPointValues({ 0: candidatePoint, 1: originPoint }, candidateRect) { switch (this.direction) { case 'ArrowUp': candidatePoint.y = Math.min(candidateRect.bottom, this.originRect.top); originPoint.y = this.originRect.top; break; case 'ArrowDown': candidatePoint.y = Math.max(candidateRect.top, this.originRect.bottom); originPoint.y = this.originRect.bottom; break; case 'ArrowRight': candidatePoint.x = Math.max(candidateRect.left, this.originRect.right); originPoint.x = this.originRect.right; break; case 'ArrowLeft': candidatePoint.x = Math.min(candidateRect.right, this.originRect.left); originPoint.x = this.originRect.left; break; } return [candidatePoint, originPoint]; } /** * Set orthogonal values between candidate and origin based on the navigation direction and * returns them. * * @param points - Current values for the candidate and origin's points. * @param candidateRect - The DOMRect of the candidate. * @returns Candidate and origin points with orthogonal values set. * @internal */ setOrthogonalPointValues({ 0: candidatePoint, 1: originPoint }, candidateRect) { switch (this.direction) { case 'ArrowUp': case 'ArrowDown': if (this.isRightSide(this.originRect, candidateRect)) { candidatePoint.x = Math.min(candidateRect.right, this.originRect.left); originPoint.x = this.originRect.left; } else if (this.isRightSide(candidateRect, this.originRect)) { candidatePoint.x = Math.max(candidateRect.left, this.originRect.right); originPoint.x = this.originRect.right; } else { candidatePoint.x = Math.max(this.originRect.left, candidateRect.left); originPoint.x = candidatePoint.x; } break; case 'ArrowRight': case 'ArrowLeft': if (this.isBelow(this.originRect, candidateRect)) { candidatePoint.y = Math.min(candidateRect.bottom, this.originRect.top); originPoint.y = this.originRect.top; } else if (this.isBelow(candidateRect, this.originRect)) { candidatePoint.y = Math.max(candidateRect.top, this.originRect.bottom); originPoint.y = this.originRect.bottom; } else { candidatePoint.y = Math.max(this.originRect.top, candidateRect.top); originPoint.y = candidatePoint.y; } break; } return [candidatePoint, originPoint]; } /** * Calculates the displacement and alignment values for the candidate relative to the origin. * * @param candidateRect - The DOMRect of the candidate. * @param intersection - Projected intersection between candidate and origin. * @param absoluteDistances - Absolute distances between candidate and origin points. * * @returns Displacement and alignment values. * @internal */ getDisplacementAndAlignment(candidateRect, intersection, absoluteDistances) { const areAligned = this.areAligned(this.originRect, candidateRect); let alignBias = 0; let orthogonalBias = 0; let displacement = 0; switch (this.direction) { case 'ArrowUp': case 'ArrowDown': if (areAligned) { alignBias = Math.min(intersection.width / this.originRect.width, 1); } else { orthogonalBias = this.originRect.width / 2; } displacement = (absoluteDistances.x + orthogonalBias) * this.orthogonalWeightVertical; break; case 'ArrowRight': case 'ArrowLeft': if (areAligned) { alignBias = Math.min(intersection.height / this.originRect.height, 1); } else { orthogonalBias = this.originRect.height / 2; } displacement = (absoluteDistances.y + orthogonalBias) * this.orthogonalWeightHorizontal; break; } return { displacement, alignment: alignBias * this.alignWeight }; } /** * Calculates the projected intersection between two * {@link https://developer.mozilla.org/en-US/docs/Web/API/DOMRect | rects}. * * @param rect1 - First {@link https://developer.mozilla.org/en-US/docs/Web/API/DOMRect | rect}. * @param rect2 - Second {@link https://developer.mozilla.org/en-US/docs/Web/API/DOMRect | rect}. * * @returns The intersection. * @internal */ getIntersection(rect1, rect2) { const intersection = { width: 0, height: 0, area: 0 }; const topLeftPoint = { x: Math.max(rect1.left, rect2.left), y: Math.max(rect1.top, rect2.top), }; const bottomRightPoint = { x: Math.min(rect1.right, rect2.right), y: Math.min(rect1.bottom, rect2.bottom), }; intersection.width = Math.abs(topLeftPoint.x - bottomRightPoint.x); intersection.height = Math.abs(topLeftPoint.y - bottomRightPoint.y); if (topLeftPoint.x < bottomRightPoint.x || topLeftPoint.y < bottomRightPoint.y) { intersection.area = intersection.width * intersection.height; } return intersection; } /** * Checks that both DOMRect are aligned based on the provided direction. * * @param rect1 - The first DOMRect. * @param rect2 - The DOMRect that the first one will be compared to. * * @returns If the DOMRect are aligned. * @internal */ areAligned(rect1, rect2) { return this.direction === 'ArrowLeft' || this.direction === 'ArrowRight' ? rect1.bottom > rect2.top && rect1.top < rect2.bottom : rect1.right > rect2.left && rect1.left < rect2.right; } /** * Checks that the first DOMRect is below the second one. * * @param rect1 - The first DOMRect. * @param rect2 - The DOMRect that the first one will be compared to. * * @returns If it's below. * @internal */ isBelow(rect1, rect2) { return (rect1.top >= rect2.bottom || (rect1.top >= rect2.top && rect1.bottom > rect2.bottom && rect1.left < rect2.right && rect1.right > rect2.left)); } /** * Checks that the first DOMRect is to the right side of the second one. * * @param rect1 - The first DOMRect. * @param rect2 - The DOMRect that the first one will be compared to. * * @returns If it's to the right side. * @internal */ isRightSide(rect1, rect2) { return (rect1.left >= rect2.right || (rect1.left >= rect2.left && rect1.right > rect2.right && rect1.bottom > rect2.top && rect1.top < rect2.bottom)); } } export { DirectionalFocusNavigationService }; //# sourceMappingURL=directional-focus-navigation.service.js.map