From d57fb6eb01f5226c01e034bd1ce535b5a7204b33 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Tue, 23 Jul 2019 16:46:24 -0700 Subject: [PATCH] [change] accessibilityRelationship and accessibilityState props Adds the accessibilityState and accessibilityRelationship object props that map to ARIA props. Removes the accessibilityStates array prop that is not compatible with web accessibility services. Ref #1172 --- .../exports/CheckBox/__tests__/index-test.js | 2 +- .../exports/Switch/__tests__/index-test.js | 2 +- .../src/exports/Text/TextPropTypes.js | 4 +- .../src/exports/View/ViewPropTypes.js | 41 +++++++----- .../__snapshots__/index-test.js.snap | 50 ++++++++++++++ .../createDOMProps/__tests__/index-test.js | 66 +++++++++++++++++-- .../src/modules/createDOMProps/index.js | 61 ++++++++++++----- 7 files changed, 187 insertions(+), 39 deletions(-) diff --git a/packages/react-native-web/src/exports/CheckBox/__tests__/index-test.js b/packages/react-native-web/src/exports/CheckBox/__tests__/index-test.js index 7f443762..74bb0c06 100644 --- a/packages/react-native-web/src/exports/CheckBox/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/CheckBox/__tests__/index-test.js @@ -10,7 +10,7 @@ describe('CheckBox', () => { describe('disabled', () => { test('when "false" a default checkbox is rendered', () => { const component = shallow(); - expect(component.find(checkboxSelector).prop('disabled')).toBe(false); + expect(component.find(checkboxSelector).prop('disabled')).toBe(undefined); }); test('when "true" a disabled checkbox is rendered', () => { diff --git a/packages/react-native-web/src/exports/Switch/__tests__/index-test.js b/packages/react-native-web/src/exports/Switch/__tests__/index-test.js index dab69396..af390699 100644 --- a/packages/react-native-web/src/exports/Switch/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Switch/__tests__/index-test.js @@ -15,7 +15,7 @@ describe('components/Switch', () => { describe('disabled', () => { test('when "false" a default checkbox is rendered', () => { const component = shallow(); - expect(component.find(checkboxSelector).prop('disabled')).toBe(false); + expect(component.find(checkboxSelector).prop('disabled')).toBe(undefined); }); test('when "true" a disabled checkbox is rendered', () => { diff --git a/packages/react-native-web/src/exports/Text/TextPropTypes.js b/packages/react-native-web/src/exports/Text/TextPropTypes.js index 9adeb3f0..6359f302 100644 --- a/packages/react-native-web/src/exports/Text/TextPropTypes.js +++ b/packages/react-native-web/src/exports/Text/TextPropTypes.js @@ -10,11 +10,12 @@ import StyleSheetPropType from '../../modules/StyleSheetPropType'; import TextStylePropTypes from './TextStylePropTypes'; -import { any, bool, func, number, oneOf, string } from 'prop-types'; +import { any, bool, func, number, object, oneOf, string } from 'prop-types'; const TextPropTypes = { accessibilityLabel: string, accessibilityLiveRegion: oneOf(['assertive', 'none', 'polite']), + accessibilityRelationship: object, accessibilityRole: oneOf([ 'button', 'header', @@ -26,6 +27,7 @@ const TextPropTypes = { 'text' ]), accessible: bool, + accessibilityState: object, children: any, importantForAccessibility: oneOf(['auto', 'no', 'no-hide-descendants', 'yes']), maxFontSizeMultiplier: number, diff --git a/packages/react-native-web/src/exports/View/ViewPropTypes.js b/packages/react-native-web/src/exports/View/ViewPropTypes.js index b3d654e4..0ca3046e 100644 --- a/packages/react-native-web/src/exports/View/ViewPropTypes.js +++ b/packages/react-native-web/src/exports/View/ViewPropTypes.js @@ -11,7 +11,7 @@ import EdgeInsetsPropType, { type EdgeInsetsProp } from '../EdgeInsetsPropType'; import StyleSheetPropType from '../../modules/StyleSheetPropType'; import ViewStylePropTypes from './ViewStylePropTypes'; -import { any, arrayOf, bool, func, object, oneOf, string } from 'prop-types'; +import { any, bool, func, object, oneOf, string } from 'prop-types'; import { type StyleObj } from '../StyleSheet/StyleSheetTypes'; const stylePropType = StyleSheetPropType(ViewStylePropTypes); @@ -33,8 +33,30 @@ export type ViewProps = { accessibilityComponentType?: string, accessibilityLabel?: string, accessibilityLiveRegion?: 'none' | 'polite' | 'assertive', + accessibilityRelationship?: { + activedescendant?: ?string, + controls?: ?string, + describedby?: ?string, + details?: ?string, + haspopup?: ?string, + labelledby?: ?string, + owns?: ?string + }, accessibilityRole?: string, - accessibilityStates?: Array, + accessibilityState?: { + busy?: ?boolean, + checked?: ?boolean | 'mixed', + disabled?: ?boolean, + expanded?: ?boolean, + grabbed?: ?boolean, + hidden?: ?boolean, + invalid?: ?boolean, + modal?: ?boolean, + pressed?: ?boolean, + readonly?: ?boolean, + required?: ?boolean, + selected?: ?boolean + }, accessible?: boolean, children?: any, className?: string, @@ -90,20 +112,9 @@ const ViewPropTypes = { accessibilityComponentType: string, accessibilityLabel: string, accessibilityLiveRegion: oneOf(['assertive', 'none', 'polite']), + accessibilityRelationship: object, accessibilityRole: string, - accessibilityStates: arrayOf( - oneOf([ - 'disabled', - 'selected', - /* web-only */ - 'busy', - 'checked', - 'expanded', - 'grabbed', - 'invalid', - 'pressed' - ]) - ), + accessibilityState: object, accessible: bool, children: any, hitSlop: EdgeInsetsPropType, diff --git a/packages/react-native-web/src/modules/createDOMProps/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/modules/createDOMProps/__tests__/__snapshots__/index-test.js.snap index ad138400..f056cdac 100644 --- a/packages/react-native-web/src/modules/createDOMProps/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/modules/createDOMProps/__tests__/__snapshots__/index-test.js.snap @@ -13,3 +13,53 @@ exports[`modules/createDOMProps includes base reset style for browser-styled ele exports[`modules/createDOMProps includes cursor style for pressable roles 1`] = `"css-cursor-18t94o4"`; exports[`modules/createDOMProps includes cursor style for pressable roles 2`] = `"css-cursor-18t94o4"`; + +exports[`modules/createDOMProps prop "accessibilityRelationship" values are "id" string 1`] = ` +Object { + "aria-activedescendant": "id", + "aria-controls": "id", + "aria-describedby": "id", + "aria-details": "id", + "aria-haspopup": "id", + "aria-labelledby": "id", + "aria-owns": "id", +} +`; + +exports[`modules/createDOMProps prop "accessibilityRelationship" values are "undefined" 1`] = `Object {}`; + +exports[`modules/createDOMProps prop "accessibilityState" values are "false" 1`] = ` +Object { + "aria-busy": false, + "aria-checked": false, + "aria-expanded": false, + "aria-grabbed": false, + "aria-invalid": false, + "aria-modal": false, + "aria-pressed": false, + "aria-readonly": false, + "aria-required": false, + "aria-selected": false, +} +`; + +exports[`modules/createDOMProps prop "accessibilityState" values are "true" 1`] = ` +Object { + "aria-busy": true, + "aria-checked": true, + "aria-disabled": true, + "aria-expanded": true, + "aria-grabbed": true, + "aria-hidden": true, + "aria-invalid": true, + "aria-modal": true, + "aria-pressed": true, + "aria-readonly": true, + "aria-required": true, + "aria-selected": true, + "disabled": true, + "hidden": true, +} +`; + +exports[`modules/createDOMProps prop "accessibilityState" values are "undefined" 1`] = `Object {}`; diff --git a/packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js b/packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js index 0946674b..41def13b 100644 --- a/packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js +++ b/packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js @@ -158,11 +158,67 @@ describe('modules/createDOMProps', () => { expect(props.role).toEqual('button'); }); - test('prop "accessibilityStates" becomes ARIA states', () => { - const accessibilityStates = ['disabled', 'selected']; - const props = createProps({ accessibilityStates }); - expect(props['aria-disabled']).toEqual(true); - expect(props['aria-selected']).toEqual(true); + describe('prop "accessibilityState"', () => { + function createAccessibilityState(value) { + return { + busy: value, + checked: value, + disabled: value, + expanded: value, + grabbed: value, + hidden: value, + invalid: value, + modal: value, + pressed: value, + readonly: value, + required: value, + selected: value + }; + } + + test('values are "undefined"', () => { + const accessibilityState = createAccessibilityState(undefined); + const props = createProps({ accessibilityState }); + expect(props).toMatchSnapshot(); + }); + + test('values are "false"', () => { + const accessibilityState = createAccessibilityState(false); + const props = createProps({ accessibilityState }); + expect(props).toMatchSnapshot(); + }); + + test('values are "true"', () => { + const accessibilityState = createAccessibilityState(true); + const props = createProps({ accessibilityState }); + expect(props).toMatchSnapshot(); + }); + }); + + describe('prop "accessibilityRelationship"', () => { + function createAccessibilityRelationship(value) { + return { + activedescendant: value, + controls: value, + describedby: value, + details: value, + haspopup: value, + labelledby: value, + owns: value + }; + } + + test('values are "undefined"', () => { + const accessibilityRelationship = createAccessibilityRelationship(undefined); + const props = createProps({ accessibilityRelationship }); + expect(props).toMatchSnapshot(); + }); + + test('values are "id" string', () => { + const accessibilityRelationship = createAccessibilityRelationship('id'); + const props = createProps({ accessibilityRelationship }); + expect(props).toMatchSnapshot(); + }); }); test('prop "className" is preserved', () => { diff --git a/packages/react-native-web/src/modules/createDOMProps/index.js b/packages/react-native-web/src/modules/createDOMProps/index.js index 65b35c64..129320ec 100644 --- a/packages/react-native-web/src/modules/createDOMProps/index.js +++ b/packages/react-native-web/src/modules/createDOMProps/index.js @@ -63,9 +63,11 @@ const createDOMProps = (component, props, styleResolver) => { const { accessibilityLabel, accessibilityLiveRegion, - accessibilityStates, + accessibilityRelationship, + accessibilityState, classList, className: deprecatedClassName, + disabled: providedDisabled, importantForAccessibility, nativeID, placeholderTextColor, @@ -79,32 +81,59 @@ const createDOMProps = (component, props, styleResolver) => { ...domProps } = props; - const disabled = AccessibilityUtil.isDisabled(props); + const disabled = + (accessibilityState != null && accessibilityState.disabled === true) || providedDisabled; const role = AccessibilityUtil.propsToAriaRole(props); - // GENERAL ACCESSIBILITY - if (importantForAccessibility === 'no-hide-descendants') { - domProps['aria-hidden'] = true; - } - if (accessibilityLabel && accessibilityLabel.constructor === String) { + // accessibilityLabel + if (accessibilityLabel != null) { domProps['aria-label'] = accessibilityLabel; } - if (accessibilityLiveRegion && accessibilityLiveRegion.constructor === String) { + + // accessibilityLiveRegion + if (accessibilityLiveRegion != null) { domProps['aria-live'] = accessibilityLiveRegion === 'none' ? 'off' : accessibilityLiveRegion; } - if (Array.isArray(accessibilityStates)) { - for (let i = 0; i < accessibilityStates.length; i += 1) { - domProps[`aria-${accessibilityStates[i]}`] = true; + + // accessibilityRelationship + if (accessibilityRelationship != null) { + for (const prop in accessibilityRelationship) { + const value = accessibilityRelationship[prop]; + if (value != null) { + domProps[`aria-${prop}`] = value; + } } } - if (role && role.constructor === String) { + + // accessibilityRole + if (role != null) { domProps.role = role; } - // DISABLED - if (disabled) { - domProps['aria-disabled'] = disabled; - domProps.disabled = disabled; + // accessibilityState + if (accessibilityState != null) { + for (const prop in accessibilityState) { + const value = accessibilityState[prop]; + if (value != null) { + if (prop === 'disabled' || prop === 'hidden') { + if (value === true) { + domProps[`aria-${prop}`] = value; + // also set prop directly to pick up host component behaviour + domProps[prop] = value; + } + } else { + domProps[`aria-${prop}`] = value; + } + } + } + } + // legacy fallbacks + if (importantForAccessibility === 'no-hide-descendants') { + domProps['aria-hidden'] = true; + } + if (disabled === true) { + domProps['aria-disabled'] = true; + domProps.disabled = true; } // FOCUS