Skip to content

Commit dc6d787

Browse files
authored
Implement useMove, use it in Slider & SplitView (#1106)
1 parent eeb0b57 commit dc6d787

File tree

32 files changed

+2546
-877
lines changed

32 files changed

+2546
-877
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
"@storybook/react": "^5.2.1",
8080
"@testing-library/dom": "^7.23.0",
8181
"@testing-library/jest-dom": "^5.11.4",
82-
"@testing-library/react": "^10.4.9",
82+
"@testing-library/react": "^11.0.4",
8383
"@testing-library/react-hooks": "^3.4.1",
8484
"@testing-library/user-event": "^12.1.3",
8585
"@types/react": "^16.9.23",

packages/@adobe/spectrum-css-temp/components/slider/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ governing permissions and limitations under the License.
5555
display: block;
5656

5757
user-select: none;
58+
touch-action: none;
5859
}
5960

6061
.spectrum-Slider-controls {

packages/@adobe/spectrum-css-temp/components/splitview/index.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ governing permissions and limitations under the License.
4343
width: var(--spectrum-rail-gripper-width);
4444
height: var(--spectrum-rail-gripper-height);
4545
border-width: var(--spectrum-rail-gripper-border-width-vertical) var(--spectrum-rail-gripper-border-width-horizontal);
46+
47+
touch-action: none;
4648
}
4749

4850
.spectrum-SplitView-splitter {
@@ -52,6 +54,8 @@ governing permissions and limitations under the License.
5254
/* Prevent text selection while dragging */
5355
user-select: none;
5456

57+
touch-action: none;
58+
5559
width: var(--spectrum-rail-handle-width);
5660
height: 100%;
5761
z-index: 1;

packages/@react-aria/interactions/src/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
export * from './usePress';
14-
export * from './useInteractOutside';
1513
export * from './Pressable';
1614
export * from './PressResponder';
17-
export * from './useKeyboard';
1815
export * from './useFocus';
19-
export * from './useFocusWithin';
2016
export * from './useFocusVisible';
17+
export * from './useFocusWithin';
2118
export * from './useHover';
19+
export * from './useInteractOutside';
20+
export * from './useKeyboard';
21+
export * from './useMove';
22+
export * from './usePress';
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {disableTextSelection, restoreTextSelection} from './textSelection';
14+
import {HTMLAttributes, useMemo, useRef} from 'react';
15+
import {useGlobalListeners} from '@react-aria/utils';
16+
17+
export interface BaseMoveEvent {
18+
pointerType: 'mouse' | 'pen' | 'touch' | 'keyboard'
19+
}
20+
21+
export interface MoveStartEvent extends BaseMoveEvent{
22+
type: 'movestart'
23+
}
24+
25+
export interface MoveMoveEvent extends BaseMoveEvent{
26+
type: 'move',
27+
deltaX: number,
28+
deltaY: number
29+
}
30+
31+
export interface MoveEndEvent extends BaseMoveEvent{
32+
type: 'moveend'
33+
}
34+
35+
export type MoveEvent = MoveStartEvent | MoveMoveEvent | MoveEndEvent;
36+
37+
export interface MoveProps {
38+
onMoveStart?: (e: MoveStartEvent) => void,
39+
onMove: (e: MoveMoveEvent) => void,
40+
onMoveEnd?: (e: MoveEndEvent) => void
41+
}
42+
43+
export function useMove(props: MoveProps): HTMLAttributes<HTMLElement> {
44+
let {onMoveStart, onMove, onMoveEnd} = props;
45+
46+
let state = useRef<{
47+
didMove: boolean,
48+
lastPosition: {pageX: number, pageY: number} | null,
49+
id: number | null
50+
}>({didMove: false, lastPosition: null, id: null});
51+
52+
let {addGlobalListener, removeGlobalListener} = useGlobalListeners();
53+
54+
let moveProps = useMemo(() => {
55+
let moveProps: HTMLAttributes<HTMLElement> = {};
56+
57+
let start = () => {
58+
disableTextSelection();
59+
state.current.didMove = false;
60+
};
61+
let move = (pointerType: BaseMoveEvent['pointerType'], deltaX: number, deltaY: number) => {
62+
if (!state.current.didMove) {
63+
state.current.didMove = true;
64+
onMoveStart?.({
65+
type: 'movestart',
66+
pointerType
67+
});
68+
}
69+
onMove({
70+
type: 'move',
71+
pointerType,
72+
deltaX: deltaX,
73+
deltaY: deltaY
74+
});
75+
};
76+
let end = (pointerType: BaseMoveEvent['pointerType']) => {
77+
restoreTextSelection();
78+
if (state.current.didMove) {
79+
onMoveEnd?.({
80+
type: 'moveend',
81+
pointerType
82+
});
83+
}
84+
};
85+
86+
if (typeof PointerEvent === 'undefined') {
87+
let onMouseMove = (e: MouseEvent) => {
88+
if (e.button === 0) {
89+
move('mouse', e.pageX - state.current.lastPosition.pageX, e.pageY - state.current.lastPosition.pageY);
90+
state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
91+
}
92+
};
93+
let onMouseUp = (e: MouseEvent) => {
94+
if (e.button === 0) {
95+
end('mouse');
96+
removeGlobalListener(window, 'mousemove', onMouseMove, false);
97+
removeGlobalListener(window, 'mouseup', onMouseUp, false);
98+
}
99+
};
100+
moveProps.onMouseDown = (e: React.MouseEvent) => {
101+
if (e.button === 0) {
102+
start();
103+
e.stopPropagation();
104+
e.preventDefault();
105+
state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
106+
addGlobalListener(window, 'mousemove', onMouseMove, false);
107+
addGlobalListener(window, 'mouseup', onMouseUp, false);
108+
}
109+
};
110+
111+
let onTouchMove = (e: TouchEvent) => {
112+
// @ts-ignore
113+
let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id);
114+
if (touch >= 0) {
115+
let {pageX, pageY} = e.changedTouches[touch];
116+
move('touch', pageX - state.current.lastPosition.pageX, pageY - state.current.lastPosition.pageY);
117+
state.current.lastPosition = {pageX, pageY};
118+
}
119+
};
120+
let onTouchEnd = (e: TouchEvent) => {
121+
// @ts-ignore
122+
let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id);
123+
if (touch >= 0) {
124+
end('touch');
125+
removeGlobalListener(window, 'touchmove', onTouchMove);
126+
removeGlobalListener(window, 'touchend', onTouchEnd);
127+
removeGlobalListener(window, 'touchcancel', onTouchEnd);
128+
}
129+
};
130+
moveProps.onTouchStart = (e: React.TouchEvent) => {
131+
if (e.targetTouches.length === 0) {
132+
return;
133+
}
134+
135+
let {pageX, pageY, identifier} = e.targetTouches[0];
136+
start();
137+
e.stopPropagation();
138+
e.preventDefault();
139+
state.current.lastPosition = {pageX, pageY};
140+
state.current.id = identifier;
141+
addGlobalListener(window, 'touchmove', onTouchMove, false);
142+
addGlobalListener(window, 'touchend', onTouchEnd, false);
143+
addGlobalListener(window, 'touchcancel', onTouchEnd, false);
144+
};
145+
} else {
146+
let onPointerMove = (e: PointerEvent) => {
147+
if (e.pointerId === state.current.id) {
148+
// @ts-ignore
149+
let pointerType: BaseMoveEvent['pointerType'] = e.pointerType || 'mouse';
150+
151+
// Problems with PointerEvent#movementX/movementY:
152+
// 1. it is always 0 on macOS Safari.
153+
// 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS
154+
move(pointerType, e.pageX - state.current.lastPosition.pageX, e.pageY - state.current.lastPosition.pageY);
155+
state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
156+
}
157+
};
158+
159+
let onPointerUp = (e: PointerEvent) => {
160+
if (e.pointerId === state.current.id) {
161+
// @ts-ignore
162+
let pointerType: BaseMoveEvent['pointerType'] = e.pointerType || 'mouse';
163+
end(pointerType);
164+
removeGlobalListener(window, 'pointermove', onPointerMove, false);
165+
removeGlobalListener(window, 'pointerup', onPointerUp, false);
166+
removeGlobalListener(window, 'pointercancel', onPointerUp, false);
167+
}
168+
};
169+
170+
moveProps.onPointerDown = (e: React.PointerEvent) => {
171+
if (e.button === 0) {
172+
start();
173+
e.stopPropagation();
174+
e.preventDefault();
175+
state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
176+
state.current.id = e.pointerId;
177+
addGlobalListener(window, 'pointermove', onPointerMove, false);
178+
addGlobalListener(window, 'pointerup', onPointerUp, false);
179+
addGlobalListener(window, 'pointercancel', onPointerUp, false);
180+
}
181+
};
182+
}
183+
184+
let triggetKeyboardMove = (deltaX: number, deltaY: number) => {
185+
start();
186+
move('keyboard', deltaX, deltaY);
187+
end('keyboard');
188+
};
189+
190+
moveProps.onKeyDown = (e) => {
191+
switch (e.key) {
192+
case 'Left':
193+
case 'ArrowLeft':
194+
e.preventDefault();
195+
e.stopPropagation();
196+
triggetKeyboardMove(-1, 0);
197+
break;
198+
case 'Right':
199+
case 'ArrowRight':
200+
e.preventDefault();
201+
e.stopPropagation();
202+
triggetKeyboardMove(1, 0);
203+
break;
204+
case 'Up':
205+
case 'ArrowUp':
206+
e.preventDefault();
207+
e.stopPropagation();
208+
triggetKeyboardMove(0, -1);
209+
break;
210+
case 'Down':
211+
case 'ArrowDown':
212+
e.preventDefault();
213+
e.stopPropagation();
214+
triggetKeyboardMove(0, 1);
215+
break;
216+
}
217+
};
218+
219+
return moveProps;
220+
}, [state, onMoveStart, onMove, onMoveEnd]);
221+
222+
return moveProps;
223+
}

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@
1717

1818
import {disableTextSelection, restoreTextSelection} from './textSelection';
1919
import {focusWithoutScrolling, mergeProps} from '@react-aria/utils';
20-
import {HTMLAttributes, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
20+
import {HTMLAttributes, RefObject, useContext, useEffect, useMemo, useRef, useState} from 'react';
2121
import {isVirtualClick} from './utils';
2222
import {PointerType, PressEvents} from '@react-types/shared';
2323
import {PressResponderContext} from './context';
24+
import {useGlobalListeners} from '@react-aria/utils';
2425

2526
export interface PressProps extends PressEvents {
2627
/** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */
@@ -112,15 +113,7 @@ export function usePress(props: PressHookProps): PressResult {
112113
isOverTarget: false
113114
});
114115

115-
let globalListeners = useRef(new Map());
116-
let addGlobalListener = useCallback((eventTarget, type, listener, options) => {
117-
globalListeners.current.set(listener, {type, eventTarget, options});
118-
eventTarget.addEventListener(type, listener, options);
119-
}, []);
120-
let removeGlobalListener = useCallback((eventTarget, type, listener, options) => {
121-
eventTarget.removeEventListener(type, listener, options);
122-
globalListeners.current.delete(listener);
123-
}, []);
116+
let {addGlobalListener, removeGlobalListener} = useGlobalListeners();
124117

125118
let pressProps = useMemo(() => {
126119
let state = ref.current;
@@ -542,15 +535,6 @@ export function usePress(props: PressHookProps): PressResult {
542535
return pressProps;
543536
}, [isDisabled, onPressStart, onPressChange, onPressEnd, onPress, onPressUp, addGlobalListener, preventFocusOnPress, removeGlobalListener]);
544537

545-
// eslint-disable-next-line arrow-body-style
546-
useEffect(() => {
547-
return () => {
548-
globalListeners.current.forEach((value, key) => {
549-
removeGlobalListener(value.eventTarget, value.type, key, value.options);
550-
});
551-
};
552-
}, [removeGlobalListener]);
553-
554538
return {
555539
isPressed: isPressedProp || isPressed,
556540
pressProps: mergeProps(domProps, pressProps)

0 commit comments

Comments
 (0)