diff --git a/README.md b/README.md index 1693607..c3e566a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The `Typeahead` component wraps the other, more functional component. It's purpo |Name|Required|Type|Default Value|Description| |----|--------|----|-----------| +|onSelect|required|function|N/A|This function is called when a TypeaheadResult is selected. It has one argument, which is the `value` prop of the selected TypeaheadResult| |onDismiss|optional|function|No-op function|This function is called when the typeahead is dismissed when the user presses the escape key| |onBlur|optional|function|No-op function|This function is called when the entire typeahead component is blurred. If you want to listen to blurs on the input, this is the place to do it| @@ -65,7 +66,7 @@ This component, which must be a direct child of the `TypeaheadResultsList` compo |Name|Required|Type|Default Value|Description| |----|--------|----|-----------| -|onSelect|required|function|N/A|This function will be called when the typeahead is selected, whether by click or by keyboard interaction.| +|value|required|any|N/A|The value of the result item. This value will be passed to the `Typeahead`'s `onSelect` prop| |onHighlight|optional|function|No-op function|This function will be called when the typeahead is highlighted through keyboard interaction| ## Examples @@ -113,7 +114,7 @@ class Demo extends Component { const { selectedResult } = this.state; return [ - + this.resultSelected(result)} key="typeahead"> this.typeaheadInputChange(evt)} @@ -121,7 +122,7 @@ class Demo extends Component { {this.state.results.map(result => ( this.resultSelected(result)} + value={result} > {result.name} @@ -193,7 +194,10 @@ class Demo extends Component { }, {}); return [ - + this.resultSelected(result)} + key="typeahead" + > this.typeaheadInputChange(evt)} @@ -205,7 +209,7 @@ class Demo extends Component { // Display county name at the top of each county group (

{county}

), ...(counties[county].map(city => ( - this.resultSelected(city)}>{city.name} + {city.name} ))) ] }, [])} diff --git a/__tests__/Typeahead-test.js b/__tests__/Typeahead-test.js index f02b69f..cf9043c 100644 --- a/__tests__/Typeahead-test.js +++ b/__tests__/Typeahead-test.js @@ -6,12 +6,15 @@ import Adapter from 'enzyme-adapter-react-16'; import Typeahead from '../src/Typeahead'; import TypeaheadInput from '../src/TypeaheadInput'; import TypeaheadResultsList from '../src/TypeaheadResultsList'; +import TypeaheadResult from '../src/TypeaheadResult'; Enzyme.configure({ adapter: new Adapter() }); +const RESULT_VALUE = { some: 'value' }; const shallowSetup = () => { const props = { - onDismiss: jest.fn() + onDismiss: jest.fn(), + onSelect: jest.fn() }; return { wrapper: shallow(), @@ -21,14 +24,18 @@ const shallowSetup = () => { const mountSetup = () => { const props = { - onDismiss: jest.fn() + onDismiss: jest.fn(), + onSelect: jest.fn() }; return { wrapper: mount( hi - RESULTS - {null} + + RESULTS + hello + world + ), props }; @@ -45,11 +52,31 @@ describe('Typeahead', () => { expect(toJson(wrapper)).toMatchSnapshot(); }); - it('calls resultsList.navigateList when an arrow key is pressed', () => { + it('updates the highlighted index when an arrow key is pressed', () => { const { wrapper } = mountSetup(); - wrapper.instance().resultsList.navigateList = jest.fn(); + wrapper.setState({highlightedIndex: -1}); wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowDown'); - expect(wrapper.instance().resultsList.navigateList).toHaveBeenCalled(); + expect(wrapper.state().highlightedIndex).toBe(0); + }); + + it('should navigate the list, go around the horn', () => { + const { wrapper } = mountSetup(); + wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowDown'); + expect(wrapper.state().highlightedIndex).toBe(0); + wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowDown'); + expect(wrapper.state().highlightedIndex).toBe(1); + wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowDown'); + expect(wrapper.state().highlightedIndex).toBe(2); + wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowDown'); + expect(wrapper.state().highlightedIndex).toBe(0); + wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowUp'); + expect(wrapper.state().highlightedIndex).toBe(2); + wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowUp'); + expect(wrapper.state().highlightedIndex).toBe(1); + wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowUp'); + expect(wrapper.state().highlightedIndex).toBe(0); + wrapper.find('TypeaheadInput').props().arrowKeyPressed('ArrowUp'); + expect(wrapper.state().highlightedIndex).toBe(2); }); it('should call props.onDismiss when escapeKeyPressed is called', () => { @@ -64,27 +91,24 @@ describe('Typeahead', () => { expect(wrapper.state().highlightedIndex).toBe(1); }); - it('should select the highlightedIndex by calling resultsList.selectResult with the state\'s highlightedIndex', () => { - const { wrapper } = mountSetup(); - const highlightedIndex = 3; + it('should select the highlighted result', () => { + const { wrapper, props } = mountSetup(); + const highlightedIndex = 0; const evt = { preventDefault: jest.fn() }; - wrapper.instance().resultsList.selectResult = jest.fn(); wrapper.setState({highlightedIndex}); wrapper.find('TypeaheadInput').props().enterKeyPressed(evt); - expect(wrapper.instance().resultsList.selectResult).toHaveBeenCalled(); - expect(wrapper.instance().resultsList.selectResult).toHaveBeenCalledWith(highlightedIndex); + expect(props.onSelect).toHaveBeenCalledWith(RESULT_VALUE); expect(evt.preventDefault).toHaveBeenCalledTimes(1); expect(wrapper.state('highlightedIndex')).toBe(-1); }); it('should not select a result or preventDefault if highlightedindex is -1 state\'s highlightedIndex', () => { - const { wrapper } = mountSetup(); + const { wrapper, props } = mountSetup(); const highlightedIndex = -1; const evt = { preventDefault: jest.fn() }; - wrapper.instance().resultsList.selectResult = jest.fn(); wrapper.setState({highlightedIndex}); wrapper.find('TypeaheadInput').props().enterKeyPressed(evt); - expect(wrapper.instance().resultsList.selectResult).toHaveBeenCalledTimes(0); + expect(props.onSelect).toHaveBeenCalledTimes(0); expect(evt.preventDefault).toHaveBeenCalledTimes(0); expect(wrapper.state('highlightedIndex')).toBe(-1); }); diff --git a/__tests__/TypeaheadResult-test.js b/__tests__/TypeaheadResult-test.js index 69a5029..7334e9b 100644 --- a/__tests__/TypeaheadResult-test.js +++ b/__tests__/TypeaheadResult-test.js @@ -7,11 +7,13 @@ import TypeaheadResult from '../src/TypeaheadResult'; Enzyme.configure({ adapter: new Adapter() }); +const RESULT_VALUE = 'RESULT_VALUE'; const shallowSetup = () => { const props = { - onSelect: jest.fn(), + _onSelect: jest.fn(), onHighlight: jest.fn(), isHighlighted: false, + value: RESULT_VALUE }; return { wrapper: shallow(Las Vegas), @@ -32,10 +34,11 @@ describe('TypeaheadResult', () => { expect(toJson(wrapper)).toMatchSnapshot(); }); - it('should call onSelect when clicked', () => { + it('should call _onSelect when clicked', () => { const { props, wrapper } = shallowSetup(); wrapper.find('typeahead-result').simulate('click'); - expect(props.onSelect).toHaveBeenCalled(); + expect(props._onSelect).toHaveBeenCalled(); + expect(props._onSelect).toHaveBeenCalledWith(props.value); }); it('should call onHighlight when result becomes highlighted', () => { diff --git a/__tests__/TypeaheadResultsList-test.js b/__tests__/TypeaheadResultsList-test.js index f1a9de8..e9fcd81 100644 --- a/__tests__/TypeaheadResultsList-test.js +++ b/__tests__/TypeaheadResultsList-test.js @@ -11,6 +11,7 @@ Enzyme.configure({ adapter: new Adapter() }); const setup = () => { const props = { updateHighlightedIndex: jest.fn(), + onResultsUpdate: jest.fn(), highlightedIndex: -1, }; return { @@ -29,38 +30,4 @@ describe('TypeaheadResultsList', () => { const { wrapper } = setup(); expect(toJson(wrapper)).toMatchSnapshot(); }); - - it('should navigate the list, go around the horn', () => { - const { wrapper } = setup(); - wrapper.setProps({ - updateHighlightedIndex: jest.fn().mockImplementation(idx => { - wrapper.setProps({highlightedIndex: idx}); - }) - }); - const updateHighlightedIndex = wrapper.props().updateHighlightedIndex; - wrapper.instance().navigateList('ArrowDown'); - expect(updateHighlightedIndex).toHaveBeenLastCalledWith(0); - wrapper.instance().navigateList('ArrowDown'); - expect(updateHighlightedIndex).toHaveBeenLastCalledWith(1); - wrapper.instance().navigateList('ArrowDown'); - expect(updateHighlightedIndex).toHaveBeenLastCalledWith(2); - wrapper.instance().navigateList('ArrowDown'); - expect(updateHighlightedIndex).toHaveBeenLastCalledWith(0); - wrapper.instance().navigateList('ArrowUp'); - expect(updateHighlightedIndex).toHaveBeenLastCalledWith(2); - wrapper.instance().navigateList('ArrowUp'); - expect(updateHighlightedIndex).toHaveBeenLastCalledWith(1); - wrapper.instance().navigateList('ArrowUp'); - expect(updateHighlightedIndex).toHaveBeenLastCalledWith(0); - wrapper.instance().navigateList('ArrowUp'); - expect(updateHighlightedIndex).toHaveBeenLastCalledWith(2); - }); - - it('should call select on the correct result', () => { - const { wrapper } = setup(); - - wrapper.instance().result1.select = jest.fn(); - wrapper.instance().selectResult(1); - expect(wrapper.instance().result1.select).toHaveBeenCalled(); - }); }); diff --git a/__tests__/__snapshots__/Typeahead-test.js.snap b/__tests__/__snapshots__/Typeahead-test.js.snap index ef68553..cba0d57 100644 --- a/__tests__/__snapshots__/Typeahead-test.js.snap +++ b/__tests__/__snapshots__/Typeahead-test.js.snap @@ -10,6 +10,7 @@ exports[`Typeahead renders the children 1`] = `
- RESULTS + + + RESULTS + + + + + hello + + + + + world + + diff --git a/__tests__/__snapshots__/TypeaheadResultsList-test.js.snap b/__tests__/__snapshots__/TypeaheadResultsList-test.js.snap index 5da6e1e..eda01e2 100644 --- a/__tests__/__snapshots__/TypeaheadResultsList-test.js.snap +++ b/__tests__/__snapshots__/TypeaheadResultsList-test.js.snap @@ -3,6 +3,7 @@ exports[`TypeaheadResultsList renders self and subcomponents 1`] = ` @@ -12,6 +13,7 @@ exports[`TypeaheadResultsList renders self and subcomponents 1`] = ` Cities @@ -24,6 +26,7 @@ exports[`TypeaheadResultsList renders self and subcomponents 1`] = ` @@ -36,6 +39,7 @@ exports[`TypeaheadResultsList renders self and subcomponents 1`] = ` diff --git a/demo/src/index.js b/demo/src/index.js index 652ff2a..973caf9 100644 --- a/demo/src/index.js +++ b/demo/src/index.js @@ -55,7 +55,7 @@ class Demo extends Component { ...arr, (

{county}

), ...(counties[county].map(city => ( - this.resultSelected(city)}>{city.name} + {city.name} ))) ] }, []); @@ -74,7 +74,9 @@ class Demo extends Component { return

Cities of Utah

- console.log('blurred')}> + this.resultSelected(city)} + onBlur={() => console.log('blurred')}> {}, @@ -17,7 +22,11 @@ export default class Typeahead extends Component { } navigateList = (dir) => { - this.resultsList.navigateList(dir); + const count = this.resultsValues.length; + const { highlightedIndex } = this.state; + this.updateHighlightedIndex( + (highlightedIndex + dirMap[dir] + count) % count + ); } updateHighlightedIndex = (highlightedIndex) => { @@ -35,7 +44,11 @@ export default class Typeahead extends Component { } selectHighlightedResult = () => { - this.resultsList.selectResult(this.state.highlightedIndex); + this.select(this.resultsValues[this.state.highlightedIndex]); + } + + select = (value) => { + this.props.onSelect(value); } dismissTypeahead = () => { @@ -56,7 +69,8 @@ export default class Typeahead extends Component { return React.cloneElement(child, { highlightedIndex: this.state.highlightedIndex, updateHighlightedIndex: this.updateHighlightedIndex, - ref: (ref => this.resultsList = ref) + onResultsUpdate: values => { this.resultsValues = values; }, + _onSelect: (value) => { this.select(value); } }); } else if (child.type === TypeaheadInput) { return React.cloneElement(child, { diff --git a/src/TypeaheadResult.js b/src/TypeaheadResult.js index bfce9f2..80cd6b0 100644 --- a/src/TypeaheadResult.js +++ b/src/TypeaheadResult.js @@ -14,7 +14,7 @@ export default class TypeaheadResult extends Component { } select = () => { - this.props.onSelect(); + this.props._onSelect(this.props.value); } render() { diff --git a/src/TypeaheadResultsList.js b/src/TypeaheadResultsList.js index c1ddc6f..b31bd12 100644 --- a/src/TypeaheadResultsList.js +++ b/src/TypeaheadResultsList.js @@ -1,48 +1,24 @@ import React, {Component} from 'react' import TypeaheadResult from './TypeaheadResult' -const dirMap = { - 'ArrowDown': 1, - 'ArrowUp': -1 -}; - export default class TypeaheadResultsList extends Component { - countResults() { - let resultsCount = 0; - React.Children.forEach(this.props.children, child => { - if (child && child.type === TypeaheadResult) { - resultsCount++; - } - }); - return resultsCount; - } - - navigateList(dir) { - const count = this.countResults(); - const { highlightedIndex } = this.props; - this.props.updateHighlightedIndex( - (highlightedIndex + dirMap[dir] + count) % count - ); - } - - selectResult(idx) { - this[`result${idx}`].select(); - } - render() { let currentIndex = -1; + const resultValues = []; const children = React.Children.map(this.props.children, child => { if (child && child.type === TypeaheadResult) { // scope the index to avoid closure problems const idx = currentIndex = currentIndex + 1; + resultValues.push(child.props.value); return React.cloneElement(child, { isHighlighted: idx === this.props.highlightedIndex, - ref: (ref => this[`result${idx}`] = ref) + _onSelect: (value) => { this.props._onSelect(value); }, }) } return child; }); + this.props.onResultsUpdate(resultValues); return {children}