@taylorvance/tv-shared-runtime
Advanced tools
| import { type HotkeyCallback, type Keys, type Options } from 'react-hotkeys-hook'; | ||
| import type { DependencyList, MutableRefObject } from 'react'; | ||
| export type FormTags = 'input' | 'textarea' | 'select' | 'INPUT' | 'TEXTAREA' | 'SELECT'; | ||
| export interface HotkeyBinding { | ||
| callback: HotkeyCallback; | ||
| keys: Keys; | ||
| } | ||
| export interface KonamiOptions { | ||
| document?: Document; | ||
| enableOnContentEditable?: boolean; | ||
| enableOnFormTags?: readonly FormTags[] | boolean; | ||
| enabled?: boolean; | ||
| preventDefault?: boolean; | ||
| timeoutMs?: number; | ||
| } | ||
| declare const KONAMI_CODE_SEQUENCE: readonly ["up", "up", "down", "down", "left", "right", "left", "right", "b", "a"]; | ||
| export { KONAMI_CODE_SEQUENCE }; | ||
| export declare function useHotkeys<T extends HTMLElement>(keys: Keys, callback: HotkeyCallback, options?: Options, deps?: DependencyList): MutableRefObject<T | null>; | ||
| export declare function useHotkeys<T extends HTMLElement>(bindings: readonly HotkeyBinding[], options?: Options, deps?: DependencyList): MutableRefObject<T | null>; | ||
| export declare function useKonami<T extends HTMLElement>(callback: (event: KeyboardEvent) => void, options?: KonamiOptions, deps?: DependencyList): MutableRefObject<T | null>; | ||
| //# sourceMappingURL=hotkeys.d.ts.map |
| {"version":3,"file":"hotkeys.d.ts","sourceRoot":"","sources":["../src/hotkeys.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,IAAI,EACT,KAAK,OAAO,EACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAE9D,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAC;AAEzF,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,cAAc,CAAC;IACzB,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,gBAAgB,CAAC,EAAE,SAAS,QAAQ,EAAE,GAAG,OAAO,CAAC;IACjD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgCD,QAAA,MAAM,oBAAoB,mFAWhB,CAAC;AAyHX,OAAO,EAAE,oBAAoB,EAAE,CAAC;AAEhC,wBAAgB,UAAU,CAAC,CAAC,SAAS,WAAW,EAC9C,IAAI,EAAE,IAAI,EACV,QAAQ,EAAE,cAAc,EACxB,OAAO,CAAC,EAAE,OAAO,EACjB,IAAI,CAAC,EAAE,cAAc,GACpB,gBAAgB,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAC9B,wBAAgB,UAAU,CAAC,CAAC,SAAS,WAAW,EAC9C,QAAQ,EAAE,SAAS,aAAa,EAAE,EAClC,OAAO,CAAC,EAAE,OAAO,EACjB,IAAI,CAAC,EAAE,cAAc,GACpB,gBAAgB,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAyD9B,wBAAgB,SAAS,CAAC,CAAC,SAAS,WAAW,EAC7C,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,EACxC,OAAO,CAAC,EAAE,aAAa,EACvB,IAAI,CAAC,EAAE,cAAc,GACpB,gBAAgB,CAAC,CAAC,GAAG,IAAI,CAAC,CAwF5B"} |
+208
| import { useCallback, useEffect, useRef } from 'react'; | ||
| import { useHotkeys as useLibraryHotkeys, } from 'react-hotkeys-hook'; | ||
| const DEFAULT_COMBINATION_KEY = '+'; | ||
| const DEFAULT_SPLIT_KEY = ','; | ||
| const KONAMI_TIMEOUT_MS = 1000; | ||
| const hotkeyKeyAliases = { | ||
| ' ': 'space', | ||
| ',': 'comma', | ||
| '-': 'slash', | ||
| '.': 'period', | ||
| '#': 'backslash', | ||
| '+': 'bracketright', | ||
| altleft: 'alt', | ||
| altright: 'alt', | ||
| cmd: 'meta', | ||
| command: 'meta', | ||
| control: 'ctrl', | ||
| controlleft: 'ctrl', | ||
| controlright: 'ctrl', | ||
| esc: 'escape', | ||
| metaleft: 'meta', | ||
| metaright: 'meta', | ||
| os: 'meta', | ||
| osleft: 'meta', | ||
| osright: 'meta', | ||
| option: 'alt', | ||
| return: 'enter', | ||
| shiftleft: 'shift', | ||
| shiftright: 'shift', | ||
| }; | ||
| const modifierKeys = new Set(['alt', 'ctrl', 'meta', 'mod', 'shift']); | ||
| const KONAMI_CODE_SEQUENCE = [ | ||
| 'up', | ||
| 'up', | ||
| 'down', | ||
| 'down', | ||
| 'left', | ||
| 'right', | ||
| 'left', | ||
| 'right', | ||
| 'b', | ||
| 'a', | ||
| ]; | ||
| const isBindingArray = (value) => (Array.isArray(value) | ||
| && value.every((item) => typeof item === 'object' && item !== null && 'callback' in item && 'keys' in item)); | ||
| const isDependencyList = (value) => (Array.isArray(value)); | ||
| const normalizeKey = (key) => { | ||
| const alias = hotkeyKeyAliases[key]; | ||
| return (alias ?? key) | ||
| .trim() | ||
| .toLowerCase() | ||
| .replace(/key|digit|numpad|arrow/g, ''); | ||
| }; | ||
| const normalizeHotkey = (hotkey, combinationKey = DEFAULT_COMBINATION_KEY) => { | ||
| const keys = hotkey | ||
| .toLowerCase() | ||
| .split(combinationKey) | ||
| .map((part) => normalizeKey(part)); | ||
| return { | ||
| alt: keys.includes('alt'), | ||
| ctrl: keys.includes('ctrl'), | ||
| meta: keys.includes('meta'), | ||
| mod: keys.includes('mod'), | ||
| shift: keys.includes('shift'), | ||
| keys: keys.filter((key) => !modifierKeys.has(key)), | ||
| }; | ||
| }; | ||
| const matchesHotkey = (event, hotkey) => { | ||
| const eventKeys = [...(event.keys ?? [])]; | ||
| return event.alt === hotkey.alt | ||
| && event.ctrl === hotkey.ctrl | ||
| && event.meta === hotkey.meta | ||
| && event.mod === hotkey.mod | ||
| && event.shift === hotkey.shift | ||
| && eventKeys.length === hotkey.keys.length | ||
| && eventKeys.every((key, index) => key === hotkey.keys[index]); | ||
| }; | ||
| const isHotkeyEnabledOnTag = (target, enabledOnTags = false) => { | ||
| const tagName = target instanceof HTMLElement ? target.tagName : null; | ||
| if (Array.isArray(enabledOnTags)) { | ||
| return Boolean(tagName | ||
| && enabledOnTags.some((tag) => tag.toLowerCase() === tagName.toLowerCase())); | ||
| } | ||
| return Boolean(tagName && enabledOnTags); | ||
| }; | ||
| const isKeyboardEventTriggeredByInput = (event) => { | ||
| const target = event.target; | ||
| if (!(target instanceof HTMLElement)) { | ||
| return false; | ||
| } | ||
| return target.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName); | ||
| }; | ||
| const isScopeActive = (scopeRef) => { | ||
| const scopeElement = scopeRef.current; | ||
| if (!scopeElement) { | ||
| return true; | ||
| } | ||
| const rootNode = scopeElement.getRootNode(); | ||
| if (!(rootNode instanceof Document || rootNode instanceof ShadowRoot)) { | ||
| return false; | ||
| } | ||
| return rootNode.activeElement === scopeElement || scopeElement.contains(rootNode.activeElement); | ||
| }; | ||
| const getBindingKeys = (keys, splitKey = DEFAULT_SPLIT_KEY) => { | ||
| const values = Array.isArray(keys) ? keys : [keys]; | ||
| return values.flatMap((value) => (value.split(splitKey).map((part) => part.trim()).filter(Boolean))); | ||
| }; | ||
| export { KONAMI_CODE_SEQUENCE }; | ||
| export function useHotkeys(keysOrBindings, callbackOrOptions, maybeOptions, maybeDeps) { | ||
| if (!isBindingArray(keysOrBindings)) { | ||
| const options = (typeof callbackOrOptions === 'function' | ||
| ? maybeOptions | ||
| : callbackOrOptions); | ||
| const deps = (typeof callbackOrOptions === 'function' | ||
| ? maybeDeps | ||
| : isDependencyList(maybeOptions) | ||
| ? maybeOptions | ||
| : maybeDeps); | ||
| const callback = (typeof callbackOrOptions === 'function' | ||
| ? callbackOrOptions | ||
| : () => { }); | ||
| return useLibraryHotkeys(keysOrBindings, callback, options, deps); | ||
| } | ||
| const options = isDependencyList(callbackOrOptions) ? undefined : callbackOrOptions; | ||
| const deps = isDependencyList(callbackOrOptions) | ||
| ? callbackOrOptions | ||
| : isDependencyList(maybeOptions) | ||
| ? maybeOptions | ||
| : maybeDeps; | ||
| const splitKey = options?.splitKey ?? DEFAULT_SPLIT_KEY; | ||
| const combinationKey = options?.combinationKey ?? DEFAULT_COMBINATION_KEY; | ||
| const compiledBindings = keysOrBindings.flatMap((binding) => (getBindingKeys(binding.keys, splitKey).map((key) => ({ | ||
| callback: binding.callback, | ||
| hotkey: normalizeHotkey(key, combinationKey), | ||
| })))); | ||
| const flattenedKeys = keysOrBindings.flatMap((binding) => getBindingKeys(binding.keys, splitKey)); | ||
| return useLibraryHotkeys(flattenedKeys, (event, hotkeyEvent) => { | ||
| const match = compiledBindings.find((binding) => matchesHotkey(hotkeyEvent, binding.hotkey)); | ||
| match?.callback(event, hotkeyEvent); | ||
| }, options, deps); | ||
| } | ||
| export function useKonami(callback, options, deps) { | ||
| const scopeRef = useRef(null); | ||
| const callbackRef = useRef(callback); | ||
| const progressRef = useRef(0); | ||
| const lastKeyTimestampRef = useRef(0); | ||
| const memoizedCallback = useCallback(callback, deps ?? []); | ||
| const timeoutMs = options?.timeoutMs ?? KONAMI_TIMEOUT_MS; | ||
| callbackRef.current = deps ? memoizedCallback : callback; | ||
| useEffect(() => { | ||
| if (options?.enabled === false) { | ||
| return undefined; | ||
| } | ||
| const hotkeyDocument = options?.document ?? document; | ||
| const handleKeyDown = (event) => { | ||
| if (isKeyboardEventTriggeredByInput(event) | ||
| && !isHotkeyEnabledOnTag(event.target, options?.enableOnFormTags)) { | ||
| return; | ||
| } | ||
| if (event.target instanceof HTMLElement | ||
| && event.target.isContentEditable | ||
| && !options?.enableOnContentEditable) { | ||
| return; | ||
| } | ||
| if (!isScopeActive(scopeRef)) { | ||
| return; | ||
| } | ||
| const normalizedKey = normalizeKey(event.key); | ||
| const expectedKey = KONAMI_CODE_SEQUENCE[progressRef.current]; | ||
| if (!expectedKey) { | ||
| progressRef.current = 0; | ||
| lastKeyTimestampRef.current = 0; | ||
| return; | ||
| } | ||
| if (lastKeyTimestampRef.current > 0 | ||
| && Date.now() - lastKeyTimestampRef.current > timeoutMs) { | ||
| progressRef.current = 0; | ||
| } | ||
| if (normalizedKey === expectedKey) { | ||
| progressRef.current += 1; | ||
| lastKeyTimestampRef.current = Date.now(); | ||
| if (progressRef.current === KONAMI_CODE_SEQUENCE.length) { | ||
| if (options?.preventDefault) { | ||
| event.preventDefault(); | ||
| } | ||
| callbackRef.current(event); | ||
| progressRef.current = 0; | ||
| lastKeyTimestampRef.current = 0; | ||
| } | ||
| return; | ||
| } | ||
| progressRef.current = normalizedKey === KONAMI_CODE_SEQUENCE[0] ? 1 : 0; | ||
| lastKeyTimestampRef.current = progressRef.current > 0 ? Date.now() : 0; | ||
| }; | ||
| hotkeyDocument.addEventListener('keydown', handleKeyDown); | ||
| return () => { | ||
| hotkeyDocument.removeEventListener('keydown', handleKeyDown); | ||
| }; | ||
| }, [ | ||
| options?.document, | ||
| options?.enableOnContentEditable, | ||
| options?.enableOnFormTags, | ||
| options?.enabled, | ||
| options?.preventDefault, | ||
| timeoutMs, | ||
| ]); | ||
| return scopeRef; | ||
| } |
+1
-0
@@ -5,2 +5,3 @@ export { BrandBadge, brandBadgeClassNames, type BrandBadgeProps, } from './BrandBadge.js'; | ||
| export { createProjectStorage, type ProjectStorageEntry, type ProjectStorage, type ProjectStorageOptions, type StorageKeyPart, type StorageLike, } from './storage.js'; | ||
| export { KONAMI_CODE_SEQUENCE, useHotkeys, useKonami, type HotkeyBinding, type KonamiOptions, } from './hotkeys.js'; | ||
| //# sourceMappingURL=index.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,oBAAoB,EACpB,KAAK,eAAe,GACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,cAAc,GACf,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,oBAAoB,EACpB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACnB,KAAK,WAAW,GACjB,MAAM,cAAc,CAAC"} | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,oBAAoB,EACpB,KAAK,eAAe,GACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,cAAc,GACf,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,oBAAoB,EACpB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACnB,KAAK,WAAW,GACjB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,oBAAoB,EACpB,UAAU,EACV,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,aAAa,GACnB,MAAM,cAAc,CAAC"} |
+1
-0
@@ -5,1 +5,2 @@ export { BrandBadge, brandBadgeClassNames, } from './BrandBadge.js'; | ||
| export { createProjectStorage, } from './storage.js'; | ||
| export { KONAMI_CODE_SEQUENCE, useHotkeys, useKonami, } from './hotkeys.js'; |
+9
-1
| { | ||
| "name": "@taylorvance/tv-shared-runtime", | ||
| "version": "0.4.0", | ||
| "version": "0.5.0", | ||
| "description": "Shared React runtime primitives for Taylor Vance portfolio projects.", | ||
@@ -31,2 +31,7 @@ "type": "module", | ||
| }, | ||
| "./hotkeys": { | ||
| "types": "./dist/hotkeys.d.ts", | ||
| "import": "./dist/hotkeys.js", | ||
| "default": "./dist/hotkeys.js" | ||
| }, | ||
| "./storage-dev": { | ||
@@ -58,2 +63,5 @@ "types": "./dist/storage-dev.d.ts", | ||
| }, | ||
| "dependencies": { | ||
| "react-hotkeys-hook": "^5.1.0" | ||
| }, | ||
| "peerDependencies": { | ||
@@ -60,0 +68,0 @@ "react": "^18.3.0 || ^19.0.0" |
+50
-0
@@ -18,2 +18,5 @@ # `@taylorvance/tv-shared-runtime` | ||
| - `createProjectStorage` | ||
| - `useHotkeys` | ||
| - `useKonami` | ||
| - `KONAMI_CODE_SEQUENCE` | ||
@@ -24,2 +27,3 @@ Explicit subpaths: | ||
| - `@taylorvance/tv-shared-runtime/assets` | ||
| - `@taylorvance/tv-shared-runtime/hotkeys` | ||
| - `@taylorvance/tv-shared-runtime/storage` | ||
@@ -135,1 +139,47 @@ - `@taylorvance/tv-shared-runtime/storage-dev` | ||
| This inspector is meant for local tooling and debug screens, not default production UI. | ||
| ## Hotkeys | ||
| Use `useHotkeys` for shared app shortcuts. It supports the library's normal global behavior by default, and it becomes element-scoped when you attach the returned ref to a focusable container. | ||
| ```tsx | ||
| import { useHotkeys } from '@taylorvance/tv-shared-runtime'; | ||
| export function SessionPanel() { | ||
| const hotkeyRef = useHotkeys<HTMLDivElement>([ | ||
| { keys: 'r', callback: () => resetGame() }, | ||
| { keys: 'z', callback: () => undoMove() }, | ||
| { keys: 'x', callback: () => redoMove() }, | ||
| ]); | ||
| return ( | ||
| <section ref={hotkeyRef} tabIndex={-1}> | ||
| ... | ||
| </section> | ||
| ); | ||
| } | ||
| ``` | ||
| If you do not attach the returned ref, the hotkeys are global for the current document. | ||
| The hook keeps the default input-safe behavior from `react-hotkeys-hook`, so shortcuts do not fire while a user is typing into an `input`, `textarea`, or `select` unless you opt in through `enableOnFormTags`. | ||
| For an easy easter-egg path, `useKonami` exposes a built-in Konami listener with the same optional scoping model: | ||
| ```tsx | ||
| import { useKonami } from '@taylorvance/tv-shared-runtime'; | ||
| export function SessionPanel() { | ||
| const hotkeyRef = useKonami<HTMLDivElement>(() => { | ||
| setDebugMode(true); | ||
| }); | ||
| return ( | ||
| <section ref={hotkeyRef} tabIndex={-1}> | ||
| ... | ||
| </section> | ||
| ); | ||
| } | ||
| ``` | ||
| The shared Konami sequence is also exported as `KONAMI_CODE_SEQUENCE`. |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
53826
29.37%28
12%894
34.64%183
37.59%0
-100%2
100%+ Added
+ Added
+ Added
+ Added