-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #17404 from robertKozik/generic-pressable
[Accessible Pressable] Create GenericPressable, PressableWithFeedback, PressableWithoutFeedback
- Loading branch information
Showing
17 changed files
with
551 additions
and
50 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
158
src/components/Pressable/GenericPressable/BaseGenericPressable.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.