UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

436 lines (396 loc) 12.3 kB
// @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;