mirror of
https://github.com/zhigang1992/react-native-web.git
synced 2026-01-12 22:51:09 +08:00
[add] Pressable support for hover state
This patch ports the 'useHover' hook to React Native for Web, providing hover state that is scoped to a pressable and does not bubble to ancestor pressables. This behavior aligns with the behavior of the focus and press states. Fix #1708
This commit is contained in:
@@ -30,8 +30,8 @@
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true,
|
||||
"jest": true
|
||||
"jest": true,
|
||||
"node": true
|
||||
},
|
||||
"globals": {
|
||||
|
||||
|
||||
@@ -37,13 +37,23 @@ export default function FeedbackEvents() {
|
||||
onPress={handlePress('press')}
|
||||
onPressIn={handlePress('pressIn')}
|
||||
onPressOut={handlePress('pressOut')}
|
||||
style={({ pressed, focused }) => ({
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: focused ? 'blue' : null,
|
||||
backgroundColor: pressed ? 'lightblue' : 'white'
|
||||
})}
|
||||
style={({ hovered, pressed, focused }) => {
|
||||
let backgroundColor = 'white';
|
||||
if (hovered) {
|
||||
backgroundColor = 'lightgray';
|
||||
}
|
||||
if (pressed) {
|
||||
backgroundColor = 'lightblue';
|
||||
}
|
||||
return {
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: focused ? 'red' : null,
|
||||
backgroundColor,
|
||||
outlineWidth: 0
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="none"
|
||||
@@ -51,13 +61,24 @@ export default function FeedbackEvents() {
|
||||
onPress={handlePress('press - inner')}
|
||||
onPressIn={handlePress('pressIn - inner')}
|
||||
onPressOut={handlePress('pressOut - inner')}
|
||||
style={({ pressed, focused }) => ({
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: focused ? 'blue' : null,
|
||||
backgroundColor: pressed ? 'lightblue' : 'white'
|
||||
})}
|
||||
style={({ hovered, pressed, focused }) => {
|
||||
console.log(focused);
|
||||
let backgroundColor = 'white';
|
||||
if (hovered) {
|
||||
backgroundColor = 'lightgray';
|
||||
}
|
||||
if (pressed) {
|
||||
backgroundColor = 'lightblue';
|
||||
}
|
||||
return {
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: focused ? 'red' : null,
|
||||
backgroundColor,
|
||||
outlineWidth: 0
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Text>Nested pressables</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
# `dom-event-testing-library`
|
||||
|
||||
A library for unit testing high-level interactions via simple pointer events, e.g.,
|
||||
`pointerdown`, that produce realistic and complete DOM event sequences.
|
||||
A library for unit testing high-level interactions via simple pointer events, e.g., `pointerdown`, that produce realistic and complete DOM event sequences.
|
||||
|
||||
There are number of challenges involved in unit testing modules that work with
|
||||
DOM events.
|
||||
There are number of challenges involved in unit testing modules that work with DOM events.
|
||||
|
||||
1. Testing environments with and without support for the `PointerEvent` API.
|
||||
2. Testing various user interaction modes including mouse, touch, and pen use.
|
||||
3. Testing against the event sequences browsers actually produce (e.g., emulated
|
||||
touch and mouse events.)
|
||||
4. Testing against the event properties DOM events include (i.e., more complete
|
||||
mock data)
|
||||
4. Testing against "virtual" events produced by tools like screen-readers.
|
||||
3. Testing against the event sequences browsers actually produce (e.g., emulated touch and mouse events.)
|
||||
4. Testing against the event properties DOM events include (i.e., more complete mock data)
|
||||
5. Testing against "virtual" events produced by tools like screen-readers.
|
||||
|
||||
Writing unit tests to cover all these scenarios is tedious and error prone. This
|
||||
event testing library is designed to avoid these issues by allowing developers to
|
||||
more easily dispatch events in unit tests, and to more reliably test interactions
|
||||
while using an API based on `PointerEvent`.
|
||||
Writing unit tests to cover all these scenarios is tedious and error prone. This event testing library is designed to avoid these issues by allowing developers to more easily dispatch events in unit tests, and to more reliably test interactions while using an API based on `PointerEvent`.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -45,34 +38,37 @@ describeWithPointerEvent('useTap', hasPointerEvent => {
|
||||
testWithPointerType('pointer down', pointerType => {
|
||||
const ref = createRef(null);
|
||||
const onTapStart = jest.fn();
|
||||
render(() => {
|
||||
useTap(ref, { onTapStart });
|
||||
|
||||
// component to test
|
||||
function Component() {
|
||||
useTapEvents(ref, { onTapStart });
|
||||
return <div ref={ref} />
|
||||
}
|
||||
|
||||
// render component
|
||||
act(() => {
|
||||
render(<Component />);
|
||||
});
|
||||
|
||||
// create an event target
|
||||
const target = createEventTarget(ref.current);
|
||||
|
||||
// dispatch high-level pointer event
|
||||
target.pointerdown({ pointerType });
|
||||
act(() => {
|
||||
target.pointerdown({ pointerType });
|
||||
});
|
||||
|
||||
// assertion
|
||||
expect(onTapStart).toBeCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
This tests the interaction in multiple scenarios. In each case, a realistic DOM
|
||||
event sequence–with complete mock events–is produced. When running in a mock
|
||||
environment without the `PointerEvent` API, the test runs for both `mouse` and
|
||||
`touch` pointer types. When `touch` is the pointer type it produces emulated mouse
|
||||
events. When running in a mock environment with the `PointerEvent` API, the test
|
||||
runs for `mouse`, `touch`, and `pen` pointer types.
|
||||
The example above tests the interaction in multiple scenarios. In each case, a realistic DOM event sequence–with complete mock events–is produced. When running in a mock environment without the `PointerEvent` API, the test runs for both `mouse` and `touch` pointer types. When `touch` is the pointer type it produces emulated mouse events. When running in a mock environment with the `PointerEvent` API, the test runs for `mouse`, `touch`, and `pen` pointer types.
|
||||
|
||||
It's important to cover all these scenarios because it's very easy to introduce
|
||||
bugs – e.g., double calling of callbacks – if not accounting for emulated mouse
|
||||
events, differences in target capturing between `touch` and `mouse` pointers, and
|
||||
the different semantics of `button` across event APIs.
|
||||
It's important to cover all these scenarios because it's very easy to introduce bugs – e.g., double calling of callbacks – if not accounting for emulated mouse events, differences in target capturing between `touch` and `mouse` pointers, and the different semantics of `button` across event APIs.
|
||||
|
||||
Default values are provided for the expected native events properties. They can
|
||||
also be customized as needed in a test.
|
||||
Default values are provided for the expected native events properties. They can also be customized as needed in a test.
|
||||
|
||||
```js
|
||||
target.pointerdown({
|
||||
@@ -87,8 +83,7 @@ target.pointerdown({
|
||||
});
|
||||
```
|
||||
|
||||
Tests that dispatch multiple pointer events will dispatch multi-touch native events
|
||||
on the target.
|
||||
Tests that dispatch multiple pointer events will dispatch multi-touch native events on the target.
|
||||
|
||||
```js
|
||||
// first pointer is active
|
||||
@@ -106,7 +101,7 @@ To create a new event target pass the DOM node to `createEventTarget(node)`. Thi
|
||||
* `blur`
|
||||
* `click`
|
||||
* `contextmenu`
|
||||
* `focus`
|
||||
* `focus` (includes the complete sequence of focus-related events)
|
||||
* `keydown`
|
||||
* `keyup`
|
||||
* `pointercancel`
|
||||
|
||||
@@ -53,14 +53,12 @@ describe('createEventTarget', () => {
|
||||
"node",
|
||||
"blur",
|
||||
"click",
|
||||
"contextmenu",
|
||||
"error",
|
||||
"focus",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"scroll",
|
||||
"select",
|
||||
"selectionchange",
|
||||
"virtualclick",
|
||||
"contextmenu",
|
||||
"load",
|
||||
"pointercancel",
|
||||
"pointerdown",
|
||||
"pointerhover",
|
||||
@@ -68,7 +66,11 @@ describe('createEventTarget', () => {
|
||||
"pointerover",
|
||||
"pointerout",
|
||||
"pointerup",
|
||||
"scroll",
|
||||
"select",
|
||||
"selectionchange",
|
||||
"tap",
|
||||
"virtualclick",
|
||||
"setBoundingClientRect",
|
||||
]
|
||||
`);
|
||||
@@ -82,7 +84,7 @@ describe('createEventTarget', () => {
|
||||
test('default', () => {
|
||||
const target = createEventTarget(node);
|
||||
node.addEventListener('blur', e => {
|
||||
expect(e.relatedTarget).toBeUndefined();
|
||||
expect(e.relatedTarget).toBeNull();
|
||||
});
|
||||
target.blur();
|
||||
});
|
||||
@@ -168,7 +170,7 @@ describe('createEventTarget', () => {
|
||||
test('default', () => {
|
||||
const target = createEventTarget(node);
|
||||
node.addEventListener('focus', e => {
|
||||
expect(e.relatedTarget).toBeUndefined();
|
||||
expect(e.relatedTarget).toBeNull();
|
||||
});
|
||||
target.focus();
|
||||
});
|
||||
|
||||
@@ -13,37 +13,30 @@ const defaultConfig = {
|
||||
};
|
||||
|
||||
const eventConfigs = {
|
||||
// Focus Events
|
||||
blur: {
|
||||
constructorType: 'FocusEvent',
|
||||
defaultInit: { bubbles: false, cancelable: false, composed: true }
|
||||
},
|
||||
focus: {
|
||||
constructorType: 'FocusEvent',
|
||||
defaultInit: { bubbles: false, cancelable: false, composed: true }
|
||||
change: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: false }
|
||||
},
|
||||
focusin: {
|
||||
constructorType: 'FocusEvent',
|
||||
defaultInit: { bubbles: true, cancelable: false, composed: true }
|
||||
},
|
||||
focusout: {
|
||||
constructorType: 'FocusEvent',
|
||||
defaultInit: { bubbles: true, cancelable: false, composed: true }
|
||||
},
|
||||
// Keyboard Events
|
||||
keydown: {
|
||||
constructorType: 'KeyboardEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
keyup: {
|
||||
constructorType: 'KeyboardEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
// Mouse Events
|
||||
click: {
|
||||
constructorType: 'MouseEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
compositionend: {
|
||||
constructorType: 'CompositionEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
compositionstart: {
|
||||
constructorType: 'CompositionEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
compositionupdate: {
|
||||
constructorType: 'CompositionEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
contextmenu: {
|
||||
constructorType: 'MouseEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
@@ -84,6 +77,42 @@ const eventConfigs = {
|
||||
constructorType: 'MouseEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
error: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: false, cancelable: false }
|
||||
},
|
||||
focus: {
|
||||
constructorType: 'FocusEvent',
|
||||
defaultInit: { bubbles: false, cancelable: false, composed: true }
|
||||
},
|
||||
focusin: {
|
||||
constructorType: 'FocusEvent',
|
||||
defaultInit: { bubbles: true, cancelable: false, composed: true }
|
||||
},
|
||||
focusout: {
|
||||
constructorType: 'FocusEvent',
|
||||
defaultInit: { bubbles: true, cancelable: false, composed: true }
|
||||
},
|
||||
input: {
|
||||
constructorType: 'InputEvent',
|
||||
defaultInit: { bubbles: true, cancelable: false, composed: true }
|
||||
},
|
||||
invalid: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: false, cancelable: true }
|
||||
},
|
||||
keydown: {
|
||||
constructorType: 'KeyboardEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
keyup: {
|
||||
constructorType: 'KeyboardEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
load: {
|
||||
constructorType: 'UIEvent',
|
||||
defaultInit: { bubbles: false, cancelable: false }
|
||||
},
|
||||
mousedown: {
|
||||
constructorType: 'MouseEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
@@ -112,12 +141,18 @@ const eventConfigs = {
|
||||
constructorType: 'MouseEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
// Selection events
|
||||
scroll: {
|
||||
constructorType: 'UIEvent',
|
||||
defaultInit: { bubbles: false, cancelable: false }
|
||||
},
|
||||
select: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: false }
|
||||
},
|
||||
// Touch events
|
||||
submit: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: true }
|
||||
},
|
||||
touchcancel: {
|
||||
constructorType: 'TouchEvent',
|
||||
defaultInit: { bubbles: true, cancelable: false, composed: true }
|
||||
@@ -134,108 +169,47 @@ const eventConfigs = {
|
||||
constructorType: 'TouchEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
// Pointer events
|
||||
// 'PointerEvent' constructor is not supported in jsdom
|
||||
gotpointercapture: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: false, cancelable: false, composed: true }
|
||||
},
|
||||
lostpointercapture: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: false, cancelable: false, composed: true }
|
||||
},
|
||||
pointercancel: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: false, composed: true }
|
||||
},
|
||||
pointerdown: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
pointerenter: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: false, cancelable: false }
|
||||
},
|
||||
pointerleave: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: false, cancelable: false }
|
||||
},
|
||||
pointermove: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
pointerout: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
pointerover: {
|
||||
constructorType: 'PointerEvent',
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
pointerup: {
|
||||
constructorType: 'PointerEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
// Image events
|
||||
error: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: false, cancelable: false }
|
||||
},
|
||||
load: {
|
||||
constructorType: 'UIEvent',
|
||||
defaultInit: { bubbles: false, cancelable: false }
|
||||
},
|
||||
// Form Events
|
||||
change: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: false }
|
||||
},
|
||||
input: {
|
||||
constructorType: 'InputEvent',
|
||||
defaultInit: { bubbles: true, cancelable: false, composed: true }
|
||||
},
|
||||
invalid: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: false, cancelable: true }
|
||||
},
|
||||
submit: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: true }
|
||||
},
|
||||
reset: {
|
||||
constructorType: 'Event',
|
||||
defaultInit: { bubbles: true, cancelable: true }
|
||||
},
|
||||
// Clipboard Events
|
||||
copy: {
|
||||
constructorType: 'ClipboardEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
cut: {
|
||||
constructorType: 'ClipboardEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
paste: {
|
||||
constructorType: 'ClipboardEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
// Composition Events
|
||||
compositionend: {
|
||||
constructorType: 'CompositionEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
compositionstart: {
|
||||
constructorType: 'CompositionEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
compositionupdate: {
|
||||
constructorType: 'CompositionEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
},
|
||||
// Other events
|
||||
scroll: {
|
||||
constructorType: 'UIEvent',
|
||||
defaultInit: { bubbles: false, cancelable: false }
|
||||
},
|
||||
wheel: {
|
||||
constructorType: 'WheelEvent',
|
||||
defaultInit: { bubbles: true, cancelable: true, composed: true }
|
||||
@@ -249,7 +223,7 @@ function getEventConfig(type) {
|
||||
export default function createEvent(type, init) {
|
||||
const config = getEventConfig(type);
|
||||
const { constructorType, defaultInit } = config;
|
||||
const eventInit = { ...init, ...defaultInit };
|
||||
const eventInit = { ...defaultInit, ...init };
|
||||
|
||||
const event = document.createEvent(constructorType);
|
||||
const { bubbles, cancelable, ...data } = eventInit;
|
||||
@@ -258,10 +232,20 @@ export default function createEvent(type, init) {
|
||||
if (data != null) {
|
||||
Object.keys(data).forEach(key => {
|
||||
const value = data[key];
|
||||
if (key === 'timeStamp' && !value) {
|
||||
return;
|
||||
// Ensure the value of 'defaultPrevented' is updated if 'preventDefault' is mocked.
|
||||
// The property is marked as 'configurable' to allow mocking.
|
||||
if (key === 'preventDefault' && typeof value === 'function') {
|
||||
const preventDefault = function() {
|
||||
value();
|
||||
Object.defineProperty(this, 'defaultPrevented', { value: true });
|
||||
};
|
||||
Object.defineProperty(event, key, {
|
||||
configurable: true,
|
||||
value: preventDefault
|
||||
});
|
||||
} else if (value != null) {
|
||||
Object.defineProperty(event, key, { configurable: true, value });
|
||||
}
|
||||
Object.defineProperty(event, key, { value });
|
||||
});
|
||||
}
|
||||
return event;
|
||||
|
||||
@@ -175,14 +175,16 @@ export function contextmenu(target, defaultPayload) {
|
||||
export function focus(target, defaultPayload = {}) {
|
||||
const dispatch = arg => target.dispatchEvent(arg);
|
||||
const { relatedTarget, ...payload } = defaultPayload;
|
||||
const blurPayload = { ...payload, relatedTarget: target };
|
||||
const focusPayload = { ...payload, relatedTarget };
|
||||
if (relatedTarget) {
|
||||
relatedTarget.dispatchEvent(domEvents.focusout({ ...payload, relatedTarget: target }));
|
||||
relatedTarget.dispatchEvent(domEvents.focusout(blurPayload));
|
||||
}
|
||||
dispatch(domEvents.focusin({ ...payload, relatedTarget }));
|
||||
dispatch(domEvents.focusin(focusPayload));
|
||||
if (relatedTarget) {
|
||||
relatedTarget.dispatchEvent(domEvents.blur({ ...payload, relatedTarget: target }));
|
||||
relatedTarget.dispatchEvent(domEvents.blur(blurPayload));
|
||||
}
|
||||
dispatch(domEvents.focus({ ...payload, relatedTarget }));
|
||||
dispatch(domEvents.focus(focusPayload));
|
||||
}
|
||||
|
||||
export function pointercancel(target, defaultPayload) {
|
||||
@@ -197,15 +199,14 @@ export function pointercancel(target, defaultPayload) {
|
||||
|
||||
if (hasPointerEvent()) {
|
||||
dispatchEvent(domEvents.pointercancel(payload));
|
||||
}
|
||||
if (pointerType === 'mouse') {
|
||||
dispatchEvent(domEvents.dragstart(payload));
|
||||
} else {
|
||||
if (pointerType === 'mouse') {
|
||||
dispatchEvent(domEvents.dragstart(payload));
|
||||
} else {
|
||||
const touch = createTouch(target, payload);
|
||||
touchStore.removeTouch(touch);
|
||||
const touchEventPayload = createTouchEventPayload(target, touch, payload);
|
||||
dispatchEvent(domEvents.touchcancel(touchEventPayload));
|
||||
}
|
||||
const touch = createTouch(target, payload);
|
||||
touchStore.removeTouch(touch);
|
||||
const touchEventPayload = createTouchEventPayload(target, touch, payload);
|
||||
dispatchEvent(domEvents.touchcancel(touchEventPayload));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,9 +259,12 @@ export function pointerover(target, defaultPayload) {
|
||||
};
|
||||
|
||||
if (hasPointerEvent()) {
|
||||
// Pointer must move before it can dispatch "over"
|
||||
dispatch(domEvents.pointermove());
|
||||
dispatch(domEvents.pointerover(payload));
|
||||
dispatch(domEvents.pointerenter(payload));
|
||||
}
|
||||
dispatch(domEvents.mousemove());
|
||||
dispatch(domEvents.mouseover(payload));
|
||||
dispatch(domEvents.mouseenter(payload));
|
||||
}
|
||||
@@ -273,12 +277,20 @@ export function pointerout(target, defaultPayload) {
|
||||
...defaultPayload
|
||||
};
|
||||
|
||||
const { relatedTarget } = payload;
|
||||
|
||||
if (hasPointerEvent()) {
|
||||
dispatch(domEvents.pointerout(payload));
|
||||
dispatch(domEvents.pointerleave(payload));
|
||||
// Only call the leave event if exiting the subtree
|
||||
if (!target.contains(relatedTarget)) {
|
||||
dispatch(domEvents.pointerleave(payload));
|
||||
}
|
||||
}
|
||||
dispatch(domEvents.mouseout(payload));
|
||||
dispatch(domEvents.mouseleave(payload));
|
||||
if (!target.contains(relatedTarget)) {
|
||||
// Only call the leave event if exiting the subtree
|
||||
dispatch(domEvents.mouseleave(payload));
|
||||
}
|
||||
}
|
||||
|
||||
// pointer is not down while moving
|
||||
@@ -341,12 +353,17 @@ export function pointerup(target, defaultPayload) {
|
||||
...defaultPayload
|
||||
};
|
||||
|
||||
const isPrimaryButton = payload.button === buttonType.primary;
|
||||
const isContextMenuAction = platform.get() === 'mac' && payload.ctrlKey === true;
|
||||
|
||||
if (pointerType === 'mouse') {
|
||||
if (hasPointerEvent()) {
|
||||
dispatch(domEvents.pointerup(payload));
|
||||
}
|
||||
dispatch(domEvents.mouseup(payload));
|
||||
dispatch(domEvents.click(payload));
|
||||
if (isPrimaryButton && !isContextMenuAction) {
|
||||
dispatch(domEvents.click(payload));
|
||||
}
|
||||
} else {
|
||||
if (hasPointerEvent()) {
|
||||
dispatch(domEvents.pointerup(payload));
|
||||
@@ -368,7 +385,9 @@ export function pointerup(target, defaultPayload) {
|
||||
if (!isGesture) {
|
||||
dispatch(domEvents.mouseup(payload));
|
||||
}
|
||||
dispatch(domEvents.click(payload));
|
||||
if (isPrimaryButton && !isContextMenuAction) {
|
||||
dispatch(domEvents.click(payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ function createMouseEvent(
|
||||
pageX,
|
||||
pageY,
|
||||
preventDefault = emptyFunction,
|
||||
relatedTarget,
|
||||
screenX,
|
||||
screenY,
|
||||
shiftKey = false,
|
||||
@@ -129,6 +130,7 @@ function createMouseEvent(
|
||||
pageX: pageX || x,
|
||||
pageY: pageY || y,
|
||||
preventDefault,
|
||||
relatedTarget,
|
||||
screenX: screenX === 0 ? screenX : x,
|
||||
screenY: screenY === 0 ? screenY : y + defaultBrowserChromeSize,
|
||||
shiftKey,
|
||||
@@ -160,6 +162,7 @@ function createPointerEvent(
|
||||
pressure = 0,
|
||||
preventDefault = emptyFunction,
|
||||
pointerType = 'mouse',
|
||||
relatedTarget,
|
||||
screenX,
|
||||
screenY,
|
||||
shiftKey = false,
|
||||
@@ -199,6 +202,7 @@ function createPointerEvent(
|
||||
pointerType,
|
||||
pressure,
|
||||
preventDefault,
|
||||
relatedTarget,
|
||||
releasePointerCapture: emptyFunction,
|
||||
screenX: screenX === 0 ? screenX : x,
|
||||
screenY: screenY === 0 ? screenY : y + defaultBrowserChromeSize,
|
||||
@@ -219,7 +223,6 @@ function createPointerEvent(
|
||||
|
||||
function createTouchEvent(type, payload) {
|
||||
return createEvent(type, {
|
||||
preventDefault: emptyFunction,
|
||||
...payload,
|
||||
detail: 0,
|
||||
sourceCapabilities: {
|
||||
@@ -257,6 +260,10 @@ export function dragstart(payload) {
|
||||
});
|
||||
}
|
||||
|
||||
export function error() {
|
||||
return createEvent('error');
|
||||
}
|
||||
|
||||
export function focus({ relatedTarget } = {}) {
|
||||
return createEvent('focus', { relatedTarget });
|
||||
}
|
||||
@@ -281,6 +288,10 @@ export function keyup(payload) {
|
||||
return createKeyboardEvent('keyup', payload);
|
||||
}
|
||||
|
||||
export function load(payload) {
|
||||
return createEvent('load', payload);
|
||||
}
|
||||
|
||||
export function lostpointercapture(payload) {
|
||||
return createPointerEvent('lostpointercapture', payload);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ const createEventTarget = node => ({
|
||||
click(payload) {
|
||||
node.dispatchEvent(domEvents.click(payload));
|
||||
},
|
||||
contextmenu(payload) {
|
||||
domEventSequences.contextmenu(node, payload);
|
||||
},
|
||||
error() {
|
||||
node.dispatchEvent(domEvents.error());
|
||||
},
|
||||
focus(payload) {
|
||||
domEventSequences.focus(node, payload);
|
||||
try {
|
||||
@@ -36,27 +42,14 @@ const createEventTarget = node => ({
|
||||
keyup(payload) {
|
||||
node.dispatchEvent(domEvents.keyup(payload));
|
||||
},
|
||||
scroll(payload) {
|
||||
node.dispatchEvent(domEvents.scroll(payload));
|
||||
},
|
||||
select(payload) {
|
||||
node.dispatchEvent(domEvents.select(payload));
|
||||
},
|
||||
// selectionchange is only dispatched on 'document'
|
||||
selectionchange(payload) {
|
||||
document.dispatchEvent(domEvents.selectionchange(payload));
|
||||
},
|
||||
virtualclick(payload) {
|
||||
node.dispatchEvent(domEvents.virtualclick(payload));
|
||||
load(payload) {
|
||||
node.dispatchEvent(domEvents.load(payload));
|
||||
},
|
||||
/**
|
||||
* PointerEvent abstraction.
|
||||
* Dispatches the expected sequence of PointerEvents, MouseEvents, and
|
||||
* TouchEvents for a given environment.
|
||||
*/
|
||||
contextmenu(payload) {
|
||||
domEventSequences.contextmenu(node, payload);
|
||||
},
|
||||
// node no longer receives events for the pointer
|
||||
pointercancel(payload) {
|
||||
domEventSequences.pointercancel(node, payload);
|
||||
@@ -85,6 +78,16 @@ const createEventTarget = node => ({
|
||||
pointerup(payload) {
|
||||
domEventSequences.pointerup(node, payload);
|
||||
},
|
||||
scroll(payload) {
|
||||
node.dispatchEvent(domEvents.scroll(payload));
|
||||
},
|
||||
select(payload) {
|
||||
node.dispatchEvent(domEvents.select(payload));
|
||||
},
|
||||
// selectionchange is only dispatched on 'document'
|
||||
selectionchange(payload) {
|
||||
document.dispatchEvent(domEvents.selectionchange(payload));
|
||||
},
|
||||
/**
|
||||
* Gesture abstractions.
|
||||
* Helpers for event sequences expected in a gesture.
|
||||
@@ -94,6 +97,9 @@ const createEventTarget = node => ({
|
||||
domEventSequences.pointerdown(payload);
|
||||
domEventSequences.pointerup(payload);
|
||||
},
|
||||
virtualclick(payload) {
|
||||
node.dispatchEvent(domEvents.virtualclick(payload));
|
||||
},
|
||||
/**
|
||||
* Utilities
|
||||
*/
|
||||
|
||||
@@ -29,8 +29,8 @@ describe('components/Button', () => {
|
||||
});
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerdown();
|
||||
target.pointerup();
|
||||
target.pointerdown({ button: 0 });
|
||||
target.pointerup({ button: 0 });
|
||||
});
|
||||
expect(onPress).toBeCalled();
|
||||
});
|
||||
|
||||
@@ -16,11 +16,13 @@ import type { ViewProps } from '../View';
|
||||
import * as React from 'react';
|
||||
import { forwardRef, memo, useMemo, useState, useRef } from 'react';
|
||||
import useMergeRefs from '../../modules/useMergeRefs';
|
||||
import useHover from '../../modules/useHover';
|
||||
import usePressEvents from '../../modules/usePressEvents';
|
||||
import View from '../View';
|
||||
|
||||
export type StateCallbackType = $ReadOnly<{|
|
||||
focused: boolean,
|
||||
hovered: boolean,
|
||||
pressed: boolean
|
||||
|}>;
|
||||
|
||||
@@ -93,6 +95,7 @@ function Pressable(props: Props, forwardedRef): React.Node {
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [hovered, setHovered] = useForceableState(false);
|
||||
const [focused, setFocused] = useForceableState(false);
|
||||
const [pressed, setPressed] = useForceableState(testOnly_pressed === true);
|
||||
|
||||
@@ -128,8 +131,10 @@ function Pressable(props: Props, forwardedRef): React.Node {
|
||||
|
||||
const pressEventHandlers = usePressEvents(hostRef, pressConfig);
|
||||
|
||||
useHover(hostRef, { contain: true, disabled, onHoverChange: setHovered });
|
||||
|
||||
const accessibilityState = { disabled, ...props.accessibilityState };
|
||||
const interactionState = { focused, pressed };
|
||||
const interactionState = { hovered, focused, pressed };
|
||||
|
||||
function createFocusHandler(callback, value) {
|
||||
return function(event) {
|
||||
|
||||
@@ -109,8 +109,8 @@ describe('components/Text', () => {
|
||||
});
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerdown();
|
||||
target.pointerup();
|
||||
target.pointerdown({ button: 0 });
|
||||
target.pointerup({ button: 0 });
|
||||
});
|
||||
expect(onPress).toBeCalled();
|
||||
});
|
||||
|
||||
458
packages/react-native-web/src/modules/createEventHandle/__tests__/index-test.js
vendored
Normal file
458
packages/react-native-web/src/modules/createEventHandle/__tests__/index-test.js
vendored
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow strict-local
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import * as ReactDOMServer from 'react-dom/server';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createEventTarget } from 'dom-event-testing-library';
|
||||
import createEventHandle from '..';
|
||||
|
||||
function createRoot(rootNode) {
|
||||
return {
|
||||
render(element) {
|
||||
ReactDOM.render(element, rootNode);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('create-event-handle', () => {
|
||||
let root;
|
||||
let rootNode;
|
||||
|
||||
beforeEach(() => {
|
||||
rootNode = document.createElement('div');
|
||||
document.body.appendChild(rootNode);
|
||||
root = createRoot(rootNode);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.render(null);
|
||||
document.body.removeChild(rootNode);
|
||||
rootNode = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
test('can render correctly using ReactDOMServer', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
return addClickListener(targetRef.current, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
const output = ReactDOMServer.renderToString(<Component />);
|
||||
expect(output).toBe('<div data-reactroot=""></div>');
|
||||
});
|
||||
|
||||
describe('createEventTarget()', () => {
|
||||
test('event dispatched on target', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
return addClickListener(targetRef.current, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const target = createEventTarget(targetRef.current);
|
||||
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('event dispatched on parent', () => {
|
||||
const listener = jest.fn();
|
||||
const listenerCapture = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const parentRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
const addClickCaptureListener = createEventHandle('click', {
|
||||
capture: true
|
||||
});
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
const removeClickListener = addClickListener(targetRef.current, listener);
|
||||
const removeClickCaptureListener = addClickCaptureListener(
|
||||
targetRef.current,
|
||||
listenerCapture
|
||||
);
|
||||
return () => {
|
||||
removeClickListener();
|
||||
removeClickCaptureListener();
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div ref={parentRef}>
|
||||
<div ref={targetRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const parent = createEventTarget(parentRef.current);
|
||||
|
||||
act(() => {
|
||||
parent.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(0);
|
||||
expect(listenerCapture).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('event dispatched on child', () => {
|
||||
const log = [];
|
||||
const listener = jest.fn(() => {
|
||||
log.push('bubble');
|
||||
});
|
||||
const listenerCapture = jest.fn(() => {
|
||||
log.push('capture');
|
||||
});
|
||||
const targetRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
const addClickCaptureListener = createEventHandle('click', {
|
||||
capture: true
|
||||
});
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
const removeClickListener = addClickListener(targetRef.current, listener);
|
||||
const removeClickCaptureListener = addClickCaptureListener(
|
||||
targetRef.current,
|
||||
listenerCapture
|
||||
);
|
||||
return () => {
|
||||
removeClickListener();
|
||||
removeClickCaptureListener();
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div ref={targetRef}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const child = createEventTarget(childRef.current);
|
||||
|
||||
act(() => {
|
||||
child.click();
|
||||
});
|
||||
|
||||
expect(listenerCapture).toBeCalledTimes(1);
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
expect(log).toEqual(['capture', 'bubble']);
|
||||
});
|
||||
|
||||
test('event dispatched on text node', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
return addClickListener(targetRef.current, listener);
|
||||
});
|
||||
return (
|
||||
<div ref={targetRef}>
|
||||
<div ref={childRef}>text</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const text = createEventTarget(childRef.current.firstChild);
|
||||
|
||||
act(() => {
|
||||
text.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listener can be attached to document', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
|
||||
function Component({ target }) {
|
||||
React.useEffect(() => {
|
||||
return addClickListener(target, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component target={document} />);
|
||||
});
|
||||
const target = createEventTarget(targetRef.current);
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listener can be attached to window ', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
|
||||
function Component({ target }) {
|
||||
React.useEffect(() => {
|
||||
return addClickListener(target, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component target={window} />);
|
||||
});
|
||||
const target = createEventTarget(targetRef.current);
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('custom event dispatched on target', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const addMagicEventListener = createEventHandle('magic-event');
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
return addMagicEventListener(targetRef.current, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('magic-event', { bubbles: true });
|
||||
targetRef.current.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listeners can be set on multiple targets simultaneously', () => {
|
||||
const log = [];
|
||||
const targetRef = React.createRef();
|
||||
const parentRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
const addClickCaptureListener = createEventHandle('click', {
|
||||
capture: true
|
||||
});
|
||||
const listener = jest.fn(e => {
|
||||
log.push(['bubble', e.currentTarget.id]);
|
||||
});
|
||||
const listenerCapture = jest.fn(e => {
|
||||
log.push(['capture', e.currentTarget.id]);
|
||||
});
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
// the same event handle is used to set listeners on different targets
|
||||
addClickListener(targetRef.current, listener);
|
||||
addClickListener(parentRef.current, listener);
|
||||
addClickCaptureListener(targetRef.current, listenerCapture);
|
||||
addClickCaptureListener(parentRef.current, listenerCapture);
|
||||
});
|
||||
return (
|
||||
<div id="parent" ref={parentRef}>
|
||||
<div id="target" ref={targetRef}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const child = createEventTarget(childRef.current);
|
||||
|
||||
act(() => {
|
||||
child.click();
|
||||
});
|
||||
|
||||
expect(listenerCapture).toBeCalledTimes(2);
|
||||
expect(listener).toBeCalledTimes(2);
|
||||
expect(log).toEqual([
|
||||
['capture', 'parent'],
|
||||
['capture', 'target'],
|
||||
['bubble', 'target'],
|
||||
['bubble', 'parent']
|
||||
]);
|
||||
});
|
||||
|
||||
test('listeners are specific to each event handle', () => {
|
||||
const log = [];
|
||||
const targetRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
const addClickAltListener = createEventHandle('click');
|
||||
const addClickCaptureListener = createEventHandle('click', {
|
||||
capture: true
|
||||
});
|
||||
const addClickCaptureAltListener = createEventHandle('click', {
|
||||
capture: true
|
||||
});
|
||||
const listener = jest.fn(e => {
|
||||
log.push(['bubble', 'target']);
|
||||
});
|
||||
const listenerAlt = jest.fn(e => {
|
||||
log.push(['bubble', 'target-alt']);
|
||||
});
|
||||
const listenerCapture = jest.fn(e => {
|
||||
log.push(['capture', 'target']);
|
||||
});
|
||||
const listenerCaptureAlt = jest.fn(e => {
|
||||
log.push(['capture', 'target-alt']);
|
||||
});
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
addClickListener(targetRef.current, listener);
|
||||
addClickAltListener(targetRef.current, listenerAlt);
|
||||
addClickCaptureListener(targetRef.current, listenerCapture);
|
||||
addClickCaptureAltListener(targetRef.current, listenerCaptureAlt);
|
||||
});
|
||||
return (
|
||||
<div id="target" ref={targetRef}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const child = createEventTarget(childRef.current);
|
||||
|
||||
act(() => {
|
||||
child.click();
|
||||
});
|
||||
|
||||
expect(listenerCapture).toBeCalledTimes(1);
|
||||
expect(listenerCaptureAlt).toBeCalledTimes(1);
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
expect(listenerAlt).toBeCalledTimes(1);
|
||||
expect(log).toEqual([
|
||||
['capture', 'target'],
|
||||
['capture', 'target-alt'],
|
||||
['bubble', 'target'],
|
||||
['bubble', 'target-alt']
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopPropagation and stopImmediatePropagation', () => {
|
||||
test('stopPropagation works as expected', () => {
|
||||
const childListener = jest.fn(e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
const targetListener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
const addClickListener = createEventHandle('click');
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
addClickListener(childRef.current, childListener);
|
||||
addClickListener(targetRef.current, targetListener);
|
||||
});
|
||||
return (
|
||||
<div ref={targetRef}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const child = createEventTarget(childRef.current);
|
||||
|
||||
act(() => {
|
||||
child.click();
|
||||
});
|
||||
|
||||
expect(childListener).toBeCalledTimes(1);
|
||||
expect(targetListener).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('stopImmediatePropagation works as expected', () => {
|
||||
const firstListener = jest.fn(e => {
|
||||
e.stopImmediatePropagation();
|
||||
});
|
||||
const secondListener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const addFirstClickListener = createEventHandle('click');
|
||||
const addSecondClickListener = createEventHandle('click');
|
||||
|
||||
function Component() {
|
||||
React.useEffect(() => {
|
||||
addFirstClickListener(targetRef.current, firstListener);
|
||||
addSecondClickListener(targetRef.current, secondListener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const target = createEventTarget(targetRef.current);
|
||||
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(firstListener).toBeCalledTimes(1);
|
||||
expect(secondListener).toBeCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
97
packages/react-native-web/src/modules/createEventHandle/index.js
vendored
Normal file
97
packages/react-native-web/src/modules/createEventHandle/index.js
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
|
||||
|
||||
type Listener = (e: any) => void;
|
||||
|
||||
type EventHandle = (target: EventTarget, callback: ?Listener) => () => void;
|
||||
|
||||
export type EventOptions = {
|
||||
capture?: boolean,
|
||||
passive?: boolean
|
||||
};
|
||||
|
||||
const emptyFunction = () => {};
|
||||
|
||||
function supportsPassiveEvents(): boolean {
|
||||
let supported = false;
|
||||
// Check if browser supports event with passive listeners
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support
|
||||
if (canUseDOM) {
|
||||
try {
|
||||
const options = {};
|
||||
Object.defineProperty(options, 'passive', {
|
||||
get() {
|
||||
supported = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
window.addEventListener('test', null, options);
|
||||
window.removeEventListener('test', null, options);
|
||||
} catch (e) {}
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
|
||||
const canUsePassiveEvents = supportsPassiveEvents();
|
||||
|
||||
function getOptions(options: ?EventOptions): EventOptions | boolean {
|
||||
if (options == null) {
|
||||
return false;
|
||||
}
|
||||
return canUsePassiveEvents ? options : Boolean(options.capture);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shim generic API compatibility with ReactDOM's synthetic events, without needing the
|
||||
* large amount of code ReactDOM uses to do this. Ideally we wouldn't use a synthetic
|
||||
* event wrapper at all.
|
||||
*/
|
||||
function isPropagationStopped() {
|
||||
return this.cancelBubble;
|
||||
}
|
||||
function isDefaultPrevented() {
|
||||
return this.defaultPrevented;
|
||||
}
|
||||
function normalizeEvent(event: any) {
|
||||
event.nativeEvent = event;
|
||||
event.persist = emptyFunction;
|
||||
event.isDefaultPrevented = isDefaultPrevented;
|
||||
event.isPropagationStopped = isPropagationStopped;
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function createEventHandle(type: string, options: ?EventOptions): EventHandle {
|
||||
const opts = getOptions(options);
|
||||
|
||||
return function(target: EventTarget, listener: ?Listener) {
|
||||
if (target == null || typeof target.addEventListener !== 'function') {
|
||||
throw new Error('createEventHandle: called on an invalid target.');
|
||||
}
|
||||
|
||||
const element = (target: any);
|
||||
if (listener != null) {
|
||||
const compatListener = e => listener(normalizeEvent(e));
|
||||
element.addEventListener(type, compatListener, opts);
|
||||
return function removeListener() {
|
||||
if (element != null) {
|
||||
element.removeEventListener(type, compatListener, opts);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return emptyFunction;
|
||||
}
|
||||
};
|
||||
}
|
||||
65
packages/react-native-web/src/modules/modality/__tests__/index-test.js
vendored
Normal file
65
packages/react-native-web/src/modules/modality/__tests__/index-test.js
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) Nicolas Gallagher.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import { getModality, getActiveModality, testOnly_resetActiveModality } from '..';
|
||||
import {
|
||||
describeWithPointerEvent,
|
||||
testWithPointerType,
|
||||
clearPointers,
|
||||
createEventTarget,
|
||||
setPointerEvent
|
||||
} from 'dom-event-testing-library';
|
||||
|
||||
describeWithPointerEvent('modules/modality', hasPointerEvent => {
|
||||
let rootNode;
|
||||
|
||||
beforeEach(() => {
|
||||
setPointerEvent(hasPointerEvent);
|
||||
rootNode = document.createElement('div');
|
||||
document.body.appendChild(rootNode);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(rootNode);
|
||||
rootNode = null;
|
||||
clearPointers();
|
||||
testOnly_resetActiveModality();
|
||||
});
|
||||
|
||||
describe('getModality', () => {
|
||||
testWithPointerType('reflects currently-in-use modality', pointerType => {
|
||||
const target = createEventTarget(rootNode);
|
||||
expect(getModality()).toBe('keyboard');
|
||||
target.pointerdown({ pointerType });
|
||||
expect(getModality()).toBe(pointerType);
|
||||
target.pointerup({ pointerType });
|
||||
target.keydown();
|
||||
expect(getModality()).toBe('keyboard');
|
||||
if (pointerType !== 'touch') {
|
||||
target.pointermove({ pointerType });
|
||||
expect(getModality()).toBe(pointerType);
|
||||
target.keydown();
|
||||
expect(getModality()).toBe('keyboard');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveModality', () => {
|
||||
testWithPointerType('reflects last actively used modality', pointerType => {
|
||||
const target = createEventTarget(rootNode);
|
||||
expect(getActiveModality()).toBe('keyboard');
|
||||
target.pointerdown({ pointerType });
|
||||
expect(getActiveModality()).toBe(pointerType);
|
||||
target.pointerup({ pointerType });
|
||||
target.keydown();
|
||||
if (pointerType !== 'touch') {
|
||||
target.pointermove({ pointerType });
|
||||
expect(getActiveModality()).toBe('keyboard');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
226
packages/react-native-web/src/modules/modality/index.js
vendored
Normal file
226
packages/react-native-web/src/modules/modality/index.js
vendored
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Copyright (c) Nicolas Gallagher.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
|
||||
import createEventHandle from '../createEventHandle';
|
||||
|
||||
export type Modality = 'keyboard' | 'mouse' | 'touch' | 'pen';
|
||||
|
||||
const supportsPointerEvent = () => !!(typeof window !== 'undefined' && window.PointerEvent != null);
|
||||
|
||||
let activeModality = 'keyboard';
|
||||
let modality = 'keyboard';
|
||||
let previousModality;
|
||||
let previousActiveModality;
|
||||
let isEmulatingMouseEvents = false;
|
||||
const listeners = new Set();
|
||||
|
||||
const KEYBOARD = 'keyboard';
|
||||
const MOUSE = 'mouse';
|
||||
const TOUCH = 'touch';
|
||||
|
||||
const BLUR = 'blur';
|
||||
const CONTEXTMENU = 'contextmenu';
|
||||
const FOCUS = 'focus';
|
||||
const KEYDOWN = 'keydown';
|
||||
const MOUSEDOWN = 'mousedown';
|
||||
const MOUSEMOVE = 'mousemove';
|
||||
const MOUSEUP = 'mouseup';
|
||||
const POINTERDOWN = 'pointerdown';
|
||||
const POINTERMOVE = 'pointermove';
|
||||
const SCROLL = 'scroll';
|
||||
const SELECTIONCHANGE = 'selectionchange';
|
||||
const TOUCHCANCEL = 'touchcancel';
|
||||
const TOUCHMOVE = 'touchmove';
|
||||
const TOUCHSTART = 'touchstart';
|
||||
const VISIBILITYCHANGE = 'visibilitychange';
|
||||
|
||||
const bubbleOptions = { passive: true };
|
||||
const captureOptions = { capture: true, passive: true };
|
||||
|
||||
// Window events
|
||||
const addBlurListener = createEventHandle(BLUR, bubbleOptions);
|
||||
const addFocusListener = createEventHandle(FOCUS, bubbleOptions);
|
||||
// Must be capture phase because 'stopPropagation' might prevent these
|
||||
// events bubbling to the document.
|
||||
const addVisibilityChangeListener = createEventHandle(VISIBILITYCHANGE, captureOptions);
|
||||
const addKeyDownListener = createEventHandle(KEYDOWN, captureOptions);
|
||||
const addPointerDownListener = createEventHandle(POINTERDOWN, captureOptions);
|
||||
const addPointerMoveListener = createEventHandle(POINTERMOVE, captureOptions);
|
||||
// Fallback events
|
||||
const addContextMenuListener = createEventHandle(CONTEXTMENU, captureOptions);
|
||||
const addMouseDownListener = createEventHandle(MOUSEDOWN, captureOptions);
|
||||
const addMouseMoveListener = createEventHandle(MOUSEMOVE, captureOptions);
|
||||
const addMouseUpListener = createEventHandle(MOUSEUP, captureOptions);
|
||||
const addScrollListener = createEventHandle(SCROLL, captureOptions);
|
||||
const addSelectiomChangeListener = createEventHandle(SELECTIONCHANGE, captureOptions);
|
||||
const addTouchCancelListener = createEventHandle(TOUCHCANCEL, captureOptions);
|
||||
const addTouchMoveListener = createEventHandle(TOUCHMOVE, captureOptions);
|
||||
const addTouchStartListener = createEventHandle(TOUCHSTART, captureOptions);
|
||||
|
||||
function restoreModality() {
|
||||
if (previousModality != null || previousActiveModality != null) {
|
||||
if (previousModality != null) {
|
||||
modality = previousModality;
|
||||
previousModality = null;
|
||||
}
|
||||
if (previousActiveModality != null) {
|
||||
activeModality = previousActiveModality;
|
||||
previousActiveModality = null;
|
||||
}
|
||||
callListeners();
|
||||
}
|
||||
}
|
||||
|
||||
function onBlurWindow() {
|
||||
previousModality = modality;
|
||||
previousActiveModality = activeModality;
|
||||
activeModality = KEYBOARD;
|
||||
modality = KEYBOARD;
|
||||
callListeners();
|
||||
// for fallback events
|
||||
isEmulatingMouseEvents = false;
|
||||
}
|
||||
|
||||
function onFocusWindow() {
|
||||
restoreModality();
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.metaKey || event.altKey || event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
if (modality !== KEYBOARD) {
|
||||
modality = KEYBOARD;
|
||||
activeModality = KEYBOARD;
|
||||
callListeners();
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState !== 'hidden') {
|
||||
restoreModality();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerish(event: any) {
|
||||
const eventType = event.type;
|
||||
|
||||
if (supportsPointerEvent()) {
|
||||
if (eventType === POINTERDOWN) {
|
||||
if (activeModality !== event.pointerType) {
|
||||
modality = event.pointerType;
|
||||
activeModality = event.pointerType;
|
||||
callListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (eventType === POINTERMOVE) {
|
||||
if (modality !== event.pointerType) {
|
||||
modality = event.pointerType;
|
||||
callListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback for non-PointerEvent environment
|
||||
else {
|
||||
if (!isEmulatingMouseEvents) {
|
||||
if (eventType === MOUSEDOWN) {
|
||||
if (activeModality !== MOUSE) {
|
||||
modality = MOUSE;
|
||||
activeModality = MOUSE;
|
||||
callListeners();
|
||||
}
|
||||
}
|
||||
if (eventType === MOUSEMOVE) {
|
||||
if (modality !== MOUSE) {
|
||||
modality = MOUSE;
|
||||
callListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag when browser may produce emulated events
|
||||
if (eventType === TOUCHSTART) {
|
||||
isEmulatingMouseEvents = true;
|
||||
if (event.touches && event.touches.length > 1) {
|
||||
isEmulatingMouseEvents = false;
|
||||
}
|
||||
if (activeModality !== TOUCH) {
|
||||
modality = TOUCH;
|
||||
activeModality = TOUCH;
|
||||
callListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove flag after emulated events are finished or cancelled, and if an
|
||||
// event occurs that cuts short a touch event sequence.
|
||||
if (
|
||||
eventType === CONTEXTMENU ||
|
||||
eventType === MOUSEUP ||
|
||||
eventType === SELECTIONCHANGE ||
|
||||
eventType === SCROLL ||
|
||||
eventType === TOUCHCANCEL ||
|
||||
eventType === TOUCHMOVE
|
||||
) {
|
||||
isEmulatingMouseEvents = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canUseDOM) {
|
||||
addBlurListener(window, onBlurWindow);
|
||||
addFocusListener(window, onFocusWindow);
|
||||
addKeyDownListener(document, onKeyDown);
|
||||
addPointerDownListener(document, onPointerish);
|
||||
addPointerMoveListener(document, onPointerish);
|
||||
addVisibilityChangeListener(document, onVisibilityChange);
|
||||
// fallbacks
|
||||
addContextMenuListener(document, onPointerish);
|
||||
addMouseDownListener(document, onPointerish);
|
||||
addMouseMoveListener(document, onPointerish);
|
||||
addMouseUpListener(document, onPointerish);
|
||||
addTouchCancelListener(document, onPointerish);
|
||||
addTouchMoveListener(document, onPointerish);
|
||||
addTouchStartListener(document, onPointerish);
|
||||
addSelectiomChangeListener(document, onPointerish);
|
||||
addScrollListener(document, onPointerish);
|
||||
}
|
||||
|
||||
function callListeners() {
|
||||
const value = { activeModality, modality };
|
||||
listeners.forEach(listener => {
|
||||
listener(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveModality(): Modality {
|
||||
return activeModality;
|
||||
}
|
||||
|
||||
export function getModality(): Modality {
|
||||
return modality;
|
||||
}
|
||||
|
||||
export function addModalityListener(
|
||||
listener: ({ activeModality: Modality, modality: Modality }) => void
|
||||
) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function testOnly_resetActiveModality() {
|
||||
isEmulatingMouseEvents = false;
|
||||
activeModality = KEYBOARD;
|
||||
modality = KEYBOARD;
|
||||
}
|
||||
510
packages/react-native-web/src/modules/useEvent/__tests__/index-test.js
vendored
Normal file
510
packages/react-native-web/src/modules/useEvent/__tests__/index-test.js
vendored
Normal file
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow strict-local
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createEventTarget } from 'dom-event-testing-library';
|
||||
import useEvent from '..';
|
||||
|
||||
function createRoot(rootNode) {
|
||||
return {
|
||||
render(element) {
|
||||
ReactDOM.render(element, rootNode);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('use-event', () => {
|
||||
let root;
|
||||
let rootNode;
|
||||
|
||||
beforeEach(() => {
|
||||
rootNode = document.createElement('div');
|
||||
document.body.appendChild(rootNode);
|
||||
root = createRoot(rootNode);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.render(null);
|
||||
document.body.removeChild(rootNode);
|
||||
rootNode = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
describe('setListener()', () => {
|
||||
test('event dispatched on target', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
|
||||
function Component() {
|
||||
const addClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addClickListener(targetRef.current, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const target = createEventTarget(targetRef.current);
|
||||
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('event dispatched on parent', () => {
|
||||
const listener = jest.fn();
|
||||
const listenerCapture = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const parentRef = React.createRef();
|
||||
|
||||
function Component() {
|
||||
const addClickListener = useEvent('click');
|
||||
const addClickCaptureListener = useEvent('click', { capture: true });
|
||||
|
||||
React.useEffect(() => {
|
||||
addClickListener(targetRef.current, listener);
|
||||
addClickCaptureListener(targetRef.current, listenerCapture);
|
||||
});
|
||||
return (
|
||||
<div ref={parentRef}>
|
||||
<div ref={targetRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const parent = createEventTarget(parentRef.current);
|
||||
|
||||
act(() => {
|
||||
parent.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(0);
|
||||
expect(listenerCapture).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('event dispatched on child', () => {
|
||||
const log = [];
|
||||
const listener = jest.fn(() => {
|
||||
log.push('bubble');
|
||||
});
|
||||
const listenerCapture = jest.fn(() => {
|
||||
log.push('capture');
|
||||
});
|
||||
const targetRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
|
||||
function Component() {
|
||||
const addClickListener = useEvent('click');
|
||||
const addClickCaptureListener = useEvent('click', { capture: true });
|
||||
|
||||
React.useEffect(() => {
|
||||
addClickListener(targetRef.current, listener);
|
||||
addClickCaptureListener(targetRef.current, listenerCapture);
|
||||
});
|
||||
return (
|
||||
<div ref={targetRef}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const child = createEventTarget(childRef.current);
|
||||
|
||||
act(() => {
|
||||
child.click();
|
||||
});
|
||||
|
||||
expect(listenerCapture).toBeCalledTimes(1);
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
expect(log).toEqual(['capture', 'bubble']);
|
||||
});
|
||||
|
||||
test('event dispatched on text node', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
|
||||
function Component() {
|
||||
const addClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addClickListener(targetRef.current, listener);
|
||||
});
|
||||
return (
|
||||
<div ref={targetRef}>
|
||||
<div ref={childRef}>text</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const text = createEventTarget(childRef.current.firstChild);
|
||||
|
||||
act(() => {
|
||||
text.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listener can be attached to document ', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
|
||||
function Component({ target }) {
|
||||
const addClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addClickListener(target, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component target={document} />);
|
||||
});
|
||||
const target = createEventTarget(targetRef.current);
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listener can be attached to window ', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
|
||||
function Component({ target }) {
|
||||
const addClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addClickListener(target, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component target={window} />);
|
||||
});
|
||||
const target = createEventTarget(targetRef.current);
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listener is replaceable', () => {
|
||||
const listener = jest.fn();
|
||||
const listenerAlt = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
|
||||
function Component({ onClick }) {
|
||||
const addClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addClickListener(targetRef.current, onClick);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component onClick={listener} />);
|
||||
});
|
||||
const target = createEventTarget(targetRef.current);
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
act(() => {
|
||||
// this should replace the listener
|
||||
root.render(<Component onClick={listenerAlt} />);
|
||||
});
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
expect(listenerAlt).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listener is removed when value is null', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
|
||||
function Component({ off }) {
|
||||
const addClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addClickListener(targetRef.current, off ? null : listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component off={false} />);
|
||||
});
|
||||
const target = createEventTarget(targetRef.current);
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
act(() => {
|
||||
// this should unset the listener
|
||||
root.render(<Component off={true} />);
|
||||
});
|
||||
listener.mockClear();
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
expect(listener).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('custom event dispatched on target', () => {
|
||||
const listener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
|
||||
function Component() {
|
||||
const addMagicEventListener = useEvent('magic-event');
|
||||
React.useEffect(() => {
|
||||
addMagicEventListener(targetRef.current, listener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('magic-event', { bubbles: true });
|
||||
targetRef.current.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('listeners can be set on multiple targets simultaneously', () => {
|
||||
const log = [];
|
||||
const targetRef = React.createRef();
|
||||
const parentRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
|
||||
const listener = jest.fn(e => {
|
||||
log.push(['bubble', e.currentTarget.id]);
|
||||
});
|
||||
const listenerCapture = jest.fn(e => {
|
||||
log.push(['capture', e.currentTarget.id]);
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const addClickListener = useEvent('click');
|
||||
const addClickCaptureListener = useEvent('click', { capture: true });
|
||||
React.useEffect(() => {
|
||||
// the same event handle is used to set listeners on different targets
|
||||
addClickListener(targetRef.current, listener);
|
||||
addClickListener(parentRef.current, listener);
|
||||
addClickCaptureListener(targetRef.current, listenerCapture);
|
||||
addClickCaptureListener(parentRef.current, listenerCapture);
|
||||
});
|
||||
return (
|
||||
<div id="parent" ref={parentRef}>
|
||||
<div id="target" ref={targetRef}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const child = createEventTarget(childRef.current);
|
||||
|
||||
act(() => {
|
||||
child.click();
|
||||
});
|
||||
|
||||
expect(listenerCapture).toBeCalledTimes(2);
|
||||
expect(listener).toBeCalledTimes(2);
|
||||
expect(log).toEqual([
|
||||
['capture', 'parent'],
|
||||
['capture', 'target'],
|
||||
['bubble', 'target'],
|
||||
['bubble', 'parent']
|
||||
]);
|
||||
});
|
||||
|
||||
test('listeners are specific to each event handle', () => {
|
||||
const log = [];
|
||||
const targetRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
|
||||
const listener = jest.fn(e => {
|
||||
log.push(['bubble', 'target']);
|
||||
});
|
||||
const listenerAlt = jest.fn(e => {
|
||||
log.push(['bubble', 'target-alt']);
|
||||
});
|
||||
const listenerCapture = jest.fn(e => {
|
||||
log.push(['capture', 'target']);
|
||||
});
|
||||
const listenerCaptureAlt = jest.fn(e => {
|
||||
log.push(['capture', 'target-alt']);
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const addClickListener = useEvent('click');
|
||||
const addClickAltListener = useEvent('click');
|
||||
const addClickCaptureListener = useEvent('click', { capture: true });
|
||||
const addClickCaptureAltListener = useEvent('click', { capture: true });
|
||||
React.useEffect(() => {
|
||||
addClickListener(targetRef.current, listener);
|
||||
addClickAltListener(targetRef.current, listenerAlt);
|
||||
addClickCaptureListener(targetRef.current, listenerCapture);
|
||||
addClickCaptureAltListener(targetRef.current, listenerCaptureAlt);
|
||||
});
|
||||
return (
|
||||
<div id="target" ref={targetRef}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const child = createEventTarget(childRef.current);
|
||||
|
||||
act(() => {
|
||||
child.click();
|
||||
});
|
||||
|
||||
expect(listenerCapture).toBeCalledTimes(1);
|
||||
expect(listenerCaptureAlt).toBeCalledTimes(1);
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
expect(listenerAlt).toBeCalledTimes(1);
|
||||
expect(log).toEqual([
|
||||
['capture', 'target'],
|
||||
['capture', 'target-alt'],
|
||||
['bubble', 'target'],
|
||||
['bubble', 'target-alt']
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
test('removes all listeners for given event type from targets', () => {
|
||||
const clickListener = jest.fn();
|
||||
function Component() {
|
||||
const addClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addClickListener(document, clickListener);
|
||||
});
|
||||
return <div />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
root.render(null);
|
||||
});
|
||||
|
||||
const target = createEventTarget(document);
|
||||
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(clickListener).toBeCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopPropagation and stopImmediatePropagation', () => {
|
||||
test('stopPropagation works as expected', () => {
|
||||
const childListener = jest.fn(e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
const targetListener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
const childRef = React.createRef();
|
||||
|
||||
function Component() {
|
||||
const addClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addClickListener(childRef.current, childListener);
|
||||
addClickListener(targetRef.current, targetListener);
|
||||
});
|
||||
return (
|
||||
<div ref={targetRef}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const child = createEventTarget(childRef.current);
|
||||
|
||||
act(() => {
|
||||
child.click();
|
||||
});
|
||||
|
||||
expect(childListener).toBeCalledTimes(1);
|
||||
expect(targetListener).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
test('stopImmediatePropagation works as expected', () => {
|
||||
const firstListener = jest.fn(e => {
|
||||
e.stopImmediatePropagation();
|
||||
});
|
||||
const secondListener = jest.fn();
|
||||
const targetRef = React.createRef();
|
||||
|
||||
function Component() {
|
||||
const addFirstClickListener = useEvent('click');
|
||||
const addSecondClickListener = useEvent('click');
|
||||
React.useEffect(() => {
|
||||
addFirstClickListener(targetRef.current, firstListener);
|
||||
addSecondClickListener(targetRef.current, secondListener);
|
||||
});
|
||||
return <div ref={targetRef} />;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const target = createEventTarget(targetRef.current);
|
||||
|
||||
act(() => {
|
||||
target.click();
|
||||
});
|
||||
|
||||
expect(firstListener).toBeCalledTimes(1);
|
||||
expect(secondListener).toBeCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
64
packages/react-native-web/src/modules/useEvent/index.js
vendored
Normal file
64
packages/react-native-web/src/modules/useEvent/index.js
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import createEventHandle from '../createEventHandle';
|
||||
import useLayoutEffect from '../useLayoutEffect';
|
||||
import useStable from '../useStable';
|
||||
|
||||
type Callback = null | (any => void);
|
||||
type AddListener = (target: EventTarget, listener: null | (any => void)) => () => void;
|
||||
|
||||
/**
|
||||
* This can be used with any event type include custom events.
|
||||
*
|
||||
* const click = useEvent('click', options);
|
||||
* useEffect(() => {
|
||||
* click.setListener(target, onClick);
|
||||
* return () => click.clear();
|
||||
* }).
|
||||
*/
|
||||
export default function useEvent(
|
||||
event: string,
|
||||
options?: ?{
|
||||
capture?: boolean,
|
||||
passive?: boolean
|
||||
}
|
||||
): AddListener {
|
||||
const targetListeners = useStable(() => new Map());
|
||||
|
||||
let addListener = useStable(() => {
|
||||
const addEventListener = createEventHandle(event, options);
|
||||
return (target: EventTarget, callback: Callback) => {
|
||||
const removeTargetListener = targetListeners.get(target);
|
||||
if (removeTargetListener != null) {
|
||||
removeTargetListener();
|
||||
}
|
||||
if (callback == null) {
|
||||
targetListeners.delete(target);
|
||||
}
|
||||
const removeEventListener = addEventListener(target, callback);
|
||||
targetListeners.set(target, removeEventListener);
|
||||
return removeEventListener;
|
||||
};
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
if (addListener != null) {
|
||||
targetListeners.forEach(removeListener => {
|
||||
removeListener();
|
||||
});
|
||||
targetListeners.clear();
|
||||
}
|
||||
addListener = null;
|
||||
};
|
||||
}, [addListener]);
|
||||
|
||||
return addListener;
|
||||
}
|
||||
341
packages/react-native-web/src/modules/useHover/__tests__/index-test.js
vendored
Normal file
341
packages/react-native-web/src/modules/useHover/__tests__/index-test.js
vendored
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import {
|
||||
describeWithPointerEvent,
|
||||
clearPointers,
|
||||
createEventTarget,
|
||||
setPointerEvent
|
||||
} from 'dom-event-testing-library';
|
||||
import useHover from '..';
|
||||
import { testOnly_resetActiveModality } from '../../modality';
|
||||
|
||||
function createRoot(rootNode) {
|
||||
return {
|
||||
render(element) {
|
||||
ReactDOM.render(element, rootNode);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describeWithPointerEvent('useHover', hasPointerEvents => {
|
||||
let root;
|
||||
let rootNode;
|
||||
|
||||
beforeEach(() => {
|
||||
setPointerEvent(hasPointerEvents);
|
||||
rootNode = document.createElement('div');
|
||||
document.body.appendChild(rootNode);
|
||||
root = createRoot(rootNode);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.render(null);
|
||||
document.body.removeChild(rootNode);
|
||||
rootNode = null;
|
||||
root = null;
|
||||
testOnly_resetActiveModality();
|
||||
// make sure all tests reset state machine tracking pointers on the mock surface
|
||||
clearPointers();
|
||||
});
|
||||
|
||||
describe('contain', () => {
|
||||
let onHoverChange, onHoverStart, onHoverUpdate, onHoverEnd, ref, childRef;
|
||||
|
||||
const componentInit = () => {
|
||||
onHoverChange = jest.fn();
|
||||
onHoverStart = jest.fn();
|
||||
onHoverUpdate = jest.fn();
|
||||
onHoverEnd = jest.fn();
|
||||
ref = React.createRef();
|
||||
childRef = React.createRef();
|
||||
const Component = () => {
|
||||
useHover(ref, {
|
||||
onHoverChange,
|
||||
onHoverStart,
|
||||
onHoverUpdate,
|
||||
onHoverEnd
|
||||
});
|
||||
useHover(childRef, { contain: true });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
};
|
||||
|
||||
test('contains the hover gesture', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
const child = createEventTarget(childRef.current);
|
||||
act(() => {
|
||||
target.pointerover();
|
||||
target.pointerout();
|
||||
child.pointerover();
|
||||
});
|
||||
expect(onHoverEnd).toBeCalled();
|
||||
act(() => {
|
||||
child.pointerout();
|
||||
});
|
||||
expect(onHoverStart).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled', () => {
|
||||
let onHoverChange, onHoverStart, onHoverUpdate, onHoverEnd, ref;
|
||||
|
||||
const componentInit = () => {
|
||||
onHoverChange = jest.fn();
|
||||
onHoverStart = jest.fn();
|
||||
onHoverUpdate = jest.fn();
|
||||
onHoverEnd = jest.fn();
|
||||
ref = React.createRef();
|
||||
const Component = () => {
|
||||
useHover(ref, {
|
||||
disabled: true,
|
||||
onHoverChange,
|
||||
onHoverStart,
|
||||
onHoverUpdate,
|
||||
onHoverEnd
|
||||
});
|
||||
return <div ref={ref} />;
|
||||
};
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
};
|
||||
|
||||
test('does not call callbacks', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerover();
|
||||
target.pointerout();
|
||||
});
|
||||
expect(onHoverChange).not.toBeCalled();
|
||||
expect(onHoverStart).not.toBeCalled();
|
||||
expect(onHoverUpdate).not.toBeCalled();
|
||||
expect(onHoverEnd).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHoverStart', () => {
|
||||
let onHoverStart, ref;
|
||||
|
||||
const componentInit = () => {
|
||||
onHoverStart = jest.fn();
|
||||
ref = React.createRef();
|
||||
const Component = () => {
|
||||
useHover(ref, { onHoverStart });
|
||||
return <div ref={ref} />;
|
||||
};
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
};
|
||||
|
||||
test('is called for mouse pointers', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerover({ pointerType: 'mouse' });
|
||||
});
|
||||
expect(onHoverStart).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('is not called for touch pointers', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerdown({ pointerType: 'touch' });
|
||||
target.pointerup({ pointerType: 'touch' });
|
||||
});
|
||||
expect(onHoverStart).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('is called if a mouse pointer is used after a touch pointer', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerdown({ pointerType: 'touch' });
|
||||
target.pointerup({ pointerType: 'touch' });
|
||||
target.pointerover({ pointerType: 'mouse' });
|
||||
});
|
||||
expect(onHoverStart).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHoverChange', () => {
|
||||
let onHoverChange, ref;
|
||||
|
||||
const componentInit = () => {
|
||||
onHoverChange = jest.fn();
|
||||
ref = React.createRef();
|
||||
const Component = () => {
|
||||
useHover(ref, { onHoverChange });
|
||||
return <div ref={ref} />;
|
||||
};
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
};
|
||||
|
||||
test('is called for mouse pointers', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerover();
|
||||
});
|
||||
expect(onHoverChange).toBeCalledTimes(1);
|
||||
expect(onHoverChange).toBeCalledWith(true);
|
||||
act(() => {
|
||||
target.pointerout();
|
||||
});
|
||||
expect(onHoverChange).toBeCalledTimes(2);
|
||||
expect(onHoverChange).toBeCalledWith(false);
|
||||
});
|
||||
|
||||
test('is not called for touch pointers', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerdown({ pointerType: 'touch' });
|
||||
target.pointerup({ pointerType: 'touch' });
|
||||
});
|
||||
expect(onHoverChange).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHoverEnd', () => {
|
||||
let onHoverEnd, ref, childRef;
|
||||
|
||||
const componentInit = () => {
|
||||
onHoverEnd = jest.fn();
|
||||
ref = React.createRef();
|
||||
childRef = React.createRef();
|
||||
const Component = () => {
|
||||
useHover(ref, { onHoverEnd });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={childRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
};
|
||||
|
||||
test('is called for mouse pointers', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerover();
|
||||
target.pointerout();
|
||||
});
|
||||
expect(onHoverEnd).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('is not called for touch pointers', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerdown({ pointerType: 'touch' });
|
||||
target.pointerup({ pointerType: 'touch' });
|
||||
});
|
||||
expect(onHoverEnd).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('is not called when entering children of the target', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
const child = createEventTarget(childRef.current);
|
||||
act(() => {
|
||||
target.pointerover();
|
||||
target.pointerout({ relatedTarget: childRef.current });
|
||||
child.pointerover({ relatedTarget: target.node });
|
||||
});
|
||||
expect(onHoverEnd).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHoverUpdate', () => {
|
||||
test('is called after the active pointer moves"', () => {
|
||||
const onHoverUpdate = jest.fn();
|
||||
const ref = React.createRef();
|
||||
const Component = () => {
|
||||
useHover(ref, { onHoverUpdate });
|
||||
return <div ref={ref} />;
|
||||
};
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerover();
|
||||
target.pointerhover({ x: 0, y: 0 });
|
||||
target.pointerhover({ x: 1, y: 1 });
|
||||
});
|
||||
expect(onHoverUpdate).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repeat use', () => {
|
||||
let onHoverChange, onHoverStart, onHoverUpdate, onHoverEnd, ref;
|
||||
|
||||
const componentInit = () => {
|
||||
onHoverChange = jest.fn();
|
||||
onHoverStart = jest.fn();
|
||||
onHoverUpdate = jest.fn();
|
||||
onHoverEnd = jest.fn();
|
||||
ref = React.createRef();
|
||||
const Component = () => {
|
||||
useHover(ref, {
|
||||
onHoverChange,
|
||||
onHoverStart,
|
||||
onHoverUpdate,
|
||||
onHoverEnd
|
||||
});
|
||||
return <div ref={ref} />;
|
||||
};
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
};
|
||||
|
||||
test('callbacks are called each time', () => {
|
||||
componentInit();
|
||||
const target = createEventTarget(ref.current);
|
||||
act(() => {
|
||||
target.pointerover();
|
||||
target.pointerhover({ x: 1, y: 1 });
|
||||
target.pointerout();
|
||||
});
|
||||
expect(onHoverStart).toBeCalledTimes(1);
|
||||
expect(onHoverUpdate).toBeCalledTimes(1);
|
||||
expect(onHoverEnd).toBeCalledTimes(1);
|
||||
expect(onHoverChange).toBeCalledTimes(2);
|
||||
act(() => {
|
||||
target.pointerover();
|
||||
target.pointerhover({ x: 1, y: 1 });
|
||||
target.pointerout();
|
||||
});
|
||||
expect(onHoverStart).toBeCalledTimes(2);
|
||||
expect(onHoverUpdate).toBeCalledTimes(2);
|
||||
expect(onHoverEnd).toBeCalledTimes(2);
|
||||
expect(onHoverChange).toBeCalledTimes(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
packages/react-native-web/src/modules/useHover/index.js
vendored
Normal file
177
packages/react-native-web/src/modules/useHover/index.js
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import { getModality } from '../modality';
|
||||
import useEvent from '../useEvent';
|
||||
import useLayoutEffect from '../useLayoutEffect';
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
|
||||
type HoverEventsConfig = {
|
||||
contain?: ?boolean,
|
||||
disabled?: ?boolean,
|
||||
onHoverStart?: ?(e: any) => void,
|
||||
onHoverChange?: ?(bool: boolean) => void,
|
||||
onHoverUpdate?: ?(e: any) => void,
|
||||
onHoverEnd?: ?(e: any) => void
|
||||
};
|
||||
|
||||
/**
|
||||
* Implementation
|
||||
*/
|
||||
|
||||
const emptyObject = {};
|
||||
const opts = { passive: true };
|
||||
const lockEventType = 'react-gui:hover:lock';
|
||||
const unlockEventType = 'react-gui:hover:unlock';
|
||||
const supportsPointerEvent = () => !!(typeof window !== 'undefined' && window.PointerEvent != null);
|
||||
|
||||
function dispatchCustomEvent(
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
payload?: {
|
||||
bubbles?: boolean,
|
||||
cancelable?: boolean,
|
||||
detail?: { [key: string]: mixed }
|
||||
}
|
||||
) {
|
||||
const event = document.createEvent('CustomEvent');
|
||||
const { bubbles = true, cancelable = true, detail } = payload || emptyObject;
|
||||
event.initCustomEvent(type, bubbles, cancelable, detail);
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// This accounts for the non-PointerEvent fallback events.
|
||||
function getPointerType(event) {
|
||||
const { pointerType } = event;
|
||||
return pointerType != null ? pointerType : getModality();
|
||||
}
|
||||
|
||||
export default function useHover(targetRef: any, config: HoverEventsConfig): void {
|
||||
const { contain, disabled, onHoverStart, onHoverChange, onHoverUpdate, onHoverEnd } = config;
|
||||
|
||||
const canUsePE = supportsPointerEvent();
|
||||
|
||||
const addMoveListener = useEvent(canUsePE ? 'pointermove' : 'mousemove', opts);
|
||||
const addEnterListener = useEvent(canUsePE ? 'pointerenter' : 'mouseenter', opts);
|
||||
const addLeaveListener = useEvent(canUsePE ? 'pointerleave' : 'mouseleave', opts);
|
||||
// These custom events are used to implement the "contain" prop.
|
||||
const addLockListener = useEvent(lockEventType, opts);
|
||||
const addUnlockListener = useEvent(unlockEventType, opts);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const target = targetRef.current;
|
||||
if (target !== null) {
|
||||
/**
|
||||
* End the hover gesture
|
||||
*/
|
||||
const hoverEnd = function(e) {
|
||||
if (onHoverEnd != null) {
|
||||
onHoverEnd(e);
|
||||
}
|
||||
if (onHoverChange != null) {
|
||||
onHoverChange(false);
|
||||
}
|
||||
// Remove the listeners once finished.
|
||||
addMoveListener(target, null);
|
||||
addLeaveListener(target, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Leave element
|
||||
*/
|
||||
const leaveListener = function(e) {
|
||||
const target = targetRef.current;
|
||||
if (target != null && getPointerType(e) !== 'touch') {
|
||||
if (contain) {
|
||||
dispatchCustomEvent(target, unlockEventType);
|
||||
}
|
||||
hoverEnd(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move within element
|
||||
*/
|
||||
const moveListener = function(e) {
|
||||
if (getPointerType(e) !== 'touch') {
|
||||
if (onHoverUpdate != null) {
|
||||
// Not all browsers have these properties
|
||||
if (e.x == null) {
|
||||
e.x = e.clientX;
|
||||
}
|
||||
if (e.y == null) {
|
||||
e.y = e.clientY;
|
||||
}
|
||||
onHoverUpdate(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the hover gesture
|
||||
*/
|
||||
const hoverStart = function(e) {
|
||||
if (onHoverStart != null) {
|
||||
onHoverStart(e);
|
||||
}
|
||||
if (onHoverChange != null) {
|
||||
onHoverChange(true);
|
||||
}
|
||||
// Set the listeners needed for the rest of the hover gesture.
|
||||
if (onHoverUpdate != null) {
|
||||
addMoveListener(target, !disabled ? moveListener : null);
|
||||
}
|
||||
addLeaveListener(target, !disabled ? leaveListener : null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Enter element
|
||||
*/
|
||||
const enterListener = function(e) {
|
||||
const target = targetRef.current;
|
||||
if (target != null && getPointerType(e) !== 'touch') {
|
||||
if (contain) {
|
||||
dispatchCustomEvent(target, lockEventType);
|
||||
}
|
||||
hoverStart(e);
|
||||
const lockListener = function(lockEvent) {
|
||||
if (lockEvent.target !== target) {
|
||||
hoverEnd(e);
|
||||
}
|
||||
};
|
||||
const unlockListener = function(lockEvent) {
|
||||
if (lockEvent.target !== target) {
|
||||
hoverStart(e);
|
||||
}
|
||||
};
|
||||
addLockListener(target, !disabled ? lockListener : null);
|
||||
addUnlockListener(target, !disabled ? unlockListener : null);
|
||||
}
|
||||
};
|
||||
|
||||
addEnterListener(target, !disabled ? enterListener : null);
|
||||
}
|
||||
}, [
|
||||
addEnterListener,
|
||||
addMoveListener,
|
||||
addLeaveListener,
|
||||
addLockListener,
|
||||
addUnlockListener,
|
||||
contain,
|
||||
disabled,
|
||||
onHoverStart,
|
||||
onHoverChange,
|
||||
onHoverUpdate,
|
||||
onHoverEnd,
|
||||
targetRef
|
||||
]);
|
||||
}
|
||||
100
packages/react-native-web/src/modules/useStable/__tests__/index-test.js
vendored
Normal file
100
packages/react-native-web/src/modules/useStable/__tests__/index-test.js
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import useStable from '..';
|
||||
|
||||
function createRoot(rootNode) {
|
||||
return {
|
||||
render(element) {
|
||||
ReactDOM.render(element, rootNode);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('useStable', () => {
|
||||
let root;
|
||||
let rootNode;
|
||||
let spy = {};
|
||||
|
||||
const TestComponent = ({ initialValueCallback }): React.Node => {
|
||||
const value = useStable(initialValueCallback);
|
||||
spy.value = value;
|
||||
return null;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
spy = {};
|
||||
rootNode = document.createElement('div');
|
||||
document.body.appendChild(rootNode);
|
||||
root = createRoot(rootNode);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.render(null);
|
||||
document.body.removeChild(rootNode);
|
||||
rootNode = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
test('correctly sets the initial value', () => {
|
||||
const initialValueCallback = () => 5;
|
||||
act(() => {
|
||||
root.render(<TestComponent initialValueCallback={initialValueCallback} />);
|
||||
});
|
||||
expect(spy.value).toBe(5);
|
||||
});
|
||||
|
||||
test('does not change the value', () => {
|
||||
let counter = 0;
|
||||
const initialValueCallback = () => counter++;
|
||||
act(() => {
|
||||
root.render(<TestComponent initialValueCallback={initialValueCallback} />);
|
||||
});
|
||||
expect(spy.value).toBe(0);
|
||||
act(() => {
|
||||
root.render(<TestComponent initialValueCallback={initialValueCallback} />);
|
||||
});
|
||||
expect(spy.value).toBe(0);
|
||||
});
|
||||
|
||||
test('only calls the callback once', () => {
|
||||
let counter = 0;
|
||||
const initialValueCallback = () => counter++;
|
||||
act(() => {
|
||||
root.render(<TestComponent initialValueCallback={initialValueCallback} />);
|
||||
});
|
||||
expect(counter).toBe(1);
|
||||
act(() => {
|
||||
root.render(<TestComponent initialValueCallback={initialValueCallback} />);
|
||||
});
|
||||
expect(counter).toBe(1);
|
||||
});
|
||||
|
||||
test('does not change the value if the callback initially returns null', () => {
|
||||
let counter = 0;
|
||||
const initialValueCallback = () => {
|
||||
if (counter === 0) {
|
||||
counter++;
|
||||
return null;
|
||||
}
|
||||
return counter++;
|
||||
};
|
||||
act(() => {
|
||||
root.render(<TestComponent initialValueCallback={initialValueCallback} />);
|
||||
});
|
||||
expect(spy.value).toBe(null);
|
||||
act(() => {
|
||||
root.render(<TestComponent initialValueCallback={initialValueCallback} />);
|
||||
});
|
||||
expect(spy.value).toBe(null);
|
||||
});
|
||||
});
|
||||
24
packages/react-native-web/src/modules/useStable/index.js
vendored
Normal file
24
packages/react-native-web/src/modules/useStable/index.js
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow strict-local
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
opaque type UninitializedMarker = Symbol | {||};
|
||||
|
||||
const UNINITIALIZED: UninitializedMarker =
|
||||
typeof Symbol === 'function' && typeof Symbol() === 'symbol' ? Symbol() : Object.freeze({});
|
||||
|
||||
export default function useStable<T>(getInitialValue: () => T): T {
|
||||
const ref = React.useRef(UNINITIALIZED);
|
||||
if (ref.current === UNINITIALIZED) {
|
||||
ref.current = getInitialValue();
|
||||
}
|
||||
// $FlowFixMe (#64650789) Trouble refining types where `Symbol` is concerned.
|
||||
return ref.current;
|
||||
}
|
||||
Reference in New Issue
Block a user