From 1afd9de0455965a852bad42ad2ffab957f7ffca0 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 10 Sep 2020 14:06:34 -0700 Subject: [PATCH] [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 --- .eslintrc | 1 + .../Pressable/examples/FeedbackEvents.js | 49 +- packages/dom-event-testing-library/README.md | 57 +- .../dom-event-testing-library/package.json | 2 +- .../src/__tests__/index-test.js | 16 +- .../src/createEvent.js | 176 +++--- .../src/domEventSequences.js | 51 +- .../src/domEvents.js | 13 +- .../dom-event-testing-library/src/index.js | 36 +- .../src/exports/Pressable/index.js | 7 +- .../createEventHandle/__tests__/index-test.js | 458 ++++++++++++++++ .../src/modules/createEventHandle/index.js | 113 ++++ .../modules/modality/__tests__/index-test.js | 65 +++ .../src/modules/modality/index.js | 226 ++++++++ .../modules/useEvent/__tests__/index-test.js | 510 ++++++++++++++++++ .../src/modules/useEvent/index.js | 64 +++ .../modules/useHover/__tests__/index-test.js | 341 ++++++++++++ .../src/modules/useHover/index.js | 177 ++++++ .../modules/useStable/__tests__/index-test.js | 100 ++++ .../src/modules/useStable/index.js | 24 + 20 files changed, 2304 insertions(+), 182 deletions(-) create mode 100644 packages/react-native-web/src/modules/createEventHandle/__tests__/index-test.js create mode 100644 packages/react-native-web/src/modules/createEventHandle/index.js create mode 100644 packages/react-native-web/src/modules/modality/__tests__/index-test.js create mode 100644 packages/react-native-web/src/modules/modality/index.js create mode 100644 packages/react-native-web/src/modules/useEvent/__tests__/index-test.js create mode 100644 packages/react-native-web/src/modules/useEvent/index.js create mode 100644 packages/react-native-web/src/modules/useHover/__tests__/index-test.js create mode 100644 packages/react-native-web/src/modules/useHover/index.js create mode 100644 packages/react-native-web/src/modules/useStable/__tests__/index-test.js create mode 100644 packages/react-native-web/src/modules/useStable/index.js diff --git a/.eslintrc b/.eslintrc index e0c377124..669b231dd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -30,6 +30,7 @@ "env": { "browser": true, "es6": true, + "jest": true, "node": true }, "globals": { diff --git a/packages/docs/src/components/Pressable/examples/FeedbackEvents.js b/packages/docs/src/components/Pressable/examples/FeedbackEvents.js index 173bf55ec..883b726b2 100644 --- a/packages/docs/src/components/Pressable/examples/FeedbackEvents.js +++ b/packages/docs/src/components/Pressable/examples/FeedbackEvents.js @@ -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 + }; + }} > ({ - 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 + }; + }} > Nested pressables diff --git a/packages/dom-event-testing-library/README.md b/packages/dom-event-testing-library/README.md index 481ef0730..45b3db8a6 100644 --- a/packages/dom-event-testing-library/README.md +++ b/packages/dom-event-testing-library/README.md @@ -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
+ } + + // render component + act(() => { + render(); }); // 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` diff --git a/packages/dom-event-testing-library/package.json b/packages/dom-event-testing-library/package.json index 4f166ccd4..1c96ceb6e 100644 --- a/packages/dom-event-testing-library/package.json +++ b/packages/dom-event-testing-library/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "dom-event-testing-library", - "version": "0.13.12", + "version": "0.0.0", "main": "index.js", "description": "Browser event sequences for unit tests", "author": "Nicolas Gallagher", diff --git a/packages/dom-event-testing-library/src/__tests__/index-test.js b/packages/dom-event-testing-library/src/__tests__/index-test.js index eaac1c884..456a92ffb 100644 --- a/packages/dom-event-testing-library/src/__tests__/index-test.js +++ b/packages/dom-event-testing-library/src/__tests__/index-test.js @@ -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(); }); diff --git a/packages/dom-event-testing-library/src/createEvent.js b/packages/dom-event-testing-library/src/createEvent.js index 84f483d80..c158829a1 100644 --- a/packages/dom-event-testing-library/src/createEvent.js +++ b/packages/dom-event-testing-library/src/createEvent.js @@ -13,35 +13,28 @@ 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 } - }, - focusin: { - constructorType: 'FocusEvent', - defaultInit: { bubbles: true, cancelable: false, composed: true } + change: { + constructorType: 'Event', + defaultInit: { bubbles: true, cancelable: false } }, - focusout: { - constructorType: 'FocusEvent', - defaultInit: { bubbles: true, cancelable: false, composed: true } + click: { + constructorType: 'MouseEvent', + defaultInit: { bubbles: true, cancelable: true, composed: true } }, - // Keyboard Events - keydown: { - constructorType: 'KeyboardEvent', + compositionend: { + constructorType: 'CompositionEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, - keyup: { - constructorType: 'KeyboardEvent', + compositionstart: { + constructorType: 'CompositionEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, - // Mouse Events - click: { - constructorType: 'MouseEvent', + compositionupdate: { + constructorType: 'CompositionEvent', defaultInit: { bubbles: true, cancelable: true, composed: true } }, contextmenu: { @@ -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; diff --git a/packages/dom-event-testing-library/src/domEventSequences.js b/packages/dom-event-testing-library/src/domEventSequences.js index bbd31e2d0..ac6093a4a 100644 --- a/packages/dom-event-testing-library/src/domEventSequences.js +++ b/packages/dom-event-testing-library/src/domEventSequences.js @@ -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)); + } } } diff --git a/packages/dom-event-testing-library/src/domEvents.js b/packages/dom-event-testing-library/src/domEvents.js index 7fc864308..3854a7c53 100644 --- a/packages/dom-event-testing-library/src/domEvents.js +++ b/packages/dom-event-testing-library/src/domEvents.js @@ -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); } diff --git a/packages/dom-event-testing-library/src/index.js b/packages/dom-event-testing-library/src/index.js index dc3703bc1..a43aea1f1 100644 --- a/packages/dom-event-testing-library/src/index.js +++ b/packages/dom-event-testing-library/src/index.js @@ -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 */ diff --git a/packages/react-native-web/src/exports/Pressable/index.js b/packages/react-native-web/src/exports/Pressable/index.js index b454d9884..8d682cbaa 100644 --- a/packages/react-native-web/src/exports/Pressable/index.js +++ b/packages/react-native-web/src/exports/Pressable/index.js @@ -16,11 +16,13 @@ import type { ViewProps } from '../View'; import * as React from 'react'; import { forwardRef, memo, useMemo, useState, useRef } from 'react'; import setAndForwardRef from '../../modules/setAndForwardRef'; +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); @@ -133,8 +136,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) { diff --git a/packages/react-native-web/src/modules/createEventHandle/__tests__/index-test.js b/packages/react-native-web/src/modules/createEventHandle/__tests__/index-test.js new file mode 100644 index 000000000..202444712 --- /dev/null +++ b/packages/react-native-web/src/modules/createEventHandle/__tests__/index-test.js @@ -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
; + } + + const output = ReactDOMServer.renderToString(); + expect(output).toBe('
'); + }); + + 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
; + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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 ( +
+
text
+
+ ); + } + + act(() => { + root.render(); + }); + + 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
; + } + + act(() => { + root.render(); + }); + 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
; + } + + act(() => { + root.render(); + }); + 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
; + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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
; + } + + act(() => { + root.render(); + }); + + const target = createEventTarget(targetRef.current); + + act(() => { + target.click(); + }); + + expect(firstListener).toBeCalledTimes(1); + expect(secondListener).toBeCalledTimes(0); + }); + }); +}); diff --git a/packages/react-native-web/src/modules/createEventHandle/index.js b/packages/react-native-web/src/modules/createEventHandle/index.js new file mode 100644 index 000000000..c7ffc6d47 --- /dev/null +++ b/packages/react-native-web/src/modules/createEventHandle/index.js @@ -0,0 +1,113 @@ +/** + * 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 normalizeEvent(event: any) { + let defaultPrevented = false; + let propagationStopped = false; + const nativePreventDefault = event.preventDefault; + const nativeStopPropagation = event.stopPropagation; + + event.nativeEvent = event; + event.persist = () => {}; + function preventDefault() { + nativePreventDefault.call(event); + defaultPrevented = true; + Object.defineProperty(event, 'defaultPrevented', { value: true }); + } + Object.defineProperty(event, 'preventDefault', { + configurable: true, + value: preventDefault + }); + function stopPropagation() { + nativeStopPropagation.call(event); + propagationStopped = true; + } + Object.defineProperty(event, 'stopPropagation', { + configurable: true, + value: stopPropagation + }); + event.isDefaultPrevented = () => defaultPrevented; + event.isPropagationStopped = () => propagationStopped; + 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; + } + }; +} diff --git a/packages/react-native-web/src/modules/modality/__tests__/index-test.js b/packages/react-native-web/src/modules/modality/__tests__/index-test.js new file mode 100644 index 000000000..f1de1e7c0 --- /dev/null +++ b/packages/react-native-web/src/modules/modality/__tests__/index-test.js @@ -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'); + } + }); + }); +}); diff --git a/packages/react-native-web/src/modules/modality/index.js b/packages/react-native-web/src/modules/modality/index.js new file mode 100644 index 000000000..3ce8e27c4 --- /dev/null +++ b/packages/react-native-web/src/modules/modality/index.js @@ -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; +} diff --git a/packages/react-native-web/src/modules/useEvent/__tests__/index-test.js b/packages/react-native-web/src/modules/useEvent/__tests__/index-test.js new file mode 100644 index 000000000..5b9d7f3e8 --- /dev/null +++ b/packages/react-native-web/src/modules/useEvent/__tests__/index-test.js @@ -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
; + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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 ( +
+
text
+
+ ); + } + + act(() => { + root.render(); + }); + + 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
; + } + + act(() => { + root.render(); + }); + 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
; + } + + act(() => { + root.render(); + }); + 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
; + } + + act(() => { + root.render(); + }); + const target = createEventTarget(targetRef.current); + act(() => { + target.click(); + }); + expect(listener).toBeCalledTimes(1); + act(() => { + // this should replace the listener + root.render(); + }); + 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
; + } + + act(() => { + root.render(); + }); + const target = createEventTarget(targetRef.current); + act(() => { + target.click(); + }); + expect(listener).toBeCalledTimes(1); + act(() => { + // this should unset the listener + root.render(); + }); + 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
; + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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 ( +
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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
; + } + + act(() => { + root.render(); + 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 ( +
+
+
+ ); + } + + act(() => { + root.render(); + }); + + 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
; + } + + act(() => { + root.render(); + }); + + const target = createEventTarget(targetRef.current); + + act(() => { + target.click(); + }); + + expect(firstListener).toBeCalledTimes(1); + expect(secondListener).toBeCalledTimes(0); + }); + }); +}); diff --git a/packages/react-native-web/src/modules/useEvent/index.js b/packages/react-native-web/src/modules/useEvent/index.js new file mode 100644 index 000000000..8f90b445a --- /dev/null +++ b/packages/react-native-web/src/modules/useEvent/index.js @@ -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; +} diff --git a/packages/react-native-web/src/modules/useHover/__tests__/index-test.js b/packages/react-native-web/src/modules/useHover/__tests__/index-test.js new file mode 100644 index 000000000..1c9313b0d --- /dev/null +++ b/packages/react-native-web/src/modules/useHover/__tests__/index-test.js @@ -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 ( +
+
+
+ ); + }; + act(() => { + root.render(); + }); + }; + + 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
; + }; + act(() => { + root.render(); + }); + }; + + 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
; + }; + act(() => { + root.render(); + }); + }; + + 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
; + }; + act(() => { + root.render(); + }); + }; + + 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 ( +
+
+
+ ); + }; + act(() => { + root.render(); + }); + }; + + 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
; + }; + act(() => { + root.render(); + }); + + 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
; + }; + act(() => { + root.render(); + }); + }; + + 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); + }); + }); +}); diff --git a/packages/react-native-web/src/modules/useHover/index.js b/packages/react-native-web/src/modules/useHover/index.js new file mode 100644 index 000000000..473dbbef8 --- /dev/null +++ b/packages/react-native-web/src/modules/useHover/index.js @@ -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 + ]); +} diff --git a/packages/react-native-web/src/modules/useStable/__tests__/index-test.js b/packages/react-native-web/src/modules/useStable/__tests__/index-test.js new file mode 100644 index 000000000..983f04de9 --- /dev/null +++ b/packages/react-native-web/src/modules/useStable/__tests__/index-test.js @@ -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(); + }); + expect(spy.value).toBe(5); + }); + + test('does not change the value', () => { + let counter = 0; + const initialValueCallback = () => counter++; + act(() => { + root.render(); + }); + expect(spy.value).toBe(0); + act(() => { + root.render(); + }); + expect(spy.value).toBe(0); + }); + + test('only calls the callback once', () => { + let counter = 0; + const initialValueCallback = () => counter++; + act(() => { + root.render(); + }); + expect(counter).toBe(1); + act(() => { + root.render(); + }); + 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(); + }); + expect(spy.value).toBe(null); + act(() => { + root.render(); + }); + expect(spy.value).toBe(null); + }); +}); diff --git a/packages/react-native-web/src/modules/useStable/index.js b/packages/react-native-web/src/modules/useStable/index.js new file mode 100644 index 000000000..a5a1f8974 --- /dev/null +++ b/packages/react-native-web/src/modules/useStable/index.js @@ -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(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; +}