Skip to content

Instantly share code, notes, and snippets.

@nickyonge
Last active March 24, 2026 04:18
Show Gist options
  • Select an option

  • Save nickyonge/728b4563fdb4600ffa57527e345de314 to your computer and use it in GitHub Desktop.

Select an option

Save nickyonge/728b4563fdb4600ffa57527e345de314 to your computer and use it in GitHub Desktop.
Input handler that unifies element input states (hover, active, focus, etc) into callbacks and attributes, built with 100% vanilla JS [WIP]
/** INPUT HANDLER
*
* A script that Input handler that unifies element input states
* (hover, active, focus, etc) into callbacks and attributes.
* Built with 100% vanilla JS
*
* Presented as-is under The Unlicense, see below for license info.
*/
// @ts-check
// TODO: documentation, summaries, provide example usage
// TODO: improved and importable types + declarations
// TODO: add separate callbacks/attributes for specific clicks
// TODO: allow disabling context menu (+auto disable if using right click)
// TODO: delineate between 'mouse' and 'pen' inputs for 'pointer' events
// TODO: globally accessible IsElementHighlighted, IsElementActive, etc checks
/**
* @typedef {'keyboard'|'pointer'} InputMode
* Mode used for input, either `"keyboard"` or
* `"pointer"`, which can be touch or mouse.
*/
/**
* @typedef {Object} InputState
* @property {boolean} highlighted
* @property {boolean} active
* @property {boolean} hovered
* @property {boolean} focusWithin
* @property {boolean} focusVisibleWithin
* @property {boolean} pointerHeld
* @property {boolean} keyHeld
* @property {InputMode} lastInputMode
*/
/**
* @typedef {Object} InputHandler
* @property {HTMLElement} element
* @property {() => InputState} getState
* @property {() => void} destroy
*/
/**
* @typedef {Object} InputHandlerTarget
* @property {InputHandler} inputHandler
* @property {(event:KeyboardEvent) => void} onKeyDown
* @property {(event:KeyboardEvent) => void} onKeyUp
* @property {(event:PointerEvent) => void} onPointerDown
*/
/**
* @typedef {Object} InputMouseButtons
* @property {boolean} [left=true]
* @property {boolean} [middle=false]
* @property {boolean} [right=false]
*/
/**
* @typedef {Object} InputEventData
* @property {InputMode} source
* @property {PointerEvent | KeyboardEvent} originalEvent
* @property {InputState} state
*/
/**
* @typedef {Object} QueuedInputEventData
* @property {InputMode} source
* @property {PointerEvent | KeyboardEvent} originalEvent
*/
/**
* @typedef {Object} InputHandlerOptions
* @property {InputAttributesOptions} [attributes]
* @property {InputMouseButtons} [mouseButtons]
* @property {((state:InputState) => void) | null} [onChange]
* @property {((state:InputEventData) => void) | null} [onSelect]
* @property {((highlighted:boolean,state:InputState) => void) | null} [onHighlightChange]
* @property {((active:boolean,state:InputState) => void) | null} [onActiveChange]
* @property {((hovered:boolean,state:InputState) => void) | null} [onHoveredChange]
* @property {((inputMode:InputMode,state:InputState) => void) | null} [onInputModeChange]
* @property {((element:HTMLElement) => void) | null} [onDestroyed]
* @property {boolean} [activeAttrOverridesHoverAndHighlightAttrs=DEFAULT_ACTIVE_ATTR_OVERRIDES_HOVERED_HIGHLIGHT_ATTRS]
* @property {string[]} [activeKeys=[' ', 'Enter']]
* @property {boolean} [outputErrors=true]
* @property {boolean} [enforceDirectFocus=false]
* @property {boolean} [allowKeySelectOnUnfocusedHighlight=false]
*/
/**
* @typedef {Object} InputAttribute
* @property {string} name
* @property {boolean} [enabled]
* @property {boolean} [usePrefix=true]
*/
/**
* @typedef {Object} InputAttributesOptions
* @property {string|null} [prefix=DEFAULT_ATTR_OPTIONS_PREFIX]
* @property {boolean} [enabled=DEFAULT_ATTR_OPTIONS_ENABLED]
* @property {InputAttribute} [highlighted]
* @property {InputAttribute} [active]
* @property {InputAttribute} [hovered]
* @property {InputAttribute} [focusWithin]
* @property {InputAttribute} [focusVisibleWithin]
* @property {InputAttribute} [pointerHeld]
* @property {InputAttribute} [keyHeld]
* @property {InputAttribute} [lastInputMode]
*/
/**
* Attaches an {@linkcode InputHandler} to the given element and returns it.
*
* This will cohesively monitor both keyboard and mouse input, and can add/remove
* CSS classes to accurately control appearance.
*
* Returns `null` on error.
* @param {HTMLElement} element
* @param {InputHandlerOptions} options
* @returns {InputHandler|null}
*/
export function AttachInputHandler(element, {
attributes,
mouseButtons,
onSelect = null,
onChange = null,
onHighlightChange = null,
onActiveChange = null,
onHoveredChange = null,
onInputModeChange = null,
onDestroyed = null,
activeAttrOverridesHoverAndHighlightAttrs = DEFAULT_ACTIVE_ATTR_OVERRIDES_HOVERED_HIGHLIGHT_ATTRS,
activeKeys = [' ', 'Enter'],
outputErrors = true,
enforceDirectFocus = false,
allowKeySelectOnUnfocusedHighlight = true,
} = {}) {
// nullcheck
if (element == null || !(element instanceof HTMLElement)) {
if (outputErrors) {
console.error('Can\'t attach input handler to null element');
}
return null;
}
// check if already being handled
if (IsElementHandled(element)) {
if (outputErrors) {
console.error(`Can't attach input handler to element ${element}, handler already added to it`, element);
}
return null;
}
// ensure direct focusing is enabled
if (enforceDirectFocus) {
if (element.tabIndex < 0 && !element.hasAttribute('tabindex')) {
element.tabIndex = 0;
}
}
// ensure attribute and mouseButton values are all present
attributes = ValidateAttributesOptions(attributes);
mouseButtons = ValidateMouseButtons(mouseButtons);
/** flag to ensure queue is only {@link ProcessQueue processed} once per tick */
let processingQueue = false;
/**
* number of times {@linkcode ProcessQueue} has been run
* @see {@linkcode MAX_PROCESS_QUEUE_PASSES} is the limit (if `0`, process infinitely)
*/
let processQueuePasses = 0;
/** flag to ensure UpdateState is called after DOM changes */
let queuedUpdate = false;
/** flag ensuring onChange is invoked once, set via {@linkcode QueueChange} */
let queuedChange = false;
/** @type {Set<() => void>} */
const queuedCallbacks = new Set();
/** @type {QueuedInputEventData|null} */
let queuedSelectInputData = null;
/** @param {string|null} prefix @param {InputAttribute} attribute @returns {string} */
function GetAttributeName(prefix, attribute) { return attribute.usePrefix && prefix != null ? `${prefix}${attribute.name}` : attribute.name; }
// calculate & declare names for used attributes
const highlightedAttributeName = attributes != null && attributes.highlighted != null ? GetAttributeName(attributes.prefix, attributes.highlighted) : DEFAULT_ATTR_NAME_HIGHLIGHTED;
const activeAttributeName = attributes != null && attributes.active != null ? GetAttributeName(attributes.prefix, attributes.active) : DEFAULT_ATTR_NAME_ACTIVE;
const hoveredAttributeName = attributes != null && attributes.hovered != null ? GetAttributeName(attributes.prefix, attributes.hovered) : DEFAULT_ATTR_NAME_HOVERED;
const focusWithinAttributeName = attributes != null && attributes.focusWithin != null ? GetAttributeName(attributes.prefix, attributes.focusWithin) : DEFAULT_ATTR_NAME_FOCUS_WITHIN;
const focusVisibleWithinAttributeName = attributes != null && attributes.focusVisibleWithin != null ? GetAttributeName(attributes.prefix, attributes.focusVisibleWithin) : DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN;
const pointerHeldAttributeName = attributes != null && attributes.pointerHeld != null ? GetAttributeName(attributes.prefix, attributes.pointerHeld) : DEFAULT_ATTR_NAME_POINTER_HELD;
const keyHeldAttributeName = attributes != null && attributes.keyHeld != null ? GetAttributeName(attributes.prefix, attributes.keyHeld) : DEFAULT_ATTR_NAME_KEY_HELD;
const lastInputModeAttributeName = attributes != null && attributes.lastInputMode != null ? GetAttributeName(attributes.prefix, attributes.lastInputMode) : DEFAULT_ATTR_NAME_LAST_INPUT_MODE;
// TODO: these names are snapshotted at instantiation time, and can't be changed later
/**
* Map of all attributes and their current settings,
* updates whenever attributes change
* @type {Map<string,[string,boolean]>}
*/
const currentAttributes = new Map([
[DEFAULT_ATTR_NAME_HIGHLIGHTED, [highlightedAttributeName, false]],
[DEFAULT_ATTR_NAME_ACTIVE, [activeAttributeName, false]],
[DEFAULT_ATTR_NAME_HOVERED, [hoveredAttributeName, false]],
[DEFAULT_ATTR_NAME_FOCUS_WITHIN, [focusWithinAttributeName, false]],
[DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN, [focusVisibleWithinAttributeName, false]],
[DEFAULT_ATTR_NAME_POINTER_HELD, [pointerHeldAttributeName, false]],
[DEFAULT_ATTR_NAME_KEY_HELD, [keyHeldAttributeName, false]],
[DEFAULT_ATTR_NAME_LAST_INPUT_MODE, [lastInputModeAttributeName, false]],
]);
/** This element/handler's current state @type {InputState} */
const state = DefaultState();
/** The {@linkcode PointerEvent.pointerId} tracked for this element */
let pointerActiveId = null;
/** currently-held keys @type {Set<string>} */
const heldKeys = new Set();
/** Keys that can affect keyboard navigation, and thus cause focus/blur */
const keyboardNavKeys = new Set([
'Tab',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
]);
/** Checks if the given `event.key` is included in {@linkcode activeKeys} @param {KeyboardEvent} event */
function isActiveKey(event) {
return activeKeys.includes(event.key);
}
/** Check if element or its children are the `document.activeElement` @returns {boolean} */
function elementContainsFocus() {
const activeElement = document.activeElement;
return activeElement != null && element.contains(activeElement);
}
/** Check if element essentially has CSS `:focus-visible` @returns {boolean} */
function IsFocusVisibleWithin() {
const activeElement = document.activeElement;
if (!activeElement || !element.contains(activeElement)) { return false; }
// use :focus-visible primarily (note: not all browser's support it)
if (typeof activeElement.matches === 'function') {
try {
if (activeElement.matches(':focus-visible')) { return true; }
} catch {
// nw
}
}
// fallback, focus is visible-like when focus came from keyboard
return state.lastInputMode === 'keyboard';
}
/** @param {InputMode} inputMode */
function SetInputMode(inputMode) {
if (state.lastInputMode === inputMode) { return; }
state.lastInputMode = inputMode;
queuedCallbacks.add(() => {
if (onInputModeChange != null && typeof onInputModeChange === 'function') {
onInputModeChange(inputMode, { ...state });
}
});
QueueChange();
}
/** @param {boolean} hovered */
function SetHovered(hovered) {
if (state.hovered === hovered) { return; }
state.hovered = hovered;
if (onHoveredChange != null && typeof onHoveredChange === 'function') {
queuedCallbacks.add(() => {
onHoveredChange(hovered, { ...state });
});
}
QueueChange();
}
/** @param {boolean} pointerHeld */
function SetPointerHeld(pointerHeld) {
if (state.pointerHeld === pointerHeld) { return; }
state.pointerHeld = pointerHeld;
QueueChange();
}
/** confirm a change has occurred, ensure queue will be processed */
function QueueChange() {
queuedChange = true;
ProcessQueue();
}
/** Re-determine the current {@linkcode InputState} based on activity */
function UpdateState() {
// snapshot previous state for callbacks
const wasHighlighted = state.highlighted;
const wasActive = state.active;
const wasFocusWithin = state.focusWithin;
const wasFocusVisibleWithin = state.focusVisibleWithin;
const wasKeyHeld = state.keyHeld;
// calculate new states (calculate in order)
const newFocusWithin = elementContainsFocus();
const newFocusVisibleWithin = IsFocusVisibleWithin();
const newKeyHeld = heldKeys.size > 0;
const newActive = state.pointerHeld ||
(newKeyHeld && newFocusVisibleWithin);
const newHighlighted = state.hovered || newFocusVisibleWithin;
// check for changes
const changedHighlighted = wasHighlighted !== newHighlighted;
const changedActive = wasActive !== newActive;
const changedFocusWithin = wasFocusWithin !== newFocusWithin;
const changedFocusVisibleWithin = wasFocusVisibleWithin !== newFocusVisibleWithin;
const changedKeyHeld = wasKeyHeld !== newKeyHeld;
// determine if invoking any callbacks
const anyCallbacks =
changedHighlighted ||
changedActive ||
changedFocusWithin ||
changedFocusVisibleWithin ||
changedKeyHeld;
// update current state
state.highlighted = newHighlighted;
state.active = newActive;
state.focusWithin = newFocusWithin;
state.focusVisibleWithin = newFocusVisibleWithin;
state.keyHeld = newKeyHeld;
// check and update attributes
if (attributes != null) {
// determine current attributes states
const highlightedEnabled = attributes.enabled && attributes.highlighted.enabled;
const activeEnabled = attributes.enabled && attributes.active.enabled;
const hoveredEnabled = attributes.enabled && attributes.hovered.enabled;
const focusWithinEnabled = attributes.enabled && attributes.focusWithin.enabled;
const focusVisibleWithinEnabled = attributes.enabled && attributes.focusVisibleWithin.enabled;
const pointerHeldEnabled = attributes.enabled && attributes.pointerHeld.enabled;
const keyHeldEnabled = attributes.enabled && attributes.keyHeld.enabled;
const lastInputModeEnabled = attributes.enabled && attributes.lastInputMode.enabled;
// determine active/highlight/hover in case of activeAttrOverridesHoverAndHighlightAttrs
const activeStateTrue = activeEnabled && attributes.active.enabled && state.active;
const activeOverride = activeAttrOverridesHoverAndHighlightAttrs && activeStateTrue;
const hoveredStateTrue = hoveredEnabled && !activeOverride && attributes.hovered.enabled && state.hovered;
const highlightStateTrue = highlightedEnabled && !activeOverride && attributes.highlighted.enabled && state.highlighted;
// update map
currentAttributes.get(DEFAULT_ATTR_NAME_HIGHLIGHTED)[1] = highlightStateTrue;
currentAttributes.get(DEFAULT_ATTR_NAME_ACTIVE)[1] = activeStateTrue;
currentAttributes.get(DEFAULT_ATTR_NAME_HOVERED)[1] = hoveredStateTrue;
currentAttributes.get(DEFAULT_ATTR_NAME_FOCUS_WITHIN)[1] = focusWithinEnabled && attributes.focusWithin.enabled && state.focusWithin;
currentAttributes.get(DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN)[1] = focusVisibleWithinEnabled && attributes.focusVisibleWithin.enabled && state.focusVisibleWithin;
currentAttributes.get(DEFAULT_ATTR_NAME_POINTER_HELD)[1] = pointerHeldEnabled && attributes.pointerHeld.enabled && state.pointerHeld;
currentAttributes.get(DEFAULT_ATTR_NAME_KEY_HELD)[1] = keyHeldEnabled && attributes.keyHeld.enabled && state.keyHeld;
currentAttributes.get(DEFAULT_ATTR_NAME_LAST_INPUT_MODE)[1] = lastInputModeEnabled && attributes.lastInputMode.enabled;
}
// update element attributes
element.toggleAttribute(highlightedAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_HIGHLIGHTED)[1]);
element.toggleAttribute(activeAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_ACTIVE)[1]);
element.toggleAttribute(hoveredAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_HOVERED)[1]);
element.toggleAttribute(focusWithinAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_FOCUS_WITHIN)[1]);
element.toggleAttribute(focusVisibleWithinAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN)[1]);
element.toggleAttribute(pointerHeldAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_POINTER_HELD)[1]);
element.toggleAttribute(keyHeldAttributeName, currentAttributes.get(DEFAULT_ATTR_NAME_KEY_HELD)[1]);
if (currentAttributes.get(DEFAULT_ATTR_NAME_LAST_INPUT_MODE)[1]) {
element.setAttribute(lastInputModeAttributeName, state.lastInputMode);
} else {
element.removeAttribute(lastInputModeAttributeName);
}
// check for significant callback changes
if (!anyCallbacks) { return; }
// invoke callbacks
const onHighlightedCallback = changedHighlighted &&
(onHighlightChange != null && typeof onHighlightChange === 'function');
const onActiveCallback = changedActive &&
(onActiveChange != null && typeof onActiveChange === 'function');
if (onHighlightedCallback) {
queuedCallbacks.add(() => { onHighlightChange(newHighlighted, { ...state }) });
}
if (onActiveCallback) {
queuedCallbacks.add(() => { onActiveChange(newActive, { ...state }) });
}
// confirm change queued
QueueChange();
}
/** @param {KeyboardEvent} event */
function _onKeyDown(event) {
SetInputMode('keyboard');
if (isActiveKey(event) && elementContainsFocus()) {
heldKeys.add(event.key);
}
const focusVisibleNow = IsFocusVisibleWithin();
const selectionValid = focusVisibleNow ||
(allowKeySelectOnUnfocusedHighlight && state.hovered);
// activate selection when focused
if (!event.repeat && isActiveKey(event) && selectionValid) {
QueueSelect('keyboard', event);
}
// note: tab/arrow nav can change focus-visible without active key hold
if (keyboardNavKeys.has(event.key)) {
// wait a tick to process keyboard input, update state
queuedUpdate = true;
ProcessQueue();
} else {
UpdateState();
}
}
/** @param {KeyboardEvent} event */
function _onKeyUp(event) {
if (heldKeys.has(event.key)) {
heldKeys.delete(event.key);
}
UpdateState();
}
function _onGlobalPointerDown() {
SetInputMode('pointer');
UpdateState();
}
/** @param {PointerEvent} event */
function _onPointerEnter(event) {
if (event.pointerType === 'mouse' || event.pointerType === 'pen') {
SetHovered(true);
UpdateState();
}
}
/** @param {PointerEvent} event */
function _onPointerLeave(event) {
if (event.pointerType === 'mouse' || event.pointerType === 'pen') {
SetHovered(false);
UpdateState();
}
}
/** @param {PointerEvent} event */
function _onLocalPointerDown(event) {
if (!event.isPrimary) { return; }
// determine if mouse button is valid
switch (event.button) {
case 0:
// left
const left = mouseButtons != null ? mouseButtons.left === true : DEFAULT_MOUSE_BUTTONS_LEFT;
if (!left) { return; }
break;
case 1:
// middle
const middle = mouseButtons != null ? mouseButtons.middle === true : DEFAULT_MOUSE_BUTTONS_LEFT;
if (!middle) { return; }
break;
case 2:
// right
const right = mouseButtons != null ? mouseButtons.right === true : DEFAULT_MOUSE_BUTTONS_LEFT;
if (!right) { return; }
break;
}
SetInputMode('pointer');
pointerActiveId = event.pointerId;
SetPointerHeld(true);
// keep receiving pointerup/pointercancel even if pointer moves away
try {
element.setPointerCapture(event.pointerId);
} catch {
// some elements/browsers don't allow this; ignore
}
UpdateState();
}
/** @param {PointerEvent} event */
function _onPointerUp(event) {
if (pointerActiveId !== null && event.pointerId !== pointerActiveId) { return; }
const shouldSelect =
state.pointerHeld &&
IsPointInsideElement(element, event.clientX, event.clientY);
_endPointerHold(event);
if (shouldSelect) {
QueueSelect('pointer', event);
}
}
/** @param {PointerEvent} event */
function _endPointerHold(event) {
if (pointerActiveId !== null && event.pointerId !== pointerActiveId) { return; }
SetPointerHeld(false);
pointerActiveId = null;
try {
if (event.pointerId != null && element.hasPointerCapture?.(event.pointerId)) {
element.releasePointerCapture(event.pointerId);
}
} catch {
// ignore
}
UpdateState();
}
function _onFocusIn() {
// wait a tick so DOM settles
queuedUpdate = true;
ProcessQueue();
}
function _onFocusOut() {
// wait a tick so DOM settles
queuedUpdate = true
ProcessQueue();
}
/**
* @param {InputMode} source
* @param {PointerEvent | KeyboardEvent} originalEvent
*/
function QueueSelect(source, originalEvent) {
/** @type {QueuedInputEventData} */
const inputData = {
source,
originalEvent
};
queuedSelectInputData = inputData;
ProcessQueue();
}
// per-element listeners
element.addEventListener('pointerenter', _onPointerEnter);
element.addEventListener('pointerleave', _onPointerLeave);
element.addEventListener('pointerdown', _onLocalPointerDown);
element.addEventListener('pointerup', _onPointerUp);
element.addEventListener('pointercancel', _endPointerHold);
element.addEventListener('lostpointercapture', _endPointerHold);
element.addEventListener('focusin', _onFocusIn);
element.addEventListener('focusout', _onFocusOut);
/** @type {InputHandler} */
const inputHandler = {
element,
getState() { return { ...state } },
destroy() {
Untarget(element);
element.removeEventListener('pointerenter', _onPointerEnter);
element.removeEventListener('pointerleave', _onPointerLeave);
element.removeEventListener('pointerdown', _onLocalPointerDown);
element.removeEventListener('pointerup', _onPointerUp);
element.removeEventListener('pointercancel', _endPointerHold);
element.removeEventListener('lostpointercapture', _endPointerHold);
element.removeEventListener('focusin', _onFocusIn);
element.removeEventListener('focusout', _onFocusOut);
if (onDestroyed != null && typeof onDestroyed === 'function') {
onDestroyed(element);
}
}
};
// send initial update
UpdateState();
// add target to global listeners
Target(inputHandler, _onKeyDown, _onKeyUp, _onGlobalPointerDown);
/**
* Process queued tasks in order,
* 1. UpdateState
* 2. General callbacks
* 2a. Type-specific callbacks
* 2b. `onChange` callback
* 3. `onSelect` callback
*/
function ProcessQueue() {
// check if already processing
if (processingQueue) { return; }
// locally queued data snapshots, so that the queue functions
// are able to themselves add queued data
let _queuedUpdateSnapshot = false;
let _queuedChangeSnapshot = false;
/** @type {QueuedInputEventData|null} */
let _queuedSelectInputDataSnapshot = null;
/** @type {(() => void)[]} */
let _queuedCallbacksSnapshot = [];
/**
* create snapshots of all the data for processing this queue,
* and clear the existing queue */
const SnapshotQueuedData = () => {
_queuedUpdateSnapshot = queuedUpdate;
_queuedChangeSnapshot = queuedChange;
_queuedSelectInputDataSnapshot = queuedSelectInputData;
_queuedCallbacksSnapshot = [...queuedCallbacks];
queuedUpdate = false;
queuedChange = false;
queuedSelectInputData = null;
queuedCallbacks.clear();
}
/** done! */
const EndProcessingQueue = () => {
processQueuePasses = 0;
processingQueue = false;
}
/** Is any work queued? (Note: checks actual queues, not local snapshots) */
const IsAnythingQueued = () => {
return queuedUpdate || queuedChange ||
queuedSelectInputData != null ||
queuedCallbacks.size > 0;
}
/** queued onSelect callback */
const ProcessOnSelectCallback = () => {
// queued onSelect
if (_queuedSelectInputDataSnapshot != null) {
if (onSelect != null && typeof onSelect === 'function') {
/** @type {InputEventData} */
const inputEventData = {
// copy source/originalEvent from queued data
..._queuedSelectInputDataSnapshot,
// add most recent source
state: { ...state }
};
onSelect(inputEventData);
}
}
// done, recheck queue for 2nd pass, in case data changed on 1st pass
processingQueue = false;
if (IsAnythingQueued()) {
queueMicrotask(ProcessQueue);
} else {
// fully done
EndProcessingQueue();
}
}
/** queued onChange / non-select callbacks */
const ProcessGeneralCallbacks = () => {
// queued callbacks
if (_queuedCallbacksSnapshot.length > 0) {
for (const callback of _queuedCallbacksSnapshot) {
if (callback != null) {
callback();
}
}
}
// queued onChange
if (_queuedChangeSnapshot) {
if (onChange != null && typeof onChange === 'function') {
onChange({ ...state })
}
}
// next step, onselect
queueMicrotask(ProcessOnSelectCallback);
}
/** queued state update */
const ProcessUpdateState = () => {
// queued state update
if (_queuedUpdateSnapshot) {
UpdateState();
}
// next step, state calbacks
queueMicrotask(ProcessGeneralCallbacks);
}
const BeginProcessingQueue = () => {
if (!IsAnythingQueued()) {
EndProcessingQueue();
return;
}
SnapshotQueuedData();
ProcessUpdateState();
}
// check if max queue exceeded work is still pending - if so,
if (MAX_PROCESS_QUEUE_PASSES > 0 && processQueuePasses >= MAX_PROCESS_QUEUE_PASSES) {
// hit the process limit! can't continue
if (outputErrors && IsAnythingQueued()) {
const warning =
'WARNING: passes on queued InputHandler work have exceeded ' +
`the max of ${MAX_PROCESS_QUEUE_PASSES} and work still remains, ` +
'investigate for infinite loops. Remaining work: \n' +
`queuedUpdate: ${queuedUpdate}, \n` +
`queuedChange: ${queuedChange}, \n` +
`queuedSelectInputData != null: ${queuedSelectInputData != null}, \n` +
`queuedCallbacks.size: ${queuedCallbacks.size} ` +
`${queuedCallbacks.size === 0 ? '' : `\nqueuedCallbacks: ${queuedCallbacks}`}`;
console.trace(warning);
}
EndProcessingQueue();
return;
}
// increment queue pass count, start processing
processQueuePasses++;
processingQueue = true;
// friendly warning
if (processQueuePasses === 100) {
console.warn('Hey babe, this is the hundredth ProcessQueue pass, maybe you should look into that...');
}
// begin processing queue
queueMicrotask(BeginProcessingQueue);
}
// done! return
return inputHandler;
}
/**
* @type {Set<InputHandlerTarget>}
*/
const inputHandlerTargets = new Set();
/**
* Check if the given element is targeted by `InputHandler`
* @param {HTMLElement} element
* @returns {boolean}
*/
export function IsElementHandled(element) {
return GetIHTarget(element) != null;
}
/**
* Get the {@linkcode InputHandler} object linked to the given element.
* If the element isn't targeted by the `InputHandler`, returns `null`
* @param {HTMLElement} element
* @returns {InputHandler|null}
* @see {@linkcode IsElementHandled}
*/
export function GetInputHandler(element) {
const ihTarget = GetIHTarget(element);
if (ihTarget == null || ihTarget.inputHandler == null) {
return null;
}
return ihTarget.inputHandler;
}
/**
* Get the {@linkcode InputHandlerTarget} related to the given element.
* If none found (eg the element isn't targeted), returns `null`
* @param {HTMLElement} element
* @returns {InputHandlerTarget|null}
*/
function GetIHTarget(element) {
for (const target of inputHandlerTargets) {
if (!target || !target.inputHandler || !target.inputHandler.element) { continue; }
if (target.inputHandler.element === element) { return target; }
}
return null;
}
/**
*
* @param {InputHandler} inputHandler
* @param {(event:KeyboardEvent) => void} onKeyDown
* @param {(event:KeyboardEvent) => void} onKeyUp
* @param {(event:PointerEvent) => void} onPointerDown
* @returns
*/
function Target(inputHandler, onKeyDown, onKeyUp, onPointerDown) {
if (IsElementHandled(inputHandler.element)) { return; }
inputHandlerTargets.add({ inputHandler, onKeyDown, onKeyUp, onPointerDown })
if (!_globalEventListenersAdded) {
_globalEventListenersAdded = true;
document.addEventListener('keydown', onGlobalKeyDown, true);
document.addEventListener('keyup', onGlobalKeyUp, true);
document.addEventListener('pointerdown', onGlobalPointerDown, true);
}
}
/**
* Untargets the given element from the InputHandler,
* removing it from {@linkcode inputHandlerTargets}
* and (optionally) if no targets are left, removing
* global event listeners.
* @param {HTMLElement} element
*/
function Untarget(element) {
const target = GetIHTarget(element);
if (target != null) {
inputHandlerTargets.delete(target);
}
if (REMOVE_GLOBAL_LISTENERS_ON_ZERO_TARGETS && inputHandlerTargets.size === 0) {
if (_globalEventListenersAdded) {
document.removeEventListener('keydown', onGlobalKeyDown, true);
document.removeEventListener('keyup', onGlobalKeyUp, true);
document.removeEventListener('pointerdown', onGlobalPointerDown, true);
_globalEventListenersAdded = false;
}
}
}
/** @param {KeyboardEvent} event */
function onGlobalKeyDown(event) {
for (const target of inputHandlerTargets) {
if (EVENT_INVOCATION_REQUIRES_DOM_CONNECTION &&
!target.inputHandler.element.isConnected) {
continue;
}
if (typeof target.onKeyDown === 'function') {
target.onKeyDown(event);
}
}
}
/** @param {KeyboardEvent} event */
function onGlobalKeyUp(event) {
for (const target of inputHandlerTargets) {
if (EVENT_INVOCATION_REQUIRES_DOM_CONNECTION &&
!target.inputHandler.element.isConnected) {
continue;
}
if (typeof target.onKeyUp === 'function') {
target.onKeyUp(event);
}
}
}
/** @param {PointerEvent} event */
function onGlobalPointerDown(event) {
for (const target of inputHandlerTargets) {
if (EVENT_INVOCATION_REQUIRES_DOM_CONNECTION &&
!target.inputHandler.element.isConnected) {
continue;
}
if (typeof target.onPointerDown === 'function') {
target.onPointerDown(event);
}
}
}
/**
* Checks if a given point is within the given
* `DOMRect` (or `HTMLElement`'s `DOMRect`)
* - **Note:** It's presumed that coordinates
* and the rect share the same coordinate space.
* @param {DOMRect|HTMLElement} rect
* @param {number} clientX
* @param {number} clientY
* @returns {boolean}
*/
function IsPointInsideElement(rect, clientX, clientY) {
if (rect == null) { return false; }
if (rect instanceof HTMLElement) { rect = rect.getBoundingClientRect(); }
return (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
);
}
/** Gets an {@linkcode InputState} with default params @returns {InputState} */
const DefaultState = () => {
return {
highlighted: DEFAULT_VALUE_HIGHLIGHTED,
active: DEFAULT_VALUE_ACTIVE,
hovered: DEFAULT_VALUE_HOVERED,
focusWithin: DEFAULT_VALUE_FOCUS_WITHIN,
focusVisibleWithin: DEFAULT_VALUE_FOCUS_VISIBLE_WITHIN,
pointerHeld: DEFAULT_VALUE_POINTER_HELD,
keyHeld: DEFAULT_VALUE_KEY_HELD,
lastInputMode: DEFAULT_VALUE_LAST_INPUT_MODE,
};
}
/**
* Ensures an {@linkcode InputMouseButtons} object has
* both any custom values and all default values for
* anything missing.
* @param {InputMouseButtons|null|undefined} mouseButtons
* @returns {InputMouseButtons}
*/
const ValidateMouseButtons = (mouseButtons) => {
const defaultMouseButtons = DefaultMouseButtons();
const {
left = defaultMouseButtons.left,
middle = defaultMouseButtons.middle,
right = defaultMouseButtons.right,
} = mouseButtons != null ? mouseButtons : {};
return { left, middle, right };
}
/** @returns {InputMouseButtons} */
const DefaultMouseButtons = () => {
return {
left: DEFAULT_MOUSE_BUTTONS_LEFT,
middle: DEFAULT_MOUSE_BUTTONS_MIDDLE,
right: DEFAULT_MOUSE_BUTTONS_RIGHT,
}
}
/**
* Ensures an {@linkcode InputAttributesOptions} has
* both any custom values and all default values for
* anything missing.
* @param {InputAttributesOptions|null|undefined} attributes
* @returns {InputAttributesOptions}
*/
const ValidateAttributesOptions = (attributes) => {
const defaultAttributes = DefaultAttributesOptions();
if (attributes == null) { return defaultAttributes; }
return {
...defaultAttributes,
...attributes,
highlighted: {
...defaultAttributes.highlighted,
...attributes.highlighted,
},
active: {
...defaultAttributes.active,
...attributes.active,
},
hovered: {
...defaultAttributes.hovered,
...attributes.hovered,
},
focusWithin: {
...defaultAttributes.focusWithin,
...attributes.focusWithin,
},
focusVisibleWithin: {
...defaultAttributes.focusVisibleWithin,
...attributes.focusVisibleWithin,
},
pointerHeld: {
...defaultAttributes.pointerHeld,
...attributes.pointerHeld,
},
keyHeld: {
...defaultAttributes.keyHeld,
...attributes.keyHeld,
},
lastInputMode: {
...defaultAttributes.lastInputMode,
...attributes.lastInputMode,
},
};
}
/** @returns {InputAttributesOptions} */
const DefaultAttributesOptions = () => {
return {
prefix: DEFAULT_ATTR_OPTIONS_PREFIX,
enabled: DEFAULT_ATTR_OPTIONS_ENABLED,
highlighted: DefaultAttribute(DEFAULT_ATTR_NAME_HIGHLIGHTED, DEFAULT_ATTR_ENABLED_HIGHLIGHTED),
active: DefaultAttribute(DEFAULT_ATTR_NAME_ACTIVE, DEFAULT_ATTR_ENABLED_ACTIVE),
hovered: DefaultAttribute(DEFAULT_ATTR_NAME_HOVERED, DEFAULT_ATTR_ENABLED_HOVERED),
focusWithin: DefaultAttribute(DEFAULT_ATTR_NAME_FOCUS_WITHIN, DEFAULT_ATTR_ENABLED_FOCUS_WITHIN),
focusVisibleWithin: DefaultAttribute(DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN, DEFAULT_ATTR_ENABLED_FOCUS_VISIBLE_WITHIN),
pointerHeld: DefaultAttribute(DEFAULT_ATTR_NAME_POINTER_HELD, DEFAULT_ATTR_ENABLED_POINTER_HELD),
keyHeld: DefaultAttribute(DEFAULT_ATTR_NAME_KEY_HELD, DEFAULT_ATTR_ENABLED_KEY_HELD),
lastInputMode: DefaultAttribute(DEFAULT_ATTR_NAME_LAST_INPUT_MODE, DEFAULT_ATTR_ENABLED_LAST_INPUT_MODE),
}
}
/**
* @param {string} name
* @param {boolean} [enabled=DEFAULT_ATTR_ENABLED]
* @param {boolean} [usePrefix=DEFAULT_ATTR_USE_PREFIX]
* @returns {InputAttribute}
*/
const DefaultAttribute = (name, enabled = DEFAULT_ATTR_ENABLED, usePrefix = DEFAULT_ATTR_USE_PREFIX) => {
return {
name,
enabled,
usePrefix,
}
}
/** Default value for {@link InputState.hovered} @readonly */
export const DEFAULT_VALUE_HIGHLIGHTED = false;
/** Default value for {@link InputState.focusWithin} @readonly */
export const DEFAULT_VALUE_ACTIVE = false;
/** Default value for {@link InputState.focusVisibleWithin} @readonly */
export const DEFAULT_VALUE_HOVERED = false;
/** Default value for {@link InputState.pointerHeld} @readonly */
export const DEFAULT_VALUE_FOCUS_WITHIN = false;
/** Default value for {@link InputState.keyHeld} @readonly */
export const DEFAULT_VALUE_FOCUS_VISIBLE_WITHIN = false;
/** Default value for {@link InputState.active} @readonly */
export const DEFAULT_VALUE_POINTER_HELD = false;
/** Default value for {@link InputState.highlighted} @readonly */
export const DEFAULT_VALUE_KEY_HELD = false;
/** Default value for {@link InputState.lastInputMode} @readonly */
export const DEFAULT_VALUE_LAST_INPUT_MODE = 'pointer';
/** Default {@link InputAttribute.name name} for the `highlighted` {@linkcode InputAttribute} @readonly */
export const DEFAULT_ATTR_NAME_HIGHLIGHTED = 'highlight';
/** Default {@link InputAttribute.name name} for the `active` {@linkcode InputAttribute} @readonly */
export const DEFAULT_ATTR_NAME_ACTIVE = 'active';
/** Default {@link InputAttribute.name name} for the `hovered` {@linkcode InputAttribute} @readonly */
export const DEFAULT_ATTR_NAME_HOVERED = 'hover';
/** Default {@link InputAttribute.name name} for the `focusWithin` {@linkcode InputAttribute} @readonly */
export const DEFAULT_ATTR_NAME_FOCUS_WITHIN = 'focus-within';
/** Default {@link InputAttribute.name name} for the `focusVisibleWithin` {@linkcode InputAttribute} @readonly */
export const DEFAULT_ATTR_NAME_FOCUS_VISIBLE_WITHIN = 'focus-visible-within';
/** Default {@link InputAttribute.name name} for the `pointerHeld` {@linkcode InputAttribute} @readonly */
export const DEFAULT_ATTR_NAME_POINTER_HELD = 'pointer-held';
/** Default {@link InputAttribute.name name} for the `keyHeld` {@linkcode InputAttribute} @readonly */
export const DEFAULT_ATTR_NAME_KEY_HELD = 'key-held';
/** Default {@link InputAttribute.name name} for the `lastInputMode` {@linkcode InputAttribute} @readonly */
export const DEFAULT_ATTR_NAME_LAST_INPUT_MODE = 'input-mode';
/** Default {@linkcode InputAttribute.enabled enabled} value for the `highlighted` {@linkcode InputAttribute} */
export let DEFAULT_ATTR_ENABLED_HIGHLIGHTED = true;
/** Default {@linkcode InputAttribute.enabled enabled} value for the `active` {@linkcode InputAttribute} */
export let DEFAULT_ATTR_ENABLED_ACTIVE = true;
/** Default {@linkcode InputAttribute.enabled enabled} value for the `hovered` {@linkcode InputAttribute} */
export let DEFAULT_ATTR_ENABLED_HOVERED = false;
/** Default {@linkcode InputAttribute.enabled enabled} value for the `keyHeld` {@linkcode InputAttribute} */
export let DEFAULT_ATTR_ENABLED_KEY_HELD = false;
/** Default {@linkcode InputAttribute.enabled enabled} value for the `focusWithin` {@linkcode InputAttribute} */
export let DEFAULT_ATTR_ENABLED_FOCUS_WITHIN = false;
/** Default {@linkcode InputAttribute.enabled enabled} value for the `focusVisibleWithin` {@linkcode InputAttribute} */
export let DEFAULT_ATTR_ENABLED_FOCUS_VISIBLE_WITHIN = false;
/** Default {@linkcode InputAttribute.enabled enabled} value for the `pointerHeld` {@linkcode InputAttribute} */
export let DEFAULT_ATTR_ENABLED_POINTER_HELD = false;
/** Default {@linkcode InputAttribute.enabled enabled} value for the `lastInputMode` {@linkcode InputAttribute} */
export let DEFAULT_ATTR_ENABLED_LAST_INPUT_MODE = false;
/** Default {@linkcode InputAttributesOptions.prefix} value to use */
export let DEFAULT_ATTR_OPTIONS_PREFIX = '';
/** Default {@linkcode InputAttributesOptions.enabled} value to use */
export let DEFAULT_ATTR_OPTIONS_ENABLED = true;
/** Default {@linkcode InputAttribute.enabled} value to use */
export let DEFAULT_ATTR_ENABLED = true;
/** Default {@linkcode InputAttribute.usePrefix} value to use */
export let DEFAULT_ATTR_USE_PREFIX = true;
/** Default {@linkcode InputHandlerOptions.activeAttrOverridesHoverAndHighlightAttrs} value to use */
export let DEFAULT_ACTIVE_ATTR_OVERRIDES_HOVERED_HIGHLIGHT_ATTRS = true;
/** Default {@linkcode InputMouseButtons.left} value to use */
export let DEFAULT_MOUSE_BUTTONS_LEFT = true;
/** Default {@linkcode InputMouseButtons.middle} value to use */
export let DEFAULT_MOUSE_BUTTONS_MIDDLE = false;
/** Default {@linkcode InputMouseButtons.right} value to use */
export let DEFAULT_MOUSE_BUTTONS_RIGHT = false;
/** track whether or not global event listeners are added @type {boolean} */
let _globalEventListenersAdded = false;
/**
* When the last element is removed from input handling,
* should global event listeners be removed?
*/
const REMOVE_GLOBAL_LISTENERS_ON_ZERO_TARGETS = false;
/**
* When passing global events to {@linkcode InputHandlerTarget}
* instances, should the event invocation be stopped if the
* target element is NOT connected to the DOM?
*/
const EVENT_INVOCATION_REQUIRES_DOM_CONNECTION = true;
/**
* Max number of times {@linkcode ProcessQueue} can run in one tick.
* If `0`, it can run infinitely (warning output at hundredth pass)
*/
const MAX_PROCESS_QUEUE_PASSES = 3;
/** Hosted on GitHub Gist
* https://gist.github.com/nickyonge/728b4563fdb4600ffa57527e345de314 */
/**
* LICENSE INFO
*
* This script was written by Nick Yonge, 2026, and is released
* under the terms of The Unlicense:
*
* This is free and unencumbered software released into the public domain.
*
* Anyone is free to copy, modify, publish, use, compile, sell, or
* distribute this software, either in source code form or as a compiled
* binary, for any purpose, commercial or non-commercial, and by any
* means.
*
* In jurisdictions that recognize copyright laws, the author or authors
* of this software dedicate any and all copyright interest in the
* software to the public domain. We make this dedication for the benefit
* of the public at large and to the detriment of our heirs and
* successors. We intend this dedication to be an overt act of
* relinquishment in perpetuity of all present and future rights to this
* software under copyright law.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
* OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* For more information, please refer to <https://unlicense.org/>
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment