Skip to content

Commit 108c1ba

Browse files
authored
fix(defaultProps): only select if there are items and one is highlighted (#1467)
1 parent 7471fba commit 108c1ba

File tree

7 files changed

+425
-46
lines changed

7 files changed

+425
-46
lines changed

src/hooks/useCombobox/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -950,7 +950,7 @@ described below.
950950
- `Alt+ArrowUp`: If the menu is open, it will close it and will select the item
951951
that was highlighted.
952952
- `CharacterKey`: Will change the `inputValue` according to the value visible in
953-
the `<input>`. `Backspace` or `Space` triggere the same event.
953+
the `<input>`. `Backspace` or `Space` trigger the same event.
954954
- `End`: If the menu is open, it will highlight the last item in the list.
955955
- `Home`: If the menu is open, it will highlight the first item in the list.
956956
- `PageUp`: If the menu is open, it will move the highlight the item 10

src/hooks/useCombobox/__tests__/getInputProps.test.js

+182-1
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,40 @@ describe('getInputProps', () => {
392392
expect(getInput()).toHaveValue('california')
393393
})
394394

395+
test('on change should remove the highlightedIndex', async () => {
396+
renderCombobox({initialHighlightedIndex: 2})
397+
398+
await changeInputValue('california')
399+
400+
expect(getInput()).toHaveAttribute('aria-activedescendant', '')
401+
})
402+
403+
test('on change should reset to defaultHighlightedIndex', async () => {
404+
const defaultHighlightedIndex = 2
405+
renderCombobox({defaultHighlightedIndex})
406+
407+
await changeInputValue('a')
408+
409+
expect(getInput()).toHaveAttribute(
410+
'aria-activedescendant',
411+
defaultIds.getItemId(defaultHighlightedIndex),
412+
)
413+
414+
await keyDownOnInput('{ArrowDown}')
415+
416+
expect(getInput()).toHaveAttribute(
417+
'aria-activedescendant',
418+
defaultIds.getItemId(defaultHighlightedIndex + 1),
419+
)
420+
421+
await changeInputValue('a')
422+
423+
expect(getInput()).toHaveAttribute(
424+
'aria-activedescendant',
425+
defaultIds.getItemId(defaultHighlightedIndex),
426+
)
427+
})
428+
395429
describe('on key down', () => {
396430
describe('arrow up', () => {
397431
test('it prevents the default event behavior', () => {
@@ -550,6 +584,61 @@ describe('getInputProps', () => {
550584
)
551585
})
552586

587+
test('with Alt selects highlighted item and resets to user defaults', async () => {
588+
const defaultHighlightedIndex = 2
589+
renderCombobox({
590+
defaultHighlightedIndex,
591+
defaultIsOpen: true,
592+
})
593+
const input = getInput()
594+
595+
await keyDownOnInput('{Alt>}{ArrowUp}{/Alt}')
596+
597+
expect(input).toHaveValue(items[defaultHighlightedIndex])
598+
expect(getItems()).toHaveLength(items.length)
599+
expect(input).toHaveAttribute(
600+
'aria-activedescendant',
601+
defaultIds.getItemId(defaultHighlightedIndex),
602+
)
603+
})
604+
605+
test('with Alt closes the menu without resetting to user defaults if no item is highlighted', async () => {
606+
const defaultHighlightedIndex = 2
607+
const initialSelectedItem = items[0]
608+
renderCombobox({
609+
defaultHighlightedIndex,
610+
defaultIsOpen: true,
611+
initialSelectedItem,
612+
})
613+
const input = getInput()
614+
615+
await mouseMoveItemAtIndex(defaultHighlightedIndex)
616+
await mouseLeaveItemAtIndex(defaultHighlightedIndex)
617+
await keyDownOnInput('{Alt>}{ArrowUp}{/Alt}')
618+
619+
expect(input).toHaveValue(initialSelectedItem)
620+
expect(getItems()).toHaveLength(0)
621+
expect(input).toHaveAttribute('aria-activedescendant', '')
622+
})
623+
624+
test('with Alt closes the menu without resetting to user defaults if lhe list is empty', async () => {
625+
const defaultHighlightedIndex = 2
626+
const initialSelectedItem = items[0]
627+
renderCombobox({
628+
defaultHighlightedIndex,
629+
defaultIsOpen: true,
630+
initialSelectedItem,
631+
items: [],
632+
})
633+
const input = getInput()
634+
635+
await keyDownOnInput('{Alt>}{ArrowUp}{/Alt}')
636+
637+
expect(input).toHaveValue(initialSelectedItem)
638+
expect(getItems()).toHaveLength(0)
639+
expect(input).toHaveAttribute('aria-activedescendant', '')
640+
})
641+
553642
test('will continue from 0 to last item', async () => {
554643
renderCombobox({
555644
isOpen: true,
@@ -848,7 +937,7 @@ describe('getInputProps', () => {
848937

849938
await keyDownOnInput('{Escape}') // focus input and close the menu.
850939
renderSpy.mockClear()
851-
940+
852941
await keyDownOnInput('{Escape}')
853942

854943
expect(renderSpy).toHaveBeenCalledTimes(0) // no re-render
@@ -917,6 +1006,43 @@ describe('getInputProps', () => {
9171006
)
9181007
})
9191008

1009+
test('enter closes the menu without resetting to user defaults if no item is highlighted', async () => {
1010+
const defaultHighlightedIndex = 2
1011+
const initialSelectedItem = items[0]
1012+
renderCombobox({
1013+
defaultHighlightedIndex,
1014+
defaultIsOpen: true,
1015+
initialSelectedItem,
1016+
})
1017+
const input = getInput()
1018+
1019+
await mouseMoveItemAtIndex(defaultHighlightedIndex)
1020+
await mouseLeaveItemAtIndex(defaultHighlightedIndex)
1021+
await keyDownOnInput('{Enter}')
1022+
1023+
expect(input).toHaveValue(initialSelectedItem)
1024+
expect(getItems()).toHaveLength(0)
1025+
expect(input).toHaveAttribute('aria-activedescendant', '')
1026+
})
1027+
1028+
test('enter closes the menu without resetting to user defaults if the list is empty', async () => {
1029+
const defaultHighlightedIndex = 2
1030+
const initialSelectedItem = items[0]
1031+
renderCombobox({
1032+
defaultHighlightedIndex,
1033+
defaultIsOpen: true,
1034+
initialSelectedItem,
1035+
items: [],
1036+
})
1037+
const input = getInput()
1038+
1039+
await keyDownOnInput('{Enter}')
1040+
1041+
expect(input).toHaveValue(initialSelectedItem)
1042+
expect(getItems()).toHaveLength(0)
1043+
expect(input).toHaveAttribute('aria-activedescendant', '')
1044+
})
1045+
9201046
test('enter while IME composing will not select highlighted item', async () => {
9211047
const initialHighlightedIndex = 2
9221048
renderCombobox({
@@ -1016,6 +1142,61 @@ describe('getInputProps', () => {
10161142
expect(getInput()).toHaveValue(items[initialHighlightedIndex])
10171143
})
10181144

1145+
test('tab closes the menu if there is no highlighted item', async () => {
1146+
const defaultHighlightedIndex = 2
1147+
const initialSelectedItem = items[0]
1148+
1149+
renderCombobox(
1150+
{
1151+
defaultHighlightedIndex,
1152+
defaultIsOpen: true,
1153+
initialSelectedItem,
1154+
},
1155+
ui => {
1156+
return (
1157+
<>
1158+
{ui}
1159+
<div tabIndex={0}>Second element</div>
1160+
</>
1161+
)
1162+
},
1163+
)
1164+
1165+
await mouseMoveItemAtIndex(defaultHighlightedIndex)
1166+
await mouseLeaveItemAtIndex(defaultHighlightedIndex)
1167+
await tab()
1168+
1169+
expect(getItems()).toHaveLength(0)
1170+
expect(getInput()).toHaveValue(initialSelectedItem)
1171+
})
1172+
1173+
test('tab closes the menu if there is no items', async () => {
1174+
const defaultHighlightedIndex = 2
1175+
const initialSelectedItem = items[0]
1176+
1177+
renderCombobox(
1178+
{
1179+
defaultHighlightedIndex,
1180+
defaultIsOpen: true,
1181+
initialSelectedItem,
1182+
items: [],
1183+
},
1184+
ui => {
1185+
return (
1186+
<>
1187+
{ui}
1188+
<div tabIndex={0}>Second element</div>
1189+
</>
1190+
)
1191+
},
1192+
)
1193+
1194+
await tab()
1195+
1196+
expect(getItems()).toHaveLength(0)
1197+
expect(getInput()).toHaveValue(initialSelectedItem)
1198+
})
1199+
10191200
test('shift+tab it closes the menu', async () => {
10201201
const initialHighlightedIndex = 2
10211202
renderCombobox(

src/hooks/useCombobox/reducer.js

+9-19
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import {getHighlightedIndexOnOpen, getDefaultValue} from '../utils'
1+
import {
2+
getHighlightedIndexOnOpen,
3+
getDefaultValue,
4+
getChangesOnSelection,
5+
} from '../utils'
26
import {getNextWrappingIndex, getNextNonDisabledIndex} from '../../utils'
37
import commonReducer from '../reducer'
48
import * as stateChangeTypes from './stateChangeTypes'
@@ -46,16 +50,7 @@ export default function downshiftUseComboboxReducer(state, action) {
4650
case stateChangeTypes.InputKeyDownArrowUp:
4751
if (state.isOpen) {
4852
if (altKey) {
49-
changes = {
50-
isOpen: getDefaultValue(props, 'isOpen'),
51-
highlightedIndex: getDefaultValue(props, 'highlightedIndex'),
52-
...(state.highlightedIndex >= 0 && {
53-
selectedItem: props.items[state.highlightedIndex],
54-
inputValue: props.itemToString(
55-
props.items[state.highlightedIndex],
56-
),
57-
}),
58-
}
53+
changes = getChangesOnSelection(props, state.highlightedIndex)
5954
} else {
6055
changes = {
6156
highlightedIndex: getNextWrappingIndex(
@@ -80,14 +75,8 @@ export default function downshiftUseComboboxReducer(state, action) {
8075
}
8176
break
8277
case stateChangeTypes.InputKeyDownEnter:
83-
changes = {
84-
isOpen: getDefaultValue(props, 'isOpen'),
85-
highlightedIndex: getDefaultValue(props, 'highlightedIndex'),
86-
...(state.highlightedIndex >= 0 && {
87-
selectedItem: props.items[state.highlightedIndex],
88-
inputValue: props.itemToString(props.items[state.highlightedIndex]),
89-
}),
90-
}
78+
changes = getChangesOnSelection(props, state.highlightedIndex)
79+
9180
break
9281
case stateChangeTypes.InputKeyDownEscape:
9382
changes = {
@@ -148,6 +137,7 @@ export default function downshiftUseComboboxReducer(state, action) {
148137
isOpen: false,
149138
highlightedIndex: -1,
150139
...(state.highlightedIndex >= 0 &&
140+
props.items?.length &&
151141
action.selectItem && {
152142
selectedItem: props.items[state.highlightedIndex],
153143
inputValue: props.itemToString(props.items[state.highlightedIndex]),

src/hooks/useCombobox/utils.js

+4-6
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '../utils'
1414
import {ControlledPropUpdatedSelectedItem} from './stateChangeTypes'
1515

16-
function getInitialState(props) {
16+
export function getInitialState(props) {
1717
const initialState = getInitialStateCommon(props)
1818
const {selectedItem} = initialState
1919
let {inputValue} = initialState
@@ -86,7 +86,7 @@ const propTypes = {
8686
* @param {Object} props The hook props.
8787
* @returns {Array} An array with the state and an action dispatcher.
8888
*/
89-
function useControlledReducer(reducer, initialState, props) {
89+
export function useControlledReducer(reducer, initialState, props) {
9090
const previousSelectedItemRef = useRef()
9191
const [state, dispatch] = useEnhancedReducer(reducer, initialState, props)
9292

@@ -111,17 +111,15 @@ function useControlledReducer(reducer, initialState, props) {
111111
}
112112

113113
// eslint-disable-next-line import/no-mutable-exports
114-
let validatePropTypes = noop
114+
export let validatePropTypes = noop
115115
/* istanbul ignore next */
116116
if (process.env.NODE_ENV !== 'production') {
117117
validatePropTypes = (options, caller) => {
118118
PropTypes.checkPropTypes(propTypes, options, 'prop', caller.name)
119119
}
120120
}
121121

122-
const defaultProps = {
122+
export const defaultProps = {
123123
...defaultPropsCommon,
124124
getA11yStatusMessage,
125125
}
126-
127-
export {validatePropTypes, useControlledReducer, getInitialState, defaultProps}

0 commit comments

Comments
 (0)