Skip to content

Commit

Permalink
Merge pull request #17404 from robertKozik/generic-pressable
Browse files Browse the repository at this point in the history
[Accessible Pressable] Create GenericPressable, PressableWithFeedback, PressableWithoutFeedback
  • Loading branch information
roryabraham authored Apr 27, 2023
2 parents 4fefc48 + 66782e5 commit 1b4e892
Show file tree
Hide file tree
Showing 17 changed files with 551 additions and 50 deletions.
5 changes: 5 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -2275,6 +2275,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;
92 changes: 42 additions & 50 deletions src/components/OpacityView.js
Original file line number Diff line number Diff line change
@@ -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 (
<Animated.View
style={[{opacity: this.opacity}, ...this.props.style]}
>
{this.props.children}
</Animated.View>
);
}
}
}, [props.shouldDim, props.dimmingValue, opacity]);

return (
<Animated.View
style={[opacityStyle, ...StyleUtils.parseStyleAsArray(props.style)]}
>
{props.children}
</Animated.View>
);
};

OpacityView.displayName = 'OpacityView';
OpacityView.propTypes = propTypes;
OpacityView.defaultProps = defaultProps;
export default OpacityView;
158 changes: 158 additions & 0 deletions src/components/Pressable/GenericPressable/BaseGenericPressable.js
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
hitSlop={shouldUseAutoHitSlop && hitSlop}
onLayout={onLayout}
ref={ref}
onPress={!isDisabled && onPressHandler}
onLongPress={!isDisabled && onLongPressHandler}
onKeyPress={!isDisabled && onKeyPressHandler}
onPressIn={!isDisabled && onPressIn}
onPressOut={!isDisabled && onPressOut}
style={state => [
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)}
</Pressable>
);
});

GenericPressable.displayName = 'GenericPressable';
GenericPressable.propTypes = genericPressablePropTypes.pressablePropTypes;
GenericPressable.defaultProps = genericPressablePropTypes.defaultProps;

export default GenericPressable;
Loading

0 comments on commit 1b4e892

Please sign in to comment.