diff --git a/packages/react-native-web/src/exports/BackHandler/index.js b/packages/react-native-web/src/exports/BackHandler/index.js index b55735295..4731fd08c 100644 --- a/packages/react-native-web/src/exports/BackHandler/index.js +++ b/packages/react-native-web/src/exports/BackHandler/index.js @@ -12,9 +12,19 @@ function emptyFunction() {} const BackHandler = { exitApp: emptyFunction, - addEventListener() { + /** + * Listen to "hardwareBackPress" event + * + * @param event + * @param callback + * @returns {{remove: remove}} + */ + addEventListener(event: string, callback: Function) { + document.addEventListener(event, callback); return { - remove: emptyFunction + remove: () => { + document.removeEventListener(event, callback); + } }; }, removeEventListener: emptyFunction diff --git a/packages/react-native-web/src/exports/Platform/index.js b/packages/react-native-web/src/exports/Platform/index.js index d7c8f766f..95ef638cd 100644 --- a/packages/react-native-web/src/exports/Platform/index.js +++ b/packages/react-native-web/src/exports/Platform/index.js @@ -16,6 +16,9 @@ const Platform = { return true; } return false; + }, + get isTV(): boolean { + return process.env.REACT_APP_IS_TV === 'true'; } }; diff --git a/packages/react-native-web/src/exports/TVEventHandler/index.js b/packages/react-native-web/src/exports/TVEventHandler/index.js index ff8b4c563..9fed73d81 100644 --- a/packages/react-native-web/src/exports/TVEventHandler/index.js +++ b/packages/react-native-web/src/exports/TVEventHandler/index.js @@ -1 +1,99 @@ -export default {}; +class TVEventHandler { + constructor() { + this.component = null; + this.callback = null; + this.onHWKeyEvent = this.onHWKeyEvent.bind(this); + } + + enable(component, callback) { + this.component = component; + this.callback = callback; + document.addEventListener('onHWKeyEvent', this.onHWKeyEvent); + } + + disable() { + document.removeEventListener('onHWKeyEvent', this.onHWKeyEvent); + this.component = null; + this.callback = null; + } + + onHWKeyEvent(event) { + if (this.callback) { + if (event && event.detail) { + const tvEvent = event.detail.tvEvent; + if (tvEvent) { + this.callback(this.component, tvEvent); + } + } + } + } + + static dispatchEvent(tvEvent) { + // Dispatch tvEvent through onHWKeyEvent + // eslint-disable-next-line no-undef + const hwKeyEvent = new CustomEvent('onHWKeyEvent', { + detail: { tvEvent: tvEvent } + }); + document.dispatchEvent(hwKeyEvent); + } + + static getTVEvent(event) { + // create tv event + const tvEvent = { + eventKeyAction: -1, + eventType: '', + tag: '' + }; + // Key Event + if (event.type === 'keydown' || event.type === 'keyup') { + // get event type + switch (event.key) { + case 'Enter': + tvEvent.eventType = 'select'; + break; + case 'ArrowUp': + tvEvent.eventType = 'up'; + break; + case 'ArrowRight': + tvEvent.eventType = 'right'; + break; + case 'ArrowDown': + tvEvent.eventType = 'down'; + break; + case 'ArrowLeft': + tvEvent.eventType = 'left'; + break; + case 'MediaPlayPause': + tvEvent.eventType = 'playPause'; + break; + case 'MediaRewind': + tvEvent.eventType = 'rewind'; + break; + case 'MediaFastForward': + tvEvent.eventType = 'fastForward'; + break; + case 'Menu': + tvEvent.eventType = 'menu'; + break; + default: + tvEvent.eventType = ''; + } + if (event.type === 'keydown') { + tvEvent.eventKeyAction = 0; + } else if (event.type === 'keyup') { + tvEvent.eventKeyAction = 1; + } + } + // Focus / Blur event + else if (event.type === 'focus' || event.type === 'blur') { + tvEvent.eventType = event.type; + } + // Get tag from id attribute + if (event.target && event.target.id) { + tvEvent.tag = event.target.id; + } + return tvEvent; + } +} + +export default TVEventHandler; diff --git a/packages/react-native-web/src/exports/TextInput/index.js b/packages/react-native-web/src/exports/TextInput/index.js index 1447e370e..6d4472a15 100644 --- a/packages/react-native-web/src/exports/TextInput/index.js +++ b/packages/react-native-web/src/exports/TextInput/index.js @@ -22,6 +22,7 @@ import usePlatformMethods from '../../modules/usePlatformMethods'; import useResponderEvents from '../../modules/useResponderEvents'; import StyleSheet from '../StyleSheet'; import TextInputState from '../../modules/TextInputState'; +import Platform from '../Platform'; /** * Determines whether a 'selection' prop differs from a node's existing @@ -244,8 +245,10 @@ const TextInput = forwardRef((props, forwardedRef) => { } function handleKeyDown(e) { - // Prevent key events bubbling (see #612) - e.stopPropagation(); + if (!Platform.isTV) { + // Prevent key events bubbling (see #612) + e.stopPropagation(); + } const blurOnSubmitDefault = !multiline; const shouldBlurOnSubmit = blurOnSubmit == null ? blurOnSubmitDefault : blurOnSubmit; diff --git a/packages/react-native-web/src/exports/TouchableHighlight/index.js b/packages/react-native-web/src/exports/TouchableHighlight/index.js index 9cf254c7e..6023fa7c3 100644 --- a/packages/react-native-web/src/exports/TouchableHighlight/index.js +++ b/packages/react-native-web/src/exports/TouchableHighlight/index.js @@ -18,6 +18,7 @@ import * as React from 'react'; import { useCallback, useMemo, useState, useRef } from 'react'; import useMergeRefs from '../../modules/useMergeRefs'; import usePressEvents from '../../modules/usePressEvents'; +import useTVEvents from '../../modules/useTVEvents'; import StyleSheet from '../StyleSheet'; import View from '../View'; @@ -30,7 +31,13 @@ type Props = $ReadOnly<{| onShowUnderlay?: ?() => void, style?: ViewStyle, testOnly_pressed?: ?boolean, - underlayColor?: ?ColorValue + underlayColor?: ?ColorValue, + hasTVPreferredFocus?: ?boolean, + nextFocusDown?: ?any, + nextFocusForward?: ?any, + nextFocusLeft?: ?any, + nextFocusRight?: ?any, + nextFocusUp?: ?any |}>; type ExtraStyles = $ReadOnly<{| @@ -78,6 +85,14 @@ function TouchableHighlight(props: Props, forwardedRef): React.Node { delayLongPress, disabled, focusable, + hasTVPreferredFocus, + nextFocusDown, + nextFocusForward, + nextFocusLeft, + nextFocusRight, + nextFocusUp, + onFocus, + onBlur, onHideUnderlay, onLongPress, onPress, @@ -159,12 +174,40 @@ function TouchableHighlight(props: Props, forwardedRef): React.Node { const pressEventHandlers = usePressEvents(hostRef, pressConfig); + const tvConfig = useMemo( + () => ({ + hasTVPreferredFocus, + nextFocusDown, + nextFocusForward, + nextFocusLeft, + nextFocusRight, + nextFocusUp, + onPress, + onFocus, + onBlur + }), + [ + hasTVPreferredFocus, + nextFocusDown, + nextFocusForward, + nextFocusLeft, + nextFocusRight, + nextFocusUp, + onPress, + onFocus, + onBlur + ] + ); + + const tvEventHandlers = useTVEvents(hostRef, tvConfig); + const child = React.Children.only(children); return ( void, + onFocus?: ?(event: Event) => void, + onBlur?: ?(event: Event) => void +|}>; + +export default function useTVEvents( + hostRef: React.ElementRef, + config: TVResponderConfig +) { + useEffect(() => { + if (Platform.isTV && config.hasTVPreferredFocus) { + if (hostRef.current) { + UIManager.focus(hostRef.current); + } + } + }, [config.hasTVPreferredFocus, hostRef]); + + function onKeyEvent(event: Event) { + const { type, key } = event; + // Get tvEvent + const tvEvent = TVEventHandler.getTVEvent(event); + // Dispatch 'select' tvEvent to component + if (tvEvent.eventType === 'select') { + if (config.onPress) { + config.onPress(tvEvent); + } + } + // Dispatch tvEvent to all listeners + TVEventHandler.dispatchEvent(tvEvent); + // Handle next focus + let nextElement = null; + // Check nextFocus properties set using : nextFocus*={findNodeHandle(ref.current)} + if (config.nextFocusUp && key === 'ArrowUp') { + nextElement = config.nextFocusUp; + } else if (config.nextFocusRight && key === 'ArrowRight') { + nextElement = config.nextFocusRight; + } else if (config.nextFocusDown && key === 'ArrowDown') { + nextElement = config.nextFocusDown; + } else if (config.nextFocusLeft && key === 'ArrowLeft') { + nextElement = config.nextFocusLeft; + } else if (config.nextFocusForward && key === 'ArrowRight') { + nextElement = config.nextFocusForward; + } + if (nextElement) { + // Focus if element is focusable + UIManager.focus(nextElement); + // Stop event propagation + event.stopPropagation(); + } + // Check nextFocus properties set using : ref.current.setNativeProps({nextFocus*: nativeID} + let nextFocusID = ''; + // Check nextFocus* properties + if (hostRef.current.hasAttribute('nextFocusUp') && key === 'ArrowUp') { + nextFocusID = hostRef.current.getAttribute('nextFocusUp'); + } else if (hostRef.current.hasAttribute('nextFocusRight') && key === 'ArrowRight') { + nextFocusID = hostRef.current.getAttribute('nextFocusRight'); + } else if (hostRef.current.hasAttribute('nextFocusDown') && key === 'ArrowDown') { + nextFocusID = hostRef.current.getAttribute('nextFocusDown'); + } else if (hostRef.current.hasAttribute('nextFocusLeft') && key === 'ArrowLeft') { + nextFocusID = hostRef.current.getAttribute('nextFocusLeft'); + } else if (hostRef.current.hasAttribute('nextFocusForward') && key === 'ArrowRight') { + nextFocusID = hostRef.current.getAttribute('nextFocusForward'); + } + if (nextFocusID && nextFocusID !== '') { + // Get DOM element + nextElement = document.getElementById(nextFocusID); + if (nextElement) { + // Focus is element if focusable + UIManager.focus(nextElement); + // Stop event propagation + event.stopPropagation(); + } + } + // Trigger Hardware Back Press for Back/Escape event keys + if (type === 'keydown' && (key === 'Back' || key === 'Escape')) { + // eslint-disable-next-line no-undef + const hwKeyEvent = new CustomEvent('hardwareBackPress', {}); + document.dispatchEvent(hwKeyEvent); + } + } + + const tvEventHandlers = Platform.isTV + ? { + onFocus: (event: Event) => { + // Get tvEvent + const tvEvent = TVEventHandler.getTVEvent(event); + // Dispatch tvEvent to component + if (config.onFocus) { + config.onFocus(tvEvent); + } + // Dispatch tvEvent to all listeners + TVEventHandler.dispatchEvent(tvEvent); + }, + onBlur: (event: Event) => { + // Get tvEvent + const tvEvent = TVEventHandler.getTVEvent(event); + // Dispatch tvEvent to component + if (config.onBlur) { + config.onBlur(tvEvent); + } + // Dispatch tvEvent to all listeners + TVEventHandler.dispatchEvent(tvEvent); + }, + onKeyDown: onKeyEvent, + onKeyUp: onKeyEvent + } + : {}; + + return tvEventHandlers; +}