higlass
Version:
HiGlass Hi-C / genomic / large data viewer
436 lines (396 loc) • 12.3 kB
JSX
// @ts-nocheck
import PropTypes from 'prop-types';
import React from 'react';
const _debugStates = [];
class Autocomplete extends React.Component {
constructor(props) {
super(props);
this.state = {
highlightedIndex: null,
menuTop: 0,
menuLeft: 0,
menuWidth: 0,
isOpen: false,
};
this.keyDownHandlers = {
ArrowDown(event) {
event.preventDefault();
const itemsLength = this.getFilteredItems().length;
if (!itemsLength) return;
const { highlightedIndex } = this.state;
const index =
highlightedIndex === null || highlightedIndex === itemsLength - 1
? 0
: highlightedIndex + 1;
this._performAutoCompleteOnKeyUp = true;
this.setState({
highlightedIndex: index,
isOpen: true,
});
},
ArrowUp(event) {
event.preventDefault();
const itemsLength = this.getFilteredItems().length;
if (!itemsLength) return;
const { highlightedIndex } = this.state;
const index =
highlightedIndex === 0 || highlightedIndex === null
? itemsLength - 1
: highlightedIndex - 1;
this._performAutoCompleteOnKeyUp = true;
this.setState({
highlightedIndex: index,
isOpen: true,
});
},
Enter(event) {
if (this.state.isOpen === false) {
// menu is closed so there is no selection to accept -> do nothing
} else if (this.state.highlightedIndex === null) {
// input has focus but no menu item is selected + enter is hit
// -> close the menu, highlight whatever's in input
this.setState(
{
isOpen: false,
},
() => {
this.inputEl.select();
},
);
} else {
// text entered + menu item has been highlighted + enter is hit
// -> update value to that of selected menu item, close the menu
event.preventDefault();
const item = this.getFilteredItems()[this.state.highlightedIndex];
const value = this.props.getItemValue(item);
this.setState(
{
isOpen: false,
highlightedIndex: null,
},
() => {
// this.refs.input.focus() // TODO: file issue
this.inputEl.setSelectionRange(value.length, value.length);
this.props.onSelect(value, item);
},
);
}
},
Escape() {
this.setState({
highlightedIndex: null,
isOpen: false,
});
},
};
}
getInitialState() {
return {
isOpen: false,
highlightedIndex: null,
};
}
UNSAFE_componentWillMount() {
this._ignoreBlur = false;
this._performAutoCompleteOnUpdate = false;
this._performAutoCompleteOnKeyUp = false;
}
UNSAFE_componentWillReceiveProps(nextProps) {
this._performAutoCompleteOnUpdate = true;
// If `items` has changed we want to reset `highlightedIndex`
// since it probably no longer refers to a relevant item
if (
this.props.items !== nextProps.items ||
// The entries in `items` may have been changed even though the
// object reference remains the same, double check by seeing
// if `highlightedIndex` points to an existing item
this.state.highlightedIndex >= nextProps.items.length
) {
this.setState({ highlightedIndex: null });
}
}
componentDidUpdate(prevProps, prevState) {
if (this.state.isOpen === true && prevState.isOpen === false) {
this.setMenuPositions();
}
if (this.state.isOpen && this._performAutoCompleteOnUpdate) {
this._performAutoCompleteOnUpdate = false;
this.maybeAutoCompleteText();
}
this.maybeScrollItemIntoView();
if (prevState.isOpen !== this.state.isOpen) {
this.props.onMenuVisibilityChange(this.state.isOpen, this.inputEl);
}
}
maybeScrollItemIntoView() {
if (this.state.isOpen === true && this.state.highlightedIndex !== null) {
const itemNode = this.refs[`item-${this.state.highlightedIndex}`];
itemNode?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
handleKeyDown(event) {
if (this.keyDownHandlers[event.key]) {
this.keyDownHandlers[event.key].call(this, event);
} else {
this.setState({
highlightedIndex: null,
isOpen: true,
});
}
}
handleChange(event) {
this._performAutoCompleteOnKeyUp = true;
this.props.onChange(event, event.target.value);
}
handleKeyUp() {
if (this._performAutoCompleteOnKeyUp) {
this._performAutoCompleteOnKeyUp = false;
this.maybeAutoCompleteText();
}
}
getFilteredItems() {
let items = this.props.items;
if (this.props.shouldItemRender) {
items = items.filter((item) =>
this.props.shouldItemRender(item, this.props.value),
);
}
if (this.props.sortItems) {
items.sort((a, b) => this.props.sortItems(a, b, this.props.value));
}
return items;
}
maybeAutoCompleteText() {
if (!this.props.autoHighlight || this.props.value === '') {
return;
}
const { highlightedIndex } = this.state;
const items = this.getFilteredItems();
if (items.length === 0) {
return;
}
const matchedItem =
highlightedIndex !== null ? items[highlightedIndex] : items[0];
const itemValue = this.props.getItemValue(matchedItem);
const itemValueDoesMatch =
itemValue.toLowerCase().indexOf(this.props.value.toLowerCase()) === 0;
if (itemValueDoesMatch && highlightedIndex === null) {
this.setState({ highlightedIndex: 0 });
}
}
setMenuPositions() {
const node = this.inputEl;
const rect = node.getBoundingClientRect();
const computedStyle = global.window.getComputedStyle(node);
const marginBottom = Number.parseInt(computedStyle.marginBottom, 10) || 0;
const marginLeft = Number.parseInt(computedStyle.marginLeft, 10) || 0;
const marginRight = Number.parseInt(computedStyle.marginRight, 10) || 0;
this.setState({
menuTop: rect.bottom + marginBottom,
menuLeft: rect.left + marginLeft,
menuWidth: rect.width + marginLeft + marginRight,
});
}
highlightItemFromMouse(index) {
this.setState({ highlightedIndex: index });
}
selectItemFromMouse(item) {
const value = this.props.getItemValue(item);
this.setState(
{
isOpen: false,
highlightedIndex: null,
},
() => {
this.props.onSelect(value, item);
this.inputEl.focus();
},
);
}
setIgnoreBlur(ignore) {
this._ignoreBlur = ignore;
}
renderMenu() {
const items = this.getFilteredItems().map((item, index) => {
const element = this.props.renderItem(
item,
this.state.highlightedIndex === index,
{ cursor: 'default' },
);
return React.cloneElement(element, {
onMouseDown: () => this.setIgnoreBlur(true),
// Ignore blur to prevent menu from de-rendering before we can process click
onMouseEnter: () => this.highlightItemFromMouse(index),
onClick: () => this.selectItemFromMouse(item),
ref: `item-${index}`,
});
});
const style = {
left: this.state.menuLeft,
top: this.state.menuTop,
minWidth: this.state.menuWidth,
};
if (!items.length) return null;
const menu = this.props.renderMenu(items, this.props.value, style);
return React.cloneElement(menu, { ref: 'menu' });
}
handleInputBlur() {
if (this.props.onFocus) {
this.props.onFocus();
}
if (this._ignoreBlur) {
return;
}
this.setState({
isOpen: false,
highlightedIndex: null,
});
}
handleInputFocus() {
if (this.props.onFocus) {
this.props.onFocus(true);
}
if (this._ignoreBlur) {
this.setIgnoreBlur(false);
return;
}
// We don't want `selectItemFromMouse` to trigger when
// the user clicks into the input to focus it, so set this
// flag to cancel out the logic in `handleInputClick`.
// The event order is: MouseDown -> Focus -> MouseUp -> Click
this._ignoreClick = true;
this.setState({ isOpen: true });
}
isInputFocused() {
return (
this.inputEl.ownerDocument &&
this.inputEl === this.inputEl.ownerDocument.activeElement
);
}
handleInputClick() {
// Input will not be focused if it's disabled
if (this.isInputFocused() && this.state.isOpen === false) {
this.setState({ isOpen: true });
} else if (this.state.highlightedIndex !== null && !this._ignoreClick) {
this.selectItemFromMouse(
this.getFilteredItems()[this.state.highlightedIndex],
);
}
this._ignoreClick = false;
}
composeEventHandlers(internal, external) {
return external
? (e) => {
internal(e);
external(e);
}
: internal;
}
/* ------------------------------ Rendering ------------------------------- */
render() {
if (this.props.debug) {
// you don't like it, you love it
_debugStates.push({
id: _debugStates.length,
state: this.state,
});
}
const { inputProps } = this.props;
return (
<div style={{ ...this.props.wrapperStyle }} {...this.props.wrapperProps}>
<input
{...inputProps}
ref={(el) => {
this.inputEl = el;
}}
aria-autocomplete="list"
autoComplete="off"
onBlur={this.composeEventHandlers(
this.handleInputBlur.bind(this),
inputProps.onBlur?.bind(this),
)}
onChange={this.handleChange.bind(this)}
onClick={this.composeEventHandlers(
this.handleInputClick.bind(this),
inputProps.onClick?.bind(this),
)}
onFocus={this.composeEventHandlers(
this.handleInputFocus.bind(this),
inputProps.onFocus?.bind(this),
)}
onKeyDown={this.composeEventHandlers(
this.handleKeyDown.bind(this),
inputProps.onKeyDown?.bind(this),
)}
onKeyUp={this.composeEventHandlers(
this.handleKeyUp.bind(this),
inputProps.onKeyUp?.bind(this),
)}
// biome-ignore lint/a11y/useAriaPropsForRole:
role="combobox"
value={this.props.value}
/>
{('open' in this.props ? this.props.open : this.state.isOpen) &&
this.renderMenu()}
{this.props.debug && (
<pre style={{ marginLeft: 300 }}>
{JSON.stringify(
_debugStates.slice(_debugStates.length - 5, _debugStates.length),
null,
2,
)}
</pre>
)}
</div>
);
}
}
Autocomplete.defaultProps = {
value: '',
wrapperProps: {},
wrapperStyle: {
display: 'inline-block',
},
inputProps: {},
onChange() {},
onSelect() {},
renderMenu(items, value, style) {
return <div style={{ ...style, ...this.menuStyle }}>{items}</div>;
},
shouldItemRender() {
return true;
},
menuStyle: {
borderRadius: '3px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.1)',
background: 'rgba(255, 255, 255, 0.9)',
padding: '2px 0',
fontSize: '90%',
position: 'fixed',
overflow: 'auto',
maxHeight: '50%', // TODO: don't cheat, let it flow to the bottom
},
autoHighlight: true,
onMenuVisibilityChange() {},
};
Autocomplete.propTypes = {
autoHighlight: PropTypes.bool,
debug: PropTypes.bool,
getItemValue: PropTypes.func.isRequired,
inputProps: PropTypes.object,
items: PropTypes.array,
menuStyle: PropTypes.object,
onChange: PropTypes.func,
onFocus: PropTypes.func,
onMenuVisibilityChange: PropTypes.func,
onSelect: PropTypes.func,
open: PropTypes.bool,
renderItem: PropTypes.func.isRequired,
renderMenu: PropTypes.func,
shouldItemRender: PropTypes.func,
sortItems: PropTypes.func,
value: PropTypes.any,
wrapperProps: PropTypes.object,
wrapperStyle: PropTypes.object,
};
export default Autocomplete;