mirror of
https://github.com/hanzoai/cd-ui.git
synced 2026-10-30 20:42:45 +00:00
* 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]>
276 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
};
|