Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for screen reader announcements in code block document elements. #16056

Closed
wants to merge 13 commits into from
6 changes: 5 additions & 1 deletion packages/ckeditor5-code-block/lang/contexts.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"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."
"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 specified programming language.",
"Entering %0 code snippet": "Assistive technologies label for entering the code block with specified programming language.",
"Entering code snippet": "Assistive technologies label for entering the code block with not specified programming language.",
"Leaving code snippet": "Assistive technologies label for leaving the code block with not specified programming language."
}
44 changes: 42 additions & 2 deletions packages/ckeditor5-code-block/src/codeblockediting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,21 @@ 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';
import type { CodeBlockLanguageDefinition } from './codeblockconfig.js';

import CodeBlockCommand from './codeblockcommand.js';
import IndentCodeBlockCommand from './indentcodeblockcommand.js';
import OutdentCodeBlockCommand from './outdentcodeblockcommand.js';
import {
getNormalizedAndLocalizedLanguageDefinitions,
getLeadingWhiteSpaces,
rawSnippetTextToViewDocumentFragment
rawSnippetTextToViewDocumentFragment,
getCodeBlockAriaAnnouncement
} from './utils.js';
import {
modelToViewCodeBlockInsertion,
Expand Down Expand Up @@ -277,6 +280,43 @@ 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 ( lastFocusedCodeBlock === focusParent || !focusParent.is( 'element' ) ) {
return;
}

let announcement: string | null = null;

if ( focusParent.is( 'element', 'codeBlock' ) ) {
announcement = getCodeBlockAriaAnnouncement( t, languageDefs, focusParent, 'enter' );
} else if ( lastFocusedCodeBlock && lastFocusedCodeBlock.is( 'element', 'codeBlock' ) ) {
announcement = getCodeBlockAriaAnnouncement( t, languageDefs, lastFocusedCodeBlock, 'leave' );
}

if ( announcement && ui ) {
ui.ariaLiveAnnouncer.announce( 'codeBlocks', announcement, 'assertive' );
}

lastFocusedCodeBlock = focusParent;
} );
}
}

Expand Down
32 changes: 31 additions & 1 deletion packages/ckeditor5-code-block/src/utils.ts
Original file line number Diff line number Diff line change
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' );
Comment on lines +285 to +289
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can safely assume there will never be a code snippet without the language upcasted in the editor. See

if ( !codeBlock.hasAttribute( 'language' ) ) {
writer.setAttribute( 'language', defaultLanguageName, codeBlock );
}
. I'd assume that element.getAttribute( 'language' )!.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can happen if language passed to codeBlock has no language definition specified in config (like FooBar). I'd stay with a bit more verbose but safer approach.

}
50 changes: 49 additions & 1 deletion packages/ckeditor5-code-block/tests/codeblockediting.js
Original file line number Diff line number Diff line change
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,51 @@ 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, '<codeBlock language="css">foo</codeBlock><paragraph>a</paragraph>' );

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

expect( announcerSpy ).to.be.calledWithExactly( 'codeBlocks', 'Entering CSS code snippet', 'assertive' );

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

expect( announcerSpy ).to.be.calledWithExactly( 'codeBlocks', 'Leaving CSS code snippet', 'assertive' );
} );

it( 'should announce enter and leave code block without language label', () => {
setModelData( model, '<codeBlock language="FooBar">foo</codeBlock><paragraph>a</paragraph>' );

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

expect( announcerSpy ).to.be.calledWithExactly( 'codeBlocks', 'Entering code snippet', 'assertive' );

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

expect( announcerSpy ).to.be.calledWithExactly( 'codeBlocks', 'Leaving code snippet', 'assertive' );
} );
} );

function createRange( startElement, startPath, endElement, endPath ) {
return model.createRange(
model.createPositionFromPath( startElement, startPath ),
model.createPositionFromPath( endElement, endPath )
);
}
} );