diff --git a/src/CONST.js b/src/CONST.js index 3326bfd69bb6..a36bf2459ee9 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -2280,6 +2280,11 @@ const CONST = { 'PLN', 'GBP', 'RUB', 'SGD', 'SEK', 'CHF', 'THB', 'USD', ], CONCIERGE_TRAVEL_URL: 'https://community.expensify.com/discussion/7066/introducing-concierge-travel', + SCREEN_READER_STATES: { + ALL: 'all', + ACTIVE: 'active', + DISABLED: 'disabled', + }, }; export default CONST; diff --git a/src/components/OpacityView.js b/src/components/OpacityView.js index 9b9d24861323..2b0958ae7db5 100644 --- a/src/components/OpacityView.js +++ b/src/components/OpacityView.js @@ -1,71 +1,63 @@ import React from 'react'; -import {Animated} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import PropTypes from 'prop-types'; +import * as StyleUtils from '../styles/StyleUtils'; +import variables from '../styles/variables'; const propTypes = { - // Should we dim the view + /** + * Should we dim the view + */ shouldDim: PropTypes.bool.isRequired, - // Content to render + /** + * Content to render + */ children: PropTypes.node.isRequired, - // Array of style objects + /** + * Array of style objects + * @default [] + */ // eslint-disable-next-line react/forbid-prop-types style: PropTypes.arrayOf(PropTypes.object), + + /** + * The value to use for the opacity when the view is dimmed + * @default 0.5 + */ + dimmingValue: PropTypes.number, }; const defaultProps = { style: [], + dimmingValue: variables.hoverDimValue, }; -class OpacityView extends React.Component { - constructor(props) { - super(props); - this.opacity = new Animated.Value(1); - this.undim = this.undim.bind(this); - } - - componentDidUpdate(prevProps) { - if (!prevProps.shouldDim && this.props.shouldDim) { - Animated.timing(this.opacity, { - toValue: 0.5, - duration: 50, - useNativeDriver: true, - }).start(); - } - - if (prevProps.shouldDim && !this.props.shouldDim) { - this.undim(); +const OpacityView = (props) => { + const opacity = useSharedValue(1); + const opacityStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + React.useEffect(() => { + if (props.shouldDim) { + opacity.value = withTiming(props.dimmingValue, {duration: 50}); + } else { + opacity.value = withTiming(1, {duration: 50}); } - } - - undim() { - Animated.timing(this.opacity, { - toValue: 1, - duration: 50, - useNativeDriver: true, - }).start(({finished}) => { - // If animation doesn't finish because Animation.stop was called - // (e.g. because it was interrupted by a gesture or another animation), - // restart animation so we always make sure the component gets completely shown. - if (finished) { - return; - } - this.undim(); - }); - } - - render() { - return ( - - {this.props.children} - - ); - } -} + }, [props.shouldDim, props.dimmingValue, opacity]); + + return ( + + {props.children} + + ); +}; +OpacityView.displayName = 'OpacityView'; OpacityView.propTypes = propTypes; OpacityView.defaultProps = defaultProps; export default OpacityView; diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js new file mode 100644 index 000000000000..6caba1c39cd0 --- /dev/null +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js @@ -0,0 +1,158 @@ +import React, { + useCallback, + useEffect, + useMemo, + forwardRef, +} from 'react'; +import {Pressable} from 'react-native'; +import _ from 'underscore'; +import Accessibility from '../../../libs/Accessibility'; +import HapticFeedback from '../../../libs/HapticFeedback'; +import KeyboardShortcut from '../../../libs/KeyboardShortcut'; +import styles from '../../../styles/styles'; +import cursor from '../../../styles/utilities/cursor'; +import genericPressablePropTypes from './PropTypes'; +import CONST from '../../../CONST'; +import * as StyleUtils from '../../../styles/StyleUtils'; + +/** + * Returns the cursor style based on the state of Pressable + * @param {Boolean} isDisabled + * @param {Boolean} isText + * @returns {Object} + */ +const getCursorStyle = (isDisabled, isText) => { + if (isDisabled) { + return cursor.cursorDisabled; + } + + if (isText) { + return cursor.cursorText; + } + + return cursor.cursorPointer; +}; + +const GenericPressable = forwardRef((props, ref) => { + const { + children, + onPress, + onLongPress, + onKeyPress, + disabled, + style, + accessibilityHint, + shouldUseHapticsOnLongPress, + shouldUseHapticsOnPress, + nextFocusRef, + keyboardShortcut, + shouldUseAutoHitSlop, + enableInScreenReaderStates, + onPressIn, + onPressOut, + ...rest + } = props; + + const isScreenReaderActive = Accessibility.useScreenReaderStatus(); + const [hitSlop, onLayout] = Accessibility.useAutoHitSlop(); + + const isDisabled = useMemo(() => { + let shouldBeDisabledByScreenReader = false; + if (enableInScreenReaderStates === CONST.SCREEN_READER_STATES.ACTIVE) { + shouldBeDisabledByScreenReader = !isScreenReaderActive; + } + + if (enableInScreenReaderStates === CONST.SCREEN_READER_STATES.DISABLED) { + shouldBeDisabledByScreenReader = isScreenReaderActive; + } + + return props.disabled || shouldBeDisabledByScreenReader; + }, [isScreenReaderActive, enableInScreenReaderStates, props.disabled]); + + const onLongPressHandler = useCallback(() => { + if (!onLongPress) { + return; + } + if (shouldUseHapticsOnLongPress) { + HapticFeedback.longPress(); + } + if (ref.current) { + ref.current.blur(); + } + onLongPress(); + + Accessibility.moveAccessibilityFocus(nextFocusRef); + }, [shouldUseHapticsOnLongPress, onLongPress, nextFocusRef, ref]); + + const onPressHandler = useCallback(() => { + if (shouldUseHapticsOnPress) { + HapticFeedback.press(); + } + if (ref.current) { + ref.current.blur(); + } + onPress(); + + Accessibility.moveAccessibilityFocus(nextFocusRef); + }, [shouldUseHapticsOnPress, onPress, nextFocusRef, ref]); + + const onKeyPressHandler = useCallback((event) => { + if (event.key !== 'Enter') { + return; + } + onPressHandler(); + }, [onPressHandler]); + + useEffect(() => { + if (!keyboardShortcut) { + return () => {}; + } + const {shortcutKey, descriptionKey, modifiers} = keyboardShortcut; + return KeyboardShortcut.subscribe(shortcutKey, onPressHandler, descriptionKey, modifiers, true, false, 0, false); + }, [keyboardShortcut, onPressHandler]); + + return ( + [ + getCursorStyle(isDisabled, [props.accessibilityRole, props.role].includes('text')), + props.style, + isScreenReaderActive && StyleUtils.parseStyleFromFunction(props.screenReaderActiveStyle, state), + state.focused && StyleUtils.parseStyleFromFunction(props.focusStyle, state), + state.hovered && StyleUtils.parseStyleFromFunction(props.hoverStyle, state), + state.pressed && StyleUtils.parseStyleFromFunction(props.pressStyle, state), + isDisabled && [...StyleUtils.parseStyleFromFunction(props.disabledStyle, state), styles.noSelect], + ]} + + // accessibility props + accessibilityState={{ + disabled: isDisabled, + ...props.accessibilityState, + }} + aria-disabled={isDisabled} + aria-keyshortcuts={keyboardShortcut && `${keyboardShortcut.modifiers}+${keyboardShortcut.shortcutKey}`} + + // ios-only form of inputs + onMagicTap={!isDisabled && onPressHandler} + onAccessibilityTap={!isDisabled && onPressHandler} + + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + > + {state => (_.isFunction(props.children) ? props.children({...state, isScreenReaderActive, isDisabled}) : props.children)} + + ); +}); + +GenericPressable.displayName = 'GenericPressable'; +GenericPressable.propTypes = genericPressablePropTypes.pressablePropTypes; +GenericPressable.defaultProps = genericPressablePropTypes.defaultProps; + +export default GenericPressable; diff --git a/src/components/Pressable/GenericPressable/PropTypes.js b/src/components/Pressable/GenericPressable/PropTypes.js new file mode 100644 index 000000000000..a87f8d83c02a --- /dev/null +++ b/src/components/Pressable/GenericPressable/PropTypes.js @@ -0,0 +1,130 @@ +import PropTypes from 'prop-types'; +import stylePropType from '../../../styles/stylePropTypes'; +import CONST from '../../../CONST'; + +const stylePropTypeWithFunction = PropTypes.oneOfType([ + stylePropType, + PropTypes.func, +]); + +const pressablePropTypes = { + /** + * onPress callback + */ + onPress: PropTypes.func.isRequired, + + /** + * Specifies keyboard shortcut to trigger onPressHandler + * @example {shortcutKey: 'a', modifiers: ['ctrl', 'shift'], descriptionKey: 'keyboardShortcut.description'} + */ + keyboardShortcut: PropTypes.shape({ + descriptionKey: PropTypes.string.isRequired, + shortcutKey: PropTypes.string.isRequired, + modifiers: PropTypes.arrayOf(PropTypes.string), + }), + + /** + * Specifies if haptic feedback should be used on press + * @default false + */ + shouldUseHapticsOnPress: PropTypes.bool, + + /** + * Specifies if haptic feedback should be used on long press + * @default false + */ + shouldUseHapticsOnLongPress: PropTypes.bool, + + /** + * style for when the component is disabled. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive) + * @default {} + * @example {backgroundColor: 'red'} + * @example state => ({backgroundColor: state.isDisabled ? 'red' : 'blue'}) + */ + disabledStyle: stylePropTypeWithFunction, + + /** + * style for when the component is hovered. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive) + * @default {} + * @example {backgroundColor: 'red'} + * @example state => ({backgroundColor: state.hovered ? 'red' : 'blue'}) + */ + hoverStyle: stylePropTypeWithFunction, + + /** + * style for when the component is focused. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive) + * @default {} + * @example {backgroundColor: 'red'} + * @example state => ({backgroundColor: state.focused ? 'red' : 'blue'}) + */ + focusStyle: stylePropTypeWithFunction, + + /** + * style for when the component is pressed. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive) + * @default {} + * @example {backgroundColor: 'red'} + * @example state => ({backgroundColor: state.pressed ? 'red' : 'blue'}) + */ + pressStyle: stylePropTypeWithFunction, + + /** + * style for when the component is active and the screen reader is on. + * Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive) + * @default {} + * @example {backgroundColor: 'red'} + * @example state => ({backgroundColor: state.isScreenReaderActive ? 'red' : 'blue'}) + */ + screenReaderActiveStyle: stylePropTypeWithFunction, + + /** + * Specifies if the component should be accessible when the screen reader is on + * @default 'all' + * @example 'all' - the component is accessible regardless of screen reader state + * @example 'active' - the component is accessible only when the screen reader is on + * @example 'disabled' - the component is not accessible when the screen reader is on + */ + enableInScreenReaderStates: PropTypes.oneOf([CONST.SCREEN_READER_STATES.ALL, CONST.SCREEN_READER_STATES.ACTIVE, CONST.SCREEN_READER_STATES.DISABLED]), + + /** + * Specifies which component should be focused after interacting with this component + */ + nextFocusRef: PropTypes.func, + + /** + * Specifies the accessibility label for the component + * @example 'Search' + * @example 'Close' + */ + accessibilityLabel: PropTypes.string.isRequired, + + /** + * Specifies the accessibility hint for the component + * @example 'Double tap to open' + */ + accessibilityHint: PropTypes.string, + + /** + * Specifies if the component should calculate its hitSlop automatically + * @default true + */ + shouldUseAutoHitSlop: PropTypes.bool, +}; + +const defaultProps = { + keyboardShortcut: undefined, + shouldUseHapticsOnPress: false, + shouldUseHapticsOnLongPress: false, + disabledStyle: {}, + hoverStyle: {}, + focusStyle: {}, + pressStyle: {}, + screenReaderActiveStyle: {}, + enableInScreenReaderStates: CONST.SCREEN_READER_STATES.ALL, + nextFocusRef: undefined, + shouldUseAutoHitSlop: true, +}; + +export default { + pressablePropTypes, + defaultProps, +}; diff --git a/src/components/Pressable/GenericPressable/index.js b/src/components/Pressable/GenericPressable/index.js new file mode 100644 index 000000000000..a5125653b949 --- /dev/null +++ b/src/components/Pressable/GenericPressable/index.js @@ -0,0 +1,24 @@ +import React, {forwardRef} from 'react'; +import GenericPressable from './BaseGenericPressable'; +import GenericPressablePropTypes from './PropTypes'; + +const WebGenericPressable = forwardRef((props, ref) => ( + +)); + +WebGenericPressable.propTypes = GenericPressablePropTypes.pressablePropTypes; +WebGenericPressable.defaultProps = GenericPressablePropTypes.defaultProps; + +export default WebGenericPressable; diff --git a/src/components/Pressable/GenericPressable/index.native.js b/src/components/Pressable/GenericPressable/index.native.js new file mode 100644 index 000000000000..02f06dad8a02 --- /dev/null +++ b/src/components/Pressable/GenericPressable/index.native.js @@ -0,0 +1,20 @@ +import React, {forwardRef} from 'react'; +import GenericPressable from './BaseGenericPressable'; +import GenericPressablePropTypes from './PropTypes'; + +const NativeGenericPressable = forwardRef((props, ref) => ( + +)); + +NativeGenericPressable.propTypes = GenericPressablePropTypes.pressablePropTypes; +NativeGenericPressable.defaultProps = GenericPressablePropTypes.defaultProps; + +export default NativeGenericPressable; diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js new file mode 100644 index 000000000000..66401fc1d974 --- /dev/null +++ b/src/components/Pressable/PressableWithFeedback.js @@ -0,0 +1,52 @@ +import React, {forwardRef} from 'react'; +import _ from 'underscore'; +import propTypes from 'prop-types'; +import GenericPressable from './GenericPressable'; +import GenericPressablePropTypes from './GenericPressable/PropTypes'; +import OpacityView from '../OpacityView'; +import variables from '../../styles/variables'; +import * as StyleUtils from '../../styles/StyleUtils'; + +const omittedProps = ['style', 'pressStyle', 'hoverStyle', 'focusStyle', 'wrapperStyle']; + +const PressableWithFeedbackPropTypes = { + ..._.omit(GenericPressablePropTypes.pressablePropTypes, omittedProps), + pressDimmingValue: propTypes.number, + hoverDimmingValue: propTypes.number, +}; + +const PressableWithFeedbackDefaultProps = { + ..._.omit(GenericPressablePropTypes.defaultProps, omittedProps), + pressDimmingValue: variables.pressDimValue, + hoverDimmingValue: variables.hoverDimValue, + wrapperStyle: [], +}; + +const PressableWithFeedback = forwardRef((props, ref) => { + const propsWithoutStyling = _.omit(props, omittedProps); + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {state => ( + + {props.children} + + )} + + ); +}); + +PressableWithFeedback.displayName = 'PressableWithFeedback'; +PressableWithFeedback.propTypes = PressableWithFeedbackPropTypes; +PressableWithFeedback.defaultProps = PressableWithFeedbackDefaultProps; + +export default PressableWithFeedback; diff --git a/src/components/Pressable/PressableWithoutFeedback.js b/src/components/Pressable/PressableWithoutFeedback.js new file mode 100644 index 000000000000..a12862bcd0fd --- /dev/null +++ b/src/components/Pressable/PressableWithoutFeedback.js @@ -0,0 +1,27 @@ +import React from 'react'; +import _ from 'underscore'; +import GenericPressable from './GenericPressable'; +import GenericPressableProps from './GenericPressable/PropTypes'; + +const omittedProps = [ + 'pressStyle', + 'hoverStyle', + 'focusStyle', + 'activeStyle', + 'disabledStyle', + 'screenReaderActiveStyle', + 'shouldUseHapticsOnPress', + 'shouldUseHapticsOnLongPress', +]; + +const PressableWithoutFeedback = (props) => { + const propsWithoutStyling = _.omit(props, omittedProps); + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +}; + +PressableWithoutFeedback.displayName = 'PressableWithoutFeedback'; +PressableWithoutFeedback.propTypes = _.omit(GenericPressableProps.pressablePropTypes, omittedProps); +PressableWithoutFeedback.defaultProps = _.omit(GenericPressableProps.defaultProps, omittedProps); + +export default PressableWithoutFeedback; diff --git a/src/components/Pressable/index.js b/src/components/Pressable/index.js new file mode 100644 index 000000000000..83f597b3de8d --- /dev/null +++ b/src/components/Pressable/index.js @@ -0,0 +1,3 @@ +export {default as GenericPressable} from './GenericPressable'; +export {default as PressableWithFeedback} from './PressableWithFeedback'; +export {default as PressableWithoutFeedback} from './PressableWithoutFeedback'; diff --git a/src/libs/Accessibility/index.js b/src/libs/Accessibility/index.js new file mode 100644 index 000000000000..607c01836405 --- /dev/null +++ b/src/libs/Accessibility/index.js @@ -0,0 +1,46 @@ +import {useEffect, useState, useCallback} from 'react'; +import {AccessibilityInfo} from 'react-native'; +import _ from 'underscore'; +import moveAccessibilityFocus from './moveAccessibilityFocus'; + +const useScreenReaderStatus = () => { + const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); + useEffect(() => { + const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', setIsScreenReaderEnabled); + + return subscription.remove; + }, []); + + return isScreenReaderEnabled; +}; + +const getHitSlopForSize = ({x, y}) => { + /* according to https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/ + the minimum tappable area is 44x44 points */ + const minimumSize = 44; + const hitSlopVertical = _.max([minimumSize - x, 0]); + const hitSlopHorizontal = _.max([minimumSize - y, 0]); + return { + top: hitSlopVertical, + bottom: hitSlopVertical, + left: hitSlopHorizontal, + right: hitSlopHorizontal, + }; +}; + +const useAutoHitSlop = () => { + const [frameSize, setFrameSize] = useState({x: 0, y: 0}); + const onLayout = useCallback((event) => { + const {layout} = event.nativeEvent; + if (layout.width !== frameSize.x && layout.height !== frameSize.y) { + setFrameSize({x: layout.width, y: layout.height}); + } + }, [frameSize]); + return [getHitSlopForSize(frameSize), onLayout]; +}; + +export default { + moveAccessibilityFocus, + useScreenReaderStatus, + useAutoHitSlop, +}; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.js b/src/libs/Accessibility/moveAccessibilityFocus/index.js new file mode 100644 index 000000000000..c9130c7e34be --- /dev/null +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.js @@ -0,0 +1,8 @@ +const moveAccessibilityFocus = (ref) => { + if (!ref || !ref.current) { + return; + } + ref.current.focus(); +}; + +export default moveAccessibilityFocus; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.native.js b/src/libs/Accessibility/moveAccessibilityFocus/index.native.js new file mode 100644 index 000000000000..91605e06243d --- /dev/null +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.native.js @@ -0,0 +1,10 @@ +import {AccessibilityInfo} from 'react-native'; + +const moveAccessibilityFocus = (ref) => { + if (!ref) { + return; + } + AccessibilityInfo.sendAccessibilityEvent(ref, 'focus'); +}; + +export default moveAccessibilityFocus; diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index 1df9d5beccd7..67e1a9335f90 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -648,6 +648,17 @@ function parseStyleAsArray(styleParam) { return _.isArray(styleParam) ? styleParam : [styleParam]; } +/** + * Parse style function and return Styles object + * @param {Object|Object[]|Function} style + * @param {Object} state + * @returns {Object[]} + */ +function parseStyleFromFunction(style, state) { + const functionAppliedStyle = _.isFunction(style) ? style(state) : style; + return parseStyleAsArray(functionAppliedStyle); +} + /** * Receives any number of object or array style objects and returns them all as an array * @param {Object|Object[]} allStyles @@ -1033,6 +1044,7 @@ export { getPaymentMethodMenuWidth, getThemeBackgroundColor, parseStyleAsArray, + parseStyleFromFunction, combineStyles, getPaddingLeft, convertToLTR, diff --git a/src/styles/styles.js b/src/styles/styles.js index afde881c9445..ce468051d9ee 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -2505,6 +2505,14 @@ const styles = { outline: 'none', }, + cursorPointer: { + cursor: 'pointer', + }, + + cursorText: { + cursor: 'text', + }, + fullscreenCard: { position: 'absolute', left: 0, diff --git a/src/styles/utilities/cursor/index.js b/src/styles/utilities/cursor/index.js index 85aef855d2d9..00f7f9a7b1e3 100644 --- a/src/styles/utilities/cursor/index.js +++ b/src/styles/utilities/cursor/index.js @@ -29,4 +29,7 @@ export default { cursorInitial: { cursor: 'initial', }, + cursorText: { + cursor: 'text', + }, }; diff --git a/src/styles/utilities/cursor/index.native.js b/src/styles/utilities/cursor/index.native.js index 99c7070fb477..95704e55b07a 100644 --- a/src/styles/utilities/cursor/index.native.js +++ b/src/styles/utilities/cursor/index.native.js @@ -9,4 +9,5 @@ export default { cursorGrabbing: {}, cursorZoomOut: {}, cursorInitial: {}, + cursorText: {}, }; diff --git a/src/styles/variables.js b/src/styles/variables.js index c63bb6905e95..9b2882a5c4e5 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -132,4 +132,6 @@ export default { // The height of the empty list is 14px (2px for borders and 12px for vertical padding) // This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility googleEmptyListViewHeight: 14, + hoverDimValue: 0.5, + pressDimValue: 0.2, };