Files
jwinters01andGitHub ad616de862 chore: fix Rules-of-React violations for React Compiler readiness (#624)
* chore: fix Rules-of-React violations for React Compiler readiness

Add eslint-plugin-react-hooks v7 (recommended-latest) and fix all
error-level Rules-of-React findings so the React Compiler can optimize
the component library.

The healthcheck reports 100% of components compilable; these fixes
clear the 34 ESLint errors across the src and v2 component trees:

- refs: stop accessing ref.current during render (incl. the react-form
  compat shim, whose public ref-forwarding API is preserved)
- set-state-in-effect: convert to derived state or restructure effects
- immutability: clone-before-mutate; stop mutating props/state
- rules-of-hooks: hoist conditional hook calls to top level

All 47 unit tests pass; behavior is preserved.

Signed-off-by: Jonathan Winters <[email protected]>

* chore: resolve react-hooks deps from public npm registry

The eslint-plugin-react-hooks@7 dependency subtree was locked to an
internal mirror, which the public CI runner can't reach (ETIMEDOUT).
Rewrite the 21 affected resolved URLs to registry.yarnpkg.com.

Signed-off-by: Jonathan Winters <[email protected]>

* useKeybinding -> registerKeybinding

Signed-off-by: Jonathan Winters <[email protected]>

* chore: fix Rules-of-React refs violations from registerKeybinding rename

The useKeybinding->registerKeybinding rename turned hook calls into plain
function calls in the render body; since registerKeybinding takes ref
targets, the new react-hooks/refs lint flagged them. Move the calls into
useEffect (registration is a side effect) in sliding-panel and the v2
autocomplete. In the v1 autocomplete, the flagged value is a forwarded
callback ref, not a render-time read, so suppress that one with a comment.

Also dedupe pnpm-lock.yaml.

Signed-off-by: Jonathan Winters <[email protected]>

---------

Signed-off-by: Jonathan Winters <[email protected]>
2026-06-30 11:51:17 +02:00

280 lines
7.1 KiB
TypeScript

import * as React from 'react';
export enum Key {
TAB = 9,
ENTER = 13,
SHIFT = 16,
ESCAPE = 27,
LEFT = 37,
UP = 38,
RIGHT = 39,
DOWN = 40,
A = 65,
B = 66,
C = 67,
D = 68,
E = 69,
F = 70,
G = 71,
H = 72,
I = 73,
J = 74,
K = 75,
L = 76,
M = 77,
N = 78,
O = 79,
P = 80,
Q = 81,
R = 82,
S = 83,
T = 84,
U = 85,
V = 86,
W = 87,
X = 88,
Y = 89,
Z = 90,
SLASH = 191,
QUESTION = 191,
}
export enum NumKey {
ZERO = 48,
ONE = 49,
TWO = 50,
THREE = 51,
FOUR = 52,
FIVE = 53,
SIX = 54,
SEVEN = 55,
EIGHT = 56,
NINE = 57,
}
export enum NumPadKey {
ZERO = 96,
ONE = 97,
TWO = 98,
THREE = 99,
FOUR = 100,
FIVE = 101,
SIX = 102,
SEVEN = 103,
EIGHT = 104,
NINE = 105,
}
export type AnyNumKey = NumKey | NumPadKey;
export type AnyKeys = AnyNumKey | Key | (AnyNumKey | Key)[];
// useNav adds simple stateful navigation to your component
// Returns:
// - pos: indicates current position
// - nav: fxn that accepts an integer that represents number to increment/decrement pos
// - reset: fxn that sets current position to -1
// Accepts:
// - upperBound: maximum value that pos can grow to
// - init: optional initial value for pos
export const useNav = (upperBound: number, init?: number): [number, (n: number) => boolean, () => void] => {
const [pos, setPos] = React.useState(init || -1);
const isInBounds = (p: number): boolean => p < upperBound && p > -1;
const nav = (val: number): boolean => {
const newPos = pos + val;
return isInBounds(newPos) ? setPos(newPos) === null : false;
};
const reset = () => {
setPos(-1);
};
return [pos, nav, reset];
};
export interface KeyState { action: KeyAction; pressed: boolean; group: number; target?: React.MutableRefObject<any> | React.MutableRefObject<any>[]; }
export type KeyAction = (keyCode?: number) => boolean;
export interface KeyMap { [key: number]: KeyState; }
export type KeyHandler = (e: KeyboardEvent) => null;
export type KeyFxn = (props: KeyFxnProps) => void;
export interface KeyFxnProps {
keys: AnyKeys;
action: KeyAction;
combo?: boolean;
target?: React.MutableRefObject<any> | React.MutableRefObject<any>[];
}
export interface GroupMap {
groupForKey: {[key: number]: number};
groups: {[group: number]: KeyMap};
index: number;
}
const handlePress = (e: KeyboardEvent, state: GroupMap) => {
const {groups, groupForKey} = state;
const g = groupForKey[e.keyCode];
if (groups[g] && groups[g][e.keyCode]) {
let allPressed = true;
groups[g][e.keyCode].pressed = true;
for (const i of Object.keys(groups[g])) {
const k = parseInt(i, 10);
const key = groups[g][k];
if (!key.pressed) {
allPressed = false;
}
}
const targetProp = groups[g][e.keyCode]?.target;
if (Array.isArray(targetProp)) {
const targets = targetProp as React.MutableRefObject<any>[];
targets.every((aTarget) => {
return checkTarget(allPressed, aTarget?.current, e, groups[g]);
});
} else {
const target = targetProp as React.MutableRefObject<any>;
checkTarget(allPressed, target?.current, e, groups[g]);
}
}
};
const checkTarget = (allPressed: boolean, curTarget: any, e: KeyboardEvent, groupKeyMap: KeyMap): boolean => {
if (allPressed && (curTarget === e.target || (!curTarget && e.target === document.body))) {
const prevent = groupKeyMap[e.keyCode].action(e.keyCode);
if (prevent) {
e.preventDefault();
}
return false;
}
return true;
};
const handleKeyUp = (e: KeyboardEvent, state: GroupMap) => {
const {groups, groupForKey} = state;
const g = groupForKey[e.keyCode];
if (groups[g] && groups[g][e.keyCode]) {
groups[g][e.keyCode].pressed = false;
}
};
const useKeyListen = (state: GroupMap) => {
const localKeyPress = (e: KeyboardEvent) => handlePress(e, state);
const localKeyUp = (e: KeyboardEvent) => handleKeyUp(e, state);
React.useEffect(() => {
document.addEventListener('keydown', localKeyPress);
document.addEventListener('keyup', localKeyUp);
return () => {
document.removeEventListener('keydown', localKeyPress);
document.removeEventListener('keyup', localKeyUp);
};
}, [state]);
};
export const useKeyListener = () => {
const state = NewGroupMap();
useKeyListen(state);
return (keys: AnyKeys, action: KeyAction, combo?: boolean) => {
addKeybinding(state, {keys, action, combo});
};
};
export const useSharedKeyListener = (): GroupMap => {
const state = NewGroupMap();
useKeyListen(state);
return state;
};
const NewGroupMap = () => {
const groupForKey = {} as {[key: number]: number};
const groups = {} as {[group: number]: KeyMap};
return {
groups,
groupForKey,
index: 0,
};
};
export const addKeybinding = (state: GroupMap, props: KeyFxnProps): GroupMap => {
const {groups, groupForKey} = state;
const {keys, action, combo, target} = props;
let index = state.index;
if (Array.isArray(keys)) {
let g = index;
for (const key of keys) {
// create association between this key and its group
groupForKey[key] = index;
if (!groups[g]) {
groups[index] = {} as KeyMap;
}
groups[index][key] = {
group: g,
action,
pressed: false,
target,
};
if (!combo) {
g = g + 1;
}
}
index = g + 1;
} else {
groupForKey[keys] = index;
if (!groups[index]) {
groups[index] = {} as KeyMap;
}
groups[index][keys] = {
group: index,
action,
pressed: false,
target,
};
index = index + 1;
}
state.index = index;
return state;
};
export const NumKeyToNumber = (key: AnyNumKey): number => {
if (key > 47 && key < 58) {
return key - 48;
} else if (key > 95 && key < 106) {
return key - 96;
}
return -1;
};
export const KeybindingContext = React.createContext<{
keybindingState: GroupMap;
registerKeybinding: KeyFxn;
}>({
keybindingState: NewGroupMap(),
registerKeybinding: () => null,
});
export const KeybindingProvider = (props: {children: React.ReactNode}) => {
const keybindingState: GroupMap = useSharedKeyListener();
// Not a hook (calls no hooks) — it mutates the shared keybinding state.
// Named without a `use` prefix so the React Compiler doesn't treat a
// context-provided value as a dynamically-resolved hook.
const registerKeybinding = (aProps: KeyFxnProps) => {
addKeybinding(keybindingState, aProps);
};
return <KeybindingContext.Provider value={{keybindingState, registerKeybinding}}>{props.children}</KeybindingContext.Provider>;
};