diff --git a/docs/app/Examples/modules/Dropdown/Usage/DropdownExampleSearchInput.js b/docs/app/Examples/modules/Dropdown/Usage/DropdownExampleSearchInput.js new file mode 100644 index 0000000000..4aca6d89a4 --- /dev/null +++ b/docs/app/Examples/modules/Dropdown/Usage/DropdownExampleSearchInput.js @@ -0,0 +1,21 @@ +import React from 'react' +import { Dropdown } from 'semantic-ui-react' + +const options = [ + { key: 100, text: '100', value: 100 }, + { key: 200, text: '200', value: 200 }, + { key: 300, text: '300', value: 300 }, + { key: 400, text: '400', value: 400 }, +] + +const DropdownExampleSearchInput = () => ( + +) + +export default DropdownExampleSearchInput diff --git a/docs/app/Examples/modules/Dropdown/Usage/index.js b/docs/app/Examples/modules/Dropdown/Usage/index.js index 0fd670bb74..25143eb24d 100644 --- a/docs/app/Examples/modules/Dropdown/Usage/index.js +++ b/docs/app/Examples/modules/Dropdown/Usage/index.js @@ -76,6 +76,11 @@ const DropdownUsageExamples = () => ( description='A dropdown item can be rendered differently inside the menu.' examplePath='modules/Dropdown/Usage/DropdownExampleItemContent' /> + , value: string) => Array); + /** A shorthand for a search input. */ + searchInput?: any; + /** Define whether the highlighted item should be selected on blur. */ selectOnBlur?: boolean; @@ -245,6 +249,7 @@ interface DropdownComponent extends React.ComponentClass { Header: typeof DropdownHeader; Item: typeof DropdownItem; Menu: typeof DropdownMenu; + SearchInput: typeof DropdownSearchInput; } declare const Dropdown: DropdownComponent; diff --git a/src/modules/Dropdown/Dropdown.js b/src/modules/Dropdown/Dropdown.js index 5919cadb47..1274d12f0e 100644 --- a/src/modules/Dropdown/Dropdown.js +++ b/src/modules/Dropdown/Dropdown.js @@ -23,6 +23,7 @@ import DropdownDivider from './DropdownDivider' import DropdownItem from './DropdownItem' import DropdownHeader from './DropdownHeader' import DropdownMenu from './DropdownMenu' +import DropdownSearchInput from './DropdownSearchInput' const debug = makeDebugger('dropdown') @@ -276,6 +277,13 @@ export default class Dropdown extends Component { PropTypes.func, ]), + /** A shorthand for a search input. */ + searchInput: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.node, + PropTypes.object, + ]), + // TODO 'searchInMenu' or 'search='in menu' or ??? How to handle this markup and functionality? /** Define whether the highlighted item should be selected on blur. */ @@ -338,6 +346,7 @@ export default class Dropdown extends Component { noResultsMessage: 'No results found.', openOnFocus: true, renderLabel: ({ text }) => text, + searchInput: 'text', selectOnBlur: true, } @@ -356,6 +365,7 @@ export default class Dropdown extends Component { static Header = DropdownHeader static Item = DropdownItem static Menu = DropdownMenu + static SearchInput = DropdownSearchInput componentWillMount() { debug('componentWillMount()') @@ -713,14 +723,16 @@ export default class Dropdown extends Component { this.setState({ focus: false, searchQuery: '' }) } - handleSearchChange = e => { - debug('handleSearchChange()', e) + handleSearchChange = (e, { value }) => { + debug('handleSearchChange()') + debug(value) + // prevent propagating to this.props.onChange() e.stopPropagation() const { minCharacters } = this.props const { open } = this.state - const newQuery = _.get(e, 'target.value', '') + const newQuery = value _.invoke(this.props, 'onSearchChange', e, newQuery) this.setState({ @@ -958,6 +970,40 @@ export default class Dropdown extends Component { handleRef = c => (this.ref = c) + // ---------------------------------------- + // Helpers + // ---------------------------------------- + + computeSearchInputTabIndex = () => { + const { disabled, tabIndex } = this.props + + if (!_.isNil(tabIndex)) return tabIndex + return disabled ? -1 : 0 + } + + computeSearchInputWidth = () => { + const { searchQuery } = this.state + + if (this.sizerRef && searchQuery) { + // resize the search input, temporarily show the sizer so we can measure it + + this.sizerRef.style.display = 'inline' + this.sizerRef.textContent = searchQuery + const searchWidth = Math.ceil(this.sizerRef.getBoundingClientRect().width) + this.sizerRef.style.removeProperty('display') + + return searchWidth + } + } + + computeTabIndex = () => { + const { disabled, search, tabIndex } = this.props + + if (!_.isNil(tabIndex)) return tabIndex + // don't set a root node tabIndex as the search input has its own tabIndex + if (!search) return disabled ? -1 : 0 + } + // ---------------------------------------- // Behavior // ---------------------------------------- @@ -1052,38 +1098,17 @@ export default class Dropdown extends Component { } renderSearchInput = () => { - const { disabled, search, tabIndex } = this.props + const { search, searchInput } = this.props const { searchQuery } = this.state if (!search) return null - - // tabIndex - let computedTabIndex - if (!_.isNil(tabIndex)) computedTabIndex = tabIndex - else computedTabIndex = disabled ? -1 : 0 - - // resize the search input, temporarily show the sizer so we can measure it - let searchWidth - if (this.sizerRef && searchQuery) { - this.sizerRef.style.display = 'inline' - this.sizerRef.textContent = searchQuery - searchWidth = Math.ceil(this.sizerRef.getBoundingClientRect().width) - this.sizerRef.style.display = 'none' - } - - return ( - - ) + return DropdownSearchInput.create(searchInput, { defaultProps: { + inputRef: this.handleSearchRef, + onChange: this.handleSearchChange, + style: { width: this.computeSearchInputWidth() }, + tabIndex: this.computeSearchInputTabIndex(), + value: searchQuery, + } }) } renderSearchSizer = () => { @@ -1172,7 +1197,6 @@ export default class Dropdown extends Component { debug('render()') debug('props', this.props) debug('state', this.state) - const { open } = this.state const { basic, @@ -1194,10 +1218,10 @@ export default class Dropdown extends Component { selection, scrolling, simple, - tabIndex, trigger, upward, } = this.props + const { open } = this.state // Classes const classes = cx( @@ -1227,21 +1251,13 @@ export default class Dropdown extends Component { useKeyOnly(upward, 'upward'), useKeyOrValueAndKey(pointing, 'pointing'), - className, 'dropdown', + className, ) const rest = getUnhandledProps(Dropdown, this.props) const ElementType = getElementType(Dropdown, this.props) const ariaOptions = this.getDropdownAriaOptions(ElementType, this.props) - let computedTabIndex - if (!_.isNil(tabIndex)) { - computedTabIndex = tabIndex - } else if (!search) { - // don't set a root node tabIndex as the search input has its own tabIndex - computedTabIndex = disabled ? -1 : 0 - } - return ( {this.renderLabels()} diff --git a/src/modules/Dropdown/DropdownSearchInput.d.ts b/src/modules/Dropdown/DropdownSearchInput.d.ts new file mode 100644 index 0000000000..bf1191e65a --- /dev/null +++ b/src/modules/Dropdown/DropdownSearchInput.d.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; + +export interface DropdownSearchInputProps { + [key: string]: any; + + /** An element type to render as (string or function). */ + as?: any; + + /** Additional classes. */ + className?: string; + + /** A ref handler for input. */ + inputRef?: (c: HTMLInputElement) => void; + + /** An input can receive focus. */ + tabIndex?: number | string; + + /** The HTML input type. */ + type?: string; + + /** Stored value. */ + value?: number | string; +} + +declare const DropdownSearchInput: React.ComponentClass; + +export default DropdownSearchInput; diff --git a/src/modules/Dropdown/DropdownSearchInput.js b/src/modules/Dropdown/DropdownSearchInput.js new file mode 100644 index 0000000000..d44bc5aac7 --- /dev/null +++ b/src/modules/Dropdown/DropdownSearchInput.js @@ -0,0 +1,84 @@ +import cx from 'classnames' +import _ from 'lodash' +import PropTypes from 'prop-types' +import React, { Component } from 'react' + +import { + createShorthandFactory, + customPropTypes, + META, + getUnhandledProps, +} from '../../lib' + +/** + * A search item sub-component for Dropdown component. + */ +class DropdownSearchInput extends Component { + static propTypes = { + /** An element type to render as (string or function). */ + as: customPropTypes.as, + + /** Additional classes. */ + className: PropTypes.string, + + /** A ref handler for input. */ + inputRef: PropTypes.func, + + /** An input can receive focus. */ + tabIndex: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string, + ]), + + /** The HTML input type. */ + type: PropTypes.string, + + /** Stored value. */ + value: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string, + ]), + } + + static defaultProps = { + type: 'text', + } + + static _meta = { + name: 'DropdownSearchInput', + parent: 'Dropdown', + type: META.TYPES.MODULE, + } + + handleChange = e => { + const value = _.get(e, 'target.value') + + _.invoke(this.props, 'onChange', e, { ...this.props, value }) + } + + handleRef = c => _.invoke(this.props, 'inputRef', c) + + render() { + const { className, tabIndex, type, value } = this.props + const classes = cx('search', className) + const rest = getUnhandledProps(DropdownSearchInput, this.props) + + return ( + + ) + } +} + +DropdownSearchInput.create = createShorthandFactory(DropdownSearchInput, type => ({ type })) + +export default DropdownSearchInput diff --git a/test/specs/modules/Dropdown/Dropdown-test.js b/test/specs/modules/Dropdown/Dropdown-test.js index ca5bcff11e..b4e6915a6b 100644 --- a/test/specs/modules/Dropdown/Dropdown-test.js +++ b/test/specs/modules/Dropdown/Dropdown-test.js @@ -9,6 +9,7 @@ import DropdownDivider from 'src/modules/Dropdown/DropdownDivider' import DropdownHeader from 'src/modules/Dropdown/DropdownHeader' import DropdownItem from 'src/modules/Dropdown/DropdownItem' import DropdownMenu from 'src/modules/Dropdown/DropdownMenu' +import DropdownSearchInput from 'src/modules/Dropdown/DropdownSearchInput' let attachTo let options @@ -70,7 +71,7 @@ describe('Dropdown', () => { common.isConformant(Dropdown) common.hasUIClassName(Dropdown) - common.hasSubComponents(Dropdown, [DropdownDivider, DropdownHeader, DropdownItem, DropdownMenu]) + common.hasSubComponents(Dropdown, [DropdownDivider, DropdownHeader, DropdownItem, DropdownMenu, DropdownSearchInput]) common.implementsIconProp(Dropdown) common.implementsShorthandProp(Dropdown, { propKey: 'header', @@ -186,22 +187,22 @@ describe('Dropdown', () => { }) it('defaults the search input to 0', () => { wrapperShallow() - .find('input.search') + .find(DropdownSearchInput) .should.have.prop('tabIndex', 0) }) it('defaults the disabled search input to -1', () => { wrapperShallow() - .find('input.search') + .find(DropdownSearchInput) .should.have.prop('tabIndex', -1) }) it('allows explicitly setting the search input value', () => { wrapperShallow() - .find('input.search') + .find(DropdownSearchInput) .should.have.prop('tabIndex', 123) }) it('allows explicitly setting the search input value when disabled', () => { wrapperShallow() - .find('input.search') + .find(DropdownSearchInput) .should.have.prop('tabIndex', 123) }) }) @@ -1623,7 +1624,7 @@ describe('Dropdown', () => { it('adds a search input when present', () => { wrapperShallow() - .should.have.exactly(1).descendants('input.search') + .should.have.exactly(1).descendants(DropdownSearchInput) }) it('sets focus to the search input on open', () => { diff --git a/test/specs/modules/Dropdown/DropdownSearchInput-test.js b/test/specs/modules/Dropdown/DropdownSearchInput-test.js new file mode 100644 index 0000000000..56e939f8ed --- /dev/null +++ b/test/specs/modules/Dropdown/DropdownSearchInput-test.js @@ -0,0 +1,96 @@ +import faker from 'faker' +import React from 'react' + +import * as common from 'test/specs/commonTests' +import { sandbox } from 'test/utils' +import DropdownSearchInput from 'src/modules/Dropdown/DropdownSearchInput' + +describe('DropdownSearchInput', () => { + common.hasValidTypings(DropdownSearchInput) + common.rendersChildren(DropdownSearchInput) + + describe('aria', () => { + it('should have aria-autocomplete', () => { + shallow() + .should.have.prop('aria-autocomplete', 'list') + }) + }) + + describe('autoComplete', () => { + it('should have autoComplete', () => { + shallow() + .should.have.prop('autoComplete', 'off') + }) + }) + + describe('onChange', () => { + it('is called with (e, data) on change', () => { + const onChange = sandbox.spy() + const e = { target: { value: 'value' } } + + shallow() + .find('input') + .simulate('change', e) + + onChange.should.have.been.calledOnce() + onChange.should.have.been.calledWithMatch(e, { value: e.target.value }) + }) + }) + + describe('inputRef', () => { + it('maintains ref on input', () => { + const inputRef = sandbox.spy() + const mountNode = document.createElement('div') + document.body.appendChild(mountNode) + + const wrapper = mount(, { attachTo: mountNode }) + const input = document.querySelector('input') + + inputRef.should.have.been.calledOnce() + inputRef.should.have.been.calledWithMatch(input) + + wrapper.detach() + document.body.removeChild(mountNode) + }) + }) + + describe('tabIndex', () => { + it('is not set by default', () => { + shallow() + .should.not.have.prop('tabIndex') + }) + + it('can be set explicitly', () => { + shallow() + .should.have.prop('tabIndex', 123) + }) + }) + + describe('type', () => { + it('should have text by default', () => { + shallow() + .should.have.prop('type', 'text') + }) + + it('can be set explicitly', () => { + const type = faker.random.word() + + shallow() + .should.have.prop('type', type) + }) + }) + + describe('value', () => { + it('is not set by default', () => { + shallow() + .should.not.have.prop('value') + }) + + it('can be set explicitly', () => { + const value = faker.random.word() + + shallow() + .should.have.prop('value', value) + }) + }) +})