Deque

Explorations in the Virtual DOM: How React.js Impacts Accessibility

By Marcy Sutton / @MarcySutton
Senior Front-End Engineer, Deque Systems

React.js

A JavaScript view-rendering library by Facebook

React logo

The JavaScript Hotness

JavaScript frameworks over the years: each one is 'so hot right now'

What made React.js different

  • Virtual DOM
  • Component lifecycle
  • One-way data flow
  • Timing in framework land
  • Developer ergonomics

Accessibility in React Apps

  • Virtual DOM
  • HTML in JS
  • User-input events
  • Focus management
  • Testing

Virtual DOM

An in-memory representation of the
Document Object Model
(browser render tree)

Virtual DOM diagram

Source

Component Instantiation

  var AdoptAPetViewer = React.createClass({
    componentDidMount: function() {},

    componentDidUpdate: function() {},

    render: function() {
      return (<div>Your Sweet Accessible App</div>);
    }
  });
  window.addEventListener('DOMContentLoaded', function() {
    React.render(<AdoptAPetViewer />, document.body);
  });
	        	

Component lifecycle diagram

Source

Mounting

  • constructor()
  • componentWillMount()
  • render()
  • componentDidMount()
  • componentWillUnmount()

Updating

  • componentWillReceiveProps()
  • shouldComponentUpdate()
  • componentWillUpdate()
  • render()
  • componentDidUpdate()

HTML in JS

https://github.com/davidtheclark/react-aria-menubutton
  import { Wrapper, Button, Menu, MenuItem } from '../..';

  render() {
    return (
      <Wrapper
        className='AriaMenuButton'
        onSelection={this.handleSelection.bind(this)}
      >
        <Button className='AriaMenuButton-trigger'>
          Select a word
        </Button>
        <Menu>
          <ul className='AriaMenuButton-menu'>
            {menuItemElements}
          </ul>
        </Menu>
      </Wrapper>
    );
  }
						

A component under the hood

https://github.com/davidtheclark/react-aria-menubutton
  var React = require('react');

  var checkedProps = {
    children: React.PropTypes.node.isRequired,
    disabled: React.PropTypes.bool,
    tag: React.PropTypes.string,
  };

  module.exports = React.createClass({
    displayName: 'AriaMenuButton-Button',

    propTypes: checkedProps,

    contextTypes: {
      ambManager: React.PropTypes.object.isRequired,
    },

    getDefaultProps: function() {
      return { tag: 'span' };
    },

    componentWillMount: function() {
      this.context.ambManager.button = this;
    },

    componentWillUnmount: function() {
      this.context.ambManager.destroy();
    },

    handleKeyDown: function(event) {
      if (this.props.disabled) return;

      var ambManager = this.context.ambManager;

      switch (event.key) {
        case 'ArrowDown':
          event.preventDefault();
          if (!ambManager.isOpen) {
            ambManager.openMenu({ focusMenu: true });
          } else {
            ambManager.focusItem(0);
          }
          break;
        case 'Enter':
        case ' ':
          event.preventDefault();
          ambManager.toggleMenu();
          break;
        case 'Escape':
          ambManager.handleMenuKey(event);
          break;
      }
    },

    handleClick: function() {
      if (this.props.disabled) return;
      this.context.ambManager.toggleMenu();
    },

    render: function() {
     var props = this.props;

      var buttonProps = {
        // "The menu button itself has a role of button."
        role: 'button',
        tabIndex: (props.disabled) ? '' : '0',
        // "The menu button has an aria-haspopup property, 
        // set to true."
        'aria-haspopup': true,
        'aria-expanded': this.context.ambManager.isOpen,
        'aria-disabled': props.disabled,
        onKeyDown: this.handleKeyDown,
        onClick: this.handleClick,
        onBlur: this.context.ambManager.handleBlur,
      };

      return React.createElement(
      	props.tag,
      	buttonProps,
      	props.children
      );
    },
  });
						

IDE Linting

https://github.com/evcohen/eslint-plugin-jsx-a11y

Eslint plugin for JSX templating and accessibility on Github

React-a11y

https://github.com/reactjs/react-a11y

Command line warning about a click handler on a DIV

React-axe

https://github.com/dylanb/react-axe

React-axe open in the browser

User-Input Events

  • Virtual DOM
  • Dynamic HTML w/ JS
  • Multiple contexts

That’s a job for: Synthetic Event Delegation

George Castanza has an ah-ha moment

React Synthetic Event documentation

Binding Events in React

 let PizzaButton = React.createClass({
  handleClick(e) {}
  handleKeyDown(e) {}

  render() {
    return (
      <button
        onClick={this.handleClick}
        onKeyDown={this.handleKeyDown}
      >
        Get Pizza
      </button>
    )
  }
 })

 module.exports = PizzaButton
	        	

SyntheticEvent Things to watch out for:

  • Bind to accessible elements
  • SyntheticEvent is a wrapper utility
  • Capturing & non-React events

https://www.youtube.com/watch?v=dRo_egw7tBc

Focus Management

Not unique to React

https://www.smashingmagazine.com/2015/05/client-rendered-accessibility

aXe React Devtools Demo

Code Example 1

https://facebook.github.io/react/docs/refs-and-the-dom.html
 class AutoFocusTextInput extends React.Component {
  componentDidMount() {
    this.textInput.focus();
  }

  render() {
    return (
      <CustomTextInput
        ref={(input) => { this.textInput = input; }}
      />
    );
  }
}
					

Code Example 2

By Ryan Florence

https://github.com/facebook/react/issues/1791
 this.setState({ gotEmail: true }, () => {
   this.refs.thanks.getDOMNode().focus();
  
   // display a "thank you" for two seconds
   setTimeout(() => {
     var focusHasNotMoved =
       (this.refs.thanks.getDOMNode() ===
        document.activeElement);
     this.setState({ gotEmail: false }, () => {
       if (focusHasNotMoved)
         this.getDOMNode().focus();
     });
   }, 2000);
 });
					

Code Example 3

Attest DevTools Extension
  componentDidUpdate () {
    // find DOM node in custom component instance
    const ourNode = ReactDOM.findDOMNode(this)
    if (document.activeElement && 
     ourNode.contains(document.activeElement)) {
     // if focused el is a component child, bail out
     return
    }
    // focus one of our elements
    ourNode.querySelector('button').focus()
  }
		        

There are multiple focus management strategies

  • User input event + focus on refs
  • Assign focus via component state
  • Focus on component mount/update
  • Focus layer system (smarter)

React.js Testing and Accessibility

  • Unit tests
  • Integration tests
  • Tools

Three Ways to Test Angular 2 Components by Victor Savkin

React Unit Tests

  • Rendering with props/state
  • Handling interactions
  • Snapshot testing

Unit Testing Tools

  • Simulate Events
  • Shallow render
  • Mount components into the DOM

Shallow Rendering

https://facebook.github.io/react/docs/test-utils.html

Lets you render a component "one level deep" and test what its render method returns, without worrying about child components, which are not instantiated or rendered. Does not require a DOM.

Shallow Rendering Example

https://github.com/davidtheclark/react-aria-menubutton
 var shallow = require('enzyme').shallow;
 
 // more code

 it('escape key', function() {
  var wrapper = shallow(el(Button, null, 'foo'), shallowOptions);
  wrapper.simulate('keyDown', escapeEvent);

  expect(ambManager.handleMenuKey).toHaveBeenCalledTimes(1);
  expect(ambManager.handleMenuKey.mock.calls[0][0].key).toBe('Escape');
 });
				    

Accessibility Tests
Requiring More than Shallow DOM

  • Computed name and role
  • Color contrast
  • Visible focus state
  • Focus mgmt with refs
  • ARIA support*
light bulb

Mounted Rendering

…attaching code to the DOM


  import {mount} from 'enzyme';
  let wrapper = mount(app, { attachTo: div })
			    

Enzyme axe-core test ~ 1 of 2

http://bit.ly/a11yHelper
import App from '../app/components/App';
import a11yHelper from "./a11yHelper";

describe('Accessibility', function () {
  this.timeout(10000);

  it('Has no errors', function () {
    let config = {
      "rules": {
        "color-contrast": { enabled: false }
      }
    };
    a11yHelper.testEnzymeComponent(<App/>, config, (error, results) => {
      expect(results.violations.length).to.equal(0);
    });
  });
});
		        

Enzyme axe-core test ~ 2 of 2

http://bit.ly/a11yHelper
 a11yHelper.testEnzymeComponent = (app, config, callback) => {
    let div = document.createElement('div');
    document.body.appendChild(div);

    let wrapper = mount(app, { attachTo: div });
    let node = findDOMNode(wrapper.component);

    if (typeof config === 'function') {
      config = {};
    }
    this.runAxe(node, config, callback);

    document.body.removeChild(div);
 }

 a11yHelper.runAxe = (node, config, callback) => {
   var oldNode = global.Node;
   global.Node = node.ownerDocument.defaultView.Node;

   axeCore.run(node, config, (error, results) => {
     global.Node = oldNode;
     callback(results);
   });
 }
		        

Other Unit Test Considerations

  • Test individual units
  • JSDOM platform gaps
  • Events on inaccessible elements

The dreaded documentElement error

Command line Mocha test stack trace saying documentElement exploded!

The Fix

http://bit.ly/jsdom-helper
 import { jsdom } from 'jsdom'
 import 'jsdom-global/register'

 export function jsdomSetup () {
  if (global.document && global.window) {
    global.document = jsdom('<!doctype html><html><body></body></html>')
    global.window = global.document.defaultView
    return
  }

  // Make all window properties available on the mocha global
  Object.keys(global.window)
  .filter(key => !(key in global))
  .forEach(key => {
    global[key] = global.window[key]
  })
 }
		        

Integration Tests

“Frontend bugs have occurred in the boundary between components or layers of our app – for example, between the API and the data layer, the data layer and our components, or between a parent and child component.”

Integration Test Considerations

  • Test components together
  • Slower, more processor heavy
  • Real browser, accurate results
  • Webdriver docs are hard to use

Testing with axe-webdriverjs 1 of 2

http://bit.ly/react-axe-webdriver-demo
{
  "name": "react-axe-webdriverjs-demo",
  "version": "0.1.0",
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject",
    "webdriver": "mocha test/test.js"
  },
  "devDependencies": {
    "axe-core": "^2.1.7",
    "axe-webdriverjs": "^1.0.14",
    "chai": "^3.5.0",
    "mocha": "^3.2.0",
    "react-scripts": "0.8.4"
  },
  "dependencies": {
    "react": "^15.4.1",
    "react-dom": "^15.4.1"
  }
}
		        

Testing with axe-webdriverjs 2 of 2

http://bit.ly/react-axe-webdriver-demo
 const WebDriver = require('selenium-webdriver'),
       AxeBuilder = require('axe-webdriverjs');

 describe('aXe demo', function() {
    this.timeout(10000);
    let driver;

    beforeEach(function(done) {
      driver = new WebDriver.Builder()
        .forBrowser('chrome')
        .build();
      done();
    });

    afterEach(function(done) {
      driver.quit().then(function() {
        done();
      });
    });

  it('should find no accessibility violations', function(  done) {
    driver
      .get('http://localhost:3000/')
      .then(function () {
        new AxeBuilder(driver)
          .analyze(function (results) {
            if (results.violations.length > 0) {
              console.log(results.violations.length + ' Accessibility Violations');
              console.log(results.violations);
            }
            else {
              console.log('No accessibility violations');
            }
            assert.equal(results.violations.length, 0);
            done();
          });
      });
    });
 });
		        

Command-Line Demo

React Accessibility Tools

Blessed React.js Components

Questions?

twitter.com/marcysutton

github.com/marcysutton