Skip to content

Commit

Permalink
Aria live announcements A11y improvements (#16197)
Browse files Browse the repository at this point in the history
Feature (code-block): Introduced screen reader announcements for entering or exiting code blocks in the editor content. Closes #16053.

Feature (image, list, ui): Introduced screen reader announcements for various actions and events in the editor.

Other (ui): Refactored the `AriaLiveAnnouncer` utility to use the `aria-relevant` attribute and make concurrent announcements queued by screen readers.

MINOR BREAKING CHANGE (ui): The region name argument of the `AriaLiveAnnouncer#announce()`  method has been dropped. Please check out the latest API documentation for more information. 

---------

Co-authored-by: Aleksander Nowodzinski <a.nowodzinski@cksource.com>
  • Loading branch information
Mati365 and oleq committed Apr 16, 2024
1 parent 887c978 commit c451c9e
Show file tree
Hide file tree
Showing 17 changed files with 840 additions and 87 deletions.
4 changes: 4 additions & 0 deletions packages/ckeditor5-code-block/lang/contexts.json
@@ -1,5 +1,9 @@
{
"Insert code block": "A label of the button that allows inserting a new code block into the editor content.",
"Plain text": "A language of the code block in the editor content when no specific programming language is associated with it.",
"Leaving %0 code snippet": "Assistive technologies label for leaving the code block with a specified programming language. Example: 'Leaving JavaScript code snippet'",
"Entering %0 code snippet": "Assistive technologies label for entering the code block with a specified programming language. Example: 'Entering JavaScript code snippet'",
"Entering code snippet": "Assistive technologies label for entering the code block with unspecified programming language.",
"Leaving code snippet": "Assistive technologies label for leaving the code block with unspecified programming language.",
"Code block": "The accessible label of the menu bar button that inserts a code block into editor content."
}
3 changes: 2 additions & 1 deletion packages/ckeditor5-code-block/package.json
Expand Up @@ -13,7 +13,8 @@
"type": "module",
"main": "src/index.ts",
"dependencies": {
"ckeditor5": "41.3.1"
"ckeditor5": "41.3.1",
"lodash-es": "4.17.21"
},
"devDependencies": {
"@ckeditor/ckeditor5-alignment": "41.3.1",
Expand Down
41 changes: 39 additions & 2 deletions packages/ckeditor5-code-block/src/codeblockediting.ts
Expand Up @@ -7,6 +7,8 @@
* @module code-block/codeblockediting
*/

import { lowerFirst, upperFirst } from 'lodash-es';

import { Plugin, type Editor, type MultiCommand } from 'ckeditor5/src/core.js';
import { ShiftEnter, type ViewDocumentEnterEvent } from 'ckeditor5/src/enter.js';

Expand All @@ -19,7 +21,8 @@ import {
type DowncastInsertEvent,
type UpcastElementEvent,
type UpcastTextEvent,
type Element
type Element,
type SelectionChangeRangeEvent
} from 'ckeditor5/src/engine.js';

import type { ListEditing } from '@ckeditor/ckeditor5-list';
Expand All @@ -30,7 +33,8 @@ import OutdentCodeBlockCommand from './outdentcodeblockcommand.js';
import {
getNormalizedAndLocalizedLanguageDefinitions,
getLeadingWhiteSpaces,
rawSnippetTextToViewDocumentFragment
rawSnippetTextToViewDocumentFragment,
getCodeBlockAriaAnnouncement
} from './utils.js';
import {
modelToViewCodeBlockInsertion,
Expand Down Expand Up @@ -277,6 +281,39 @@ export default class CodeBlockEditing extends Plugin {
data.preventDefault();
evt.stop();
}, { context: 'pre' } );

this._initAriaAnnouncements( );
}

/**
* Observe when user enters or leaves code block and set proper aria value in global live announcer.
* This allows screen readers to indicate when the user has entered and left the specified code block.
*
* @internal
*/
private _initAriaAnnouncements( ) {
const { model, ui, t } = this.editor;
const languageDefs = getNormalizedAndLocalizedLanguageDefinitions( this.editor );

let lastFocusedCodeBlock: Element | null = null;

model.document.selection.on<SelectionChangeRangeEvent>( 'change:range', () => {
const focusParent = model.document.selection.focus!.parent;

if ( !ui || lastFocusedCodeBlock === focusParent || !focusParent.is( 'element' ) ) {
return;
}

if ( lastFocusedCodeBlock && lastFocusedCodeBlock.is( 'element', 'codeBlock' ) ) {
ui.ariaLiveAnnouncer.announce( getCodeBlockAriaAnnouncement( t, languageDefs, lastFocusedCodeBlock, 'leave' ) );
}

if ( focusParent.is( 'element', 'codeBlock' ) ) {
ui.ariaLiveAnnouncer.announce( getCodeBlockAriaAnnouncement( t, languageDefs, focusParent, 'enter' ) );
}

lastFocusedCodeBlock = focusParent;
} );
}
}

Expand Down
32 changes: 31 additions & 1 deletion packages/ckeditor5-code-block/src/utils.ts
Expand Up @@ -9,7 +9,6 @@

import type { Editor } from 'ckeditor5/src/core.js';
import type { CodeBlockLanguageDefinition } from './codeblockconfig.js';
import { first } from 'ckeditor5/src/utils.js';
import type {
DocumentSelection,
Element,
Expand All @@ -22,6 +21,8 @@ import type {
ViewElement
} from 'ckeditor5/src/engine.js';

import { first, type LocaleTranslate } from 'ckeditor5/src/utils.js';

/**
* Returns code block languages as defined in `config.codeBlock.languages` but processed:
*
Expand Down Expand Up @@ -258,3 +259,32 @@ export function canBeCodeBlock( schema: Schema, element: Element ): boolean {

return schema.checkChild( element.parent as Element, 'codeBlock' );
}

/**
* Get the translated message read by the screen reader when you enter or exit an element with your cursor.
*/
export function getCodeBlockAriaAnnouncement(
t: LocaleTranslate,
languageDefs: Array<CodeBlockLanguageDefinition>,
element: Element,
direction: 'enter' | 'leave'
): string {
const languagesToLabels = getPropertyAssociation( languageDefs, 'language', 'label' );
const codeBlockLanguage = element.getAttribute( 'language' ) as string;

if ( codeBlockLanguage in languagesToLabels ) {
const language = languagesToLabels[ codeBlockLanguage ];

if ( direction === 'enter' ) {
return t( 'Entering %0 code snippet', language );
}

return t( 'Leaving %0 code snippet', language );
}

if ( direction === 'enter' ) {
return t( 'Entering code snippet' );
}

return t( 'Leaving code snippet' );
}
170 changes: 169 additions & 1 deletion packages/ckeditor5-code-block/tests/codeblockediting.js
Expand Up @@ -30,7 +30,7 @@ import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils
import { _clear as clearTranslations, add as addTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service.js';

describe( 'CodeBlockEditing', () => {
let editor, element, model, view, viewDoc;
let editor, element, model, view, viewDoc, root;

before( () => {
addTranslations( 'en', {
Expand Down Expand Up @@ -60,6 +60,7 @@ describe( 'CodeBlockEditing', () => {
model = editor.model;
view = editor.editing.view;
viewDoc = view.document;
root = model.document.getRoot();
} );
} );

Expand Down Expand Up @@ -1788,4 +1789,171 @@ describe( 'CodeBlockEditing', () => {
} );
} );
} );

describe( 'accessibility', () => {
let announcerSpy;

beforeEach( () => {
announcerSpy = sinon.spy( editor.ui.ariaLiveAnnouncer, 'announce' );
} );

it( 'should announce enter and leave code block with specified language label', () => {
setModelData( model, join( codeblock( 'css' ), tag( 'paragraph' ) ) );

model.change( writer => {
writer.setSelection( createRange( root, [ 0, 0 ], root, [ 0, 1 ] ) );
} );

expectAnnounce( 'Entering CSS code snippet' );

model.change( writer => {
writer.setSelection( createRange( root, [ 1, 0 ], root, [ 1, 1 ] ) );
} );

expectAnnounce( 'Leaving CSS code snippet' );
} );

it( 'should announce enter and leave code block without language label', () => {
setModelData( model, join( codeblock( 'FooBar' ), tag( 'paragraph' ) ) );

model.change( writer => {
writer.setSelection( createRange( root, [ 0, 0 ], root, [ 0, 1 ] ) );
} );

expectAnnounce( 'Entering code snippet' );

model.change( writer => {
writer.setSelection( createRange( root, [ 1, 0 ], root, [ 1, 1 ] ) );
} );

expectAnnounce( 'Leaving code snippet' );
} );

it( 'should announce sequential entry and exit of a code block with paragraph between', () => {
setModelData( model, join( codeblock( 'php' ), tag( 'paragraph' ), codeblock( 'css' ) ) );

model.change( writer => {
writer.setSelection( createRange( root, [ 0, 0 ], root, [ 0, 1 ] ) );
} );

expectAnnounce( 'Entering PHP code snippet' );
announcerSpy.resetHistory();

model.change( writer => {
writer.setSelection( createRange( root, [ 1, 0 ], root, [ 1, 1 ] ) );
} );

expectAnnounce( 'Leaving PHP code snippet' );
announcerSpy.resetHistory();

model.change( writer => {
writer.setSelection( createRange( root, [ 2, 0 ], root, [ 2, 1 ] ) );
} );

expectAnnounce( 'Entering CSS code snippet' );
} );

it( 'should announce sequential entry and exit of a code block that starts immediately after another code block', () => {
setModelData(
model,
join(
codeblock( 'css' ),
codeblock( 'php' ),
tag( 'paragraph' )
)
);

model.change( writer => {
writer.setSelection( createRange( root, [ 0, 0 ], root, [ 0, 1 ] ) );
} );

expectAnnounce( 'Entering CSS code snippet' );
announcerSpy.resetHistory();

model.change( writer => {
writer.setSelection( createRange( root, [ 1, 0 ], root, [ 1, 1 ] ) );
} );

expectAnnounce( 'Leaving CSS code snippet' );
expectAnnounce( 'Entering PHP code snippet' );
announcerSpy.resetHistory();

model.change( writer => {
writer.setSelection( createRange( root, [ 2, 0 ], root, [ 2, 1 ] ) );
} );

expectAnnounce( 'Leaving PHP code snippet' );
} );

it( 'should announce random enter and exit of a code block that starts immediately after another code block', () => {
setModelData(
model,
join(
codeblock( 'css' ),
codeblock( 'php' ),
codeblock( 'ruby' ),
codeblock( 'xml' ),
codeblock( 'FooBar' )
)
);

model.change( writer => {
writer.setSelection( createRange( root, [ 2, 0 ], root, [ 2, 1 ] ) );
} );

expectAnnounce( 'Entering Ruby code snippet' );
announcerSpy.resetHistory();

model.change( writer => {
writer.setSelection( createRange( root, [ 0, 0 ], root, [ 0, 1 ] ) );
} );

expectAnnounce( 'Leaving Ruby code snippet' );
expectAnnounce( 'Entering CSS code snippet' );
announcerSpy.resetHistory();

model.change( writer => {
writer.setSelection( createRange( root, [ 3, 0 ], root, [ 3, 1 ] ) );
} );

expectAnnounce( 'Leaving CSS code snippet' );
expectAnnounce( 'Entering XML code snippet' );
announcerSpy.resetHistory();

model.change( writer => {
writer.setSelection( createRange( root, [ 4, 0 ], root, [ 4, 1 ] ) );
} );

expectAnnounce( 'Leaving XML code snippet' );
expectAnnounce( 'Entering code snippet' );
} );

function expectAnnounce( message ) {
expect( announcerSpy ).to.be.calledWithExactly( message );
}
} );

function join( ...lines ) {
return lines.filter( Boolean ).join( '' );
}

function tag( name, attributes = {}, content = 'Example' ) {
const formattedAttributes = Object
.entries( attributes || {} )
.map( ( [ key, value ] ) => `${ key }="${ value }"` )
.join( ' ' );

return `<${ name }${ formattedAttributes ? ` ${ formattedAttributes }` : '' }>${ content }</${ name }>`;
}

function codeblock( language, content = 'Example code' ) {
return tag( 'codeBlock', { language }, content );
}

function createRange( startElement, startPath, endElement, endPath ) {
return model.createRange(
model.createPositionFromPath( startElement, startPath ),
model.createPositionFromPath( endElement, endPath )
);
}
} );
5 changes: 4 additions & 1 deletion packages/ckeditor5-image/lang/contexts.json
Expand Up @@ -35,5 +35,8 @@
"Caption for the image": "Text used by screen readers do describe an image when the image has no text alternative.",
"Caption for image: %0": "Text used by screen readers do describe an image when there is a text alternative available, e.g. 'Caption for image: this is a description of the image.'",
"The value must not be empty.": "Text used as error label when user submitted custom image resize form with blank value.",
"The value should be a plain number.": "Text used as error label when user submitted custom image resize form with incorrect value."
"The value should be a plain number.": "Text used as error label when user submitted custom image resize form with incorrect value.",
"Uploading image": "Aria status message indicating that the image is being uploaded. Example: 'Uploading image'.",
"Image upload complete": "Aria status message indicating that the image has been uploaded successfully. Example: 'Image upload complete'.",
"Error during image upload": "Aria status message indicating that an error has occurred during image upload. Example: 'Error during image upload'."
}
12 changes: 12 additions & 0 deletions packages/ckeditor5-image/src/imageupload/imageuploadediting.ts
Expand Up @@ -320,6 +320,10 @@ export default class ImageUploadEditing extends Plugin {
} );
}

if ( editor.ui ) {
editor.ui.ariaLiveAnnouncer.announce( t( 'Uploading image' ) );
}

model.enqueueChange( { isUndoable: false }, writer => {
writer.setAttribute( 'uploadStatus', 'uploading', imageElement );
} );
Expand All @@ -332,12 +336,20 @@ export default class ImageUploadEditing extends Plugin {

writer.setAttribute( 'uploadStatus', 'complete', imageElement );

if ( editor.ui ) {
editor.ui.ariaLiveAnnouncer.announce( t( 'Image upload complete' ) );
}

this.fire<ImageUploadCompleteEvent>( 'uploadComplete', { data, imageElement } );
} );

clean();
} )
.catch( error => {
if ( editor.ui ) {
editor.ui.ariaLiveAnnouncer.announce( t( 'Error during image upload' ) );
}

// If status is not 'error' nor 'aborted' - throw error because it means that something else went wrong,
// it might be generic error and it would be real pain to find what is going on.
if ( loader.status !== 'error' && loader.status !== 'aborted' ) {
Expand Down

0 comments on commit c451c9e

Please sign in to comment.