Skip to content
This repository was archived by the owner on Jul 19, 2019. It is now read-only.

Commit 7134228

Browse files
committed
Add props.isItemSelectable
Using this prop allows rendering items that will not function as selectable items, but instead as static information/decoration elements such as headers.
1 parent 69d052c commit 7134228

File tree

3 files changed

+186
-26
lines changed

3 files changed

+186
-26
lines changed

lib/Autocomplete.js

+53-24
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ class Autocomplete extends React.Component {
5656
* By default all items are always rendered.
5757
*/
5858
shouldItemRender: PropTypes.func,
59+
/**
60+
* Arguments: `item: Any`
61+
*
62+
* Invoked when attempting to select an item. The return value is used to
63+
* determine whether the item should be selectable or not.
64+
* By default all items are selectable.
65+
*/
66+
isItemSelectable: PropTypes.func,
5967
/**
6068
* Arguments: `itemA: Any, itemB: Any, value: String`
6169
*
@@ -163,6 +171,7 @@ class Autocomplete extends React.Component {
163171
},
164172
onChange() {},
165173
onSelect() {},
174+
isItemSelectable() { return true },
166175
renderMenu(items, value, style) {
167176
return <div style={{ ...style, ...this.menuStyle }} children={items}/>
168177
},
@@ -271,32 +280,44 @@ class Autocomplete extends React.Component {
271280
static keyDownHandlers = {
272281
ArrowDown(event) {
273282
event.preventDefault()
274-
const itemsLength = this.getFilteredItems(this.props).length
275-
if (!itemsLength) return
283+
const items = this.getFilteredItems(this.props)
284+
if (!items.length) return
276285
const { highlightedIndex } = this.state
277-
const index = (
278-
highlightedIndex === null ||
279-
highlightedIndex === itemsLength - 1
280-
) ? 0 : highlightedIndex + 1
281-
this.setState({
282-
highlightedIndex: index,
283-
isOpen: true,
284-
})
286+
let index = highlightedIndex === null ? -1 : highlightedIndex
287+
for (let i = 0; i < items.length ; i++) {
288+
const p = (index + i + 1) % items.length
289+
if (this.props.isItemSelectable(items[p])) {
290+
index = p
291+
break
292+
}
293+
}
294+
if (index > -1 && index !== highlightedIndex) {
295+
this.setState({
296+
highlightedIndex: index,
297+
isOpen: true,
298+
})
299+
}
285300
},
286301

287302
ArrowUp(event) {
288303
event.preventDefault()
289-
const itemsLength = this.getFilteredItems(this.props).length
290-
if (!itemsLength) return
304+
const items = this.getFilteredItems(this.props)
305+
if (!items.length) return
291306
const { highlightedIndex } = this.state
292-
const index = (
293-
highlightedIndex === 0 ||
294-
highlightedIndex === null
295-
) ? itemsLength - 1 : highlightedIndex - 1
296-
this.setState({
297-
highlightedIndex: index,
298-
isOpen: true,
299-
})
307+
let index = highlightedIndex === null ? items.length : highlightedIndex
308+
for (let i = 0; i < items.length ; i++) {
309+
const p = (index - 1 + items.length) % items.length
310+
if (this.props.isItemSelectable(items[p])) {
311+
index = p
312+
break
313+
}
314+
}
315+
if (index !== items.length) {
316+
this.setState({
317+
highlightedIndex: index,
318+
isOpen: true,
319+
})
320+
}
300321
},
301322

302323
Enter(event) {
@@ -371,8 +392,14 @@ class Autocomplete extends React.Component {
371392
maybeAutoCompleteText(state, props) {
372393
const { highlightedIndex } = state
373394
const { value, getItemValue } = props
374-
const index = highlightedIndex === null ? 0 : highlightedIndex
375-
const matchedItem = this.getFilteredItems(props)[index]
395+
let index = highlightedIndex === null ? 0 : highlightedIndex
396+
let items = this.getFilteredItems(props)
397+
for (let i = 0; i < items.length ; i++) {
398+
if (props.isItemSelectable(items[index]))
399+
break
400+
index = (index + 1) % items.length
401+
}
402+
const matchedItem = items[index] && props.isItemSelectable(items[index]) ? items[index] : null
376403
if (value !== '' && matchedItem) {
377404
const itemValue = getItemValue(matchedItem)
378405
const itemValueDoesMatch = (itemValue.toLowerCase().indexOf(
@@ -434,8 +461,10 @@ class Autocomplete extends React.Component {
434461
{ cursor: 'default' }
435462
)
436463
return React.cloneElement(element, {
437-
onMouseEnter: () => this.highlightItemFromMouse(index),
438-
onClick: () => this.selectItemFromMouse(item),
464+
onMouseEnter: this.props.isItemSelectable(item) ?
465+
() => this.highlightItemFromMouse(index) : null,
466+
onClick: this.props.isItemSelectable(item) ?
467+
() => this.selectItemFromMouse(item) : null,
439468
ref: e => this.refs[`item-${index}`] = e,
440469
})
441470
})

lib/__tests__/Autocomplete-test.js

+57-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react'
22
import { mount, shallow } from 'enzyme'
33
import Autocomplete from '../Autocomplete'
4-
import { getStates, matchStateToTerm } from '../utils'
4+
import { getStates, getCategorizedStates, matchStateToTermWithHeaders } from '../utils'
55

66
function AutocompleteComponentJSX(extraProps) {
77
return (
@@ -13,7 +13,7 @@ function AutocompleteComponentJSX(extraProps) {
1313
key={item.abbr}
1414
>{item.name}</div>
1515
)}
16-
shouldItemRender={matchStateToTerm}
16+
shouldItemRender={matchStateToTermWithHeaders}
1717
{...extraProps}
1818
/>
1919
)
@@ -444,6 +444,18 @@ describe('Autocomplete keyDown->ArrowDown event handlers', () => {
444444
expect(autocompleteWrapper.state('highlightedIndex')).toEqual(0)
445445
})
446446

447+
it('should not select anything if there are no selectable items', () => {
448+
autocompleteWrapper.setState({
449+
isOpen: true,
450+
highlightedIndex: null,
451+
})
452+
autocompleteWrapper.setProps({ isItemSelectable: () => false })
453+
454+
autocompleteInputWrapper.simulate('keyDown', { key : 'ArrowDown', keyCode: 40, which: 40 })
455+
456+
expect(autocompleteWrapper.state('highlightedIndex')).toBe(null)
457+
})
458+
447459
})
448460

449461
describe('Autocomplete keyDown->ArrowUp event handlers', () => {
@@ -490,6 +502,18 @@ describe('Autocomplete keyDown->ArrowUp event handlers', () => {
490502
expect(autocompleteWrapper.state('highlightedIndex')).toEqual(49)
491503
})
492504

505+
it('should not select anything if there are no selectable items', () => {
506+
autocompleteWrapper.setState({
507+
isOpen: true,
508+
highlightedIndex: null,
509+
})
510+
autocompleteWrapper.setProps({ isItemSelectable: () => false })
511+
512+
autocompleteInputWrapper.simulate('keyDown', { key : 'ArrowUp', keyCode: 38, which: 38 })
513+
514+
expect(autocompleteWrapper.state('highlightedIndex')).toBe(null)
515+
})
516+
493517
})
494518

495519
describe('Autocomplete keyDown->Enter event handlers', () => {
@@ -699,6 +723,37 @@ describe('Autocomplete#renderMenu', () => {
699723
})
700724
})
701725

726+
describe('Autocomplete isItemSelectable', () => {
727+
const autocompleteWrapper = mount(AutocompleteComponentJSX({
728+
open: true,
729+
items: getCategorizedStates(),
730+
isItemSelectable: item => !item.header
731+
}))
732+
733+
it('should automatically highlight the first selectable item', () => {
734+
// Inputting 'ne' will cause Nevada to be the first selectable state to show up under the header 'West'
735+
// The header (index 0) is not selectable, so should not be automatically highlighted.
736+
autocompleteWrapper.setProps({ value: 'ne' })
737+
expect(autocompleteWrapper.state('highlightedIndex')).toBe(1)
738+
})
739+
740+
it('should automatically highlight the next available item', () => {
741+
// After inputting 'new h' to get New Hampshire you have a list that looks like:
742+
// [ header, header, header, header, New Hampshire, ... ]
743+
// This test makes sure that New Hampshire is selected, at index 4.
744+
// (maybeAutocompleteText should skip over the headers correctly)
745+
autocompleteWrapper.setProps({ value: 'new h' })
746+
expect(autocompleteWrapper.state('highlightedIndex')).toBe(4)
747+
})
748+
749+
it('should highlight nothing automatically if there are no selectable items', () => {
750+
// No selectable results should appear in the results, only headers.
751+
// As a result there should be no highlighted index.
752+
autocompleteWrapper.setProps({ value: 'new hrhrhhrr' })
753+
expect(autocompleteWrapper.state('highlightedIndex')).toBe(null)
754+
})
755+
})
756+
702757
describe('Public imperative API', () => {
703758
it('should expose select APIs available on HTMLInputElement', () => {
704759
const tree = mount(AutocompleteComponentJSX({ value: 'foo' }))

lib/utils.js

+76
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ export function matchStateToTerm(state, value) {
55
)
66
}
77

8+
export function matchStateToTermWithHeaders(state, value) {
9+
return (
10+
state.header ||
11+
state.name.toLowerCase().indexOf(value.toLowerCase()) !== -1 ||
12+
state.abbr.toLowerCase().indexOf(value.toLowerCase()) !== -1
13+
)
14+
}
15+
816
/**
917
* An example of how to implement a relevancy-based sorting method. States are
1018
* sorted based on the location of the match - For example, a search for "or"
@@ -32,6 +40,14 @@ export function fakeRequest(value, cb) {
3240
)
3341
}
3442

43+
44+
export function fakeCategorizedRequest(value, cb) {
45+
setTimeout(cb, 500, value ?
46+
getCategorizedStates().filter(state => matchStateToTermWithHeaders(state, value)) :
47+
getCategorizedStates()
48+
)
49+
}
50+
3551
export function getStates() {
3652
return [
3753
{ abbr: 'AL', name: 'Alabama' },
@@ -87,4 +103,64 @@ export function getStates() {
87103
]
88104
}
89105

106+
export function getCategorizedStates() {
107+
return [
108+
{ header: 'West' },
109+
{ abbr: 'AZ', name: 'Arizona' },
110+
{ abbr: 'CA', name: 'California' },
111+
{ abbr: 'CO', name: 'Colorado' },
112+
{ abbr: 'ID', name: 'Idaho' },
113+
{ abbr: 'MT', name: 'Montana' },
114+
{ abbr: 'NV', name: 'Nevada' },
115+
{ abbr: 'NM', name: 'New Mexico' },
116+
{ abbr: 'OK', name: 'Oklahoma' },
117+
{ abbr: 'OR', name: 'Oregon' },
118+
{ abbr: 'TX', name: 'Texas' },
119+
{ abbr: 'UT', name: 'Utah' },
120+
{ abbr: 'WA', name: 'Washington' },
121+
{ abbr: 'WY', name: 'Wyoming' },
122+
{ header: 'Southeast' },
123+
{ abbr: 'AL', name: 'Alabama' },
124+
{ abbr: 'AR', name: 'Arkansas' },
125+
{ abbr: 'FL', name: 'Florida' },
126+
{ abbr: 'GA', name: 'Georgia' },
127+
{ abbr: 'KY', name: 'Kentucky' },
128+
{ abbr: 'LA', name: 'Louisiana' },
129+
{ abbr: 'MS', name: 'Mississippi' },
130+
{ abbr: 'NC', name: 'North Carolina' },
131+
{ abbr: 'SC', name: 'South Carolina' },
132+
{ abbr: 'TN', name: 'Tennessee' },
133+
{ abbr: 'VA', name: 'Virginia' },
134+
{ abbr: 'WV', name: 'West Virginia' },
135+
{ header: 'Midwest' },
136+
{ abbr: 'IL', name: 'Illinois' },
137+
{ abbr: 'IN', name: 'Indiana' },
138+
{ abbr: 'IA', name: 'Iowa' },
139+
{ abbr: 'KS', name: 'Kansas' },
140+
{ abbr: 'MI', name: 'Michigan' },
141+
{ abbr: 'MN', name: 'Minnesota' },
142+
{ abbr: 'MO', name: 'Missouri' },
143+
{ abbr: 'NE', name: 'Nebraska' },
144+
{ abbr: 'OH', name: 'Ohio' },
145+
{ abbr: 'ND', name: 'North Dakota' },
146+
{ abbr: 'SD', name: 'South Dakota' },
147+
{ abbr: 'WI', name: 'Wisconsin' },
148+
{ header: 'Northeast' },
149+
{ abbr: 'CT', name: 'Connecticut' },
150+
{ abbr: 'DE', name: 'Delaware' },
151+
{ abbr: 'ME', name: 'Maine' },
152+
{ abbr: 'MD', name: 'Maryland' },
153+
{ abbr: 'MA', name: 'Massachusetts' },
154+
{ abbr: 'NH', name: 'New Hampshire' },
155+
{ abbr: 'NJ', name: 'New Jersey' },
156+
{ abbr: 'NY', name: 'New York' },
157+
{ abbr: 'PA', name: 'Pennsylvania' },
158+
{ abbr: 'RI', name: 'Rhode Island' },
159+
{ abbr: 'VT', name: 'Vermont' },
160+
{ header:'Pacific' },
161+
{ abbr: 'AK', name: 'Alaska' },
162+
{ abbr: 'HI', name: 'Hawaii' },
163+
]
164+
}
165+
90166

0 commit comments

Comments
 (0)