Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/Scanner/Scanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
126 changes: 126 additions & 0 deletions src/strategies/InteractiveStrategy.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/strategies/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import AutomaticStrategy from './AutomaticStrategy';
import ManualStrategy from './ManualStrategy';
import InteractiveStrategy from './InteractiveStrategy';

export default {
automatic: AutomaticStrategy,
manual: ManualStrategy,
interactive: InteractiveStrategy,
};
31 changes: 31 additions & 0 deletions src/utils/elementGeometry.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
2 changes: 2 additions & 0 deletions src/utils/getConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const configKeys = [
'selectKeyCodes',
'strategy',
'target',
'moveUpKeyCodes',
'moveDownKeyCodes',
];

const getConfig = (props) => {
Expand Down
5 changes: 5 additions & 0 deletions src/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,4 +16,7 @@ export default {
getConfig,
getStrategy,
getTreeForElement,
NavigationCandidate,
getElementRect,
getElementCenter,
};
96 changes: 96 additions & 0 deletions src/utils/models/NavigationElement.js
Original file line number Diff line number Diff line change
@@ -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;