diff --git a/README.md b/README.md index 1fa35feef..53c726077 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@
Primitives to build simple, flexible, WAI-ARIA compliant React autocomplete, combobox or select dropdown components.
-> [Read the docs](https://downshift-js.com) -> | +> [Read the docs](https://downshift-js.com) | > [See the intro blog post](https://kentcdodds.com/blog/introducing-downshift-for-react) > | > [Listen to the Episode 79 of the Full Stack Radio podcast](https://simplecast.com/s/f2e65eaf) @@ -109,7 +108,6 @@ and `side-effect free`. - - [Installation](#installation) - [Usage](#usage) - [Basic Props](#basic-props) @@ -972,9 +970,9 @@ described below. work normally). See below for customizing the handlers. - `Escape`: will clear downshift's state. This means that `highlightedIndex` - will be set to the `defaultHighlightedIndex`, the `inputValue` will be set to - empty string, `selectedItem` will be set to `null`, and the `isOpen` state - will be set to the `defaultIsOpen`. + will be set to the `defaultHighlightedIndex` and the `isOpen` state will be + set to the `defaultIsOpen`. If `isOpen` is already false, the `inputValue` + will be set to an empty string and `selectedItem` will be set to `null` ### customizing handlers @@ -1426,6 +1424,7 @@ Thanks goes to these people ([emoji key][emojis]): + This project follows the [all-contributors][all-contributors] specification. diff --git a/src/__tests__/downshift.get-input-props.js b/src/__tests__/downshift.get-input-props.js index 49b45e77b..166d1b6f0 100644 --- a/src/__tests__/downshift.get-input-props.js +++ b/src/__tests__/downshift.get-input-props.js @@ -403,12 +403,14 @@ test('enter on an input with an open menu and a highlightedIndex but with IME co // now it behaves normally expect(childrenSpy).toHaveBeenCalledTimes(1) - expect(childrenSpy).toHaveBeenCalledWith(expect.objectContaining({ - selectedItem: colors[0], - inputValue: colors[0], - isOpen: false, - highlightedIndex: null, - })) + expect(childrenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + selectedItem: colors[0], + inputValue: colors[0], + isOpen: false, + highlightedIndex: null, + }), + ) }) test('enter on an input with an open menu and a highlightedIndex selects that item', () => { @@ -451,9 +453,18 @@ test('escape on an input without a selection should reset downshift and close th ) }) -test('escape on an input with a selection should reset downshift, clear input and close the menu', () => { +test('escape on an input with a selection and open should only reset downshift', () => { + const {escapeOnInput, childrenSpy} = renderDownshift() + escapeOnInput() + expect(childrenSpy).toHaveBeenLastCalledWith( + expect.objectContaining({isOpen: false}), + ) +}) + +test('escape on an input with a selection and closed menu should reset downshift, clear input and close the menu', () => { const {escapeOnInput, childrenSpy} = setupDownshiftWithState() escapeOnInput() + escapeOnInput() expect(childrenSpy).toHaveBeenLastCalledWith( expect.objectContaining({ isOpen: false, diff --git a/src/downshift.js b/src/downshift.js index b7f4942bd..1e46a7836 100644 --- a/src/downshift.js +++ b/src/downshift.js @@ -27,7 +27,7 @@ import { getNextNonDisabledIndex, getState, isControlledProp, - validateControlledUnchanged + validateControlledUnchanged, } from './utils' class Downshift extends Component { @@ -597,8 +597,7 @@ class Downshift extends Component { event.preventDefault() this.reset({ type: stateChangeTypes.keyDownEscape, - selectedItem: null, - inputValue: '', + ...(!this.state.isOpen && {selectedItem: null, inputValue: ''}), }) }, } diff --git a/src/hooks/useCombobox/README.md b/src/hooks/useCombobox/README.md index ff7326268..6c1f3bde2 100644 --- a/src/hooks/useCombobox/README.md +++ b/src/hooks/useCombobox/README.md @@ -865,13 +865,16 @@ described below. - `Home`: Moves `highlightedIndex` to first position. - `Enter`: If there is a highlighted option, it will select it and close the menu. -- `Escape`: It will close the menu if open and will clear selection: the value - in the `input` and the item stored as `selectedItem`. -- `Blur(Tab, Shift+Tab, MouseClick outside)`: It will close the menu select the - highlighted item if any. In the case of `(Shift+)Tab` the focus will move - naturally. - - #### Menu +- `Escape`: It will close the menu if open. If the menu is closed, it will clear + selection: the value in the `input` will become an empty string and the item + stored as `selectedItem` will become `null`. +- `Blur(Tab, Shift+Tab)`: It will close the menu and select the highlighted + item, if any. The focus will move naturally to the next/previous element in + the Tab order. +- `Blur(mouse click outside)`: It will close the menu without selecting any + element, even if there is one highlighted. + +#### Menu - `MouseLeave`: Will clear the value of the `highlightedIndex` if it was set. diff --git a/src/hooks/useCombobox/__tests__/getInputProps.test.js b/src/hooks/useCombobox/__tests__/getInputProps.test.js index 727bb1744..b02178802 100644 --- a/src/hooks/useCombobox/__tests__/getInputProps.test.js +++ b/src/hooks/useCombobox/__tests__/getInputProps.test.js @@ -661,7 +661,7 @@ describe('getInputProps', () => { ) }) - test('escape it has the menu closed, item removed and focused kept on input', () => { + test('escape with menu open has the menu closed and focused kept on input', () => { const {keyDownOnInput, input, getItems} = renderCombobox({ initialIsOpen: true, initialHighlightedIndex: 2, @@ -671,7 +671,21 @@ describe('getInputProps', () => { keyDownOnInput('Escape') expect(getItems()).toHaveLength(0) - expect(input.value).toBe('') + expect(input).toHaveValue(items[0]) + expect(input).toHaveFocus() + }) + + test('escape with closed menu has item removed and focused kept on input', () => { + const {keyDownOnInput, input, getItems} = renderCombobox({ + initialHighlightedIndex: 2, + initialSelectedItem: items[0], + }) + + input.focus() + keyDownOnInput('Escape') + + expect(getItems()).toHaveLength(0) + expect(input).toHaveValue('') expect(input).toHaveFocus() }) @@ -845,13 +859,14 @@ describe('getInputProps', () => { await changeInputValue(inputValue) blurInput() - expect(input.value).toBe(inputValue) + expect(input).toHaveValue(inputValue) }) test('by mouse is not triggered if target is within downshift', () => { const stateReducer = jest.fn().mockImplementation(s => s) const {input, container} = renderCombobox({ isOpen: true, + highlightedIndex: 0, stateReducer, }) document.body.appendChild(container) @@ -866,8 +881,21 @@ describe('getInputProps', () => { expect(stateReducer).toHaveBeenCalledTimes(1) expect(stateReducer).toHaveBeenCalledWith( - expect.objectContaining({}), - expect.objectContaining({type: stateChangeTypes.InputBlur}), + { + highlightedIndex: 0, + inputValue: '', + isOpen: true, + selectedItem: null, + }, + expect.objectContaining({ + type: stateChangeTypes.InputBlur, + changes: { + highlightedIndex: -1, + inputValue: '', + isOpen: false, + selectedItem: null, + }, + }), ) }) @@ -875,6 +903,7 @@ describe('getInputProps', () => { const stateReducer = jest.fn().mockImplementation(s => s) const {container, input} = renderCombobox({ isOpen: true, + highlightedIndex: 0, stateReducer, }) document.body.appendChild(container) @@ -890,8 +919,21 @@ describe('getInputProps', () => { expect(stateReducer).toHaveBeenCalledTimes(1) expect(stateReducer).toHaveBeenCalledWith( - expect.objectContaining({}), - expect.objectContaining({type: stateChangeTypes.InputBlur}), + { + highlightedIndex: 0, + inputValue: '', + isOpen: true, + selectedItem: null, + }, + expect.objectContaining({ + type: stateChangeTypes.InputBlur, + changes: { + highlightedIndex: -1, + inputValue: '', + isOpen: false, + selectedItem: null, + }, + }), ) }) }) @@ -919,7 +961,7 @@ describe('getInputProps', () => { }) getMenuProps({}, {suppressRefError: true}) getComboboxProps({}, {suppressRefError: true}) - + if (firstRender) { firstRender = false getInputProps({}, {suppressRefError: true}) diff --git a/src/hooks/useCombobox/__tests__/props.test.js b/src/hooks/useCombobox/__tests__/props.test.js index af050e406..e5f31cfff 100644 --- a/src/hooks/useCombobox/__tests__/props.test.js +++ b/src/hooks/useCombobox/__tests__/props.test.js @@ -917,6 +917,7 @@ describe('props', () => { expect(onSelectedItemChange).toHaveBeenCalledWith( expect.objectContaining({ selectedItem: items[itemIndex], + type: stateChangeTypes.ItemClick, }), ) }) @@ -981,6 +982,7 @@ describe('props', () => { expect(onHighlightedIndexChange).toHaveBeenCalledWith( expect.objectContaining({ highlightedIndex: 0, + type: stateChangeTypes.InputKeyDownArrowDown, }), ) }) @@ -1058,6 +1060,7 @@ describe('props', () => { expect(onIsOpenChange).toHaveBeenCalledWith( expect.objectContaining({ isOpen: false, + type: stateChangeTypes.InputKeyDownEscape, }), ) }) diff --git a/src/hooks/useCombobox/index.js b/src/hooks/useCombobox/index.js index 266c9f0b7..2054da035 100644 --- a/src/hooks/useCombobox/index.js +++ b/src/hooks/useCombobox/index.js @@ -185,6 +185,7 @@ function useCombobox(userProps = {}) { () => { dispatch({ type: stateChangeTypes.InputBlur, + selectItem: false, }) }, ) @@ -425,6 +426,7 @@ function useCombobox(userProps = {}) { if (!mouseAndTouchTrackersRef.current.isMouseDown) { dispatch({ type: stateChangeTypes.InputBlur, + selectItem: true, }) } } diff --git a/src/hooks/useCombobox/reducer.js b/src/hooks/useCombobox/reducer.js index 67c36009c..355e1f944 100644 --- a/src/hooks/useCombobox/reducer.js +++ b/src/hooks/useCombobox/reducer.js @@ -80,9 +80,11 @@ export default function downshiftUseComboboxReducer(state, action) { case stateChangeTypes.InputKeyDownEscape: changes = { isOpen: false, - selectedItem: null, highlightedIndex: -1, - inputValue: '', + ...(!state.isOpen && { + selectedItem: null, + inputValue: '', + }), } break case stateChangeTypes.InputKeyDownHome: @@ -110,11 +112,12 @@ export default function downshiftUseComboboxReducer(state, action) { case stateChangeTypes.InputBlur: changes = { isOpen: false, - ...(state.highlightedIndex >= 0 && { - selectedItem: props.items[state.highlightedIndex], - inputValue: props.itemToString(props.items[state.highlightedIndex]), - highlightedIndex: -1, - }), + highlightedIndex: -1, + ...(state.highlightedIndex >= 0 && + action.selectItem && { + selectedItem: props.items[state.highlightedIndex], + inputValue: props.itemToString(props.items[state.highlightedIndex]), + }), } break case stateChangeTypes.InputChange: @@ -157,7 +160,7 @@ export default function downshiftUseComboboxReducer(state, action) { case stateChangeTypes.FunctionSelectItem: changes = { selectedItem: action.selectedItem, - inputValue: props.itemToString(action.selectedItem) + inputValue: props.itemToString(action.selectedItem), } break case stateChangeTypes.ControlledPropUpdatedSelectedItem: diff --git a/src/hooks/useSelect/__tests__/props.test.js b/src/hooks/useSelect/__tests__/props.test.js index e053b0838..758a06f04 100644 --- a/src/hooks/useSelect/__tests__/props.test.js +++ b/src/hooks/useSelect/__tests__/props.test.js @@ -930,6 +930,7 @@ describe('props', () => { expect(onSelectedItemChange).toHaveBeenCalledWith( expect.objectContaining({ selectedItem: items[index], + type: stateChangeTypes.ItemClick, }), ) }) @@ -994,6 +995,7 @@ describe('props', () => { expect(onHighlightedIndexChange).toHaveBeenCalledWith( expect.objectContaining({ highlightedIndex: 0, + type: stateChangeTypes.ToggleButtonKeyDownArrowDown, }), ) }) @@ -1066,6 +1068,7 @@ describe('props', () => { expect(onIsOpenChange).toHaveBeenCalledWith( expect.objectContaining({ isOpen: false, + type: stateChangeTypes.MenuKeyDownEscape, }), ) }) diff --git a/src/hooks/utils.js b/src/hooks/utils.js index 049ad7916..9551a8f6e 100644 --- a/src/hooks/utils.js +++ b/src/hooks/utils.js @@ -22,7 +22,7 @@ function callOnChangeProps(action, state, newState) { const changes = {} Object.keys(state).forEach(key => { - invokeOnChangeHandler(key, props, state, newState) + invokeOnChangeHandler(key, action, state, newState) if (newState[key] !== state[key]) { changes[key] = newState[key] @@ -34,14 +34,15 @@ function callOnChangeProps(action, state, newState) { } } -function invokeOnChangeHandler(key, props, state, newState) { +function invokeOnChangeHandler(key, action, state, newState) { + const {props, type} = action const handler = `on${capitalizeString(key)}Change` if ( props[handler] && newState[key] !== undefined && newState[key] !== state[key] ) { - props[handler](newState) + props[handler]({type, ...newState}) } } diff --git a/typings/index.d.ts b/typings/index.d.ts index d32fad365..ec803b634 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -88,11 +88,11 @@ export interface A11yStatusMessageOptions