diff --git a/blocks/editable/format-toolbar/index.js b/blocks/editable/format-toolbar/index.js index 023e2408974bdb..1e16b6f783970b 100644 --- a/blocks/editable/format-toolbar/index.js +++ b/blocks/editable/format-toolbar/index.js @@ -8,7 +8,7 @@ import { isUndefined } from 'lodash'; */ import { __ } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; -import { IconButton, Toolbar } from '@wordpress/components'; +import { IconButton, Toolbar, withA11yMessages } from '@wordpress/components'; import { keycodes } from '@wordpress/utils'; /** @@ -125,6 +125,12 @@ class FormatToolbar extends Component { this.setState( { isEditingLink: false, } ); + if ( + this.props.formats.link.value === '' && + !! this.state.linkValue.length + ) { + this.props.speak( __( 'Link inserted.' ), 'assertive' ); + } } updateLinkValue( linkValue ) { @@ -187,4 +193,4 @@ class FormatToolbar extends Component { } } -export default FormatToolbar; +export default withA11yMessages( FormatToolbar ); diff --git a/blocks/url-input/index.js b/blocks/url-input/index.js index 98972d36484dfa..b410f8a95da4e3 100644 --- a/blocks/url-input/index.js +++ b/blocks/url-input/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { throttle, debounce } from 'lodash'; +import { throttle } from 'lodash'; import classnames from 'classnames'; import scrollIntoView from 'dom-scroll-into-view'; @@ -11,7 +11,7 @@ import scrollIntoView from 'dom-scroll-into-view'; import { __, sprintf, _n } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; import { keycodes } from '@wordpress/utils'; -import { Spinner, withInstanceId } from '@wordpress/components'; +import { Spinner, withInstanceId, withA11yMessages } from '@wordpress/components'; const { UP, DOWN, ENTER } = keycodes; @@ -22,7 +22,6 @@ class UrlInput extends Component { this.onKeyDown = this.onKeyDown.bind( this ); this.bindListNode = this.bindListNode.bind( this ); this.updateSuggestions = throttle( this.updateSuggestions.bind( this ), 200 ); - this.debouncedSpeakAssertive = debounce( this.speakAssertive.bind( this ), 500 ); this.suggestionNodes = []; this.state = { posts: [], @@ -77,13 +76,13 @@ class UrlInput extends Component { } ); if ( !! posts.length ) { - this.debouncedSpeakAssertive( sprintf( _n( + this.props.debouncedSpeak( sprintf( _n( '%d result found, use up and down arrow keys to navigate.', '%d results found, use up and down arrow keys to navigate.', posts.length - ), posts.length ) ); + ), posts.length ), 'assertive' ); } else { - this.debouncedSpeakAssertive( __( 'No results.' ) ); + this.props.debouncedSpeak( __( 'No results.' ), 'assertive' ); } }, ( xhr ) => { @@ -137,15 +136,10 @@ class UrlInput extends Component { } } - speakAssertive( message ) { - wp.a11y.speak( message, 'assertive' ); - } - componentWillUnmount() { if ( this.suggestionsRequest ) { this.suggestionsRequest.abort(); } - this.debouncedSpeakAssertive.cancel(); } componentDidUpdate() { @@ -220,4 +214,4 @@ class UrlInput extends Component { } } -export default withInstanceId( UrlInput ); +export default withA11yMessages( withInstanceId( UrlInput ) ); diff --git a/components/form-token-field/index.js b/components/form-token-field/index.js index 03400386a5fc3a..c698d27e8e7f31 100644 --- a/components/form-token-field/index.js +++ b/components/form-token-field/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { last, take, clone, uniq, map, difference, each, identity, some, debounce } from 'lodash'; +import { last, take, clone, uniq, map, difference, each, identity, some } from 'lodash'; import classnames from 'classnames'; /** @@ -18,6 +18,7 @@ import Token from './token'; import TokenInput from './token-input'; import SuggestionsList from './suggestions-list'; import withInstanceId from '../higher-order/with-instance-id'; +import withA11yMessages from '../higher-order/with-a11y-messages'; const initialState = { incompleteTokenValue: '', @@ -46,7 +47,6 @@ class FormTokenField extends Component { this.onInputChange = this.onInputChange.bind( this ); this.bindInput = this.bindInput.bind( this ); this.bindTokensAndInput = this.bindTokensAndInput.bind( this ); - this.debouncedSpeakAssertive = debounce( this.speakAssertive.bind( this ), 500 ); } componentDidUpdate() { @@ -64,10 +64,6 @@ class FormTokenField extends Component { } } - componentWillUnmount() { - this.debouncedSpeakAssertive.cancel(); - } - bindInput( ref ) { this.input = ref; } @@ -197,13 +193,13 @@ class FormTokenField extends Component { if ( showMessage ) { const matchingSuggestions = this.getMatchingSuggestions( tokenValue ); if ( !! matchingSuggestions.length ) { - this.debouncedSpeakAssertive( sprintf( _n( + this.props.debouncedSpeak( sprintf( _n( '%d result found, use up and down arrow keys to navigate.', '%d results found, use up and down arrow keys to navigate.', matchingSuggestions.length - ), matchingSuggestions.length ) ); + ), matchingSuggestions.length ), 'assertive' ); } else { - this.debouncedSpeakAssertive( __( 'No results.' ) ); + this.props.debouncedSpeak( __( 'No results.' ), 'assertive' ); } } } @@ -344,7 +340,7 @@ class FormTokenField extends Component { addNewToken( token ) { this.addNewTokens( [ token ] ); - this.speakAssertive( this.props.messages.added ); + this.props.speak( this.props.messages.added, 'assertive' ); this.setState( { incompleteTokenValue: '', @@ -362,7 +358,7 @@ class FormTokenField extends Component { return this.getTokenValue( item ) !== this.getTokenValue( token ); } ); this.props.onChange( newTokens ); - this.speakAssertive( this.props.messages.removed ); + this.props.speak( this.props.messages.removed, 'assertive' ); } getTokenValue( token ) { @@ -406,10 +402,6 @@ class FormTokenField extends Component { return take( suggestions, maxSuggestions ); } - speakAssertive( message ) { - wp.a11y.speak( message, 'assertive' ); - } - getSelectedSuggestion() { if ( this.state.selectedSuggestionIndex !== -1 ) { return this.getMatchingSuggestions()[ this.state.selectedSuggestionIndex ]; @@ -572,4 +564,4 @@ FormTokenField.defaultProps = { }, }; -export default withInstanceId( FormTokenField ); +export default withA11yMessages( withInstanceId( FormTokenField ) ); diff --git a/components/higher-order/with-a11y-messages/index.js b/components/higher-order/with-a11y-messages/index.js new file mode 100644 index 00000000000000..8ee02598e48b38 --- /dev/null +++ b/components/higher-order/with-a11y-messages/index.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { debounce } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component } from 'element'; + +/** + * A Higher Order Component used to be provide a unique instance ID by component + * + * @param {WPElement} WrappedComponent The wrapped component + * + * @return {Component} Component with an instanceId prop. + */ +function withA11yMessages( WrappedComponent ) { + return class extends Component { + constructor() { + super( ...arguments ); + this.debouncedSpeak = debounce( this.speak.bind( this ), 500 ); + } + + speak( message, type = 'polite' ) { + wp.a11y.speak( message, type ); + } + + componentWillUnmount() { + this.debouncedSpeak.cancel(); + } + + render() { + return ( + + ); + } + }; +} + +export default withA11yMessages; diff --git a/components/higher-order/with-a11y-messages/test/index.js b/components/higher-order/with-a11y-messages/test/index.js new file mode 100644 index 00000000000000..01d8b0b7eccaa8 --- /dev/null +++ b/components/higher-order/with-a11y-messages/test/index.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { render } from 'enzyme'; +import { isFunction } from 'lodash'; + +/** + * Internal dependencies + */ +import withA11yMessages from '../'; + +describe( 'withA11yMessages', () => { + it( 'should generate speak and debouncedSpeak props', () => { + const testSpeak = jest.fn(); + const testDebouncedSpeak = jest.fn(); + const DumpComponent = withA11yMessages( ( { speak, debouncedSpeak } ) => { + testSpeak( isFunction( speak ) ); + testDebouncedSpeak( isFunction( debouncedSpeak ) ); + return
; + } ); + render( ); + + // Unrendered element. + expect( testSpeak ).toBeCalledWith( true ); + expect( testDebouncedSpeak ).toBeCalledWith( true ); + } ); +} ); diff --git a/components/index.js b/components/index.js index 8d823b582bd77a..e6eb5723e3a037 100644 --- a/components/index.js +++ b/components/index.js @@ -25,3 +25,4 @@ export { default as Popover } from './popover'; // Higher-Order Components export { default as withFocusReturn } from './higher-order/with-focus-return'; export { default as withInstanceId } from './higher-order/with-instance-id'; +export { default as withA11yMessages } from './higher-order/with-a11y-messages'; diff --git a/editor/inserter/menu.js b/editor/inserter/menu.js index 7404f5f54708c9..56c75b508d806a 100644 --- a/editor/inserter/menu.js +++ b/editor/inserter/menu.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { flow, groupBy, sortBy, findIndex, filter, debounce, find, some } from 'lodash'; +import { flow, groupBy, sortBy, findIndex, filter, find, some } from 'lodash'; import { connect } from 'react-redux'; /** @@ -9,7 +9,7 @@ import { connect } from 'react-redux'; */ import { __, _n, sprintf } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; -import { Popover, withFocusReturn, withInstanceId } from '@wordpress/components'; +import { Popover, withFocusReturn, withInstanceId, withA11yMessages } from '@wordpress/components'; import { keycodes } from '@wordpress/utils'; import { getCategories, getBlockTypes, BlockIcon } from '@wordpress/blocks'; @@ -47,8 +47,6 @@ export class InserterMenu extends Component { this.getBlocksForCurrentTab = this.getBlocksForCurrentTab.bind( this ); this.sortBlocks = this.sortBlocks.bind( this ); this.addRecentBlocks = this.addRecentBlocks.bind( this ); - const speakAssertive = ( message ) => wp.a11y.speak( message, 'assertive' ); - this.debouncedSpeakAssertive = debounce( speakAssertive, 500 ); } componentDidMount() { @@ -57,20 +55,19 @@ export class InserterMenu extends Component { componentWillUnmount() { document.removeEventListener( 'keydown', this.onKeyDown ); - this.debouncedSpeakAssertive.cancel(); } componentDidUpdate() { const searchResults = this.searchBlocks( getBlockTypes() ); // Announce the blocks search results to screen readers. if ( !! searchResults.length ) { - this.debouncedSpeakAssertive( sprintf( _n( + this.props.debouncedSpeak( sprintf( _n( '%d result found', '%d results found', searchResults.length - ), searchResults.length ) ); + ), searchResults.length ), 'assertive' ); } else { - this.debouncedSpeakAssertive( __( 'No results.' ) ); + this.props.debouncedSpeak( __( 'No results.' ), 'assertive' ); } } @@ -443,6 +440,7 @@ const connectComponent = connect( export default flow( withInstanceId, + withA11yMessages, withFocusReturn, connectComponent )( InserterMenu ); diff --git a/editor/inserter/test/menu.js b/editor/inserter/test/menu.js index da072298a6dc53..baf910987e0a71 100644 --- a/editor/inserter/test/menu.js +++ b/editor/inserter/test/menu.js @@ -91,6 +91,7 @@ describe( 'InserterMenu', () => { instanceId={ 1 } blocks={ [] } recentlyUsedBlocks={ [] } + debouncedSpeak={ noop } /> ); @@ -107,6 +108,7 @@ describe( 'InserterMenu', () => { instanceId={ 1 } blocks={ [] } recentlyUsedBlocks={ [ advancedTextBlock ] } + debouncedSpeak={ noop } /> ); @@ -122,6 +124,7 @@ describe( 'InserterMenu', () => { instanceId={ 1 } blocks={ [] } recentlyUsedBlocks={ [] } + debouncedSpeak={ noop } /> ); const embedTab = wrapper.find( '.editor-inserter__tab' ) @@ -143,6 +146,7 @@ describe( 'InserterMenu', () => { instanceId={ 1 } blocks={ [] } recentlyUsedBlocks={ [] } + debouncedSpeak={ noop } /> ); const blocksTab = wrapper.find( '.editor-inserter__tab' ) @@ -166,6 +170,7 @@ describe( 'InserterMenu', () => { instanceId={ 1 } blocks={ [ { name: moreBlock.name } ] } recentlyUsedBlocks={ [] } + debouncedSpeak={ noop } /> ); const blocksTab = wrapper.find( '.editor-inserter__tab' ) @@ -183,6 +188,7 @@ describe( 'InserterMenu', () => { instanceId={ 1 } blocks={ [] } recentlyUsedBlocks={ [] } + debouncedSpeak={ noop } /> ); wrapper.setState( { filterValue: 'text' } ); @@ -203,6 +209,7 @@ describe( 'InserterMenu', () => { instanceId={ 1 } blocks={ [] } recentlyUsedBlocks={ [] } + debouncedSpeak={ noop } /> ); wrapper.setState( { filterValue: ' text' } );