Skip to content

Commit 9b3199a

Browse files
authored
feat(useCombobox): selectedItemChanged support (#1480)
1 parent 108c1ba commit 9b3199a

File tree

4 files changed

+136
-12
lines changed

4 files changed

+136
-12
lines changed

src/hooks/useCombobox/README.md

+8
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,14 @@ reset or when an item is selected.
383383
Pass a string that sets the content of the input when downshift is reset or when
384384
an item is selected.
385385

386+
### selectedItemChanged
387+
388+
> `function(prevItem: any, item: any)` | defaults to:
389+
> `(prevItem, item) => (prevItem !== item)`
390+
391+
Used to determine if the new `selectedItem` has changed compared to the previous
392+
`selectedItem` and properly update Downshift's internal state.
393+
386394
### getA11yStatusMessage
387395

388396
> `function({/* see below */})` | default messages provided in English

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

+105
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,111 @@ describe('props', () => {
9191
})
9292
})
9393

94+
describe('selectedItemChanged', () => {
95+
test('props update of selectedItem will update inputValue state with default selectedItemChanged referential equality check', () => {
96+
const selectedItem = {id: 1, value: 'wow'}
97+
const newSelectedItem = {id: 1, value: 'not wow'}
98+
function itemToString(item) {
99+
return item.value
100+
}
101+
const stateReducer = jest
102+
.fn()
103+
.mockImplementation((_state, {changes}) => changes)
104+
105+
const {rerender} = renderCombobox({
106+
stateReducer,
107+
itemToString,
108+
selectedItem,
109+
})
110+
111+
expect(stateReducer).toHaveBeenCalledTimes(1)
112+
expect(stateReducer).toHaveBeenCalledWith(
113+
{
114+
inputValue: itemToString(selectedItem),
115+
selectedItem,
116+
highlightedIndex: -1,
117+
isOpen: false,
118+
},
119+
expect.objectContaining({
120+
type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem,
121+
changes: {
122+
inputValue: itemToString(selectedItem),
123+
selectedItem,
124+
highlightedIndex: -1,
125+
isOpen: false,
126+
},
127+
}),
128+
)
129+
130+
stateReducer.mockClear()
131+
rerender({
132+
stateReducer,
133+
selectedItem: newSelectedItem,
134+
itemToString
135+
})
136+
137+
expect(stateReducer).toHaveBeenCalledTimes(1)
138+
expect(stateReducer).toHaveBeenCalledWith(
139+
{
140+
inputValue: itemToString(selectedItem),
141+
selectedItem: newSelectedItem,
142+
highlightedIndex: -1,
143+
isOpen: false,
144+
},
145+
expect.objectContaining({
146+
changes: {
147+
inputValue: itemToString(newSelectedItem),
148+
selectedItem: newSelectedItem,
149+
highlightedIndex: -1,
150+
isOpen: false,
151+
},
152+
type: useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem,
153+
}),
154+
)
155+
expect(getInput()).toHaveValue(itemToString(newSelectedItem))
156+
})
157+
158+
test('props update of selectedItem will not update inputValue state', () => {
159+
const selectedItem = {id: 1, value: 'wow'}
160+
const newSelectedItem = {id: 1, value: 'not wow'}
161+
function itemToString(item) {
162+
return item.value
163+
}
164+
const selectedItemChanged = jest.fn().mockReturnValue((prev, next) => prev.id !== next.id)
165+
const stateReducer = jest
166+
.fn()
167+
.mockImplementation((_state, {changes}) => changes)
168+
169+
const {rerender} = renderCombobox({
170+
selectedItemChanged,
171+
stateReducer,
172+
selectedItem,
173+
itemToString
174+
})
175+
176+
expect(getInput()).toHaveValue(itemToString(selectedItem))
177+
expect(selectedItemChanged).toHaveBeenCalledTimes(1)
178+
expect(selectedItemChanged).toHaveBeenCalledWith(undefined, selectedItem)
179+
180+
stateReducer.mockReset()
181+
selectedItemChanged.mockReset()
182+
rerender({
183+
stateReducer,
184+
itemToString,
185+
selectedItem: newSelectedItem,
186+
selectedItemChanged,
187+
})
188+
189+
expect(selectedItemChanged).toHaveBeenCalledTimes(1)
190+
expect(selectedItemChanged).toHaveBeenCalledWith(
191+
selectedItem,
192+
newSelectedItem,
193+
)
194+
expect(stateReducer).not.toHaveBeenCalled()
195+
expect(getInput()).toHaveValue(itemToString(selectedItem))
196+
})
197+
})
198+
94199
describe('getA11ySelectionMessage', () => {
95200
beforeEach(jest.useFakeTimers)
96201
afterEach(() => {

src/hooks/useCombobox/utils.js

+22-12
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function getInitialState(props) {
3737
const propTypes = {
3838
items: PropTypes.array.isRequired,
3939
itemToString: PropTypes.func,
40+
selectedItemChanged: PropTypes.func,
4041
getA11yStatusMessage: PropTypes.func,
4142
getA11ySelectionMessage: PropTypes.func,
4243
highlightedIndex: PropTypes.number,
@@ -92,20 +93,28 @@ export function useControlledReducer(reducer, initialState, props) {
9293

9394
// ToDo: if needed, make same approach as selectedItemChanged from Downshift.
9495
useEffect(() => {
95-
if (isControlledProp(props, 'selectedItem')) {
96-
if (previousSelectedItemRef.current !== props.selectedItem) {
97-
dispatch({
98-
type: ControlledPropUpdatedSelectedItem,
99-
inputValue: props.itemToString(props.selectedItem),
100-
})
101-
}
96+
if (!isControlledProp(props, 'selectedItem')) {
97+
return
98+
}
10299

103-
previousSelectedItemRef.current =
104-
state.selectedItem === previousSelectedItemRef.current
105-
? props.selectedItem
106-
: state.selectedItem
100+
if (
101+
props.selectedItemChanged(
102+
previousSelectedItemRef.current,
103+
props.selectedItem,
104+
)
105+
) {
106+
dispatch({
107+
type: ControlledPropUpdatedSelectedItem,
108+
inputValue: props.itemToString(props.selectedItem),
109+
})
107110
}
108-
}, [props.selectedItem, state.selectedItem])
111+
112+
previousSelectedItemRef.current =
113+
state.selectedItem === previousSelectedItemRef.current
114+
? props.selectedItem
115+
: state.selectedItem
116+
// eslint-disable-next-line react-hooks/exhaustive-deps
117+
}, [state.selectedItem, props.selectedItem])
109118

110119
return [getState(state, props), dispatch]
111120
}
@@ -121,5 +130,6 @@ if (process.env.NODE_ENV !== 'production') {
121130

122131
export const defaultProps = {
123132
...defaultPropsCommon,
133+
selectedItemChanged: (prevItem, item) => prevItem !== item,
124134
getA11yStatusMessage,
125135
}

typings/index.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ export enum UseComboboxStateChangeTypes {
426426
export interface UseComboboxProps<Item> {
427427
items: Item[]
428428
itemToString?: (item: Item | null) => string
429+
selectedItemChanged?: (prevItem: Item, item: Item) => boolean
429430
getA11yStatusMessage?: (options: A11yStatusMessageOptions<Item>) => string
430431
getA11ySelectionMessage?: (options: A11yStatusMessageOptions<Item>) => string
431432
highlightedIndex?: number

0 commit comments

Comments
 (0)