[add] mark focusable DOM nodes with 'data-focusable'

Focus-based UIs can use out-of-tree focus algorithms to manage focus
using remote control devices. This patch marks DOM nodes that React
Native considers "focusable".

Close #827
This commit is contained in:
Nicolas Gallagher
2018-03-29 16:29:11 -07:00
parent dcdf1468f9
commit e8f2c98786
10 changed files with 229 additions and 216 deletions

View File

@@ -11,6 +11,7 @@ exports[`components/Picker prop "children" items 1`] = `
exports[`components/Picker prop "children" renders items 1`] = `
<select
className="rn-fontFamily-14xgk7a rn-fontSize-7cikom rn-marginTop-1mnahxq rn-marginRight-61z16t rn-marginBottom-p1pxzi rn-marginLeft-11wrixw"
data-focusable={true}
onChange={[Function]}
>
<PickerItem

View File

@@ -3,6 +3,7 @@
exports[`components/Text prop "onPress" 1`] = `
<div
className="rn-borderTopWidth-13yce4e rn-borderRightWidth-fnigne rn-borderBottomWidth-ndvcnb rn-borderLeftWidth-gxnn5r rn-boxSizing-deolkf rn-color-homxoj rn-cursor-1loqt21 rn-display-1471scf rn-fontFamily-14xgk7a rn-fontSize-1b43r93 rn-fontStyle-o11vmf rn-fontVariant-ebii48 rn-fontWeight-gul640 rn-lineHeight-t9a87b rn-marginTop-1mnahxq rn-marginRight-61z16t rn-marginBottom-p1pxzi rn-marginLeft-11wrixw rn-paddingTop-wk8lta rn-paddingRight-9aemit rn-paddingBottom-1mdbw0j rn-paddingLeft-gy4na3 rn-textDecoration-bauka4 rn-whiteSpace-q42fyq rn-wordWrap-qvutc0"
data-focusable={true}
dir="auto"
onClick={[Function]}
onKeyDown={[Function]}

View File

@@ -1,116 +0,0 @@
/* eslint-env jasmine, jest */
import propsToTabIndex from '../propsToTabIndex';
describe('modules/AccessibilityUtil/propsToTabIndex', () => {
test('with no accessibility props', () => {
expect(propsToTabIndex({})).toBeUndefined();
});
describe('"accessibilityRole" of "link"', () => {
const accessibilityRole = 'link';
test('default case', () => {
expect(propsToTabIndex({ accessibilityRole })).toBeUndefined();
});
test('when "accessible" is true', () => {
expect(propsToTabIndex({ accessibilityRole, accessible: true })).toBeUndefined();
});
test('when "accessible" is false', () => {
expect(propsToTabIndex({ accessibilityRole, accessible: false })).toEqual('-1');
});
test('when "disabled" is true', () => {
expect(propsToTabIndex({ accessibilityRole, disabled: true })).toEqual('-1');
expect(propsToTabIndex({ accessibilityRole, 'aria-disabled': true })).toEqual('-1');
});
test('when "disabled" is false', () => {
expect(propsToTabIndex({ accessibilityRole, disabled: false })).toBeUndefined();
expect(propsToTabIndex({ accessibilityRole, 'aria-disabled': false })).toBeUndefined();
});
test('when "importantForAccessibility" is "no"', () => {
expect(propsToTabIndex({ accessibilityRole, importantForAccessibility: 'no' })).toEqual('-1');
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
propsToTabIndex({
accessibilityRole,
importantForAccessibility: 'no-hide-descendants'
})
).toEqual('-1');
});
});
describe('"accessibilityRole" of "button"', () => {
const accessibilityRole = 'button';
test('default case', () => {
expect(propsToTabIndex({ accessibilityRole })).toEqual('0');
});
test('when "accessible" is true', () => {
expect(propsToTabIndex({ accessibilityRole, accessible: true })).toEqual('0');
});
test('when "accessible" is false', () => {
expect(propsToTabIndex({ accessibilityRole, accessible: false })).toBeUndefined();
});
test('when "disabled" is true', () => {
expect(propsToTabIndex({ accessibilityRole, disabled: true })).toBeUndefined();
expect(propsToTabIndex({ accessibilityRole, 'aria-disabled': true })).toBeUndefined();
});
test('when "disabled" is false', () => {
expect(propsToTabIndex({ accessibilityRole, disabled: false })).toEqual('0');
expect(propsToTabIndex({ accessibilityRole, 'aria-disabled': false })).toEqual('0');
});
test('when "importantForAccessibility" is "no"', () => {
expect(
propsToTabIndex({ accessibilityRole, importantForAccessibility: 'no' })
).toBeUndefined();
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
propsToTabIndex({
accessibilityRole,
importantForAccessibility: 'no-hide-descendants'
})
).toBeUndefined();
});
});
describe('with unfocusable accessibilityRole', () => {
test('default case', () => {
expect(propsToTabIndex({})).toBeUndefined();
});
test('when "accessible" is true', () => {
expect(propsToTabIndex({ accessible: true })).toEqual('0');
});
test('when "accessible" is false', () => {
expect(propsToTabIndex({ accessible: false })).toBeUndefined();
});
test('when "importantForAccessibility" is "no"', () => {
expect(propsToTabIndex({ importantForAccessibility: 'no' })).toBeUndefined();
expect(
propsToTabIndex({ accessible: true, importantForAccessibility: 'no' })
).toBeUndefined();
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
propsToTabIndex({ accessible: true, importantForAccessibility: 'no-hide-descendants' })
).toBeUndefined();
});
});
});

View File

@@ -10,13 +10,11 @@
import isDisabled from './isDisabled';
import propsToAccessibilityComponent from './propsToAccessibilityComponent';
import propsToAriaRole from './propsToAriaRole';
import propsToTabIndex from './propsToTabIndex';
const AccessibilityUtil = {
isDisabled,
propsToAccessibilityComponent,
propsToAriaRole,
propsToTabIndex
propsToAriaRole
};
export default AccessibilityUtil;

View File

@@ -1,37 +0,0 @@
/**
* Copyright (c) 2017-present, Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import isDisabled from './isDisabled';
import propsToAriaRole from './propsToAriaRole';
const propsToTabIndex = (props: Object) => {
const role = propsToAriaRole(props);
const focusable =
!isDisabled(props) &&
props.importantForAccessibility !== 'no' &&
props.importantForAccessibility !== 'no-hide-descendants';
// Assume that 'link' is focusable by default (uses <a>).
// Assume that 'button' is not (uses <div role='button'>) but must be treated as such.
if (role === 'link') {
if (props.accessible === false || !focusable) {
return '-1';
}
} else if (role === 'button') {
if (props.accessible !== false && focusable) {
return '0';
}
} else {
if (props.accessible === true && focusable) {
return '0';
}
}
};
export default propsToTabIndex;

View File

@@ -2,28 +2,10 @@
exports[`modules/createDOMProps includes "rel" values for "a" elements (to securely open external links) 1`] = `" noopener noreferrer"`;
exports[`modules/createDOMProps includes cursor style for "button" role 1`] = `
Object {
"className": "rn-cursor-1loqt21",
"role": "button",
"tabIndex": "0",
}
`;
exports[`modules/createDOMProps includes cursor style for "button" role 1`] = `"rn-cursor-1loqt21"`;
exports[`modules/createDOMProps includes reset styles for "a" elements 1`] = `
Object {
"className": "rn-backgroundColor-wib322 rn-color-homxoj rn-textDecoration-bauka4",
}
`;
exports[`modules/createDOMProps includes reset styles for "a" elements 1`] = `"rn-backgroundColor-wib322 rn-color-homxoj rn-textDecoration-bauka4"`;
exports[`modules/createDOMProps includes reset styles for "button" elements 1`] = `
Object {
"className": "rn-appearance-30o5oe rn-backgroundColor-wib322 rn-color-homxoj rn-fontFamily-poiln3 rn-fontSize-7cikom rn-fontStyle-o11vmf rn-fontVariant-ebii48 rn-fontWeight-gul640 rn-lineHeight-t9a87b rn-textAlign-1ttztb7",
}
`;
exports[`modules/createDOMProps includes reset styles for "button" elements 1`] = `"rn-appearance-30o5oe rn-backgroundColor-wib322 rn-color-homxoj rn-fontFamily-poiln3 rn-fontSize-7cikom rn-fontStyle-o11vmf rn-fontVariant-ebii48 rn-fontWeight-gul640 rn-lineHeight-t9a87b rn-textAlign-1ttztb7"`;
exports[`modules/createDOMProps includes reset styles for "ul" elements 1`] = `
Object {
"className": "rn-listStyle-1ebb2ja",
}
`;
exports[`modules/createDOMProps includes reset styles for "ul" elements 1`] = `"rn-listStyle-1ebb2ja"`;

View File

@@ -5,6 +5,147 @@ import createDOMProps from '..';
const createProps = props => createDOMProps(null, props);
describe('modules/createDOMProps', () => {
describe('focus-related accessibility attributes', () => {
test('with no accessibility props', () => {
expect(createProps({})).toEqual({});
});
describe('"accessibilityRole" of "link"', () => {
const accessibilityRole = 'link';
test('default case', () => {
expect(createProps({ accessibilityRole })).toEqual(
expect.objectContaining({ 'data-focusable': true })
);
});
test('when "accessible" is true', () => {
expect(createProps({ accessibilityRole, accessible: true })).toEqual(
expect.objectContaining({ 'data-focusable': true })
);
});
test('when "accessible" is false', () => {
expect(createProps({ accessibilityRole, accessible: false })).toEqual(
expect.objectContaining({ tabIndex: '-1' })
);
});
test('when "disabled" is true', () => {
expect(createProps({ accessibilityRole, disabled: true })).toEqual(
expect.objectContaining({ 'aria-disabled': true, disabled: true, tabIndex: '-1' })
);
expect(createProps({ accessibilityRole, 'aria-disabled': true })).toEqual(
expect.objectContaining({ 'aria-disabled': true, disabled: true, tabIndex: '-1' })
);
});
test('when "disabled" is false', () => {
expect(createProps({ accessibilityRole, disabled: false })).toEqual(
expect.objectContaining({ 'data-focusable': true })
);
expect(createProps({ accessibilityRole, 'aria-disabled': false })).toEqual(
expect.objectContaining({ 'data-focusable': true })
);
});
test('when "importantForAccessibility" is "no"', () => {
expect(createProps({ accessibilityRole, importantForAccessibility: 'no' })).toEqual(
expect.objectContaining({ tabIndex: '-1' })
);
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
createProps({
accessibilityRole,
importantForAccessibility: 'no-hide-descendants'
})
).toEqual(expect.objectContaining({ tabIndex: '-1' }));
});
});
describe('"accessibilityRole" of "button"', () => {
const accessibilityRole = 'button';
test('default case', () => {
expect(createProps({ accessibilityRole })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
});
test('when "accessible" is true', () => {
expect(createProps({ accessibilityRole, accessible: true })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
});
test('when "accessible" is false', () => {
expect(createProps({ accessibilityRole, accessible: false })).not.toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
});
test('when "disabled" is true', () => {
expect(createProps({ accessibilityRole, disabled: true })).toEqual(
expect.objectContaining({ 'aria-disabled': true, disabled: true })
);
expect(createProps({ accessibilityRole, 'aria-disabled': true })).toEqual(
expect.objectContaining({ 'aria-disabled': true, disabled: true })
);
});
test('when "disabled" is false', () => {
expect(createProps({ accessibilityRole, disabled: false })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
expect(createProps({ accessibilityRole, 'aria-disabled': false })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
});
test('when "importantForAccessibility" is "no"', () => {
expect(createProps({ accessibilityRole, importantForAccessibility: 'no' })).not.toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
createProps({
accessibilityRole,
importantForAccessibility: 'no-hide-descendants'
})
).not.toEqual(expect.objectContaining({ 'data-focusable': true, tabIndex: '0' }));
});
});
describe('with unfocusable accessibilityRole', () => {
test('when "accessible" is true', () => {
expect(createProps({ accessible: true })).toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
});
test('when "accessible" is false', () => {
expect(createProps({ accessible: false })).toEqual({});
});
test('when "importantForAccessibility" is "no"', () => {
expect(createProps({ importantForAccessibility: 'no' })).toEqual({});
expect(createProps({ accessible: true, importantForAccessibility: 'no' })).not.toEqual(
expect.objectContaining({ 'data-focusable': true, tabIndex: '0' })
);
});
test('when "importantForAccessibility" is "no-hide-descendants"', () => {
expect(
createProps({ accessible: true, importantForAccessibility: 'no-hide-descendants' })
).not.toEqual(expect.objectContaining({ 'data-focusable': true, tabIndex: '0' }));
});
});
});
test('prop "accessibilityLabel" becomes "aria-label"', () => {
const accessibilityLabel = 'accessibilityLabel';
const props = createProps({ accessibilityLabel });
@@ -42,33 +183,28 @@ describe('modules/createDOMProps', () => {
expect(props['data-testid']).toEqual(testID);
});
test('includes reset styles for "a" elements', () => {
const props = createDOMProps('a');
expect(props).toMatchSnapshot();
});
test('includes "rel" values for "a" elements (to securely open external links)', () => {
const props = createDOMProps('a', { target: '_blank' });
expect(props.rel).toMatchSnapshot();
});
test('includes reset styles for "a" elements', () => {
const props = createDOMProps('a');
expect(props.className).toMatchSnapshot();
});
test('includes reset styles for "button" elements', () => {
const props = createDOMProps('button');
expect(props).toMatchSnapshot();
expect(props.className).toMatchSnapshot();
});
test('includes cursor style for "button" role', () => {
const props = createDOMProps('span', { accessibilityRole: 'button' });
expect(props).toMatchSnapshot();
expect(props.className).toMatchSnapshot();
});
test('includes reset styles for "ul" elements', () => {
const props = createDOMProps('ul');
expect(props).toMatchSnapshot();
});
test('includes tabIndex when needed', () => {
const props = createProps({ accessible: true });
expect(props.tabIndex).toBeDefined();
expect(props.className).toMatchSnapshot();
});
});

View File

@@ -90,24 +90,10 @@ const createDOMProps = (component, props, styleResolver) => {
...domProps
} = props;
const isDisabled = AccessibilityUtil.isDisabled(props);
const disabled = AccessibilityUtil.isDisabled(props);
const role = AccessibilityUtil.propsToAriaRole(props);
const tabIndex = AccessibilityUtil.propsToTabIndex(props);
const reactNativeStyle = [
component === 'a' && resetStyles.link,
component === 'button' && resetStyles.button,
role === 'heading' && resetStyles.heading,
component === 'ul' && resetStyles.list,
role === 'button' && !isDisabled && resetStyles.ariaButton,
pointerEvents && pointerEventsStyles[pointerEvents],
providedStyle,
placeholderTextColor && { placeholderTextColor }
];
const { className, style } = styleResolver(reactNativeStyle);
if (isDisabled) {
domProps['aria-disabled'] = true;
}
// GENERAL ACCESSIBILITY
if (importantForAccessibility === 'no-hide-descendants') {
domProps['aria-hidden'] = true;
}
@@ -117,20 +103,70 @@ const createDOMProps = (component, props, styleResolver) => {
if (accessibilityLiveRegion && accessibilityLiveRegion.constructor === String) {
domProps['aria-live'] = accessibilityLiveRegion === 'none' ? 'off' : accessibilityLiveRegion;
}
if (className && className.constructor === String) {
domProps.className = domProps.className ? `${domProps.className} ${className}` : className;
}
if (component === 'a' && domProps.target === '_blank') {
domProps.rel = `${domProps.rel || ''} noopener noreferrer`;
}
if (role && role.constructor === String && role !== 'label') {
domProps.role = role;
}
// DISABLED
if (disabled) {
domProps['aria-disabled'] = disabled;
domProps.disabled = disabled;
}
// FOCUS
// Assume that 'link' is focusable by default (uses <a>).
// Assume that 'button' is not (uses <div role='button'>) but must be treated as such.
const focusable =
!disabled &&
importantForAccessibility !== 'no' &&
importantForAccessibility !== 'no-hide-descendants';
if (
role === 'link' ||
component === 'input' ||
component === 'select' ||
component === 'textarea'
) {
if (accessible === false || !focusable) {
domProps.tabIndex = '-1';
} else {
domProps['data-focusable'] = true;
}
} else if (role === 'button' || role === 'textbox') {
if (accessible !== false && focusable) {
domProps['data-focusable'] = true;
domProps.tabIndex = '0';
}
} else {
if (accessible === true && focusable) {
domProps['data-focusable'] = true;
domProps.tabIndex = '0';
}
}
// STYLE
// Resolve React Native styles to optimized browser equivalent
const reactNativeStyle = [
component === 'a' && resetStyles.link,
component === 'button' && resetStyles.button,
role === 'heading' && resetStyles.heading,
component === 'ul' && resetStyles.list,
role === 'button' && !disabled && resetStyles.ariaButton,
pointerEvents && pointerEventsStyles[pointerEvents],
providedStyle,
placeholderTextColor && { placeholderTextColor }
];
const { className, style } = styleResolver(reactNativeStyle);
if (className && className.constructor === String) {
domProps.className = props.className ? `${props.className} ${className}` : className;
}
if (style) {
domProps.style = style;
}
if (tabIndex) {
domProps.tabIndex = tabIndex;
// OTHER
// Link security and automation test ids
if (component === 'a' && domProps.target === '_blank') {
domProps.rel = `${domProps.rel || ''} noopener noreferrer`;
}
if (testID && testID.constructor === String) {
domProps['data-testid'] = testID;

View File

@@ -114,6 +114,18 @@ value of `no` will remove a focusable element from the tab flow, and a value of
`no-hide-descendants` will also hide the entire subtree from assistive
technologies (this is implemented using `aria-hidden`).
### Spatial navigation
Focus-based web UIs, e.g., for TVs and Game Consoles can implement [TV remote
control navigation](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox_OS_for_TV/TV_remote_control_navigation)
outside of React using existing directional-focus libraries. Every DOM element
that React Native considers focusable can be matched by the attribute
`data-focusable="true"`.
```js
const focusableElements = document.querySelectorAll('[data-focusable="true"]');
```
### Other
Other ARIA properties can be set via [direct

View File

@@ -9,7 +9,7 @@ import { StyleSheet, TextInput, TouchableWithoutFeedback, View } from 'react-nat
export default class TouchableWrapper extends React.Component {
render() {
return (
<TouchableWithoutFeedback onPress={this._handlePress}>
<TouchableWithoutFeedback importantForAccessibility="no" onPress={this._handlePress}>
<View style={styles.container}>
<TextInput multiline={false} ref={this._setRef} style={helperStyles.textinput} />
</View>