diff --git a/packages/ckeditor5-code-block/lang/contexts.json b/packages/ckeditor5-code-block/lang/contexts.json index 63f14568b92..55f006ac416 100644 --- a/packages/ckeditor5-code-block/lang/contexts.json +++ b/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." } diff --git a/packages/ckeditor5-code-block/package.json b/packages/ckeditor5-code-block/package.json index 36629f74b14..a2a458c9d12 100644 --- a/packages/ckeditor5-code-block/package.json +++ b/packages/ckeditor5-code-block/package.json @@ -13,7 +13,8 @@ "type": "module", "main": "src/index.ts", "dependencies": { - "ckeditor5": "41.3.0" + "ckeditor5": "41.3.0", + "lodash-es": "4.17.21" }, "devDependencies": { "@ckeditor/ckeditor5-alignment": "41.3.0", diff --git a/packages/ckeditor5-code-block/src/codeblockediting.ts b/packages/ckeditor5-code-block/src/codeblockediting.ts index ad7f560333c..850ff5a25d1 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.ts +++ b/packages/ckeditor5-code-block/src/codeblockediting.ts @@ -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'; @@ -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'; @@ -30,7 +33,8 @@ import OutdentCodeBlockCommand from './outdentcodeblockcommand.js'; import { getNormalizedAndLocalizedLanguageDefinitions, getLeadingWhiteSpaces, - rawSnippetTextToViewDocumentFragment + rawSnippetTextToViewDocumentFragment, + getCodeBlockAriaAnnouncement } from './utils.js'; import { modelToViewCodeBlockInsertion, @@ -277,6 +281,70 @@ 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; + let lastAnnouncement: string | null = null; + + const joinAnnouncements = ( announcements: Array ) => upperFirst( + announcements + .map( lowerFirst ) + .join( ', ' ) + ); + + model.document.selection.on( 'change:range', () => { + const focusParent = model.document.selection.focus!.parent; + + if ( lastFocusedCodeBlock === focusParent || !focusParent.is( 'element' ) ) { + return; + } + + const announcements: Array = []; + + if ( lastFocusedCodeBlock && lastFocusedCodeBlock.is( 'element', 'codeBlock' ) ) { + announcements.push( getCodeBlockAriaAnnouncement( t, languageDefs, lastFocusedCodeBlock, 'leave' ) ); + } + + if ( focusParent.is( 'element', 'codeBlock' ) ) { + announcements.push( getCodeBlockAriaAnnouncement( t, languageDefs, focusParent, 'enter' ) ); + } + + if ( ui && announcements.length ) { + let announcement = joinAnnouncements( announcements ); + + // Handle edge case when: + // + // 1. user enters code block #1 with PHP language. + // 2. user enters code block #2 with PHP language. + // 3. user leaves code block #2 and comes back to code block #1 with identical language + // + // In this scenario `announcement` will be identical (`Leaving PHP code block, entering PHP code block`) + // Screen reader will not detect this change because `aria-live` is identical with previous one and + // will skip reading the label. + // + // Try to bypass this issue by toggling non readable character at the end of phrase. + if ( lastAnnouncement === announcement ) { + announcement += '.'; + } + + ui.ariaLiveAnnouncer.announce( 'codeBlocks', announcement, 'assertive' ); + lastAnnouncement = announcement; + } + + lastFocusedCodeBlock = focusParent; + } ); } } diff --git a/packages/ckeditor5-code-block/src/utils.ts b/packages/ckeditor5-code-block/src/utils.ts index a280556eb4d..cb7eae968d1 100644 --- a/packages/ckeditor5-code-block/src/utils.ts +++ b/packages/ckeditor5-code-block/src/utils.ts @@ -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, @@ -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: * @@ -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, + 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' ); +} diff --git a/packages/ckeditor5-code-block/tests/codeblockediting.js b/packages/ckeditor5-code-block/tests/codeblockediting.js index 3877e7fafb1..2fa901a45a5 100644 --- a/packages/ckeditor5-code-block/tests/codeblockediting.js +++ b/packages/ckeditor5-code-block/tests/codeblockediting.js @@ -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', { @@ -60,6 +60,7 @@ describe( 'CodeBlockEditing', () => { model = editor.model; view = editor.editing.view; viewDoc = view.document; + root = model.document.getRoot(); } ); } ); @@ -1788,4 +1789,216 @@ 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 ] ) ); + } ); + + 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, join( codeblock( 'FooBar' ), tag( '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' ); + } ); + + 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 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( 'codeBlocks', 'Entering PHP code snippet', 'assertive' ); + + model.change( writer => { + writer.setSelection( createRange( root, [ 1, 0 ], root, [ 1, 1 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( 'codeBlocks', 'Leaving PHP code snippet', 'assertive' ); + + model.change( writer => { + writer.setSelection( createRange( root, [ 2, 0 ], root, [ 2, 1 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( 'codeBlocks', 'Entering CSS code snippet', 'assertive' ); + } ); + + 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 ] ) ); + } ); + + 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, entering PHP code snippet', + 'assertive' + ); + + model.change( writer => { + writer.setSelection( createRange( root, [ 2, 0 ], root, [ 2, 1 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( + 'codeBlocks', + 'Leaving PHP code snippet', + 'assertive' + ); + } ); + + 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 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( 'codeBlocks', 'Entering Ruby code snippet', 'assertive' ); + + model.change( writer => { + writer.setSelection( createRange( root, [ 0, 0 ], root, [ 0, 1 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( + 'codeBlocks', + 'Leaving Ruby code snippet, entering CSS code snippet', + 'assertive' + ); + + model.change( writer => { + writer.setSelection( createRange( root, [ 3, 0 ], root, [ 3, 1 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( + 'codeBlocks', + 'Leaving CSS code snippet, entering XML code snippet', + 'assertive' + ); + + model.change( writer => { + writer.setSelection( createRange( root, [ 4, 0 ], root, [ 4, 1 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( + 'codeBlocks', + 'Leaving XML code snippet, entering code snippet', + 'assertive' + ); + } ); + + it( 'should force trigger announce if leaving and entering again code block with the same language', () => { + setModelData( model, join( codeblock( 'css' ), codeblock( 'css' ) ) ); + + 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, entering CSS code snippet', + 'assertive' + ); + + model.change( writer => { + writer.setSelection( createRange( root, [ 0, 0 ], root, [ 0, 1 ] ) ); + } ); + + expect( announcerSpy ).to.be.calledWithExactly( + 'codeBlocks', + 'Leaving CSS code snippet, 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, entering CSS code snippet', + 'assertive' + ); + } ); + } ); + + 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 }`; + } + + 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 ) + ); + } } );