diff --git a/.travis.yml b/.travis.yml index 83891f417bd9ce..411cc075760fed 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,9 @@ node_js: - '8' - '6' -script: npm run test:coverage-ci +script: + - lerna bootstrap && npm run build + - npm run test:coverage-ci notifications: slack: diff --git a/packages/a11y/README.md b/packages/a11y/README.md new file mode 100644 index 00000000000000..45a4c0aa2bfccf --- /dev/null +++ b/packages/a11y/README.md @@ -0,0 +1,51 @@ +# @wordpress/a11y + +Collection of JS modules for enhancing accessibility. + +## Installation + +Install the module + +```bash +npm install @wordpress/a11y --save +``` + +## speak + +Allows you to easily announce dynamic interface updates to screen readers using ARIA live regions. This module is inspired by the `speak` function in wp-a11y.js + +### Usage + +To make the `wp.a11y.speak` functionality more universally available, we've decided to create a dedicated JS module for it, called `speak`. Usage is very simple: + +```JS +import { speak } from '@wordpress/a11y'; + +// For polite messages that shouldn't interrupt what screen readers are currently announcing. +speak( 'The message you want to send to the ARIA live region' ); + +// For assertive messages that should interrupt what screen readers are currently announcing. +speak( 'The message you want to send to the ARIA live region', 'assertive' ); +``` + +### Background +For context I'll quote [this article on WordPress.org](https://make.wordpress.org/accessibility/2015/04/15/let-wordpress-speak-new-in-wordpress-4-2/) by [@joedolson](https://github.com/joedolson): + +> #### Why. +> In modern web development, updating discrete regions of a screen with JavaScript is common. The use of AJAX responses in modern JavaScript-based User Interfaces allows web developers to create beautiful interfaces similar to Desktop applications that don’t require pages to reload or refresh. + +> JavaScript can create great usability improvements for most users – but when content is updated dynamically, it has the potential to introduce accessibility issues. This is one of the steps you can take to handle that problem. + +> #### What. +> When a portion of a page is updated with JavaScript, the update is usually highlighted with animation and bright colors, and is easy to see. But if you don’t have the ability to see the screen, you don’t know this has happened, unless the updated region is marked as an ARIA-live region. + +> If this isn’t marked, there’s no notification for screen readers. But it’s also possible that all the region content will be announced after an update, if the ARIA live region is too large. You want to provide users with just a simple, concise message. + +> #### How. +> That’s what `wp.a11y.speak()` is meant for. It’s a simple tool that creates and appends an ARIA live notifications area to the element where developers can dispatch text messages. Assistive technologies will automatically announce any text change in this area. This ARIA live region has an ARIA role of “status” so it has an implicit aria-live value of polite and an implicit aria-atomic value of true. + +> This means assistive technologies will notify users of updates but generally do not interrupt the current task, and updates take low priority. If you’re creating an application with higher priority updates (such as a notification that their current session is about to expire, for example), then you’ll want to create your own method with an aria-live value of assertive. + +## Browser support + +See https://make.wordpress.org/design/handbook/design-guide/browser-support/ diff --git a/packages/a11y/package.json b/packages/a11y/package.json new file mode 100644 index 00000000000000..307516f3c13ad1 --- /dev/null +++ b/packages/a11y/package.json @@ -0,0 +1,23 @@ +{ + "name": "@wordpress/a11y", + "version": "0.1.0-dev.1", + "description": "Collection of JS modules and tools for WordPress development", + "homepage": "https://github.com/WordPress/packages/tree/master/packages/a11y/README.md", + "author": "WordPress", + "keywords": [ + "a11y", + "aria-live" + ], + "main": "build/index.js", + "module": "build-module/index.js", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/packages.git" + }, + "bugs": { + "url": "https://github.com/WordPress/packages/issues" + }, + "dependencies": { + "@wordpress/dom-ready": "^0.1.0-dev" + } +} diff --git a/packages/a11y/src/addContainer.js b/packages/a11y/src/addContainer.js new file mode 100644 index 00000000000000..d478ffcf887f3a --- /dev/null +++ b/packages/a11y/src/addContainer.js @@ -0,0 +1,25 @@ +/** + * Build the live regions markup. + * + * @param {String} ariaLive Optional. Value for the 'aria-live' attribute, default 'polite'. + * + * @returns {Object} $container The ARIA live region jQuery object. + */ +const addContainer = function( ariaLive ) { + ariaLive = ariaLive || 'polite'; + + let container = document.createElement( 'div' ); + container.id = 'a11y-speak-' + ariaLive; + container.className = 'a11y-speak-region'; + + let screenReaderTextStyle = 'clip: rect(1px, 1px, 1px, 1px); position: absolute; height: 1px; width: 1px; overflow: hidden; word-wrap: normal;'; + container.setAttribute( 'style', screenReaderTextStyle ); + container.setAttribute( 'aria-live', ariaLive ); + container.setAttribute( 'aria-relevant', 'additions text' ); + container.setAttribute( 'aria-atomic', 'true' ); + + document.querySelector( 'body' ).appendChild( container ); + return container; +}; + +export default addContainer; diff --git a/packages/a11y/src/clear.js b/packages/a11y/src/clear.js new file mode 100644 index 00000000000000..b2226f0210806f --- /dev/null +++ b/packages/a11y/src/clear.js @@ -0,0 +1,11 @@ +/** + * Clear the a11y-speak-region elements. + */ +const clear = function() { + let regions = document.querySelectorAll( '.a11y-speak-region' ); + for ( let i = 0; i < regions.length; i++ ) { + regions[i].textContent = ''; + } +}; + +export default clear; diff --git a/packages/a11y/src/filterMessage.js b/packages/a11y/src/filterMessage.js new file mode 100644 index 00000000000000..dc68bc6064117a --- /dev/null +++ b/packages/a11y/src/filterMessage.js @@ -0,0 +1,30 @@ +let previousMessage = ''; + +/** + * Filter the message to be announced to the screenreader. + * + * @param {String} message The message to be announced. + * + * @returns {String} + */ +const filterMessage = function( message ) { + + /* + * Strip HTML tags (if any) from the message string. Ideally, messages should + * be simple strings, carefully crafted for specific use with A11ySpeak. + * When re-using already existing strings this will ensure simple HTML to be + * stripped out and replaced with a space. Browsers will collapse multiple + * spaces natively. + */ + message = message.replace( /<[^<>]+>/g, ' ' ); + + if ( previousMessage === message ) { + message += '\u00A0'; + } + + previousMessage = message; + + return message; +}; + +export default filterMessage; diff --git a/packages/a11y/src/index.js b/packages/a11y/src/index.js new file mode 100644 index 00000000000000..b6eb15aeb2f9a1 --- /dev/null +++ b/packages/a11y/src/index.js @@ -0,0 +1,47 @@ +import addContainer from './addContainer'; +import clear from './clear'; +import domReady from '@wordpress/dom-ready'; +import filterMessage from './filterMessage'; + +/** + * Create the live regions. + */ +export const setup = function() { + let containerPolite = document.getElementById( 'a11y-speak-polite' ); + let containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + + if ( containerPolite === null ) { + containerPolite = addContainer( 'polite' ); + } + if ( containerAssertive === null ) { + containerAssertive = addContainer( 'assertive' ); + } +}; + +/** + * Run setup on domReady. + */ +domReady( setup ); + +/** + * Update the ARIA live notification area text node. + * + * @param {String} message The message to be announced by Assistive Technologies. + * @param {String} ariaLive Optional. The politeness level for aria-live. Possible values: + * polite or assertive. Default polite. + */ +export const speak = function( message, ariaLive ) { + // Clear previous messages to allow repeated strings being read out. + clear(); + + message = filterMessage( message ); + + let containerPolite = document.getElementById( 'a11y-speak-polite' ); + let containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + + if ( containerAssertive && 'assertive' === ariaLive ) { + containerAssertive.textContent = message; + } else if ( containerPolite ) { + containerPolite.textContent = message; + } +}; diff --git a/packages/a11y/src/test/addContainer.test.js b/packages/a11y/src/test/addContainer.test.js new file mode 100644 index 00000000000000..8bde66e6711b27 --- /dev/null +++ b/packages/a11y/src/test/addContainer.test.js @@ -0,0 +1,45 @@ +import addContainer from '../addContainer'; + +describe( 'addContainer', () => { + describe( 'with polite param', () => { + it( 'should create an aria-live element with aria-live attr set to polite', () => { + let container = addContainer( 'polite' ); + + expect( container ).not.toBe( null ); + expect( container.className ).toBe( 'a11y-speak-region' ); + expect( container.id ).toBe( 'a11y-speak-polite' ); + expect( container.getAttribute( 'style' ) ).toBe( 'clip: rect(1px, 1px, 1px, 1px); position: absolute; height: 1px; width: 1px; overflow: hidden; word-wrap: normal;' ); + expect( container.getAttribute( 'aria-live' ) ).toBe( 'polite' ); + expect( container.getAttribute( 'aria-relevant' ) ).toBe( 'additions text' ); + expect( container.getAttribute( 'aria-atomic' ) ).toBe( 'true' ); + }); + }); + + describe( 'with assertive param', () => { + it( 'should create an aria-live element with aria-live attr set to assertive', () => { + let container = addContainer( 'assertive' ); + + expect( container ).not.toBe( null ); + expect( container.className ).toBe( 'a11y-speak-region' ); + expect( container.id ).toBe( 'a11y-speak-assertive' ); + expect( container.getAttribute( 'style' ) ).toBe( 'clip: rect(1px, 1px, 1px, 1px); position: absolute; height: 1px; width: 1px; overflow: hidden; word-wrap: normal;' ); + expect( container.getAttribute( 'aria-live' ) ).toBe( 'assertive' ); + expect( container.getAttribute( 'aria-relevant' ) ).toBe( 'additions text' ); + expect( container.getAttribute( 'aria-atomic' ) ).toBe( 'true' ); + }); + }); + + describe( 'without param', () => { + it( 'should default to creating an aria-live element with aria-live attr set to polite', () => { + let container = addContainer( 'polite' ); + + expect( container ).not.toBe( null ); + expect( container.className ).toBe( 'a11y-speak-region' ); + expect( container.id ).toBe( 'a11y-speak-polite' ); + expect( container.getAttribute( 'style' ) ).toBe( 'clip: rect(1px, 1px, 1px, 1px); position: absolute; height: 1px; width: 1px; overflow: hidden; word-wrap: normal;' ); + expect( container.getAttribute( 'aria-live' ) ).toBe( 'polite' ); + expect( container.getAttribute( 'aria-relevant' ) ).toBe( 'additions text' ); + expect( container.getAttribute( 'aria-atomic' ) ).toBe( 'true' ); + }); + }); +}); diff --git a/packages/a11y/src/test/clear.test.js b/packages/a11y/src/test/clear.test.js new file mode 100644 index 00000000000000..70655bfc748b01 --- /dev/null +++ b/packages/a11y/src/test/clear.test.js @@ -0,0 +1,19 @@ +import clear from '../clear'; + +describe( 'clear', () => { + it( 'should clear all a11y-speak-region elements', () => { + let container1 = document.createElement( 'div' ); + container1.className = 'a11y-speak-region'; + container1.textContent = 'not empty'; + document.querySelector( 'body' ).appendChild( container1 ); + + let container2 = document.createElement( 'div' ); + container2.className = 'a11y-speak-region'; + container2.textContent = 'not empty'; + document.querySelector( 'body' ).appendChild( container2 ); + + clear(); + expect( container1.textContent ).toBe( '' ); + expect( container2.textContent ).toBe( '' ); + }); +}); diff --git a/packages/a11y/src/test/filterMessage.test.js b/packages/a11y/src/test/filterMessage.test.js new file mode 100644 index 00000000000000..575dd11c6bd7e7 --- /dev/null +++ b/packages/a11y/src/test/filterMessage.test.js @@ -0,0 +1,25 @@ +import filterMessage from '../filterMessage'; + +describe( 'filterMessage', () => { + describe( 'when a clean message is passed in', () => { + it( 'should return the message unfiltered', () => { + let actual = filterMessage( 'clean message.' ); + expect( actual ).toBe( 'clean message.' ); + }); + }); + + describe( 'when a message is passed in twice in a row', () => { + it( 'should add a space to the message to make sure it is announced again', () => { + filterMessage( 'repeated message.' ); + let actual = filterMessage( 'repeated message.' ); + expect( actual ).toBe( 'repeated message.' + '\u00A0' ); + }); + }); + + describe( 'when a message contains html tags', () => { + it( 'should strip the html tags and replace them with spaces', () => { + let actual = filterMessage( '

html paragraph

' ); + expect( actual ).toBe( ' html paragraph ' ); + }); + }); +}); diff --git a/packages/a11y/src/test/index.test.js b/packages/a11y/src/test/index.test.js new file mode 100644 index 00000000000000..211cb39d8da99c --- /dev/null +++ b/packages/a11y/src/test/index.test.js @@ -0,0 +1,102 @@ +import { setup, speak } from '../'; + +jest.mock( '../clear', () => { + return jest.fn(); +}); +jest.mock( '@wordpress/dom-ready', () => { + return jest.fn(( callback ) => { callback(); }); +}); +jest.mock( '../filterMessage', () => { + return jest.fn(( message ) => { return message; }); +}); + +import clear from '../clear'; +import domReady from '@wordpress/dom-ready'; +import filterMessage from '../filterMessage'; + +describe( 'speak', () => { + let containerPolite = document.getElementById( 'a11y-speak-polite' ); + let containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + + beforeEach(() => { + containerPolite.textContent = ''; + containerAssertive.textContent = ''; + }); + + describe( 'on import', () => { + it( 'should call domReady', () => { + expect( domReady ).toHaveBeenCalled(); + }); + }); + + describe( 'in default mode', () => { + it( 'should set the textcontent of the polite aria-live region', () => { + speak( 'default message' ); + expect( containerPolite.textContent ).toBe( 'default message' ); + expect( containerAssertive.textContent ).toBe( '' ); + expect( clear ).toHaveBeenCalled(); + expect( filterMessage ).toHaveBeenCalledWith( 'default message' ); + }); + }); + + describe( 'in assertive mode', () => { + it( 'should set the textcontent of the assertive aria-live region', () => { + speak( 'assertive message', 'assertive' ); + expect( containerPolite.textContent ).toBe( '' ); + expect( containerAssertive.textContent ).toBe( 'assertive message' ); + }); + }); + + describe( 'in explicit polite mode', () => { + it( 'should set the textcontent of the polite aria-live region', () => { + speak( 'polite message', 'polite' ); + expect( containerPolite.textContent ).toBe( 'polite message' ); + expect( containerAssertive.textContent ).toBe( '' ); + }); + }); + + describe( 'when somehow the assertive container is not present', () => { + beforeEach(() => { + document.getElementById( 'a11y-speak-assertive' ).remove(); + }); + + afterEach(() => { + setup(); + containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + }); + + it( 'should set the textcontent of the polite aria-live region', () => { + speak( 'message', 'assertive' ); + expect( containerPolite.textContent ).toBe( 'message' ); + expect( document.getElementById( 'a11y-speak-assertive' ) ).toBe( null ); + }); + }); + + describe( 'when somehow the both containers are not present', () => { + beforeEach(() => { + containerAssertive.remove(); + containerPolite.remove(); + }); + + afterEach(() => { + setup(); + containerPolite = document.getElementById( 'a11y-speak-polite' ); + containerAssertive = document.getElementById( 'a11y-speak-assertive' ); + }); + + it( 'should set the textcontent of the polite aria-live region', () => { + expect( document.getElementById( 'a11y-speak-polite' ) ).toBe( null ); + expect( document.getElementById( 'a11y-speak-assertive' ) ).toBe( null ); + }); + }); + + describe( 'setup when the elements already exist', () => { + it( 'should not create the aria live regions again', () => { + let before = document.getElementsByClassName( 'a11y-speak-region' ).length; + setup(); + let after = document.getElementsByClassName( 'a11y-speak-region' ).length; + + expect( before ).toBe( after ); + }); + }); +}); diff --git a/packages/dom-ready/README.md b/packages/dom-ready/README.md new file mode 100644 index 00000000000000..3e132701d3bbf6 --- /dev/null +++ b/packages/dom-ready/README.md @@ -0,0 +1,25 @@ +# @wordpress/domReady + +Executes a function after the DOM has loaded. + +## Installation + +Install the module + +```bash +npm install @wordpress/domReady --save +``` + +### Usage + +```JS +import domReady from '@wordpress/domReady'; + +domReady( function() { + //do something after DOM loads. +} ); +``` + +## Browser support + +See https://make.wordpress.org/design/handbook/design-guide/browser-support/ diff --git a/packages/dom-ready/package.json b/packages/dom-ready/package.json new file mode 100644 index 00000000000000..dfc07e90925445 --- /dev/null +++ b/packages/dom-ready/package.json @@ -0,0 +1,20 @@ +{ + "name": "@wordpress/dom-ready", + "version": "0.1.0-dev.1", + "description": "Execute callback after the DOM is loaded.", + "homepage": "https://github.com/WordPress/packages/tree/master/packages/domReady/README.md", + "author": "WordPress", + "keywords": [ + "DOM", + "Readystate" + ], + "main": "build/index.js", + "module": "build-module/index.js", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/packages.git" + }, + "bugs": { + "url": "https://github.com/WordPress/packages/issues" + } +} diff --git a/packages/dom-ready/src/index.js b/packages/dom-ready/src/index.js new file mode 100644 index 00000000000000..4e955fe59cd284 --- /dev/null +++ b/packages/dom-ready/src/index.js @@ -0,0 +1,16 @@ +/** + * Specify a function to execute when the DOM is fully loaded. + * + * @param {Function} callback A function to execute after the DOM is ready. + * + * @returns {void} + */ +const domReady = function( callback ) { + if ( document.readyState === 'complete' ) { + return callback(); + } + + document.addEventListener( 'DOMContentLoaded', callback ); +}; + +export default domReady; diff --git a/packages/dom-ready/src/test/index.test.js b/packages/dom-ready/src/test/index.test.js new file mode 100644 index 00000000000000..556594c1ca4522 --- /dev/null +++ b/packages/dom-ready/src/test/index.test.js @@ -0,0 +1,30 @@ +import domReady from '../'; + +describe( 'domReady', () => { + describe( 'when document readystate is complete', () => { + it( 'should call the callback.', () => { + let callback = jest.fn(() => {}); + + domReady( callback ); + expect( callback ).toHaveBeenCalled(); + }); + }); + + describe( 'when document readystate is still loading', () => { + it( 'should add the callback as an event listener to the DOMContentLoaded event.', () => { + let addEventListener = jest.fn(() => {}); + + Object.defineProperty( document, 'readyState', { + value: 'loading', + }); + Object.defineProperty( document, 'addEventListener', { + value: addEventListener, + }); + + let callback = jest.fn(() => {}); + domReady( callback ); + expect( callback ).not.toHaveBeenCalled(); + expect( addEventListener ).toHaveBeenCalledWith( 'DOMContentLoaded', callback ); + }); + }); +});