From 48ddb304ee155042b7612eb80853f685d4c9db84 Mon Sep 17 00:00:00 2001 From: Darien Lombardi Date: Fri, 15 Feb 2019 11:40:26 -0500 Subject: [PATCH 1/2] Accessibility for action buttons. Announces action button items when action button is active and the platform is ios. Focuses on top button when action button is active. --- ActionButton.js | 144 +++++++++++++++++++++++++++++++++++++------- ActionButtonItem.js | 129 ++++++++++++++++++++------------------- 2 files changed, 190 insertions(+), 83 deletions(-) diff --git a/ActionButton.js b/ActionButton.js index b8306c2..da00fcc 100644 --- a/ActionButton.js +++ b/ActionButton.js @@ -6,6 +6,10 @@ import { View, Animated, TouchableOpacity, + findNodeHandle, + UIManager, + Platform, + AccessibilityInfo, } from "react-native"; import ActionButtonItem from "./ActionButtonItem"; import { @@ -14,18 +18,52 @@ import { getTouchableComponent, isAndroid, touchableBackground, - DEFAULT_ACTIVE_OPACITY + DEFAULT_ACTIVE_OPACITY, } from "./shared"; +////////////////////// +// HELPER FUNCTIONS +////////////////////// + +const focusOnView = (ref) => { + if (!ref) { + console.warn('ref is null'); + return; + } + const reactTag = findNodeHandle(ref); + + Platform.OS === 'android' ? UIManager.sendAccessibilityEvent( + reactTag, + 8 + ) : AccessibilityInfo.setAccessibilityFocus(reactTag) +}; + +const filterActionButtons = (children) => { + const actionButtons = !Array.isArray(children) ? [children] : children; + return actionButtons.filter(actionButton => (typeof actionButton === 'object')); +}; + export default class ActionButton extends Component { constructor(props) { super(props); + const { + children, + } = props; + this.state = { resetToken: props.resetToken, - active: props.active + active: props.active, }; + const actionButtons = filterActionButtons(children); + + this.refIndexes = []; + actionButtons.forEach((button, idx) => { + this[`actionButton${idx}Ref`] = React.createRef(); + this.refIndexes.push(idx); + }); + this.anim = new Animated.Value(props.active ? 1 : 0); this.timeout = null; } @@ -34,25 +72,32 @@ export default class ActionButton extends Component { this.mounted = true; } - componentWillUnmount() { - this.mounted = false; - clearTimeout(this.timeout); - } - componentWillReceiveProps(nextProps) { - if (nextProps.resetToken !== this.state.resetToken) { - if (nextProps.active === false && this.state.active === true) { - if (this.props.onReset) this.props.onReset(); + const { + resetToken, + active, + } = this.state; + + const { + onReset, + } = this.props; + + if (nextProps.resetToken !== resetToken) { + if (nextProps.active === false && active === true) { + if (onReset) { + onReset(); + } Animated.spring(this.anim, { toValue: 0 }).start(); - setTimeout( - () => - this.setState({ active: false, resetToken: nextProps.resetToken }), - 250 - ); + setTimeout(() => { + this.setState({ + active: false, + resetToken: nextProps.resetToken, + }); + }, 250); return; } - if (nextProps.active === true && this.state.active === false) { + if (nextProps.active === true && active === false) { Animated.spring(this.anim, { toValue: 1 }).start(); this.setState({ active: true, resetToken: nextProps.resetToken }); return; @@ -60,11 +105,32 @@ export default class ActionButton extends Component { this.setState({ resetToken: nextProps.resetToken, - active: nextProps.active + active: nextProps.active, }); } } + componentDidUpdate(prevProps, prevState) { + const { + active, + } = this.state; + + if (prevState.active !== active && active) { + setTimeout(() => { + this.focusOnTopActionButton(); + }, 500); + + setTimeout(() => { + this.announceActionButtons(); + }, 2000); + } + } + + componentWillUnmount() { + this.mounted = false; + clearTimeout(this.timeout); + } + ////////////////////// // STYLESHEET GETTERS ////////////////////// @@ -93,6 +159,42 @@ export default class ActionButton extends Component { ]; } + ////////////////////// + // ACCESSIBILITY METHODS + ////////////////////// + + announceActionButtons() { + const { + children, + } = this.props; + + actionButtons = filterActionButtons(children); + + const actionButtonsAccessibilityAnnouncement = actionButtons.reduce((acc, actionButton) => { + return `${acc} ${actionButton.props.accessibilityLabel}, `; + }, 'Available actions from top to bottom: '); + + AccessibilityInfo.announceForAccessibility(actionButtonsAccessibilityAnnouncement); + } + + focusOnTopActionButton() { + const { + active, + } = this.state; + + const actionButtonRefs = this.refIndexes.reduce((acc, refIdx) => { + const buttonRef = this[`actionButton${refIdx}Ref`].current; + if (!!buttonRef) { + acc = [...acc, buttonRef]; + } + return acc; + }, []); + + if (active && actionButtonRefs.length) { + focusOnView(actionButtonRefs[0]); + } + } + ////////////////////// // RENDER METHODS ////////////////////// @@ -142,6 +244,7 @@ export default class ActionButton extends Component { ); } + _renderMainButton() { const animatedViewStyle = { transform: [ @@ -261,9 +364,7 @@ export default class ActionButton extends Component { if (!this.state.active) return null; - let actionButtons = !Array.isArray(children) ? [children] : children; - - actionButtons = actionButtons.filter( actionButton => (typeof actionButton == 'object') ) + actionButtons = filterActionButtons(children); const actionStyle = { flex: 1, @@ -284,6 +385,7 @@ export default class ActionButton extends Component { anim={this.anim} {...this.props} {...ActionButton.props} + buttonRef={this[`actionButton${idx}Ref`]} parentSize={this.props.size} btnColor={this.props.btnOutRange} onPress={() => { @@ -434,4 +536,4 @@ const styles = StyleSheet.create({ fontSize: 24, backgroundColor: "transparent" } -}); +}); \ No newline at end of file diff --git a/ActionButtonItem.js b/ActionButtonItem.js index 7475bff..e75e9c4 100644 --- a/ActionButtonItem.js +++ b/ActionButtonItem.js @@ -29,6 +29,7 @@ const TextTouchable = isAndroid export default class ActionButtonItem extends Component { static get defaultProps() { return { + buttonRef: null, active: true, spaceBetween: 15, useNativeFeedback: true, @@ -40,6 +41,7 @@ export default class ActionButtonItem extends Component { static get propTypes() { return { + buttonRef: PropTypes.any, active: PropTypes.bool, useNativeFeedback: PropTypes.bool, fixNativeFeedbackRadius: PropTypes.bool, @@ -48,8 +50,70 @@ export default class ActionButtonItem extends Component { }; } + _renderTitle() { + if (!this.props.title) return null; + + const { + textContainerStyle, + hideLabelShadow, + offsetX, + parentSize, + size, + position, + spaceBetween + } = this.props; + const offsetTop = Math.max(size / 2 - TEXT_HEIGHT / 2, 0); + const positionStyles = { top: offsetTop }; + const hideShadow = hideLabelShadow === undefined + ? this.props.hideShadow + : hideLabelShadow; + + if (position !== "center") { + positionStyles[position] = + offsetX + (parentSize - size) / 2 + size + spaceBetween; + } else { + positionStyles.right = WIDTH / 2 + size / 2 + spaceBetween; + } + + const textStyles = [ + styles.textContainer, + positionStyles, + !hideShadow && shadowStyle, + textContainerStyle + ]; + + const title = ( + React.isValidElement(this.props.title) ? + this.props.title + : ( + + {this.props.title} + + ) + ) + + return ( + + + {title} + + + ); + } + render() { const { + buttonRef, size, position, verticalOrientation, @@ -101,6 +165,7 @@ export default class ActionButtonItem extends Component { paddingHorizontal: this.props.offsetX, height: size + SHADOW_SPACE + spacing }; + return ( ); } - - _renderTitle() { - if (!this.props.title) return null; - - const { - textContainerStyle, - hideLabelShadow, - offsetX, - parentSize, - size, - position, - spaceBetween - } = this.props; - const offsetTop = Math.max(size / 2 - TEXT_HEIGHT / 2, 0); - const positionStyles = { top: offsetTop }; - const hideShadow = hideLabelShadow === undefined - ? this.props.hideShadow - : hideLabelShadow; - - if (position !== "center") { - positionStyles[position] = - offsetX + (parentSize - size) / 2 + size + spaceBetween; - } else { - positionStyles.right = WIDTH / 2 + size / 2 + spaceBetween; - } - - const textStyles = [ - styles.textContainer, - positionStyles, - !hideShadow && shadowStyle, - textContainerStyle - ]; - - const title = ( - React.isValidElement(this.props.title) ? - this.props.title - : ( - - {this.props.title} - - ) - ) - - return ( - - - {title} - - - ); - } } const styles = StyleSheet.create({ @@ -208,4 +213,4 @@ const styles = StyleSheet.create({ fontSize: 12, color: "#444" } -}); +}); \ No newline at end of file From e766b9a0df842d26bb64a5972209c1f76a1a78fb Mon Sep 17 00:00:00 2001 From: Darien Lombardi Date: Fri, 15 Feb 2019 15:59:57 -0500 Subject: [PATCH 2/2] update --- ActionButton.js | 15 ++++++++++----- package-lock.json | 36 ++++++++++++++++++------------------ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/ActionButton.js b/ActionButton.js index da00fcc..d384ac0 100644 --- a/ActionButton.js +++ b/ActionButton.js @@ -122,7 +122,7 @@ export default class ActionButton extends Component { setTimeout(() => { this.announceActionButtons(); - }, 2000); + }, 3000); } } @@ -166,15 +166,18 @@ export default class ActionButton extends Component { announceActionButtons() { const { children, + announceActionsLabel, } = this.props; - actionButtons = filterActionButtons(children); + const actionButtons = filterActionButtons(children); const actionButtonsAccessibilityAnnouncement = actionButtons.reduce((acc, actionButton) => { return `${acc} ${actionButton.props.accessibilityLabel}, `; - }, 'Available actions from top to bottom: '); + }, announceActionsLabel); - AccessibilityInfo.announceForAccessibility(actionButtonsAccessibilityAnnouncement); + if (actionButtons.length && Platform.OS === 'ios') { + AccessibilityInfo.announceForAccessibility(actionButtonsAccessibilityAnnouncement); + } } focusOnTopActionButton() { @@ -364,7 +367,7 @@ export default class ActionButton extends Component { if (!this.state.active) return null; - actionButtons = filterActionButtons(children); + const actionButtons = filterActionButtons(children); const actionStyle = { flex: 1, @@ -488,6 +491,7 @@ ActionButton.propTypes = { testID: PropTypes.string, accessibilityLabel: PropTypes.string, + announceActionsLabel: PropTypes.string, accessible: PropTypes.bool }; @@ -519,6 +523,7 @@ ActionButton.defaultProps = { nativeFeedbackRippleColor: "rgba(255,255,255,0.75)", testID: undefined, accessibilityLabel: undefined, + announceActionsLabel: 'Available actions from top to bottom: ', accessible: undefined }; diff --git a/package-lock.json b/package-lock.json index cc96fdf..79b4d96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", "requires": { - "iconv-lite": "0.4.23" + "iconv-lite": "~0.4.13" } }, "fbjs": { @@ -27,13 +27,13 @@ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.16.tgz", "integrity": "sha1-XmdDL1UNxBtXK/VYR7ispk5TN9s=", "requires": { - "core-js": "1.2.7", - "isomorphic-fetch": "2.2.1", - "loose-envify": "1.3.1", - "object-assign": "4.1.1", - "promise": "7.3.1", - "setimmediate": "1.0.5", - "ua-parser-js": "0.7.18" + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.9" } }, "iconv-lite": { @@ -41,7 +41,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", "requires": { - "safer-buffer": "2.1.2" + "safer-buffer": ">= 2.1.2 < 3" } }, "is-stream": { @@ -54,8 +54,8 @@ "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", "requires": { - "node-fetch": "1.7.3", - "whatwg-fetch": "2.0.4" + "node-fetch": "^1.0.1", + "whatwg-fetch": ">=0.10.0" } }, "js-tokens": { @@ -68,7 +68,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", "requires": { - "js-tokens": "3.0.2" + "js-tokens": "^3.0.0" } }, "node-fetch": { @@ -76,8 +76,8 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", "requires": { - "encoding": "0.1.12", - "is-stream": "1.1.0" + "encoding": "^0.1.11", + "is-stream": "^1.0.1" } }, "object-assign": { @@ -90,7 +90,7 @@ "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", "requires": { - "asap": "2.0.6" + "asap": "~2.0.3" } }, "prop-types": { @@ -98,9 +98,9 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.1.tgz", "integrity": "sha512-4ec7bY1Y66LymSUOH/zARVYObB23AT2h8cf6e/O6ZALB/N0sqZFEx7rq6EYPX2MkOdKORuooI/H5k9TlR4q7kQ==", "requires": { - "fbjs": "0.8.16", - "loose-envify": "1.3.1", - "object-assign": "4.1.1" + "fbjs": "^0.8.16", + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" } }, "safer-buffer": {