Created
September 15, 2025 21:48
-
-
Save EmmanuelOga/efb9693b7a93d188aa3c5db28ba11e7b to your computer and use it in GitHub Desktop.
lrud-spatial refactor to TypeScript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* LRUD: Spatial Edition | |
* | |
* @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ | |
* @@@@@@ '@@@@@@@@@ @@@@@@ '@@@@@@@@@ @@@@@@@@ '@@@@@@@ | |
* @@@@@@ @@. @@@@@@@@ @@@@@@ @@. @@@@@@@ @@@@@ @@@@. @@@@ | |
* @@@@@@ @@@@ @@@@@@@@ @@@@@@ @@@@ @@@@@@@ @@@@ @@@@@@@@@@@@@@@ | |
* @@@@@@ @@@@@@@@ @@@@@@ @@@@@@@@ @@@ @@@@@@@@@@@@@@@@ | |
* @@@@@@ @@@@@. @@@@@@ @@@@@@ @@@@@. @@@@@@ @@@ @@@@@@@@@@@@@@@@ | |
* @@@@@@ @@@@@ @@@@@@ @@@@@@ @@@@@ @@@@@@ @@@@ @@@@@@@@/ @@@@ | |
* @@@@@@ /@@@@@@@ @@@@@@ /@@@@@@@ @@@@@@\, @@@@@ | |
* @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ | |
* | |
* Copyright (C) 2023 BBC. | |
*/ | |
interface Point { x: number; y: number }; | |
export type Direction = 'left' | 'right' | 'up' | 'down'; | |
// Any "interactive content" https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Interactive_content | |
export const SEL_FOCUSABLE = '[tabindex], a, input, button'; | |
export const SEL_CONTAINER = 'nav, section, .lrud-container'; | |
export const SEL_CONTAINER_ATTR = '[data-lrud-consider-container-distance]'; | |
export const SEL_IGNORE = '.lrud-ignore, [disabled]'; | |
/** Traverse DOM ancestors until we find a focus container */ | |
function getParent(elem: HTMLElement): HTMLElement | null { | |
if (!elem.parentElement || elem.parentElement.tagName === 'BODY') return null; | |
if (elem.parentElement.matches(SEL_CONTAINER)) return elem.parentElement; | |
return getParent(elem.parentElement); | |
} | |
/** | |
* Get all focusable elements inside `scope`, | |
* discounting any that are ignored or inside an ignored container | |
*/ | |
function getFocusables(scope?: HTMLElement): HTMLElement[] { | |
if (!scope) return []; | |
const ignoredElements = Array.from(scope.querySelectorAll(SEL_IGNORE)); | |
const focusables: HTMLElement[] = Array.from(scope.querySelectorAll(SEL_FOCUSABLE)) | |
return focusables | |
.filter((node) => !ignoredElements.some(ignored => ignored == node || ignored.contains(node))) | |
.filter((node) => parseInt(node.getAttribute('tabindex') ?? '0', 10) > -1); | |
} | |
/** Get all the focusable candidates inside `scope`, including focusable containers */ | |
function getAllFocusables(scope: HTMLElement): HTMLElement[] { | |
const focusables: HTMLElement[] = Array.from(scope.querySelectorAll(SEL_CONTAINER_ATTR)); | |
const ignoredElements = Array.from(scope.querySelectorAll(SEL_IGNORE)); | |
return focusables | |
.filter((node) => !ignoredElements.some(ignored => ignored == node || ignored.contains(node))) | |
.filter((container) => getFocusables(container).length > 0) | |
.concat(getFocusables(scope)) | |
} | |
/** Build an array of ancestor containers */ | |
function collectContainers(initialContainer?: HTMLElement): HTMLElement[] { | |
if (!initialContainer) return []; | |
const acc = [initialContainer]; | |
let cur: HTMLElement | null = initialContainer; | |
while (cur) { | |
cur = getParent(cur); | |
if (cur) acc.push(cur); | |
} | |
return acc; | |
} | |
/** | |
* Get the middle point of a given edge | |
* | |
* @param {Object} rect An object representing the rectangle | |
* @param {String} dir The direction of the edge (left, right, up, down) | |
* @return {Point} An object with the X and Y coordinates of the point | |
*/ | |
function getMidpointForEdge(rect: DOMRect, dir: string): Point { | |
switch (dir) { | |
case 'left': | |
return { x: rect.left, y: (rect.top + rect.bottom) / 2 }; | |
case 'right': | |
return { x: rect.right, y: (rect.top + rect.bottom) / 2 }; | |
case 'up': | |
return { x: (rect.left + rect.right) / 2, y: rect.top }; | |
case 'down': | |
return { x: (rect.left + rect.right) / 2, y: rect.bottom }; | |
default: | |
throw new Error(`Invalid direction: ${dir}`); | |
} | |
} | |
/** | |
* Gets the nearest point on `rect` that a line in direction `dir` from `point` would hit | |
* If the rect is exactly in direction `dir` then the point will be in a straight line from `point`. | |
* Otherwise it will be the nearest corner of the target rect. | |
* | |
* @param {Point} point The point to start from | |
* @param {String} dir The direction to draw the line in | |
* @param {Object} rect An object representing the rectangle of the item we're going to | |
* @return {Point} An object with the X/Y coordinates of the nearest point | |
*/ | |
const getNearestPoint = (point: Point, dir: Direction, rect: DOMRect): Point | undefined => { | |
if (dir === 'left' || dir === 'right') { // Horizontal | |
// The nearest X is always the nearest edge, left or right | |
const x = dir === 'left' ? rect.right : rect.left; | |
// If the start point is higher than the rect, nearest Y is the top corner | |
if (point.y < rect.top) return { x, y: rect.top }; | |
// If the start point is lower than the rect, nearest Y is the bottom corner | |
if (point.y > rect.bottom) return { x, y: rect.bottom }; | |
// Else the nearest Y is aligned with where we started | |
return { x, y: point.y }; | |
} else { // Vertical | |
// The nearest Y is always the nearest edge, top or bottom | |
const y = dir === 'up' ? rect.bottom : rect.top; | |
// If the start point is left-er than the rect, nearest X is the left corner | |
if (point.x < rect.left) return { x: rect.left, y }; | |
// If the start point is right-er than the rect, nearest X is the right corner | |
if (point.x > rect.right) return { x: rect.right, y }; | |
// Else the nearest X is aligned with where we started | |
return { x: point.x, y }; | |
} | |
}; | |
const isBelow = (a: Point, b: Point): boolean => a.y > b.y; | |
const isRight = (a: Point, b: Point): boolean => a.x > b.x; | |
/** | |
* Get blocked exit directions for current node | |
* @return {string[]} Array of strings representing blocked directions | |
*/ | |
function getBlockedExitDirs(current: HTMLElement, candidate: HTMLElement): string[] { | |
const currentAncestorContainers = collectContainers(current); | |
const candidateAncestorContainers = collectContainers(candidate); | |
// Find common container for current container and candidate container and | |
// remove everything above it | |
for (const commonCandidate of candidateAncestorContainers) { | |
const spliceIndex = currentAncestorContainers.indexOf(commonCandidate); | |
if (spliceIndex > -1) { | |
currentAncestorContainers.splice(spliceIndex); | |
break; | |
} | |
} | |
return currentAncestorContainers.reduce((acc: string[], cur) => { | |
const dirs = (cur.getAttribute('data-block-exit') ?? '').split(' '); | |
return acc.concat(dirs); | |
}, []); | |
} | |
/** | |
* Check if the candidate is in the `exitDir` direction from the rect we're leaving, | |
* with an overlap allowance of entryWeighting as a percentage of the candidate's width. | |
* | |
* @param entryRect An object representing the rectangle of the item we're moving to | |
* @param exitDir The direction we're moving in | |
* @param exitPoint The midpoint of the edge we're leaving | |
* @param entryWeighting Percentage of the candidate that is allowed to be behind the target | |
* @return true if candidate is in the correct dir, false if not | |
*/ | |
function isValidCandidate(entryRect: DOMRect, exitDir: Direction, exitPoint: Point, entryWeighting: number): boolean { | |
if (entryRect.width === 0 && entryRect.height === 0) return false; | |
if (!entryWeighting && entryWeighting != 0) entryWeighting = 0.3; | |
const x = entryRect.left + entryRect.width * | |
(exitDir === 'left' ? 1 - entryWeighting | |
: exitDir === 'right' ? entryWeighting | |
: 0.5); | |
const y = entryRect.top + entryRect.height * | |
(exitDir === 'up' ? 1 - entryWeighting | |
: exitDir === 'down' ? entryWeighting | |
: 0.5); | |
const entryPoint = { x, y }; | |
return (exitDir === 'left' && isRight(exitPoint, entryPoint)) || | |
(exitDir === 'right' && isRight(entryPoint, exitPoint)) || | |
(exitDir === 'up' && isBelow(exitPoint, entryPoint)) || | |
(exitDir === 'down' && isBelow(entryPoint, exitPoint)); | |
} | |
const getRank = (a, b) => Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); | |
/** | |
* Sort the candidates ordered by distance to the elem, and filter out invalid candidates. | |
* @return {HTMLElement[]} The valid candidates, in order by distance | |
*/ | |
const sortValidCandidates = (candidates: HTMLElement[], origin: HTMLElement, exitDir: Direction): HTMLElement[] => { | |
const exitp = getMidpointForEdge(origin.getBoundingClientRect(), exitDir); | |
return candidates | |
.filter(elem => { | |
// Filter out candidates that are in the opposite direction or have no dimensions | |
const entryRect = elem.getBoundingClientRect(); | |
const allowedOverlap = parseFloat(elem.getAttribute('data-lrud-overlap-threshold') ?? ''); | |
return isValidCandidate(entryRect, exitDir, exitp, allowedOverlap); | |
}) | |
.map(elem => { | |
const entryRect = elem.getBoundingClientRect(); | |
const nearestPoint = getNearestPoint(exitp, exitDir, entryRect); | |
const rank = getRank(exitp, nearestPoint); | |
return { candidate: elem, rank }; | |
}) | |
.sort((a, b) => a.rank - b.rank) | |
.map(({ candidate }) => candidate); | |
}; | |
/** | |
* Get the first parent container that matches the focusable candidate selector | |
* @param {HTMLElement} elem The starting candidate to get the parent container of | |
* @return {HTMLElement} The container that matches or null | |
*/ | |
const getParentFocusable = (elem: HTMLElement | null): HTMLElement | null => { | |
if (!elem) return null; | |
do { | |
elem = getParent(elem); | |
} while (elem && !elem.matches(SEL_CONTAINER_ATTR)); | |
return elem; | |
}; | |
/** | |
* Get the next focus candidate | |
* | |
* @param {HTMLElement} elem The search origin | |
* @param {string|number} keyOrKeyCode The key or keyCode value (from KeyboardEvent) of the pressed key | |
* @param {HTMLElement} scope The element LRUD spatial is scoped to operate within | |
* @return {HTMLElement} The element that should receive focus next | |
*/ | |
export const getNextFocus = (elem?: HTMLElement, exitDir: Direction, scope: HTMLElement = document.body): HTMLElement | undefined => { | |
if (!elem) return getFocusables(scope)[0]; | |
const parent = getParent(elem); | |
if (parent && elem.matches(SEL_FOCUSABLE)) { | |
parent.setAttribute('data-focus', elem.id); | |
getParentFocusable(parent)?.setAttribute('data-focus', elem.id); | |
} | |
let candidates: HTMLElement[] = []; | |
// Get all siblings within a prioritised container | |
if (parent?.getAttribute('data-lrud-prioritise-children') !== 'false' && scope.contains(parent)) { | |
const focusableSiblings = parent ? getAllFocusables(parent) : []; | |
candidates = sortValidCandidates(focusableSiblings, elem, exitDir); | |
} | |
if (candidates.length === 0) { | |
candidates = sortValidCandidates(getAllFocusables(scope), elem, exitDir); | |
} | |
for (const elem of candidates) { | |
const elemIsContainer = elem.matches(SEL_CONTAINER); | |
const container = elemIsContainer ? elem : getParent(elem); | |
const isCurrentContainer = container === parent; | |
const isNestedContainer = parent?.contains(container); | |
const isAnscestorContainer = container?.contains(parent); | |
if (!isCurrentContainer && (!isNestedContainer || elemIsContainer)) { | |
const blockedExitDirs = parent && container ? getBlockedExitDirs(parent, container) : []; | |
if (blockedExitDirs.includes(exitDir)) continue; | |
if (container && !isAnscestorContainer) { | |
// Ignore active child behaviour when moving into a container that we are already nested in | |
const lastActiveChild = document.getElementById(container.getAttribute('data-focus') ?? ''); | |
const newFocus = lastActiveChild ?? getFocusables(container).at(0); | |
getParentFocusable(container)?.setAttribute('data-focus', newFocus?.id ?? ''); | |
container.setAttribute('data-focus', newFocus?.id ?? ''); | |
return newFocus; | |
} | |
} | |
if (!elemIsContainer) { | |
getParentFocusable(container)?.setAttribute('data-focus', elem.id); | |
container?.setAttribute('data-focus', elem.id); | |
} | |
return elem; | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example with React: