Last active
March 5, 2026 13:13
-
-
Save artursopelnik/b81948d01add8f800d076fb0cfde8897 to your computer and use it in GitHub Desktop.
what-input-lite (ESM + TypeScript, no legacy code, no touch)
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
| /** | |
| * what-iput-lite | |
| * A global utility for tracking the current input method (mouse/pointer, keyboard). | |
| * ESM + TypeScript, no touch detection, no legacy code. | |
| */ | |
| export type InputType = 'mouse' | 'keyboard' | 'initial'; | |
| export type InputIntent = InputType; | |
| export type ChangeCallback = (input: InputType) => void; | |
| export type WatchTarget = 'input' | 'intent'; | |
| // --------------------------------------------------------------------------- | |
| // State | |
| // --------------------------------------------------------------------------- | |
| let currentInput: InputType = 'initial'; | |
| let currentIntent: InputIntent = 'initial'; | |
| const inputCallbacks: ChangeCallback[] = []; | |
| const intentCallbacks: ChangeCallback[] = []; | |
| let ignoreKeys: Set<number> = new Set([ | |
| 16, // Shift | |
| 17, // Control | |
| 18, // Alt | |
| 91, // Meta left (Cmd / Win) | |
| 92, // Win right | |
| 93, // Meta right / Context Menu | |
| ]); | |
| let specificKeys: Set<number> | null = null; | |
| // While a form element is focused via mouse, freeze whatintent so typing | |
| // inside the field doesn't flip intent to "keyboard". | |
| let intentFrozen = false; | |
| const FORM_ELEMENTS = new Set(['input', 'select', 'textarea', 'button']); | |
| // --------------------------------------------------------------------------- | |
| // Shadow DOM helper | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Returns the actual event target, piercing shadow DOM boundaries. | |
| * e.target is retargeted to the shadow host for events inside shadow roots, | |
| * so composedPath()[0] gives the real element (e.g. <input> inside <k-input>). | |
| */ | |
| function getDeepTarget(e: Event): Element | null { | |
| const path = e.composedPath(); | |
| return (path.length > 0 ? path[0] : e.target) as Element | null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // DOM attribute helpers | |
| // --------------------------------------------------------------------------- | |
| const ROOT = document.documentElement; | |
| function setAttr(key: string, value: string): void { | |
| ROOT.setAttribute(key, value); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Update helpers | |
| // --------------------------------------------------------------------------- | |
| function updateInput(type: InputType): void { | |
| if (currentInput !== type) { | |
| currentInput = type; | |
| setAttr('data-whatinput', type); | |
| inputCallbacks.forEach(cb => cb(type)); | |
| } | |
| } | |
| function updateIntent(type: InputIntent): void { | |
| if (intentFrozen) return; | |
| if (currentIntent !== type) { | |
| currentIntent = type; | |
| setAttr('data-whatintent', type); | |
| intentCallbacks.forEach(cb => cb(type)); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Form-element focus tracking (intent buffer) | |
| // --------------------------------------------------------------------------- | |
| function onFocusIn(e: FocusEvent): void { | |
| const tag = getDeepTarget(e)?.tagName?.toLowerCase(); | |
| if (tag && FORM_ELEMENTS.has(tag)) { | |
| // Only freeze if the current intent is mouse (user clicked into the field) | |
| if (currentIntent === 'mouse') intentFrozen = true; | |
| } | |
| } | |
| function onFocusOut(e: FocusEvent): void { | |
| const tag = getDeepTarget(e)?.tagName?.toLowerCase(); | |
| if (tag && FORM_ELEMENTS.has(tag)) { | |
| intentFrozen = false; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Event handlers | |
| // --------------------------------------------------------------------------- | |
| function onPointerDown(e: PointerEvent): void { | |
| // Ignore non-mouse pointers (pen → treat as mouse if you want, or skip) | |
| if (e.pointerType !== 'mouse') return; | |
| updateInput('mouse'); | |
| updateIntent('mouse'); | |
| } | |
| function onPointerMove(e: PointerEvent): void { | |
| if (e.pointerType !== 'mouse') return; | |
| updateIntent('mouse'); | |
| } | |
| function onKeyDown(e: KeyboardEvent): void { | |
| const code = e.keyCode ?? e.which; | |
| if (specificKeys) { | |
| if (!specificKeys.has(code)) return; | |
| } else if (ignoreKeys.has(code)) { | |
| return; | |
| } | |
| updateInput('keyboard'); | |
| updateIntent('keyboard'); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Init | |
| // --------------------------------------------------------------------------- | |
| setAttr('data-whatinput', currentInput); | |
| setAttr('data-whatintent', currentIntent); | |
| window.addEventListener('pointerdown', onPointerDown, { capture: true, passive: true }); | |
| window.addEventListener('pointermove', onPointerMove, { capture: true, passive: true }); | |
| window.addEventListener('keydown', onKeyDown, { capture: true, passive: true }); | |
| window.addEventListener('focusin', onFocusIn, { capture: true, passive: true }); | |
| window.addEventListener('focusout', onFocusOut, { capture: true, passive: true }); | |
| // --------------------------------------------------------------------------- | |
| // Public API | |
| // --------------------------------------------------------------------------- | |
| /** Returns the current input type or intent. */ | |
| export function ask(which: 'input' | 'intent' = 'input'): InputType { | |
| return which === 'intent' ? currentIntent : currentInput; | |
| } | |
| /** Register a callback fired when input or intent changes. */ | |
| export function registerOnChange(callback: ChangeCallback, target: WatchTarget = 'input'): void { | |
| const list = target === 'intent' ? intentCallbacks : inputCallbacks; | |
| if (!list.includes(callback)) list.push(callback); | |
| } | |
| /** Remove a previously registered callback. */ | |
| export function unRegisterOnChange(callback: ChangeCallback): void { | |
| for (const list of [inputCallbacks, intentCallbacks]) { | |
| const idx = list.indexOf(callback); | |
| if (idx !== -1) list.splice(idx, 1); | |
| } | |
| } | |
| /** | |
| * Override which keyCodes are ignored (won't switch to "keyboard"). | |
| * Replaces the default set: Shift, Ctrl, Alt, Meta. | |
| */ | |
| export function setIgnoreKeys(keys: number[]): void { | |
| ignoreKeys = new Set(keys); | |
| specificKeys = null; | |
| } | |
| /** | |
| * Only these keyCodes will trigger the keyboard intent. | |
| * Overrides ignoreKeys. Pass null to reset. | |
| */ | |
| export function setSpecificKeys(keys: number[] | null): void { | |
| specificKeys = keys ? new Set(keys) : null; | |
| } | |
| /** Destroy all listeners (useful for SPA cleanup). */ | |
| export function destroy(): void { | |
| window.removeEventListener('pointerdown', onPointerDown, { capture: true }); | |
| window.removeEventListener('pointermove', onPointerMove, { capture: true }); | |
| window.removeEventListener('keydown', onKeyDown, { capture: true }); | |
| window.removeEventListener('focusin', onFocusIn, { capture: true }); | |
| window.removeEventListener('focusout', onFocusOut, { capture: true }); | |
| inputCallbacks.length = 0; | |
| intentCallbacks.length = 0; | |
| } | |
| const whatInput = { ask, registerOnChange, unRegisterOnChange, setIgnoreKeys, setSpecificKeys, destroy }; | |
| export default whatInput; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment