UNPKG

semantic-ui-react

Version:
684 lines (555 loc) 18.8 kB
import cx from 'clsx' import keyboardKey from 'keyboard-key' import _ from 'lodash' import PropTypes from 'prop-types' import React from 'react' import shallowEqual from 'shallowequal' import { ModernAutoControlledComponent as Component, customPropTypes, eventStack, getElementType, getUnhandledProps, htmlInputAttrs, isBrowser, makeDebugger, objectDiff, partitionHTMLProps, SUI, useKeyOnly, useValueAndKey, } from '../../lib' import Input from '../../elements/Input' import SearchCategory from './SearchCategory' import SearchResult from './SearchResult' import SearchResults from './SearchResults' const debug = makeDebugger('search') const overrideSearchInputProps = (predefinedProps) => { const { input } = predefinedProps if (_.isUndefined(input)) { return { ...predefinedProps, input: { className: 'prompt' } } } if (_.isPlainObject(input)) { return { ...predefinedProps, input: { ...input, className: cx(input.className, 'prompt') } } } return predefinedProps } /** * A search module allows a user to query for results from a selection of data */ export default class Search extends Component { static getAutoControlledStateFromProps(props, state) { debug('getAutoControlledStateFromProps()') // We need to store a `prevValue` to compare as in `getDerivedStateFromProps` we don't have // prevState if (typeof state.prevValue !== 'undefined' && shallowEqual(state.prevValue, state.value)) { return { prevValue: state.value } } const selectedIndex = props.selectFirstResult ? 0 : -1 debug('value changed, setting selectedIndex', selectedIndex) return { prevValue: state.value, selectedIndex } } shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(nextProps, this.props) || !shallowEqual(nextState, this.state) } componentDidUpdate(prevProps, prevState) { // eslint-disable-line complexity debug('componentDidUpdate()') debug('to state:', objectDiff(prevState, this.state)) // focused / blurred if (!prevState.focus && this.state.focus) { debug('search focused') if (!this.isMouseDown) { debug('mouse is not down, opening') this.tryOpen() } if (this.state.open) { eventStack.sub('keydown', [this.moveSelectionOnKeyDown, this.selectItemOnEnter]) } } else if (prevState.focus && !this.state.focus) { debug('search blurred') if (!this.isMouseDown) { debug('mouse is not down, closing') this.close() } eventStack.unsub('keydown', [this.moveSelectionOnKeyDown, this.selectItemOnEnter]) } // opened / closed if (!prevState.open && this.state.open) { debug('search opened') this.open() eventStack.sub('click', this.closeOnDocumentClick) eventStack.sub('keydown', [ this.closeOnEscape, this.moveSelectionOnKeyDown, this.selectItemOnEnter, ]) } else if (prevState.open && !this.state.open) { debug('search closed') this.close() eventStack.unsub('click', this.closeOnDocumentClick) eventStack.unsub('keydown', [ this.closeOnEscape, this.moveSelectionOnKeyDown, this.selectItemOnEnter, ]) } } componentWillUnmount() { debug('componentWillUnmount()') eventStack.unsub('click', this.closeOnDocumentClick) eventStack.unsub('keydown', [ this.closeOnEscape, this.moveSelectionOnKeyDown, this.selectItemOnEnter, ]) } // ---------------------------------------- // Document Event Handlers // ---------------------------------------- handleResultSelect = (e, result) => { debug('handleResultSelect()') debug(result) _.invoke(this.props, 'onResultSelect', e, { ...this.props, result }) } handleSelectionChange = (e) => { debug('handleSelectionChange()') const result = this.getSelectedResult() _.invoke(this.props, 'onSelectionChange', e, { ...this.props, result }) } closeOnEscape = (e) => { if (keyboardKey.getCode(e) !== keyboardKey.Escape) return e.preventDefault() this.close() } moveSelectionOnKeyDown = (e) => { debug('moveSelectionOnKeyDown()') debug(keyboardKey.getKey(e)) switch (keyboardKey.getCode(e)) { case keyboardKey.ArrowDown: e.preventDefault() this.moveSelectionBy(e, 1) break case keyboardKey.ArrowUp: e.preventDefault() this.moveSelectionBy(e, -1) break default: break } } selectItemOnEnter = (e) => { debug('selectItemOnEnter()') debug(keyboardKey.getKey(e)) if (keyboardKey.getCode(e) !== keyboardKey.Enter) return const result = this.getSelectedResult() // prevent selecting null if there was no selected item value if (!result) return e.preventDefault() // notify the onResultSelect prop that the user is trying to change value this.setValue(result.title) this.handleResultSelect(e, result) this.close() } closeOnDocumentClick = (e) => { debug('closeOnDocumentClick()') debug(e) this.close() } // ---------------------------------------- // Component Event Handlers // ---------------------------------------- handleMouseDown = (e) => { debug('handleMouseDown()') this.isMouseDown = true _.invoke(this.props, 'onMouseDown', e, this.props) eventStack.sub('mouseup', this.handleDocumentMouseUp) } handleDocumentMouseUp = () => { debug('handleDocumentMouseUp()') this.isMouseDown = false eventStack.unsub('mouseup', this.handleDocumentMouseUp) } handleInputClick = (e) => { debug('handleInputClick()', e) // prevent closeOnDocumentClick() e.nativeEvent.stopImmediatePropagation() this.tryOpen() } handleItemClick = (e, { id }) => { debug('handleItemClick()') debug(id) const result = this.getSelectedResult(id) // prevent closeOnDocumentClick() e.nativeEvent.stopImmediatePropagation() // notify the onResultSelect prop that the user is trying to change value this.setValue(result.title) this.handleResultSelect(e, result) this.close() } handleItemMouseDown = (e) => { debug('handleItemMouseDown()') // Heads up! We should prevent default to prevent blur events. // https://github.com/Semantic-Org/Semantic-UI-React/issues/3298 e.preventDefault() } handleFocus = (e) => { debug('handleFocus()') _.invoke(this.props, 'onFocus', e, this.props) this.setState({ focus: true }) } handleBlur = (e) => { debug('handleBlur()') _.invoke(this.props, 'onBlur', e, this.props) this.setState({ focus: false }) } handleSearchChange = (e) => { debug('handleSearchChange()') debug(e.target.value) // prevent propagating to this.props.onChange() e.stopPropagation() const { minCharacters } = this.props const { open } = this.state const newQuery = e.target.value _.invoke(this.props, 'onSearchChange', e, { ...this.props, value: newQuery }) // open search dropdown on search query if (newQuery.length < minCharacters) { this.close() } else if (!open) { this.tryOpen(newQuery) } this.setValue(newQuery) } // ---------------------------------------- // Getters // ---------------------------------------- getFlattenedResults = () => { const { category, results } = this.props return !category ? results : _.reduce(results, (memo, categoryData) => memo.concat(categoryData.results), []) } getSelectedResult = (index = this.state.selectedIndex) => { const results = this.getFlattenedResults() return _.get(results, index) } // ---------------------------------------- // Setters // ---------------------------------------- setValue = (value) => { debug('setValue()') debug('value', value) const { selectFirstResult } = this.props this.setState({ value, selectedIndex: selectFirstResult ? 0 : -1 }) } moveSelectionBy = (e, offset) => { debug('moveSelectionBy()') debug(`offset: ${offset}`) const { selectedIndex } = this.state const results = this.getFlattenedResults() const lastIndex = results.length - 1 // next is after last, wrap to beginning // next is before first, wrap to end let nextIndex = selectedIndex + offset if (nextIndex > lastIndex) nextIndex = 0 else if (nextIndex < 0) nextIndex = lastIndex this.setState({ selectedIndex: nextIndex }) this.scrollSelectedItemIntoView() this.handleSelectionChange(e) } // ---------------------------------------- // Behavior // ---------------------------------------- scrollSelectedItemIntoView = () => { debug('scrollSelectedItemIntoView()') // Do not access document when server side rendering if (!isBrowser()) return const menu = document.querySelector('.ui.search.active.visible .results.visible') if (!menu) return debug(`menu (results): ${menu}`) const item = menu.querySelector('.result.active') if (!item) return debug(`menu (results): ${menu}`) debug(`item (result): ${item}`) const isOutOfUpperView = item.offsetTop < menu.scrollTop const isOutOfLowerView = item.offsetTop + item.clientHeight > menu.scrollTop + menu.clientHeight if (isOutOfUpperView) { menu.scrollTop = item.offsetTop } else if (isOutOfLowerView) { menu.scrollTop = item.offsetTop + item.clientHeight - menu.clientHeight } } // Open if the current value is greater than the minCharacters prop tryOpen = (currentValue = this.state.value) => { debug('open()') const { minCharacters } = this.props if (currentValue.length < minCharacters) return this.open() } open = () => { debug('open()') this.setState({ open: true }) } close = () => { debug('close()') this.setState({ open: false }) } // ---------------------------------------- // Render // ---------------------------------------- renderSearchInput = (rest) => { const { icon, input } = this.props const { value } = this.state return Input.create(input, { autoGenerateKey: false, defaultProps: { ...rest, autoComplete: 'off', icon, onChange: this.handleSearchChange, onClick: this.handleInputClick, tabIndex: '0', value, }, // Nested shorthand props need special treatment to survive the shallow merge overrideProps: overrideSearchInputProps, }) } renderNoResults = () => { const { noResultsDescription, noResultsMessage } = this.props return ( <div className='message empty'> <div className='header'>{noResultsMessage}</div> {noResultsDescription && <div className='description'>{noResultsDescription}</div>} </div> ) } /** * Offset is needed for determining the active item for results within a * category. Since the index is reset to 0 for each new category, an offset * must be passed in. */ renderResult = ({ childKey, ...result }, index, _array, offset = 0) => { const { resultRenderer } = this.props const { selectedIndex } = this.state const offsetIndex = index + offset return ( <SearchResult key={childKey || result.id || result.title} active={selectedIndex === offsetIndex} onClick={this.handleItemClick} onMouseDown={this.handleItemMouseDown} renderer={resultRenderer} {...result} id={offsetIndex} // Used to lookup the result on item click /> ) } renderResults = () => { const { results } = this.props return _.map(results, this.renderResult) } renderCategories = () => { const { categoryLayoutRenderer, categoryRenderer, results: categories } = this.props const { selectedIndex } = this.state let count = 0 return _.map(categories, ({ childKey, ...category }) => { const categoryProps = { key: childKey || category.name, active: _.inRange(selectedIndex, count, count + category.results.length), layoutRenderer: categoryLayoutRenderer, renderer: categoryRenderer, ...category, } const renderFn = _.partialRight(this.renderResult, count) count += category.results.length return <SearchCategory {...categoryProps}>{category.results.map(renderFn)}</SearchCategory> }) } renderMenuContent = () => { const { category, showNoResults, results } = this.props if (_.isEmpty(results)) { return showNoResults ? this.renderNoResults() : null } return category ? this.renderCategories() : this.renderResults() } renderResultsMenu = () => { const { open } = this.state const resultsClasses = open ? 'visible' : '' const menuContent = this.renderMenuContent() if (!menuContent) return return <SearchResults className={resultsClasses}>{menuContent}</SearchResults> } render() { debug('render()') debug('props', this.props) debug('state', this.state) const { searchClasses, focus, open } = this.state const { aligned, category, className, fluid, loading, size } = this.props // Classes const classes = cx( 'ui', open && 'active visible', size, searchClasses, useKeyOnly(category, 'category'), useKeyOnly(focus, 'focus'), useKeyOnly(fluid, 'fluid'), useKeyOnly(loading, 'loading'), useValueAndKey(aligned, 'aligned'), 'search', className, ) const unhandled = getUnhandledProps(Search, this.props) const ElementType = getElementType(Search, this.props) const [htmlInputProps, rest] = partitionHTMLProps(unhandled, { htmlProps: htmlInputAttrs, }) return ( <ElementType {...rest} className={classes} onBlur={this.handleBlur} onFocus={this.handleFocus} onMouseDown={this.handleMouseDown} > {this.renderSearchInput(htmlInputProps)} {this.renderResultsMenu()} </ElementType> ) } } Search.propTypes = { /** An element type to render as (string or function). */ as: PropTypes.elementType, // ------------------------------------ // Behavior // ------------------------------------ /** Initial value of open. */ defaultOpen: PropTypes.bool, /** Initial value. */ defaultValue: PropTypes.string, /** Shorthand for Icon. */ icon: PropTypes.oneOfType([PropTypes.node, PropTypes.object]), /** Minimum characters to query for results */ minCharacters: PropTypes.number, /** Additional text for "No Results" message with less emphasis. */ noResultsDescription: PropTypes.node, /** Message to display when there are no results. */ noResultsMessage: PropTypes.node, /** Controls whether or not the results menu is displayed. */ open: PropTypes.bool, /** * One of: * - array of Search.Result props e.g. `{ title: '', description: '' }` or * - object of categories e.g. `{ name: '', results: [{ title: '', description: '' }]` */ results: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.shape(SearchResult.propTypes)), PropTypes.shape(SearchCategory.propTypes), ]), /** Whether the search should automatically select the first result after searching. */ selectFirstResult: PropTypes.bool, /** Whether a "no results" message should be shown if no results are found. */ showNoResults: PropTypes.bool, /** Current value of the search input. Creates a controlled component. */ value: PropTypes.string, // ------------------------------------ // Rendering // ------------------------------------ /** * Renders the SearchCategory layout. * * @param {object} categoryContent - The Renderable SearchCategory contents. * @param {object} resultsContent - The Renderable SearchResult contents. * @returns {*} - Renderable SearchCategory layout. */ categoryLayoutRenderer: PropTypes.func, /** * Renders the SearchCategory contents. * * @param {object} props - The SearchCategory props object. * @returns {*} - Renderable SearchCategory contents. */ categoryRenderer: PropTypes.func, /** * Renders the SearchResult contents. * * @param {object} props - The SearchResult props object. * @returns {*} - Renderable SearchResult contents. */ resultRenderer: PropTypes.func, // ------------------------------------ // Callbacks // ------------------------------------ /** * Called on blur. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onBlur: PropTypes.func, /** * Called on focus. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onFocus: PropTypes.func, /** * Called on mousedown. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onMouseDown: PropTypes.func, /** * Called when a result is selected. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onResultSelect: PropTypes.func, /** * Called on search input change. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props, includes current value of search input. */ onSearchChange: PropTypes.func, /** * Called when the active selection index is changed. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onSelectionChange: PropTypes.func, // ------------------------------------ // Style // ------------------------------------ /** A search can have its results aligned to its left or right container edge. */ aligned: PropTypes.string, /** A search can display results from remote content ordered by categories. */ category: PropTypes.bool, /** Additional classes. */ className: PropTypes.string, /** A search can have its results take up the width of its container. */ fluid: PropTypes.bool, /** Shorthand for input element. */ input: customPropTypes.itemShorthand, /** A search can show a loading indicator. */ loading: PropTypes.bool, /** A search can have different sizes. */ size: PropTypes.oneOf(_.without(SUI.SIZES, 'medium')), } Search.defaultProps = { icon: 'search', input: 'text', minCharacters: 1, noResultsMessage: 'No results found.', showNoResults: true, } Search.autoControlledProps = ['open', 'value'] Search.Category = SearchCategory Search.Result = SearchResult Search.Results = SearchResults