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

276 lines
9.4 KiB
TypeScript

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {Key, KeybindingContext, KeybindingProvider, useNav} from '../../shared';
import {Input, InputProps, SetInputFxn, useDebounce, useInput} from '../input/input';
import ThemeDiv from '../theme-div/theme-div';
import './autocomplete.scss';
import {Minimatch, MinimatchOptions} from 'minimatch';
interface AutocompleteProps extends InputProps {
inputref?: React.MutableRefObject<HTMLInputElement>;
}
export const useAutocomplete = (init: string): [string, SetInputFxn, AutocompleteProps] => {
const [state, setState, input] = useInput(init);
let autocomplete = input as AutocompleteProps;
if (input.ref) {
const {ref, ...rest} = input;
autocomplete = {...rest, inputref: ref} as AutocompleteProps;
}
return [state, setState, autocomplete];
};
export const Autocomplete = (
props: React.InputHTMLAttributes<HTMLInputElement> & {
items: string[];
abbreviations?: Map<string, string>;
inputStyle?: React.CSSProperties;
onItemClick?: (item: string) => void;
icon?: string;
inputref?: React.MutableRefObject<HTMLInputElement>;
value: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
className?: string;
style?: React.CSSProperties;
}
) => {
return (
<KeybindingProvider>
<RenderAutocomplete {...props} />
</KeybindingProvider>
);
};
export const RenderAutocomplete = (
props: React.InputHTMLAttributes<HTMLInputElement> & {
items: string[];
abbreviations?: Map<string, string>;
inputStyle?: React.CSSProperties;
onItemClick?: (item: string) => void;
icon?: string;
inputref?: React.MutableRefObject<HTMLInputElement>;
value: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
className?: string;
style?: React.CSSProperties;
glob?: boolean | MinimatchOptions;
}
) => {
const nullInputRef = React.useRef<HTMLInputElement>(null);
const inputRef = props.inputref || nullInputRef;
const autocompleteRef = React.useRef(null);
const [showSuggestions, setShowSuggestions] = React.useState(false);
const [pos, nav, reset] = useNav(props.items?.length);
const menuRef = React.useRef(null);
React.useEffect(() => {
function unfocus(e: any) {
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) && menuRef.current && !menuRef.current.contains(e.target)) {
setShowSuggestions(false);
reset();
}
}
document.addEventListener('mousedown', unfocus);
return () => document.removeEventListener('mousedown', unfocus);
}, [autocompleteRef]);
const debouncedVal = useDebounce(props.value as string, 350);
const curItems = React.useMemo(() => {
const filtered = (props.items || []).filter((i) => {
if (i) {
const searchValue = debouncedVal?.toLowerCase() || '';
const useGlob = typeof props.glob === 'boolean' ? props.glob : !!props.glob;
const globOptions = typeof props.glob === 'boolean' ? null : props.glob;
const globMatcher = useGlob && searchValue ? new Minimatch(searchValue, globOptions) : null;
if (globMatcher) {
return props.abbreviations !== undefined ? globMatcher.match(i) || globMatcher.match(props.abbreviations?.get(i) ?? '') : globMatcher.match(i);
}
return props.abbreviations !== undefined
? i.toLowerCase().includes(searchValue) || props.abbreviations.get(i)?.toLowerCase().includes(searchValue)
: i.toLowerCase().includes(searchValue);
}
return false;
});
return filtered.length > 0 ? filtered : props.items;
}, [debouncedVal, props.items, props.glob, props.abbreviations]);
const [prevValue, setPrevValue] = React.useState(props.value);
if (props.value !== prevValue) {
setPrevValue(props.value);
if (props.value !== null && props.value !== '') {
setShowSuggestions(true);
}
}
const {registerKeybinding} = React.useContext(KeybindingContext);
React.useEffect(() => {
const target = {
combo: false,
target: inputRef,
};
registerKeybinding({
keys: Key.TAB,
action: () => {
if (showSuggestions) {
if (pos === curItems.length - 1) {
reset();
}
nav(1);
return true;
}
return false;
},
...target,
});
registerKeybinding({
keys: Key.ESCAPE,
action: () => {
if (showSuggestions) {
reset();
setShowSuggestions(false);
if (inputRef && inputRef.current) {
inputRef.current.blur();
}
return true;
}
return false;
},
...target,
});
registerKeybinding({
keys: Key.ENTER,
action: () => {
if (showSuggestions && props.onItemClick) {
props.onItemClick(curItems[pos]);
setShowSuggestions(false);
return true;
}
return false;
},
...target,
});
registerKeybinding({
keys: Key.UP,
action: () => {
if (showSuggestions) {
nav(-1);
return false;
}
return true;
},
...target,
});
registerKeybinding({
keys: Key.DOWN,
action: () => {
if (showSuggestions) {
nav(1);
return false;
}
return true;
},
...target,
});
});
const style = props.style;
const trimmedProps = {...props};
delete trimmedProps.style;
delete trimmedProps.inputStyle;
delete trimmedProps.onItemClick;
const [position, setPosition] = React.useState({top: 0, left: 0});
const checkDirection = () => {
if (autocompleteRef && autocompleteRef.current && menuRef.current) {
if (inputRef.current && menuRef.current) {
const rect = inputRef.current.getBoundingClientRect();
const menuHeight = menuRef.current.clientHeight;
const offset = window.innerHeight - rect.bottom;
const inverted = offset < menuHeight;
const newPos = {
top: inverted ? rect.top - menuRef.current.clientHeight : rect.top + rect.height,
left: rect.left,
};
if (position.left !== newPos.left || position.top !== newPos.top) {
setPosition(newPos);
}
}
}
};
React.useEffect(() => {
checkDirection();
document.addEventListener('scroll', checkDirection, true);
document.addEventListener('resize', checkDirection, true);
return () => {
document.removeEventListener('scroll', checkDirection);
document.removeEventListener('resize', checkDirection);
};
}, []);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (props.onChange) {
props.onChange(e);
}
};
return (
<div className='autocomplete' ref={autocompleteRef} style={style as any}>
<Input
{...trimmedProps}
style={props.inputStyle}
innerref={inputRef}
className={(props.className || '') + ' autocomplete__input'}
onChange={onChange}
onFocus={() => {
setShowSuggestions(true);
checkDirection();
}}
/>
{ReactDOM.createPortal(
<ThemeDiv
className='autocomplete__items'
style={{
visibility: !showSuggestions || (props.items || []).length < 1 ? 'hidden' : 'visible',
overflow: !showSuggestions || (props.items || []).length < 1 ? 'hidden' : null,
top: position.top,
left: position.left,
}}
innerref={menuRef}>
{(curItems || []).map((i, n) => (
<div
key={i}
onClick={() => {
onChange({target: {value: i}} as React.ChangeEvent<HTMLInputElement>);
setShowSuggestions(false);
if (props.onItemClick) {
props.onItemClick(i);
}
}}
className={`autocomplete__items__item ${pos === n ? 'autocomplete__items__item--selected' : ''}`}>
{i}
</div>
))}
</ThemeDiv>,
document.body
)}
</div>
);
};