From 4b4897b6fe08c600cd91b2796718e8d2beb1e908 Mon Sep 17 00:00:00 2001 From: abansal21 Date: Sun, 14 Dec 2025 19:11:56 +0000 Subject: [PATCH] add a new interactive strategy for spatial up and down arrow navigation --- src/components/Scanner/Scanner.js | 6 ++ src/constants.js | 2 + src/strategies/InteractiveStrategy.js | 126 ++++++++++++++++++++++++++ src/strategies/index.js | 2 + src/utils/elementGeometry.js | 31 +++++++ src/utils/getConfig.js | 2 + src/utils/index.js | 5 + src/utils/models/NavigationElement.js | 96 ++++++++++++++++++++ 8 files changed, 270 insertions(+) create mode 100644 src/strategies/InteractiveStrategy.js create mode 100644 src/utils/elementGeometry.js create mode 100644 src/utils/models/NavigationElement.js diff --git a/src/components/Scanner/Scanner.js b/src/components/Scanner/Scanner.js index e894e95..6293c68 100644 --- a/src/components/Scanner/Scanner.js +++ b/src/components/Scanner/Scanner.js @@ -18,6 +18,8 @@ import { SCANNER_AUTODEACTIVATE_KEYCODES, SCANNER_AUTODEACTIVATE_COUNT, SCANNER_MOVE_BACK_KEYS, + SCANNER_MOVE_UP_KEYS, + SCANNER_MOVE_DOWN_KEYS, } from '../../constants'; import utils from '../../utils'; import '../../utils/polyfill'; @@ -274,6 +276,8 @@ Scanner.defaultProps = { advanceKeyCodes: SCANNER_ADVANCE_KEYCODES, advanceClickEvent: SCANNER_ADVANCE_CLICKEVENT, moveBackKeyCodes: SCANNER_MOVE_BACK_KEYS, + moveUpKeyCodes: SCANNER_MOVE_UP_KEYS, + moveDownKeyCodes: SCANNER_MOVE_DOWN_KEYS, className: SCANNER_CLASSNAME, classNameActive: SCANNER_CLASSNAME_ACTIVE, events: SCANNER_EVENTS, @@ -300,6 +304,8 @@ Scanner.propTypes = { advanceKeyCodes: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), advanceClickEvent: PropTypes.string, moveBackKeyCodes: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), + moveUpKeyCodes: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), + moveDownKeyCodes: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), children: PropTypes.node.isRequired, className: PropTypes.string, classNameActive: PropTypes.string, diff --git a/src/constants.js b/src/constants.js index 7126c25..3f59b23 100644 --- a/src/constants.js +++ b/src/constants.js @@ -13,6 +13,8 @@ export const SCANNER_CLASSNAME_ACTIVE = 'Scanner__Container Scanner__Container-- export const SCANNER_ITERATION_INTERVAL = 2000; export const SCANNABLE_FOCUSED_CLASSNAME = 'scanner__focused'; export const SCANNABLE_FOCUSED_VISIBLE_THRESHOLD = 5; +export const SCANNER_MOVE_UP_KEYS = ['upArrow']; +export const SCANNER_MOVE_DOWN_KEYS = ['downArrow']; export const KEY_CODE_MAP = { enter: 13, diff --git a/src/strategies/InteractiveStrategy.js b/src/strategies/InteractiveStrategy.js new file mode 100644 index 0000000..b9b49a8 --- /dev/null +++ b/src/strategies/InteractiveStrategy.js @@ -0,0 +1,126 @@ +import ManualStrategy from './ManualStrategy'; +import { KEY_CODE_MAP } from '../constants'; +import NavigationElement from '../utils/models/NavigationElement'; + +class InteractiveStrategy extends ManualStrategy { + constructor(scanner) { + super(scanner); + + const { moveUpKeyCodes, moveDownKeyCodes } = scanner.config; + + this.moveUpKeyCodes = new Set(moveUpKeyCodes.map((kc) => KEY_CODE_MAP[kc] || kc)); + this.moveDownKeyCodes = new Set(moveDownKeyCodes.map((kc) => KEY_CODE_MAP[kc] || kc)); + } + + findScannableInDirection(direction) { + const { keysToIterate, elementsToIterate, focusedId } = this.scanner.state; + if (keysToIterate.length === 0) return null; + if (!focusedId || focusedId === 'not-valid-id' || !elementsToIterate[focusedId]) { + // if no current element, return the first available + return keysToIterate[0]; + } + + const currentElement = new NavigationElement(focusedId, elementsToIterate[focusedId]); + const currentRect = currentElement.rect; + if (!currentRect) { + // If current element has no position, move to first element + return keysToIterate[0]; + } + + // Track best candidates for direct navigation + let bestUp = null; + let bestDown = null; + + // Track wrap candidates (elements at opposite end for wrapping) + let bestUpWrap = { + element: currentElement, + score: -1, + }; + let bestDownWrap = { + element: currentElement, + score: -1, + }; + + keysToIterate.forEach((key) => { + if (key === focusedId) return; + + const element = new NavigationElement(key, elementsToIterate[key]); + if (!element || !element.center) return; + + // Only consider elements with horizontal overlap + if (!element.hasHorizontalOverlap(currentElement)) return; + + const verticalDistance = element.verticalDistance(currentElement); + + if (element.isAbove(currentElement)) { + // Update best upward candidate + if (element.isBetterThan(bestUp, verticalDistance)) { + bestUp = { element, score: verticalDistance }; + } + // Update downward wrap candidate (topmost element for wrapping down) + if (element.isBetterWrapThan(bestDownWrap, verticalDistance, true)) { + bestDownWrap = { element, score: verticalDistance }; + } + } else if (element.isBelow(currentElement)) { + // Update best downward candidate + if (element.isBetterThan(bestDown, verticalDistance)) { + bestDown = { element, score: verticalDistance }; + } + + // Update upward wrap candidate (bottommost element for wrapping up) + if (element.isBetterWrapThan(bestUpWrap, verticalDistance)) { + bestUpWrap = { element, score: verticalDistance }; + } + } + }); + // Return appropriate candidate based on direction + if (direction === 'up') { + return bestUp ? bestUp.element.id : bestUpWrap.element.id; + } + + if (direction === 'down') { + return bestDown ? bestDown.element.id : bestDownWrap.element.id; + } + + return keysToIterate[0]; + } + + /** + * Navigate in the specified vertical direction + * @param {string} direction - Direction to navigate ('up' or 'down') + * @param {Event} event - The keyboard event + */ + navigateInDirection(direction, event) { + const newFocusedId = this.findScannableInDirection(direction); + if (newFocusedId) { + this.scanner.focusScannable(newFocusedId); + } + } + + /** + * Handle element selection based on event type + * @param {Object} scannable - The scannable element + * @param {Event} event - The triggering event + * @override + */ + selectElement(scannable, event) { + const { type: eventType, keyCode } = event; + + // Handle arrow keys first + if (eventType === 'keydown') { + if (this.moveUpKeyCodes.has(keyCode)) { + this.navigateInDirection('up', event); + return; + } + if (this.moveDownKeyCodes.has(keyCode)) { + this.navigateInDirection('down', event); + return; + } + } + + // Delegate all other cases to parent class + super.selectElement(scannable, event); + } +} + +export default InteractiveStrategy; diff --git a/src/strategies/index.js b/src/strategies/index.js index fd53678..a2a7429 100644 --- a/src/strategies/index.js +++ b/src/strategies/index.js @@ -1,7 +1,9 @@ import AutomaticStrategy from './AutomaticStrategy'; import ManualStrategy from './ManualStrategy'; +import InteractiveStrategy from './InteractiveStrategy'; export default { automatic: AutomaticStrategy, manual: ManualStrategy, + interactive: InteractiveStrategy, }; diff --git a/src/utils/elementGeometry.js b/src/utils/elementGeometry.js new file mode 100644 index 0000000..e12729b --- /dev/null +++ b/src/utils/elementGeometry.js @@ -0,0 +1,31 @@ +/** + * Utility functions for scannable geometry calculations + * Used for spatial navigation in scanning strategies + */ + +/** + * Get the position and dimensions of and element + * @param {Object} scannable - Scannable element + * @returns {DOMRect | null} Element bounding rectangle or null + */ +export function getElementRect(scannable) { + if (!scannable || !scannable.node) { + return null; + } + return scannable.node.getBoundingClientRect(); +} + +/** + * Get the center point of an element + * @param {DOMRect} rect - Element bounding rectangle + * @returns {Object} Center point with x and y coordinates + */ +export function getElementCenter(rect) { + if (!rect) { + return null; + } + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; +} diff --git a/src/utils/getConfig.js b/src/utils/getConfig.js index b2a999f..6b59dab 100644 --- a/src/utils/getConfig.js +++ b/src/utils/getConfig.js @@ -15,6 +15,8 @@ const configKeys = [ 'selectKeyCodes', 'strategy', 'target', + 'moveUpKeyCodes', + 'moveDownKeyCodes', ]; const getConfig = (props) => { diff --git a/src/utils/index.js b/src/utils/index.js index 4ffbf23..a0b71e3 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -2,9 +2,11 @@ import checkVisible from './checkVisible'; import checkVisibleAndScroll from './checkVisibleAndScroll'; import debounce from './debounce'; import dispatchEvent from './dispatchEvent'; +import { getElementCenter, getElementRect } from './elementGeometry'; import getConfig from './getConfig'; import getStrategy from './getStrategy'; import getTreeForElement from './getTreeForElement'; +import NavigationCandidate from './models/NavigationElement'; export default { checkVisible, @@ -14,4 +16,7 @@ export default { getConfig, getStrategy, getTreeForElement, + NavigationCandidate, + getElementRect, + getElementCenter, }; diff --git a/src/utils/models/NavigationElement.js b/src/utils/models/NavigationElement.js new file mode 100644 index 0000000..92226de --- /dev/null +++ b/src/utils/models/NavigationElement.js @@ -0,0 +1,96 @@ +import { getElementCenter, getElementRect } from '../elementGeometry'; + +/** + * Candidate wrapper class for navigation + * Encapsulates scannable geometry data for spatial navigation algorithms + */ +class NavigationElement { + /** + * Create a navigation candidate + * @param {string} id - Unique identifier for the scannable element + * @param {Object} scannable - Scannable element object + */ + constructor(id, scannable) { + this.id = id; + this.scannable = scannable; + this.rect = scannable ? getElementRect(scannable) : null; + this.center = this.rect ? getElementCenter(this.rect) : null; + } + + /** + * Check if this candidate has horizontal overlap with another + * @param {NavigationElement} other - First rectangle + * @returns {boolean} True if candidates overlap horizontally + */ + hasHorizontalOverlap(other) { + if (!this.rect || !other.rect) return false; + return this.rect.left < other.rect.right && this.rect.right > other.rect.left; + } + + /** + * Calculate vertical distance to another candiate + * @param {NavigationElement} other - Another navigation element + * @returns {number} Vertical distance in pixels + */ + verticalDistance(other) { + if (!this.rect || !other.rect) return Infinity; + return Math.abs(this.rect.top - other.rect.top); + } + + /** + * Check if this element is above another + * @param {NavigationElement} other - Another navigation element + * @returns {boolean} True if this element is above the other + */ + isAbove(other) { + if (!this.center || !other.rect) return false; + return this.center.y < other.rect.top; + } + + /** + * Check if this element is below another + * @param {NavigationElement} other - Another navigation element + * @returns {boolean} True if this element is below the other + */ + isBelow(other) { + if (!this.center || !other.rect) return false; + return this.center.y > other.rect.top; + } + + /** + * Check if this element is left of another + * @param {NavigationElement} other - Another navigation element + * @returns {boolean} True if this element is to the left + */ + isLeftOf(other) { + if (!this.rect || !other.rect) return false; + return this.rect.left < other.rect.left; + } + + /** + * Check if this element is better than another based on distance and position + * @param {Object} currentBest - Current best candidate object with {element, score} + * @param {number} distance - Distance to compare + * @returns {boolean} True if this element is better + */ + isBetterThan(currentBest, distance) { + if (!currentBest) return true; + if (distance < currentBest.score) return true; + if (distance === currentBest.score && this.isLeftOf(currentBest.element)) return true; + return false; + } + + /** + * Check if this element is better for wrapping + * @param {Object} currentWrap - Current wrap candidate object with {element, score} + * @param {number} distance - Distance to compare + * @param {boolean} preferLeft - Whether to prefer left position on tie + * @returns {boolean} True if this element is better for wrapping + */ + isBetterWrapThan(currentWrap, distance, preferLeft = false) { + if (distance < currentWrap.score) return false; + if (distance > currentWrap.score) return true; + return preferLeft ? this.isLeftOf(currentWrap.element) : currentWrap.element.isLeftOf(this); + } +} +export default NavigationElement;