From 0d5c9873ec9f0eabad57e93becf33dfff4fe45dc Mon Sep 17 00:00:00 2001 From: Jeff Carbonella Date: Tue, 23 Aug 2016 11:51:47 -0400 Subject: [PATCH] feat(Search): update to v1 API * Add "Component Explorer" functionality * Use Input, clean up unused props * Category search * Update Search parts to use ElementType * Add specs --- .../Examples/modules/Search/Types/Search.js | 200 ++++ docs/app/Examples/modules/Search/index.js | 17 + src/elements/Input/Input.js | 5 +- src/modules/Search/Search.js | 664 ++++++++++++++ src/modules/Search/SearchCategory.js | 58 ++ src/modules/Search/SearchResult.js | 112 +++ src/modules/Search/SearchResults.js | 35 + src/modules/index.js | 1 + test/specs/modules/Search/Search-test.js | 853 ++++++++++++++++++ .../modules/Search/SearchCategory-test.js | 7 + .../specs/modules/Search/SearchResult-test.js | 7 + .../modules/Search/SearchResults-test.js | 7 + 12 files changed, 1964 insertions(+), 2 deletions(-) create mode 100644 docs/app/Examples/modules/Search/Types/Search.js create mode 100644 docs/app/Examples/modules/Search/index.js create mode 100644 src/modules/Search/Search.js create mode 100644 src/modules/Search/SearchCategory.js create mode 100644 src/modules/Search/SearchResult.js create mode 100644 src/modules/Search/SearchResults.js create mode 100644 test/specs/modules/Search/Search-test.js create mode 100644 test/specs/modules/Search/SearchCategory-test.js create mode 100644 test/specs/modules/Search/SearchResult-test.js create mode 100644 test/specs/modules/Search/SearchResults-test.js diff --git a/docs/app/Examples/modules/Search/Types/Search.js b/docs/app/Examples/modules/Search/Types/Search.js new file mode 100644 index 0000000000..76ddd9c62f --- /dev/null +++ b/docs/app/Examples/modules/Search/Types/Search.js @@ -0,0 +1,200 @@ +import _ from 'lodash' +import faker from 'faker' +import React, { Component } from 'react' +import { Search, Select, Grid, Header } from 'stardust' + +const searchResults = _.times(5, () => { + return { + title: faker.company.companyName(), + description: faker.company.catchPhrase(), + image: 'http://semantic-ui.com/images/wireframe/image.png', + } +}) + +const categoryResults = _.range(0, 3).reduce((memo, index) => { + const category = faker.company.bsBuzz() + + memo[category] = { + name: category, + results: _.times(5, () => { + return { + title: faker.company.companyName(), + description: faker.company.catchPhrase(), + image: 'http://semantic-ui.com/images/wireframe/image.png', + } + }), + } + + return memo +}, {}) + +const prettyPrint = (object) => JSON.stringify(object, null, 2) + +const componentPropsOptions = { + aligned: ['left', 'right'], + category: [true, false], + fluid: [true, false], + icon: ['search', 'dropdown', 'calendar'], + loading: [true, false], + maxResults: _.range(1, 20), + minCharacters: _.range(1, 5), + noResultsDescription: ['There are no matching results', 'Could not find anything'], + noResultsMessage: ['No results', 'Nothing here'], + placeholder: ['Search people...', 'Search'], + scrolling: [true, false], + searchFields: [['description'], ['title', 'description']], + selectFirstResult: [true, false], + selection: [true, false], + showNoResults: [true, false], + size: Search._meta.props.size, + source: [true], +} + +export default class SearchSelectionExample extends Component { + componentWillMount() { + this.setState({ + minCharacters: 1, + value: '', + componentProps: {}, + }) + } + + getResults = () => { + const { componentProps } = this.state + + return componentProps.category ? categoryResults : searchResults + } + + getFilteredResults = () => { + const { componentProps, filteredCategoryResults, filteredSearchResults } = this.state + + return componentProps.category ? filteredCategoryResults : filteredSearchResults + } + + handleChange = (e, result) => this.setState({ value: result.title }) + handleSearchChange = (e, value) => { + this.setState({ value }) + + const { source, minCharacters = 1 } = this.state.componentProps + + if (value.length < minCharacters) { + this.setState({ results: undefined }) + } else if (!source) { + this.fetchOptions() + } + } + + fetchOptions = () => { + this.setState({ loading: true }) + + setTimeout(() => { + const re = new RegExp(_.escapeRegExp(this.state.value), 'i') + const isMatch = (result) => re.test(result.title) + + const filteredCategoryResults = _.reduce(categoryResults, (memo, categoryData, name) => { + const filteredResults = _.filter(categoryData.results, isMatch) + + if (filteredResults.length) { + memo[name] = { name, results: filteredResults } + } + + return memo + }, {}) + + const filteredSearchResults = _.filter(searchResults, isMatch) + + this.setState({ + loading: false, + filteredCategoryResults, + filteredSearchResults, + }) + }, 500) + } + + toggleComponentProp = (propName, e) => { + this.setState({ + componentProps: { + ...this.state.componentProps, + [propName]: e.target.checked ? componentPropsOptions[propName][0] : undefined, + }, + }) + } + + setComponentProp = (propName, e, value) => { + this.setState({ + componentProps: { + ...this.state.componentProps, + [propName]: value, + }, + }) + } + + renderComponentProp = (values, propName) => { + const currentValue = this.state.componentProps[propName] + const active = currentValue !== undefined + + const dropdownOptions = values.map((value) => { + return { text: prettyPrint(value), value } + }) + + return ( +

+ {propName}: +
+ + {' '} + + {icon && } {isRightLabeled && labelChildren} {isRightAction && actionChildren} diff --git a/src/modules/Search/Search.js b/src/modules/Search/Search.js new file mode 100644 index 0000000000..d6bf0a3fec --- /dev/null +++ b/src/modules/Search/Search.js @@ -0,0 +1,664 @@ +import _ from 'lodash' +import cx from 'classnames' +import React, { PropTypes } from 'react' + +import { + AutoControlledComponent as Component, + customPropTypes, + getElementType, + getUnhandledProps, + keyboardKey, + makeDebugger, + META, + objectDiff, + useKeyOnly, + useValueAndKey, +} from '../../lib' +import { Input } from '../../elements' +import SearchCategory from './SearchCategory' +import SearchResult from './SearchResult' +import SearchResults from './SearchResults' + +const debug = makeDebugger('search') + +const _meta = { + name: 'Search', + type: META.TYPES.MODULE, + props: { + size: ['mini', 'small', 'large', 'big', 'huge', 'massive'], + }, +} + +/** + * A search module allows a user to query for results from a selection of data + */ +export default class Search extends Component { + static propTypes = { + /** An element type to render as (string or function). */ + as: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + ]), + + // ------------------------------------ + // Behavior + // ------------------------------------ + /** Add an icon by name or as a component. */ + icon: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.string, + ]), + + /** Array of Search.Result props e.g. `{ title: '', description: '' }` */ + results: customPropTypes.every([ + customPropTypes.disallow(['source']), + PropTypes.arrayOf(PropTypes.shape(SearchResult.propTypes)), + ]), + + /** Array of Search.Result props used for local search */ + source: customPropTypes.every([ + customPropTypes.disallow(['results']), + PropTypes.arrayOf(PropTypes.shape(SearchResult.propTypes)), + ]), + + /** Controls whether or not the results menu is displayed. */ + open: PropTypes.bool, + + /** Initial value of open. */ + defaultOpen: PropTypes.bool, + + /** Current value of the search input. Creates a controlled component. */ + value: PropTypes.string, + + /** Initial value. */ + defaultValue: PropTypes.string, + + /** Placeholder of the search input. */ + placeholder: PropTypes.string, + + /** + * Maximum results to display when using local and simple search, maximum + * category count for category search. + */ + maxResults: customPropTypes.every([ + customPropTypes.demand(['source']), + PropTypes.number, + ]), + + /** Minimum characters to query for results */ + minCharacters: PropTypes.number, + + /** Message to display when there are no results. */ + noResultsMessage: PropTypes.string, + + /** Additional text for "No Results" message with less emphasis. */ + noResultsDescription: PropTypes.string, + + /** Specify object properties inside local source object which will be searched. */ + searchFields: customPropTypes.every([ + customPropTypes.demand(['source']), + PropTypes.arrayOf(PropTypes.string), + ]), + + /** 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, + + // ------------------------------------ + // Callbacks + // ------------------------------------ + + /** Called with the React Synthetic Event on Search blur. */ + onBlur: PropTypes.func, + + /** Called with the React Synthetic Event, the selected result. */ + onChange: PropTypes.func, + + /** Called with the React Synthetic Event and current value on search input change. */ + onSearchChange: PropTypes.func, + + /** Called with the React Synthetic Event on Search focus. */ + onFocus: 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 added to the root element. */ + className: PropTypes.string, + + /** A search can have its results take up the width of its container. */ + fluid: PropTypes.bool, + + size: PropTypes.oneOf(_meta.props.size), + + loading: PropTypes.bool, + } + + static defaultProps = { + icon: 'search', + maxResults: 7, + minCharacters: 1, + noResultsMessage: 'No results found.', + searchFields: ['title'], + showNoResults: true, + } + + static autoControlledProps = [ + 'open', + 'value', + ] + + static _meta = _meta + static Result = SearchResult + static Results = SearchResults + static Category = SearchCategory + + componentWillMount() { + if (super.componentWillMount) super.componentWillMount() + debug('componentWillMount()') + const { open, value } = this.state + + this.setValue(value) + if (open) this.open() + } + + shouldComponentUpdate(nextProps, nextState) { + return !_.isEqual(nextProps, this.props) || !_.isEqual(nextState, this.state) + } + + componentWillReceiveProps(nextProps) { + super.componentWillReceiveProps(nextProps) + debug('componentWillReceiveProps()') + // TODO objectDiff still runs in prod, stop it + debug('changed props:', objectDiff(nextProps, this.props)) + + if (!_.isEqual(nextProps.value, this.props.value)) { + debug('value changed, setting', nextProps.value) + this.setValue(nextProps.value) + } + } + + componentDidUpdate(prevProps, prevState) { // eslint-disable-line complexity + debug('componentDidUpdate()') + // TODO objectDiff still runs in prod, stop it + debug('changed state:', objectDiff(this.state, prevState)) + + // focused / blurred + if (!prevState.focus && this.state.focus) { + if (!this.state.open) { + document.addEventListener('keydown', this.openOnArrow) + } else { + document.addEventListener('keydown', this.moveSelectionOnKeyDown) + document.addEventListener('keydown', this.selectItemOnEnter) + } + } else if (prevState.focus && !this.state.focus) { + document.removeEventListener('keydown', this.openOnArrow) + document.removeEventListener('keydown', this.moveSelectionOnKeyDown) + document.removeEventListener('keydown', this.selectItemOnEnter) + this.close() + } + + // opened / closed + if (!prevState.open && this.state.open) { + this.open() + document.addEventListener('keydown', this.closeOnEscape) + document.addEventListener('keydown', this.moveSelectionOnKeyDown) + document.addEventListener('keydown', this.selectItemOnEnter) + document.addEventListener('click', this.closeOnDocumentClick) + document.removeEventListener('keydown', this.openOnArrow) + } else if (prevState.open && !this.state.open) { + this.close() + document.removeEventListener('keydown', this.closeOnEscape) + document.removeEventListener('keydown', this.moveSelectionOnKeyDown) + document.removeEventListener('keydown', this.selectItemOnEnter) + document.removeEventListener('click', this.closeOnDocumentClick) + if (prevState.focus && this.state.focus) { + document.addEventListener('keydown', this.openOnArrow) + } + } + } + + componentWillUnmount() { + debug('componentWillUnmount()') + document.removeEventListener('keydown', this.openOnArrow) + document.removeEventListener('keydown', this.moveSelectionOnKeyDown) + document.removeEventListener('keydown', this.selectItemOnEnter) + document.removeEventListener('keydown', this.closeOnEscape) + document.removeEventListener('click', this.closeOnDocumentClick) + } + + // ---------------------------------------- + // Document Event Handlers + // ---------------------------------------- + + // onChange needs to receive a value + // can't rely on props.value if we are controlled + onChange = (e, result) => { + debug('onChange()') + debug(result) + const { onChange } = this.props + if (onChange) onChange(e, result) + } + + closeOnEscape = (e) => { + if (keyboardKey.getCode(e) !== keyboardKey.Escape) return + e.preventDefault() + this.close() + } + + moveSelectionOnKeyDown = (e) => { + debug('moveSelectionOnKeyDown()') + debug(keyboardKey.getName(e)) + switch (keyboardKey.getCode(e)) { + case keyboardKey.ArrowDown: + e.preventDefault() + this.moveSelectionBy(1) + break + case keyboardKey.ArrowUp: + e.preventDefault() + this.moveSelectionBy(-1) + break + default: + break + } + } + + openOnArrow = (e) => { + const code = keyboardKey.getCode(e) + if (!_.includes([keyboardKey.ArrowDown, keyboardKey.ArrowUp], code)) return + if (this.state.open) return + + const { minCharacters } = this.props + const { value } = this.state + if (value.length < minCharacters) return + + e.preventDefault() + // TODO open/close should only change state, open/closeMenu should be called on did update + this.trySetState({ open: true }) + } + + selectItemOnEnter = (e) => { + debug('selectItemOnEnter()') + debug(keyboardKey.getName(e)) + if (keyboardKey.getCode(e) !== keyboardKey.Enter) return + e.preventDefault() + + const result = this.getSelectedResult() + + // prevent selecting null if there was no selected item value + if (!result) return + + // notify the onChange prop that the user is trying to change value + this.setValue(result.title) + this.onChange(e, result) + this.close() + } + + closeOnDocumentClick = (e) => { + debug('closeOnDocumentClick()') + debug(e) + this.close() + } + + // ---------------------------------------- + // Component Event Handlers + // ---------------------------------------- + + handleItemClick = (e, id) => { + debug('handleItemClick()') + debug(id) + const result = this.getSelectedResult(id) + + // prevent toggle() in handleClick() + e.stopPropagation() + + // notify the onChange prop that the user is trying to change value + this.setValue(result.title) + this.onChange(e, result) + this.close() + } + + handleFocus = (e) => { + debug('handleFocus()') + const { onFocus } = this.props + if (onFocus) onFocus(e) + this.setState({ focus: true }) + // TODO + // handleFocus() is called on mouse down + // handleClick() it called on mouse up + // open() here is immediately toggled to close() by click + // + // const { value, open } = this.state + // if (value && !open) this.open() + } + + handleBlur = (e) => { + debug('handleBlur()') + const { onBlur } = this.props + if (onBlur) onBlur(e) + this.setState({ focus: false }) + } + + handleSearchChange = (e) => { + debug('handleSearchChange()') + debug(e.target.value) + // prevent propagating to this.props.onChange() + e.stopPropagation() + const { onSearchChange, minCharacters } = this.props + const { open } = this.state + const newQuery = e.target.value + + if (onSearchChange) onSearchChange(e, newQuery) + + // open search dropdown on search query + if (newQuery.length < minCharacters) { + this.close() + } else if (!open) { + this.open() + } + + this.setValue(newQuery) + } + + // ---------------------------------------- + // Getters + // ---------------------------------------- + + getResults = () => { + const { category, results, source, searchFields, maxResults } = this.props + const { value } = this.state + + // filter by search query + if (!source) return results + + const re = new RegExp(_.escapeRegExp(value), 'i') + + const isMatch = (result) => + _.some(searchFields, (field) => result[field] && re.test(result[field])) + + if (category) { + return _.reduce(source, (memo, categoryData, name) => { + if (_.size(memo) >= maxResults) return memo + + const categoryResults = _.filter(categoryData.results, isMatch) + + if (categoryResults.length) { + memo[name] = { name, results: categoryResults } + } + + return memo + }, {}) + } + + return _.filter(source, isMatch).slice(0, maxResults) + } + + getFlattenedResults = () => { + const { category } = this.props + let results = this.getResults() + + if (category) { + results = _.reduce(results, + (memo, categoryData) => memo.concat(categoryData.results), + [] + ) + } + + return 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.trySetState( + { value }, + { selectedIndex: selectFirstResult ? 0 : -1 } + ) + } + + moveSelectionBy = (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() + } + + // ---------------------------------------- + // Behavior + // ---------------------------------------- + + scrollSelectedItemIntoView = () => { + debug('scrollSelectedItemIntoView()') + const menu = document.querySelector('.ui.search.active.visible .results.visible') + const item = menu.querySelector('.result.active') + 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 || isOutOfLowerView) { + menu.scrollTop = item.offsetTop + } + } + + open = () => { + debug('open()') + debug(`dropdown is ${this.state.open ? 'already' : 'not yet'} open`) + + this.trySetState({ + open: true, + }, { + searchClasses: 'active visible', + resultsClasses: 'visible', + }) + } + + close = () => { + debug('close()') + debug(`dropdown is ${!this.state.open ? 'already' : 'not yet'} closed`) + if (!this.state.open) return + + this.trySetState({ + open: false, + }, { + searchClasses: '', + resultsClasses: '', + }) + } + + // ---------------------------------------- + // Render + // ---------------------------------------- + + renderSearchInput = () => { + const { icon, placeholder } = this.props + const { value } = this.state + + return ( + + ) + } + + renderNoResults = () => { + const { noResultsMessage, noResultsDescription, showNoResults } = this.props + + if (!showNoResults) { + this.close() + return null + } + + return ( +

+
{noResultsMessage}
+ {noResultsDescription && +
{noResultsDescription}
+ } +
+ ) + } + + /** + * 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 = (result, index, _array, offset = 0) => { + const { selectedIndex } = this.state + const offsetIndex = index + offset + + return ( + e.preventDefault()} // prevent default to allow item select without closing on blur + {...result} + id={offsetIndex} // Used to lookup the result on item click + /> + ) + } + + renderResults = () => { + const { value } = this.state + const results = this.getResults() + + if (value && _.isEmpty(results)) { + return this.renderNoResults() + } + + return _.map(results, this.renderResult) + } + + renderCategories = () => { + const { selectedIndex, value } = this.state + const categories = this.getResults() + + if (value && _.isEmpty(categories)) { + return this.renderNoResults() + } + + let count = 0 + + return _.map(categories, (category, name, index) => { + const categoryProps = { + key: `${category.name}-${index}`, + active: _.inRange(selectedIndex, count, count + category.results.length), + ...category, + } + const renderFn = _.partialRight(this.renderResult, count) + + count = count + category.results.length + + return ( + + {category.results.map(renderFn)} + + ) + }) + } + + renderResultsMenu = () => { + const { category } = this.props + const { resultsClasses } = this.state + + return ( + + {category ? this.renderCategories() : this.renderResults()} + + ) + } + + render() { + debug('render()') + debug('props', this.props) + debug('state', this.state) + const { searchClasses, focus } = this.state + + const { + aligned, + category, + className, + fluid, + loading, + size, + } = this.props + + // Classes + const classes = cx( + 'ui', + size, + searchClasses, + useKeyOnly(loading, 'loading'), + + useValueAndKey(aligned, 'aligned'), + useKeyOnly(category, 'category'), + useKeyOnly(focus, 'focus'), + useKeyOnly(fluid, 'fluid'), + + className, + 'search', + ) + + const ElementType = getElementType(Search, this.props) + const rest = getUnhandledProps(Search, this.props) + + return ( + + {this.renderSearchInput()} + {this.renderResultsMenu()} + + ) + } +} diff --git a/src/modules/Search/SearchCategory.js b/src/modules/Search/SearchCategory.js new file mode 100644 index 0000000000..b29b29a4aa --- /dev/null +++ b/src/modules/Search/SearchCategory.js @@ -0,0 +1,58 @@ +import React, { PropTypes } from 'react' +import cx from 'classnames' + +import { + META, + getElementType, + getUnhandledProps, + useKeyOnly, +} from '../../lib' + +function SearchCategory(props) { + const { active, className, children, name } = props + const classes = cx( + useKeyOnly(active, 'active'), + 'category', + className + ) + const rest = getUnhandledProps(SearchCategory, props) + const ElementType = getElementType(SearchCategory, props) + + return ( + +
{name}
+ {children} +
+ ) +} + +SearchCategory.propTypes = { + /** An element type to render as (string or function). */ + as: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + ]), + + /** The item currently selected by keyboard shortcut. */ + active: PropTypes.bool, + + /** Should be components. */ + children: PropTypes.node, + + /** Classes to add to the className. */ + className: PropTypes.string, + + /** Display name. */ + name: PropTypes.string, + + /** Array of Search.Result props */ + results: PropTypes.array, +} + +SearchCategory._meta = { + name: 'SearchCategory', + parent: 'Search', + type: META.TYPES.MODULE, +} + +export default SearchCategory diff --git a/src/modules/Search/SearchResult.js b/src/modules/Search/SearchResult.js new file mode 100644 index 0000000000..6b57eca5c2 --- /dev/null +++ b/src/modules/Search/SearchResult.js @@ -0,0 +1,112 @@ +import React, { PropTypes } from 'react' +import cx from 'classnames' + +import { + customPropTypes, + META, + getElementType, + getUnhandledProps, + useKeyOnly, +} from '../../lib' +import { createImg } from '../../factories' + +function renderImage(image) { + if (!image) return null + + return ( +
+ {createImg(image)} +
+ ) +} + +function SearchResult(props) { + const { + active, + children, + className, + description, + id, + image, + onClick, + price, + title, + } = props + + const handleClick = (e) => { + if (onClick) onClick(e, id) + } + + const classes = cx( + useKeyOnly(active, 'active'), + 'result', + className, + ) + const ElementType = getElementType(SearchResult, props) + const rest = getUnhandledProps(SearchResult, props) + + // Note: You technically only need the 'content' wrapper when there's an + // image. However, optionally wrapping it makes this function a lot more + // complicated and harder to read. Since always wrapping it doesn't affect + // the style in any way let's just do that. + return ( + + {renderImage(image)} +
+ {title &&
{title}
} + {description &&
{description}
} + {price &&
{price}
} + {children} +
+
+ ) +} + +SearchResult._meta = { + name: 'SearchResult', + parent: 'Search', + type: META.TYPES.MODULE, +} + +SearchResult.propTypes = { + /** An element type to render as (string or function). */ + as: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + ]), + + /** The item currently selected by keyboard shortcut. */ + active: PropTypes.bool, + + /** Primary content. */ + children: customPropTypes.every([ + customPropTypes.disallow(['title']), + PropTypes.node, + ]), + + /** Additional className. */ + className: PropTypes.string, + + /** Additional text with less emphasis. */ + description: PropTypes.string, + + /** A unique identifier. */ + id: PropTypes.string.isRequired, + + /** Add an image to the item. */ + image: PropTypes.string, + + /** Customized text for price. */ + price: PropTypes.string, + + /** Display title. */ + title: customPropTypes.every([ + customPropTypes.disallow(['children']), + PropTypes.string, + ]), + + /** Called on click with (event, value, text). */ + onClick: PropTypes.func, +} + +export default SearchResult diff --git a/src/modules/Search/SearchResults.js b/src/modules/Search/SearchResults.js new file mode 100644 index 0000000000..6309ada0ed --- /dev/null +++ b/src/modules/Search/SearchResults.js @@ -0,0 +1,35 @@ +import React, { PropTypes } from 'react' +import cx from 'classnames' + +import { getElementType, getUnhandledProps, META } from '../../lib' + +function SearchResults(props) { + const { children, className } = props + const classes = cx('results transition', className) + const rest = getUnhandledProps(SearchResults, props) + const ElementType = getElementType(SearchResults, props) + + return {children} +} + +SearchResults._meta = { + name: 'SearchResults', + parent: 'Search', + type: META.TYPES.MODULE, +} + +SearchResults.propTypes = { + /** An element type to render as (string or function). */ + as: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + ]), + + /** Should be components. */ + children: PropTypes.node, + + /** Classes to add to the className. */ + className: PropTypes.string, +} + +export default SearchResults diff --git a/src/modules/index.js b/src/modules/index.js index 3fbf5d2d38..a608067e89 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -4,3 +4,4 @@ export { default as Dropdown } from './Dropdown/Dropdown' export { default as Modal } from './Modal/Modal' export { default as Progress } from './Progress/Progress' export { default as Rating } from './Rating/Rating' +export { default as Search } from './Search/Search' diff --git a/test/specs/modules/Search/Search-test.js b/test/specs/modules/Search/Search-test.js new file mode 100644 index 0000000000..cf328a1272 --- /dev/null +++ b/test/specs/modules/Search/Search-test.js @@ -0,0 +1,853 @@ +import _ from 'lodash' +import faker from 'faker' +import React from 'react' + +import * as common from 'test/specs/commonTests' +import { domEvent, sandbox } from 'test/utils' +import Search from 'src/modules/Search/Search' +import SearchCategory from 'src/modules/Search/SearchCategory' +import SearchResult from 'src/modules/Search/SearchResult' +import SearchResults from 'src/modules/Search/SearchResults' + +let attachTo +let options +let wrapper + +// ---------------------------------------- +// Wrapper +// ---------------------------------------- +// we need to unmount the dropdown after every test to ensure all event listeners are cleaned up +// wrap the render methods to update a global wrapper that is unmounted after each test +const wrapperMount = (node, opts) => { + attachTo = document.createElement('div') + document.body.appendChild(attachTo) + + wrapper = mount(node, { ...opts, attachTo }) + return wrapper +} +const wrapperShallow = (...args) => (wrapper = shallow(...args)) +const wrapperRender = (...args) => (wrapper = render(...args)) + +// ---------------------------------------- +// Options +// ---------------------------------------- +const getOptions = (count = 5) => _.times(count, n => { + const title = _.times(3, faker.hacker.noun).join(' ') + const description = _.times(3, faker.hacker.noun).join(' ') + const image = faker.internet.avatar() + const price = `$${_.random(1, 100)}.99` + return { title, description, image, price } +}) + +// ------------------------------- +// Common Assertions +// ------------------------------- +const searchResultsIsClosed = () => { + const menu = wrapper.find('SearchResults') + wrapper.should.not.have.className('visible') + menu.should.not.have.className('visible') +} + +const searchResultsIsOpen = () => { + const menu = wrapper.find('SearchResults') + wrapper.should.have.className('active') + wrapper.should.have.className('visible') + menu.should.have.className('visible') +} + +// ---------------------------------------- +// Helpers +// ---------------------------------------- +const openSearchResults = () => { + wrapper + .simulate('focus') + + domEvent.keyDown(document, { key: 'ArrowDown' }) +} + +describe('Search Component', () => { + beforeEach(() => { + attachTo = undefined + wrapper = undefined + options = getOptions() + }) + + afterEach(() => { + if (wrapper && wrapper.unmount) wrapper.unmount() + if (attachTo) document.body.removeChild(attachTo) + }) + + common.isConformant(Search) + common.hasUIClassName(Search) + common.hasSubComponents(Search, [SearchCategory, SearchResult, SearchResults]) + common.isTabbable(Search) + common.propKeyOnlyToClassName(Search, 'category') + common.propKeyOnlyToClassName(Search, 'fluid') + common.propKeyOnlyToClassName(Search, 'loading') + + it('closes on blur', () => { + wrapperMount() + + openSearchResults() + + searchResultsIsOpen() + + wrapper + .simulate('blur') + + searchResultsIsClosed() + }) + + // TODO: see Search.handleFocus() todo + // it('opens on focus', () => { + // wrapperMount() + // + // searchResultsIsClosed() + // + // wrapper + // .simulate('focus') + // + // searchResultsIsOpen() + // }) + + describe('icon', () => { + it('defaults to a search icon', () => { + Search.defaultProps.icon.should.equal('search') + wrapperRender() + .should.contain.descendants('.search.icon') + }) + }) + + describe('active item', () => { + it('defaults to no result active', () => { + wrapperRender() + .should.not.contain.descendants('.result.active') + }) + it('defaults to the first item with selectFirstResult', () => { + wrapperShallow() + .find('SearchResult') + .first() + .should.have.prop('active', true) + }) + it('moves down on arrow down when open', () => { + wrapperMount() + + // open + openSearchResults() + searchResultsIsOpen() + + // arrow to second + domEvent.keyDown(document, { key: 'ArrowDown' }) + + // selection moved to second item + wrapper + .find('SearchResult') + .first() + .should.have.prop('active', false) + + wrapper + .find('SearchResult') + .at(1) + .should.have.prop('active', true) + }) + it('moves up on arrow up when open', () => { + wrapperMount() + + // open + openSearchResults() + searchResultsIsOpen() + + // arrow up + domEvent.keyDown(document, { key: 'ArrowUp' }) + + // selection moved to last item + wrapper + .find('SearchResult') + .first() + .should.have.prop('active', false) + + wrapper + .find('SearchResult') + .at(options.length - 1) + .should.have.prop('active', true) + }) + it('skips over items filtered by search', () => { + const opts = [ + { title: 'a1' }, + { title: 'skip this one' }, + { title: 'a3' }, + ] + wrapperMount() + + openSearchResults() + searchResultsIsOpen() + + // search for 'a' + wrapper + .find('input.prompt') + .simulate('change', { target: { value: 'a' } }) + + wrapper + .find('.result.active') + .should.contain.text('a1') + + // move selection down + domEvent.keyDown(document, { key: 'ArrowDown' }) + + wrapper + .find('.result.active') + .should.contain.text('a3') + }) + it('scrolls the selected item into view', () => { + // get enough options to make the menu scrollable + const opts = getOptions(20) + + wrapperMount() + + openSearchResults() + searchResultsIsOpen() + const menu = document.querySelector('.ui.search .results.visible') + + // + // Scrolls to bottom + // + + // make sure first item is selected + wrapper + .find('.result.active') + .should.contain.text(opts[0].title) + + // wrap selection to last item + domEvent.keyDown(document, { key: 'ArrowUp' }) + + // make sure last item is selected + wrapper + .find('.result.active') + .should.contain.text(_.tail(opts).title) + + // menu should be completely scrolled to the bottom + const isMenuScrolledToBottom = menu.scrollTop + menu.clientHeight === menu.scrollHeight + isMenuScrolledToBottom.should.be.true( + 'When the last item in the list was selected, SearchResults did not scroll to bottom.' + ) + + // + // Scrolls back to top + // + + // wrap selection to last item + domEvent.keyDown(document, { key: 'ArrowDown' }) + + // make sure first item is selected + wrapper + .find('.result.active') + .should.contain.text(opts[0].title) + + // menu should be completely scrolled to the bottom + const isMenuScrolledToTop = menu.scrollTop === 0 + isMenuScrolledToTop.should.be.true( + 'When the first item in the list was selected, SearchResults did not scroll to top.' + ) + }) + it('closes the menu', () => { + wrapperMount() + + openSearchResults() + searchResultsIsOpen() + + // choose an item closes + domEvent.keyDown(document, { key: 'Enter' }) + searchResultsIsClosed() + }) + }) + + describe('category', () => { + const categoryLength = 3 + const categoryResultsLength = 5 + const categoryOptions = _.range(0, categoryLength).reduce((memo, index) => { + const category = faker.hacker.noun() + + memo[category] = { + name: category, + results: getOptions(categoryResultsLength), + } + + return memo + }, {}) + + it('defaults to the first item with selectFirstResult', () => { + wrapperShallow() + + wrapper + .find('SearchCategory') + .first() + .should.have.prop('active', true) + + wrapper + .find('SearchResult') + .first() + .should.have.prop('active', true) + }) + it('moves down on arrow down when open', () => { + wrapperMount() + + // open + openSearchResults() + searchResultsIsOpen() + + // arrow to new category + _.times(categoryResultsLength, () => domEvent.keyDown(document, { key: 'ArrowDown' })) + + // selection moved to second item + wrapper + .find('SearchCategory') + .first() + .should.have.prop('active', false) + + wrapper + .find('SearchResult') + .first() + .should.have.prop('active', false) + + wrapper + .find('SearchCategory') + .at(1) + .should.have.prop('active', true) + + wrapper + .find('SearchResult') + .at(categoryResultsLength) + .should.have.prop('active', true) + }) + it('moves up on arrow up when open', () => { + wrapperMount() + + // open + openSearchResults() + searchResultsIsOpen() + + // arrow down + domEvent.keyDown(document, { key: 'ArrowUp' }) + + // selection moved to last item + wrapper + .find('SearchCategory') + .first() + .should.have.prop('active', false) + + wrapper + .find('SearchResult') + .first() + .should.have.prop('active', false) + + wrapper + .find('SearchCategory') + .at(categoryLength - 1) + .should.have.prop('active', true) + + wrapper + .find('SearchResult') + .at(categoryLength * categoryResultsLength - 1) + .should.have.prop('active', true) + }) + it('skips over items filtered by search', () => { + const opts = { + cat1: { name: 'cat1', results: [{ title: 'a1' }] }, + cat2: { name: 'cat2', results: [{ title: 'skip this one' }] }, + cat3: { name: 'cat3', results: [{ title: 'a3' }] }, + } + wrapperMount() + + openSearchResults() + searchResultsIsOpen() + + // search for 'a' + wrapper + .find('input.prompt') + .simulate('change', { target: { value: 'a' } }) + + wrapper + .find('.results .category.active') + .should.contain.text('cat1') + + wrapper + .find('.results .result.active') + .should.contain.text('a1') + + // move selection down + domEvent.keyDown(document, { key: 'ArrowDown' }) + + wrapper + .find('.results .category.active') + .should.contain.text('cat3') + + wrapper + .find('.results .result.active') + .should.contain.text('a3') + }) + it('uses default noResultsMessage', () => { + const search = wrapperMount() + .find('input.prompt') + + // search for something we know will not exist + search.simulate('change', { target: { value: '_________________' } }) + + wrapper + .find('.message.empty') + .should.have.text('No results found.') + }) + it('closes the menu', () => { + wrapperMount() + + openSearchResults() + searchResultsIsOpen() + + // choose an item closes + domEvent.keyDown(document, { key: 'Enter' }) + searchResultsIsClosed() + }) + }) + + describe('value', () => { + it('updates text when value changed', () => { + const initialValue = faker.hacker.noun() + const nextValue = faker.hacker.noun() + + wrapperMount() + .find('.prompt') + .should.have.value(initialValue) + + wrapper + .setProps({ value: nextValue }) + .find('.prompt') + .should.have.value(nextValue) + }) + }) + + describe('results', () => { + // DO NOT simulate events on 'document', use the 'domEvent` util + // simulate() only uses React's internal event system + // it does not touch the actual DOM at all so it can't use any of the DOM event handlers. + // We listen for keydown on the raw DOM, not in a React component. + // https://github.com/facebook/react/issues/5043 + + it('opens on arrow down when focused', () => { + wrapperMount() + + searchResultsIsClosed() + wrapper.simulate('focus') + domEvent.keyDown(document, { key: 'ArrowDown' }) + searchResultsIsOpen() + }) + + it('opens on arrow up when focused', () => { + wrapperMount() + + searchResultsIsClosed() + wrapper.simulate('focus') + domEvent.keyDown(document, { key: 'ArrowUp' }) + searchResultsIsOpen() + }) + + it('opens after min characters', () => { + const title = options[0].title + wrapperMount() + .simulate('focus') + + searchResultsIsClosed() + + wrapper + .find('input.prompt') + .simulate('change', { target: { value: title.slice(0, 1) } }) + searchResultsIsClosed() + + wrapper + .find('input.prompt') + .simulate('change', { target: { value: title.slice(0, 2) } }) + searchResultsIsOpen() + }) + + it('does not open on arrow down when not focused', () => { + wrapperMount() + + searchResultsIsClosed() + domEvent.keyDown(document, { key: 'ArrowDown' }) + searchResultsIsClosed() + }) + + it('does not open on space when not focused', () => { + wrapperMount() + + searchResultsIsClosed() + domEvent.keyDown(document, { key: ' ' }) + searchResultsIsClosed() + }) + + it('closes on menu item click', () => { + wrapperMount() + const item = wrapper + .find('SearchResult') + .at(_.random(options.length - 1)) + + // open + openSearchResults() + searchResultsIsOpen() + + // select item + item.simulate('click') + searchResultsIsClosed() + }) + + it('blurs after menu item click (mousedown)', () => { + wrapperMount() + const item = wrapper + .find('SearchResult') + .at(_.random(options.length - 1)) + + // open + openSearchResults() + searchResultsIsOpen() + + // select item + item.simulate('mousedown') + searchResultsIsOpen() + item.simulate('click') + searchResultsIsClosed() + }) + + it('closes on click outside', () => { + wrapperMount() + + // open + openSearchResults() + searchResultsIsOpen() + + // click outside + domEvent.click(document) + searchResultsIsClosed() + }) + + it('closes on esc key', () => { + wrapperMount() + + // open + openSearchResults() + searchResultsIsOpen() + + // esc + domEvent.keyDown(document, { key: 'Escape' }) + searchResultsIsClosed() + }) + }) + + describe('open', () => { + it('defaultOpen opens the menu when true', () => { + wrapperShallow() + searchResultsIsOpen() + }) + it('defaultOpen closes the menu when false', () => { + wrapperShallow() + searchResultsIsClosed() + }) + it('opens the menu when true', () => { + wrapperShallow() + searchResultsIsOpen() + }) + it('closes the menu when false', () => { + wrapperShallow() + searchResultsIsClosed() + }) + it('closes the menu when toggled from true to false', () => { + wrapperShallow() + .setProps({ open: false }) + searchResultsIsOpen() + }) + it('opens the menu when toggled from false to true', () => { + wrapperShallow() + .setProps({ open: true }) + searchResultsIsClosed() + }) + }) + + describe('onChange', () => { + let spy + beforeEach(() => { + spy = sandbox.spy() + }) + + it('is called with event and value on item click', () => { + const randomIndex = _.random(options.length - 1) + const randomResult = options[randomIndex] + wrapperMount() + + // open + openSearchResults() + searchResultsIsOpen() + + wrapper + .find('SearchResult') + .at(randomIndex) + .simulate('click') + + spy.should.have.been.calledOnce() + spy.firstCall.args[1].should.deep.equal(randomResult) + }) + it('is called with event and value when pressing enter on a selected item', () => { + const firstResult = options[0] + wrapperMount() + + // open + openSearchResults() + searchResultsIsOpen() + + domEvent.keyDown(document, { key: 'Enter' }) + + spy.should.have.been.calledOnce() + spy.firstCall.args[1].should.deep.equal(firstResult) + }) + it('is not called when updating the value prop', () => { + const value = _.sample(options).title + const next = _.sample(_.without(options, value)).title + + wrapperMount() + .setProps({ value: next }) + + spy.should.not.have.been.called() + }) + it('does not call onChange on query change', () => { + const onChangeSpy = sandbox.spy() + wrapperMount() + + // simulate search + wrapper + .find('input.prompt') + .simulate('change', { target: { value: faker.hacker.noun() } }) + + onChangeSpy.should.not.have.been.called() + }) + }) + + describe('onSearchChange', () => { + it('is called with (event, value) on search input change', () => { + const spy = sandbox.spy() + wrapperMount() + .find('input.prompt') + .simulate('change', { target: { value: 'a' }, stopPropagation: _.noop }) + + spy.should.have.been.calledOnce() + spy.should.have.been.calledWithMatch({ target: { value: 'a' } }, 'a') + }) + }) + + describe('results', () => { + it('adds the onClick handler to all items', () => { + wrapperShallow() + .find('SearchResult') + .everyWhere(item => item.should.have.prop('onClick')) + }) + it('calls handleItemClick when an item is clicked', () => { + wrapperMount() + + const instance = wrapper.instance() + sandbox.spy(instance, 'handleItemClick') + + // open + openSearchResults() + searchResultsIsOpen() + + instance.handleItemClick.should.not.have.been.called() + + // click random item + wrapper + .find('SearchResult') + .at(_.random(0, options.length - 1)) + .simulate('click') + + instance.handleItemClick.should.have.been.calledOnce() + }) + it('renders new options when options change', () => { + const customOptions = [ + { title: 'abra', description: 'abra' }, + { title: 'cadabra', description: 'cadabra' }, + { title: 'bang', description: 'bang' }, + ] + wrapperMount() + .find('input.prompt') + + wrapper + .find('SearchResult') + .should.have.lengthOf(3) + + wrapper.setProps({ results: [...customOptions, { title: 'bar', description: 'bar' }] }) + + wrapper + .find('SearchResult') + .should.have.lengthOf(4) + + const newItem = wrapper + .find('SearchResult') + .last() + + newItem.should.have.prop('title', 'bar') + newItem.should.have.prop('description', 'bar') + }) + + it('passes options as props', () => { + const customOptions = [ + { title: 'abra', description: 'abra', 'data-foo': 'someValue' }, + { title: 'cadabra', description: 'cadabra', 'data-foo': 'someValue' }, + { title: 'bang', description: 'bang', 'data-foo': 'someValue' }, + ] + wrapperShallow() + .find('SearchResult') + .everyWhere(item => item.should.have.prop('data-foo', 'someValue')) + }) + it('ignores search value', () => { + wrapperMount() + + openSearchResults() + searchResultsIsOpen() + + // search for something we know will not exist + wrapper + .find('input.prompt') + .simulate('change', { target: { value: '_________________' } }) + + wrapper + .find('SearchResult') + .should.have.lengthOf(options.length) + }) + }) + + describe('searchFields', () => { + it('filters the items based on title by default', () => { + const search = wrapperMount() + .find('input.prompt') + + // search for value yields 0 results + search.simulate('change', { target: { value: _.sample(options).description } }) + + wrapper + .find('SearchResult') + .should.have.lengthOf(0, "Searching for an item's description did not yield 0 results.") + + // search for title yields 1 result + search.simulate('change', { target: { value: _.sample(options).title } }) + + wrapper + .find('SearchResult') + .should.have.lengthOf(1, "Searching for an item's title did not yield any results.") + }) + + it('filters the items based on searchFields', () => { + const search = wrapperMount() + .find('input.prompt') + + // search for value yields 1 result + search.simulate('change', { target: { value: _.sample(options).description } }) + + wrapper + .find('SearchResult') + .should.have.lengthOf(1, "Searching for an item's description did not yield any results.") + + // search for title yields 1 result + search.simulate('change', { target: { value: _.sample(options).title } }) + + wrapper + .find('SearchResult') + .should.have.lengthOf(1, "Searching for an item's title did not yield any results.") + }) + }) + + describe('no results message', () => { + it('is shown when a search yields no results', () => { + const search = wrapperMount() + .find('input.prompt') + + wrapper + .find('.message.empty') + .should.not.be.present() + + // search for something we know will not exist + search.simulate('change', { target: { value: '_________________' } }) + + wrapper + .find('.message.empty') + .should.be.present() + }) + it('uses default noResultsMessage', () => { + const search = wrapperMount() + .find('input.prompt') + + // search for something we know will not exist + search.simulate('change', { target: { value: '_________________' } }) + + wrapper + .find('.message.empty .header') + .should.have.text('No results found.') + }) + it('uses custom noResultsMessage', () => { + const search = wrapperMount() + .find('input.prompt') + + // search for something we know will not exist + search.simulate('change', { target: { value: '_________________' } }) + + wrapper + .find('.message.empty .header') + .should.have.text('Something custom') + }) + it('uses custom noResultsDescription if present', () => { + const search = wrapperMount() + .find('input.prompt') + + // search for something we know will not exist + search.simulate('change', { target: { value: '_________________' } }) + + wrapper + .find('.message.empty .header') + .should.have.text('No results found.') + + wrapper + .find('.message.empty .description') + .should.have.text('Something custom') + }) + it('uses no noResultsMessage', () => { + const search = wrapperMount() + .find('input.prompt') + + // search for something we know will not exist + search.simulate('change', { target: { value: '_________________' } }) + + wrapper + .find('.message.empty .header') + .should.have.text('') + }) + it('shows no message with showNoResults=false', () => { + const search = wrapperMount() + .find('input.prompt') + + // search for something we know will not exist + search.simulate('change', { target: { value: '_________________' } }) + + wrapper + .find('.message.empty') + .should.not.be.present() + }) + }) + + describe('placeholder', () => { + it('is present when defined', () => { + wrapperShallow() + .find('Input') + .first() + .should.have.prop('placeholder', 'hi') + }) + it('is not present when not defined', () => { + wrapperShallow() + .find('Input') + .first() + .should.not.have.prop('placeholder') + }) + }) +}) diff --git a/test/specs/modules/Search/SearchCategory-test.js b/test/specs/modules/Search/SearchCategory-test.js new file mode 100644 index 0000000000..7060fdd471 --- /dev/null +++ b/test/specs/modules/Search/SearchCategory-test.js @@ -0,0 +1,7 @@ +import SearchCategory from 'src/modules/Search/SearchCategory' +import * as common from 'test/specs/commonTests' + +describe('SearchCategory', () => { + common.isConformant(SearchCategory) + common.rendersChildren(SearchCategory) +}) diff --git a/test/specs/modules/Search/SearchResult-test.js b/test/specs/modules/Search/SearchResult-test.js new file mode 100644 index 0000000000..d143325792 --- /dev/null +++ b/test/specs/modules/Search/SearchResult-test.js @@ -0,0 +1,7 @@ +import SearchResult from 'src/modules/Search/SearchResult' +import * as common from 'test/specs/commonTests' + +describe('SearchResult', () => { + common.isConformant(SearchResult) + common.propKeyOnlyToClassName(SearchResult, 'active') +}) diff --git a/test/specs/modules/Search/SearchResults-test.js b/test/specs/modules/Search/SearchResults-test.js new file mode 100644 index 0000000000..5b53f10f83 --- /dev/null +++ b/test/specs/modules/Search/SearchResults-test.js @@ -0,0 +1,7 @@ +import SearchResults from 'src/modules/Search/SearchResults' +import * as common from 'test/specs/commonTests' + +describe('SearchResults', () => { + common.isConformant(SearchResults) + common.rendersChildren(SearchResults) +})