Skip to content

Commit dc9da74

Browse files
authored
fix(onMouseMove): only call handler on non-touch devices (#1571)
1 parent a5d6310 commit dc9da74

File tree

10 files changed

+172
-92
lines changed

10 files changed

+172
-92
lines changed

src/hooks/__tests__/utils.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('utils', () => {
149149
)
150150

151151
expect(result.current).toEqual({
152-
current: {isMouseDown: false, isTouchMove: false},
152+
current: {isMouseDown: false, isTouchMove: false, isTouchEnd: false},
153153
})
154154
})
155155
})

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

+31
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,37 @@ describe('getItemProps', () => {
280280
await mouseMoveItemAtIndex(disabledIndex)
281281
expect(input).toHaveAttribute('aria-activedescendant', '')
282282
})
283+
284+
// Test that we don't call the mouse move handler on mobile.
285+
test('does not set highlight the item if a touch event ocurred', async () => {
286+
let touchEndHandler
287+
const index = 1
288+
289+
renderCombobox({
290+
isOpen: true,
291+
environment: {
292+
addEventListener: (name, handler) => {
293+
// eslint-disable-next-line jest/no-conditional-in-test
294+
if (name === 'touchend') {
295+
touchEndHandler = handler
296+
}
297+
},
298+
removeEventListener: () => {},
299+
document: {
300+
createElement: () => {},
301+
getElementById: () => {},
302+
activeElement: () => {},
303+
body: {},
304+
},
305+
Node: () => {},
306+
},
307+
})
308+
309+
act(() => touchEndHandler({target: null}))
310+
await mouseMoveItemAtIndex(index)
311+
312+
expect(getInput()).toHaveAttribute('aria-activedescendant', '')
313+
})
283314
})
284315

285316
describe('on click', () => {

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

+18-16
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe('props', () => {
9393

9494
describe('selectedItemChanged', () => {
9595
test('props update of selectedItem will update inputValue state with default selectedItemChanged referential equality check', () => {
96+
const initialSelectedItem = {id: 3, value: 'init'}
9697
const selectedItem = {id: 1, value: 'wow'}
9798
const newSelectedItem = {id: 1, value: 'not wow'}
9899
function itemToString(item) {
@@ -103,6 +104,14 @@ describe('props', () => {
103104
.mockImplementation((_state, {changes}) => changes)
104105

105106
const {rerender} = renderCombobox({
107+
stateReducer,
108+
itemToString,
109+
selectedItem: initialSelectedItem,
110+
})
111+
112+
expect(stateReducer).not.toHaveBeenCalled() // won't get called on first render
113+
114+
rerender({
106115
stateReducer,
107116
itemToString,
108117
selectedItem,
@@ -111,7 +120,7 @@ describe('props', () => {
111120
expect(stateReducer).toHaveBeenCalledTimes(1)
112121
expect(stateReducer).toHaveBeenCalledWith(
113122
{
114-
inputValue: itemToString(selectedItem),
123+
inputValue: itemToString(initialSelectedItem),
115124
selectedItem,
116125
highlightedIndex: -1,
117126
isOpen: false,
@@ -155,46 +164,39 @@ describe('props', () => {
155164
expect(getInput()).toHaveValue(itemToString(newSelectedItem))
156165
})
157166

158-
test('props update of selectedItem will not update inputValue state', () => {
167+
test('props update of selectedItem will not update inputValue state if selectedItemChanged returns false', () => {
168+
const initialSelectedItem = {id: 1, value: 'hmm'}
159169
const selectedItem = {id: 1, value: 'wow'}
160-
const newSelectedItem = {id: 1, value: 'not wow'}
161170
function itemToString(item) {
162171
return item.value
163172
}
164173
const selectedItemChanged = jest
165174
.fn()
166-
.mockReturnValue((prev, next) => prev.id !== next.id)
175+
.mockImplementation((prev, next) => prev.id !== next.id)
167176
const stateReducer = jest
168177
.fn()
169178
.mockImplementation((_state, {changes}) => changes)
170179

171180
const {rerender} = renderCombobox({
172181
selectedItemChanged,
173182
stateReducer,
174-
selectedItem,
183+
selectedItem: initialSelectedItem,
175184
itemToString,
176185
})
177186

178-
expect(getInput()).toHaveValue(itemToString(selectedItem))
179-
expect(selectedItemChanged).toHaveBeenCalledTimes(1)
180-
expect(selectedItemChanged).toHaveBeenCalledWith(undefined, selectedItem)
181-
182-
stateReducer.mockReset()
183-
selectedItemChanged.mockReset()
184187
rerender({
188+
selectedItemChanged,
185189
stateReducer,
190+
selectedItem,
186191
itemToString,
187-
selectedItem: newSelectedItem,
188-
selectedItemChanged,
189192
})
190193

194+
expect(getInput()).toHaveValue(itemToString(initialSelectedItem))
191195
expect(selectedItemChanged).toHaveBeenCalledTimes(1)
192196
expect(selectedItemChanged).toHaveBeenCalledWith(
197+
initialSelectedItem,
193198
selectedItem,
194-
newSelectedItem,
195199
)
196-
expect(stateReducer).not.toHaveBeenCalled()
197-
expect(getInput()).toHaveValue(itemToString(selectedItem))
198200
})
199201
})
200202

src/hooks/useCombobox/index.js

+15-20
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
useElementIds,
1212
getItemAndIndex,
1313
getInitialValue,
14-
isDropdownsStateEqual
14+
isDropdownsStateEqual,
15+
useIsInitialMount,
1516
} from '../utils'
1617
import {
1718
getInitialState,
@@ -44,7 +45,7 @@ function useCombobox(userProps = {}) {
4445
downshiftUseComboboxReducer,
4546
props,
4647
getInitialState,
47-
isDropdownsStateEqual
48+
isDropdownsStateEqual,
4849
)
4950
const {isOpen, highlightedIndex, selectedItem, inputValue} = state
5051

@@ -53,7 +54,8 @@ function useCombobox(userProps = {}) {
5354
const itemRefs = useRef({})
5455
const inputRef = useRef(null)
5556
const toggleButtonRef = useRef(null)
56-
const isInitialMountRef = useRef(true)
57+
const isInitialMount = useIsInitialMount()
58+
5759
// prevent id re-generation between renders.
5860
const elementIds = useElementIds(props)
5961
// used to keep track of how many items we had on previous cycle.
@@ -72,7 +74,6 @@ function useCombobox(userProps = {}) {
7274
getA11yStatusMessage,
7375
[isOpen, highlightedIndex, inputValue, items],
7476
{
75-
isInitialMount: isInitialMountRef.current,
7677
previousResultCount: previousResultCountRef.current,
7778
items,
7879
environment,
@@ -82,7 +83,6 @@ function useCombobox(userProps = {}) {
8283
)
8384
// Sets a11y status message on changes in selectedItem.
8485
useA11yMessageSetter(getA11ySelectionMessage, [selectedItem], {
85-
isInitialMount: isInitialMountRef.current,
8686
previousResultCount: previousResultCountRef.current,
8787
items,
8888
environment,
@@ -99,7 +99,6 @@ function useCombobox(userProps = {}) {
9999
getItemNodeFromIndex,
100100
})
101101
useControlPropsValidator({
102-
isInitialMount: isInitialMountRef.current,
103102
props,
104103
state,
105104
})
@@ -113,11 +112,9 @@ function useCombobox(userProps = {}) {
113112
// eslint-disable-next-line react-hooks/exhaustive-deps
114113
}, [])
115114
useEffect(() => {
116-
if (isInitialMountRef.current) {
117-
return
115+
if (!isInitialMount) {
116+
previousResultCountRef.current = items.length
118117
}
119-
120-
previousResultCountRef.current = items.length
121118
})
122119
// Add mouse/touch events to document.
123120
const mouseAndTouchTrackersRef = useMouseAndTouchTracker(
@@ -135,14 +132,6 @@ function useCombobox(userProps = {}) {
135132
'getInputProps',
136133
'getMenuProps',
137134
)
138-
// Make initial ref false.
139-
useEffect(() => {
140-
isInitialMountRef.current = false
141-
142-
return () => {
143-
isInitialMountRef.current = true
144-
}
145-
}, [])
146135
// Reset itemRefs on close.
147136
useEffect(() => {
148137
if (!isOpen) {
@@ -319,10 +308,15 @@ function useCombobox(userProps = {}) {
319308
: onClick
320309

321310
const itemHandleMouseMove = () => {
322-
if (index === latestState.highlightedIndex) {
311+
if (
312+
mouseAndTouchTrackersRef.current.isTouchEnd ||
313+
index === latestState.highlightedIndex
314+
) {
323315
return
324316
}
317+
325318
shouldScrollRef.current = false
319+
326320
dispatch({
327321
type: stateChangeTypes.ItemMouseMove,
328322
index,
@@ -358,7 +352,8 @@ function useCombobox(userProps = {}) {
358352
...rest,
359353
}
360354
},
361-
[dispatch, latest, shouldScrollRef, elementIds],
355+
356+
[dispatch, elementIds, latest, mouseAndTouchTrackersRef, shouldScrollRef],
362357
)
363358

364359
const getToggleButtonProps = useCallback(

src/hooks/useCombobox/utils.js

+10-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
defaultProps as defaultPropsCommon,
1212
getInitialState as getInitialStateCommon,
1313
useEnhancedReducer,
14+
useIsInitialMount,
1415
} from '../utils'
1516
import {ControlledPropUpdatedSelectedItem} from './stateChangeTypes'
1617

@@ -61,22 +62,28 @@ const propTypes = {
6162
* @param {Function} isStateEqual Function that checks if a previous state is equal to the next.
6263
* @returns {Array} An array with the state and an action dispatcher.
6364
*/
64-
export function useControlledReducer(reducer, props, createInitialState, isStateEqual) {
65+
export function useControlledReducer(
66+
reducer,
67+
props,
68+
createInitialState,
69+
isStateEqual,
70+
) {
6571
const previousSelectedItemRef = useRef()
6672
const [state, dispatch] = useEnhancedReducer(
6773
reducer,
6874
props,
6975
createInitialState,
70-
isStateEqual
76+
isStateEqual,
7177
)
78+
const isInitialMount = useIsInitialMount()
7279

73-
// ToDo: if needed, make same approach as selectedItemChanged from Downshift.
7480
useEffect(() => {
7581
if (!isControlledProp(props, 'selectedItem')) {
7682
return
7783
}
7884

7985
if (
86+
!isInitialMount && // on first mount we already have the proper inputValue for a initial selected item.
8087
props.selectedItemChanged(
8188
previousSelectedItemRef.current,
8289
props.selectedItem,

src/hooks/useMultipleSelection/index.js

+7-15
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import {
88
useLatestRef,
99
useControlPropsValidator,
1010
getItemAndIndex,
11+
useIsInitialMount,
1112
} from '../utils'
1213
import {
1314
getInitialState,
1415
defaultProps,
1516
isKeyDownOperationPermitted,
1617
validatePropTypes,
17-
isStateEqual
18+
isStateEqual,
1819
} from './utils'
1920
import downshiftMultipleSelectionReducer from './reducer'
2021
import * as stateChangeTypes from './stateChangeTypes'
@@ -41,12 +42,12 @@ function useMultipleSelection(userProps = {}) {
4142
downshiftMultipleSelectionReducer,
4243
props,
4344
getInitialState,
44-
isStateEqual
45+
isStateEqual,
4546
)
4647
const {activeIndex, selectedItems} = state
4748

4849
// Refs.
49-
const isInitialMountRef = useRef(true)
50+
const isInitialMount = useIsInitialMount()
5051
const dropdownRef = useRef(null)
5152
const previousSelectedItemsRef = useRef(selectedItems)
5253
const selectedItemRefs = useRef()
@@ -56,7 +57,7 @@ function useMultipleSelection(userProps = {}) {
5657
// Effects.
5758
/* Sets a11y status message on changes in selectedItem. */
5859
useEffect(() => {
59-
if (isInitialMountRef.current || isReactNative || !environment?.document) {
60+
if (isInitialMount || isReactNative || !environment?.document) {
6061
return
6162
}
6263

@@ -83,7 +84,7 @@ function useMultipleSelection(userProps = {}) {
8384
}, [selectedItems.length])
8485
// Sets focus on active item.
8586
useEffect(() => {
86-
if (isInitialMountRef.current) {
87+
if (isInitialMount) {
8788
return
8889
}
8990

@@ -92,21 +93,12 @@ function useMultipleSelection(userProps = {}) {
9293
} else if (selectedItemRefs.current[activeIndex]) {
9394
selectedItemRefs.current[activeIndex].focus()
9495
}
95-
}, [activeIndex])
96+
}, [activeIndex, isInitialMount])
9697
useControlPropsValidator({
97-
isInitialMount: isInitialMountRef.current,
9898
props,
9999
state,
100100
})
101101
const setGetterPropCallInfo = useGetterPropsCalledChecker('getDropdownProps')
102-
// Make initial ref false.
103-
useEffect(() => {
104-
isInitialMountRef.current = false
105-
106-
return () => {
107-
isInitialMountRef.current = true
108-
}
109-
}, [])
110102

111103
// Event handler functions.
112104
const selectedItemKeyDownHandlers = useMemo(

0 commit comments

Comments
 (0)