Skip to content

Instantly share code, notes, and snippets.

@EmmanuelOga
Created September 15, 2025 21:48
Show Gist options
  • Save EmmanuelOga/efb9693b7a93d188aa3c5db28ba11e7b to your computer and use it in GitHub Desktop.
Save EmmanuelOga/efb9693b7a93d188aa3c5db28ba11e7b to your computer and use it in GitHub Desktop.
lrud-spatial refactor to TypeScript
/**
* 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;
}
};
@EmmanuelOga
Copy link
Author

Example with React:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { getNextFocus, SEL_FOCUSABLE, type Direction } from "./lrud.ts";

const handleKeyDown = (event: KeyboardEvent) => {
  if (!event.target || !(event.target instanceof HTMLElement)) return;

  // Focus first focusable if focus is missing.
  if (document.activeElement && !document.activeElement.matches(SEL_FOCUSABLE)) {
    event.preventDefault();
    document.body.querySelector<HTMLElement>(SEL_FOCUSABLE)?.focus();
    return;
  }

  // Navigate with arrow keys.
  if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) {
    const direction = event.key.replace("Arrow", "").toLowerCase() as Direction;
    const nextFocus = getNextFocus(event.target, direction);

    if (nextFocus) {
      event.preventDefault();
      nextFocus.focus();
    }
  }
};

window.addEventListener("keydown", handleKeyDown);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);

@EmmanuelOga
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment