From e2b2f9dc33b395e151b6c36d79b8011cdb4458f1 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 26 Feb 2024 13:50:51 +0100 Subject: [PATCH] Created the accessibility help dialog. (#15702) Feature (ui): Implemented the `AccessibilityHelp` plugin that brings a dialog displaying keyboard shortcuts available in the editor. Closes #1014. Feature (core): Brought the `editor.accessibility` namespace to the base `Editor` class as a container for accessibility-related features and systems. See #1014. Other (essentials): Enabled the `AccessibilityHelp` plugin by default. See #1014. Fix (utils): The exported `keyCodes` object should contain correct codes for keys related to punctuation, brackets, braces, etc. See #1014. --- docs/_snippets/features/keyboard-support.html | 6 + docs/_snippets/features/keyboard-support.js | 538 ++++++++++++++++++ docs/features/keyboard-support.md | 10 + docs/tutorials/crash-course/keystrokes.md | 23 + .../ckeditor5-autoformat/lang/contexts.json | 3 + .../ckeditor5-autoformat/src/autoformat.ts | 13 + .../ckeditor5-autoformat/tests/autoformat.js | 11 + .../ckeditor5-basic-styles/lang/contexts.json | 7 +- .../src/bold/boldediting.ts | 12 + .../src/code/codeediting.ts | 14 + .../src/italic/italicediting.ts | 11 + .../src/strikethrough/strikethroughediting.ts | 11 + .../src/underline/underlineediting.ts | 11 + .../tests/bold/boldediting.js | 7 + .../tests/code/codeediting.js | 10 + .../tests/italic/italicediting.js | 7 + .../strikethrough/strikethroughediting.js | 7 + .../tests/underline/underlineediting.js | 7 + .../ckeditor5-clipboard/lang/contexts.json | 5 + packages/ckeditor5-clipboard/src/clipboard.ts | 26 + .../ckeditor5-clipboard/tests/clipboard.js | 37 ++ packages/ckeditor5-core/src/accessibility.ts | 525 +++++++++++++++++ packages/ckeditor5-core/src/editor/editor.ts | 8 + packages/ckeditor5-core/src/index.ts | 7 + .../ckeditor5-core/tests/accessibility.js | 443 ++++++++++++++ .../ckeditor5-core/tests/editor/editor.js | 2 + .../ckeditor5-editor-multi-root/src/index.ts | 6 +- packages/ckeditor5-enter/lang/contexts.json | 4 + packages/ckeditor5-enter/src/enter.ts | 11 + packages/ckeditor5-enter/src/shiftenter.ts | 11 + packages/ckeditor5-enter/tests/enter.js | 11 + packages/ckeditor5-enter/tests/shiftenter.js | 11 + packages/ckeditor5-essentials/package.json | 1 + .../ckeditor5-essentials/src/essentials.ts | 3 +- .../ckeditor5-essentials/tests/essentials.js | 2 + .../lang/contexts.json | 3 +- .../src/findandreplaceui.ts | 11 + .../tests/findandreplaceui.js | 14 + packages/ckeditor5-link/lang/contexts.json | 4 +- packages/ckeditor5-link/src/linkui.ts | 18 + packages/ckeditor5-link/tests/linkui.js | 15 + packages/ckeditor5-list/lang/contexts.json | 5 +- .../ckeditor5-list/src/list/listediting.ts | 24 + .../ckeditor5-list/tests/list/listediting.js | 16 + .../src/selectallediting.ts | 11 + .../tests/selectallediting.js | 7 + packages/ckeditor5-table/lang/contexts.json | 7 +- packages/ckeditor5-table/src/tablekeyboard.ts | 28 +- .../ckeditor5-table/tests/tablekeyboard.js | 26 + .../components/editorui/accessibilityhelp.css | 103 ++++ packages/ckeditor5-ui/ckeditor5-metadata.json | 14 + packages/ckeditor5-ui/lang/contexts.json | 18 +- packages/ckeditor5-ui/package.json | 1 + packages/ckeditor5-ui/src/augmentation.ts | 4 +- packages/ckeditor5-ui/src/dialog/dialog.ts | 12 + .../accessibilityhelp/accessibilityhelp.ts | 132 +++++ .../accessibilityhelpcontentview.ts | 147 +++++ .../src/formheader/formheaderview.ts | 3 +- packages/ckeditor5-ui/src/index.ts | 2 + packages/ckeditor5-ui/tests/dialog/dialog.js | 12 + .../accessibilityhelp/accessibilityhelp.js | 178 ++++++ .../accessibilityhelpcontentview.js | 429 ++++++++++++++ .../tests/formheader/formheaderview.js | 1 + .../tests/manual/dialog/dialog.ts | 2 + .../components/editorui/accessibilityhelp.css | 10 + .../theme/icons/accessibility.svg | 1 + packages/ckeditor5-undo/src/undoediting.ts | 17 +- packages/ckeditor5-undo/tests/undoediting.js | 16 + packages/ckeditor5-utils/src/keyboard.ts | 38 +- packages/ckeditor5-utils/tests/keyboard.js | 55 +- packages/ckeditor5-widget/lang/contexts.json | 7 +- packages/ckeditor5-widget/src/widget.ts | 25 + packages/ckeditor5-widget/tests/widget.js | 30 + 73 files changed, 3242 insertions(+), 25 deletions(-) create mode 100644 docs/_snippets/features/keyboard-support.html create mode 100644 docs/_snippets/features/keyboard-support.js create mode 100644 packages/ckeditor5-autoformat/lang/contexts.json create mode 100644 packages/ckeditor5-clipboard/lang/contexts.json create mode 100644 packages/ckeditor5-core/src/accessibility.ts create mode 100644 packages/ckeditor5-core/tests/accessibility.js create mode 100644 packages/ckeditor5-enter/lang/contexts.json create mode 100644 packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/editorui/accessibilityhelp.css create mode 100644 packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelp.ts create mode 100644 packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelpcontentview.ts create mode 100644 packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelp.js create mode 100644 packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelpcontentview.js create mode 100644 packages/ckeditor5-ui/theme/components/editorui/accessibilityhelp.css create mode 100644 packages/ckeditor5-ui/theme/icons/accessibility.svg diff --git a/docs/_snippets/features/keyboard-support.html b/docs/_snippets/features/keyboard-support.html new file mode 100644 index 00000000000..6baf4d350a7 --- /dev/null +++ b/docs/_snippets/features/keyboard-support.html @@ -0,0 +1,6 @@ + + + +
+

Press Alt+0 (⌥0 on Mac) while editing to display the list of available keyboard shortcuts.

+
diff --git a/docs/_snippets/features/keyboard-support.js b/docs/_snippets/features/keyboard-support.js new file mode 100644 index 00000000000..4c6bd9145d6 --- /dev/null +++ b/docs/_snippets/features/keyboard-support.js @@ -0,0 +1,538 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals window, document, open, console, LICENSE_KEY */ + +// Keep the guide listing updated with each change + +import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; +import { Alignment } from '@ckeditor/ckeditor5-alignment'; +import { Autoformat } from '@ckeditor/ckeditor5-autoformat'; +import { Bold, Code, Italic, Strikethrough, Subscript, Superscript, Underline } from '@ckeditor/ckeditor5-basic-styles'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; +import { CaseChange } from '@ckeditor/ckeditor5-case-change'; +import { CKBox, CKBoxImageEdit } from '@ckeditor/ckeditor5-ckbox'; +import { CloudServices } from '@ckeditor/ckeditor5-cloud-services'; +import { CodeBlock } from '@ckeditor/ckeditor5-code-block'; +import { TableOfContents } from '@ckeditor/ckeditor5-document-outline'; +import { Essentials } from '@ckeditor/ckeditor5-essentials'; +import { ExportPdf } from '@ckeditor/ckeditor5-export-pdf'; +import { ExportWord } from '@ckeditor/ckeditor5-export-word'; +import { FindAndReplace } from '@ckeditor/ckeditor5-find-and-replace'; +import { Font } from '@ckeditor/ckeditor5-font'; +import { FormatPainter } from '@ckeditor/ckeditor5-format-painter'; +import { GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; +import { Heading } from '@ckeditor/ckeditor5-heading'; +import { Highlight } from '@ckeditor/ckeditor5-highlight'; +import { HorizontalLine } from '@ckeditor/ckeditor5-horizontal-line'; +import { HtmlEmbed } from '@ckeditor/ckeditor5-html-embed'; +import { AutoImage, + Image, + ImageCaption, + ImageInsert, + ImageResize, + ImageStyle, + ImageToolbar, + ImageUpload, + PictureEditing +} from '@ckeditor/ckeditor5-image'; +import { ImportWord } from '@ckeditor/ckeditor5-import-word'; +import { Indent, IndentBlock } from '@ckeditor/ckeditor5-indent'; +import { AutoLink, Link, LinkImage } from '@ckeditor/ckeditor5-link'; +import { List, ListProperties, TodoList } from '@ckeditor/ckeditor5-list'; +import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed'; +import { Mention } from '@ckeditor/ckeditor5-mention'; +import { PageBreak } from '@ckeditor/ckeditor5-page-break'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { PasteFromOffice } from '@ckeditor/ckeditor5-paste-from-office'; +import { PasteFromOfficeEnhanced } from '@ckeditor/ckeditor5-paste-from-office-enhanced'; +import { RemoveFormat } from '@ckeditor/ckeditor5-remove-format'; +import { ShowBlocks } from '@ckeditor/ckeditor5-show-blocks'; +import { SlashCommand } from '@ckeditor/ckeditor5-slash-command'; +import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; +import { SpecialCharacters, SpecialCharactersEssentials } from '@ckeditor/ckeditor5-special-characters'; +import { Style } from '@ckeditor/ckeditor5-style'; +import { Table, TableCaption, TableCellProperties, TableColumnResize, TableProperties, TableToolbar } from '@ckeditor/ckeditor5-table'; +import { Template } from '@ckeditor/ckeditor5-template'; +import { TextTransformation } from '@ckeditor/ckeditor5-typing'; +import WProofreader from '@webspellchecker/wproofreader-ckeditor5/src/wproofreader.js'; + +// Additional protection for internal license keys CF#2555. +window.open.closed = 1; + +// import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; +import { TOKEN_URL } from '@ckeditor/ckeditor5-ckbox/tests/_utils/ckbox-config.js'; + +// Allow using internal license keys in this sample. See CF#2555. +open.closed = 1; + +// templates icons +import articleImageRightIcon from '../../assets/img/article-image-right.svg'; +import financialReportIcon from '../../assets/img/financial-report.svg'; +import formalLetterIcon from '../../assets/img/formal-letter.svg'; +import resumeIcon from '../../assets/img/resume.svg'; +import richTableIcon from '../../assets/img/rich-table.svg'; + +ClassicEditor + .create( document.querySelector( '#keyboard-support' ), { + // cloudServices: CS_CONFIG, + ui: { + viewportOffset: { + top: window.getViewportTopOffsetConfig() + } + }, + poweredBy: { + position: 'inside', + side: 'left', + label: 'This is' + }, + plugins: [ + Autoformat, BlockQuote, Bold, Heading, CaseChange, Image, ImageCaption, FormatPainter, + ImageStyle, ImageToolbar, Indent, Italic, Link, List, MediaEmbed, + Paragraph, Table, TableToolbar, Alignment, AutoImage, AutoLink, + CKBox, CKBoxImageEdit, CloudServices, Code, CodeBlock, Essentials, ExportPdf, + ExportWord, ImportWord, FindAndReplace, Font, Highlight, HorizontalLine, + HtmlEmbed, ImageInsert, ImageResize, ImageUpload, IndentBlock, GeneralHtmlSupport, + LinkImage, ListProperties, TodoList, Mention, PageBreak, PasteFromOffice, + PasteFromOfficeEnhanced, PictureEditing, RemoveFormat, ShowBlocks, SlashCommand, SourceEditing, + SpecialCharacters, SpecialCharactersEssentials, Style, Strikethrough, Subscript, Superscript, + TableCaption, TableCellProperties, TableColumnResize, + TableProperties, TableOfContents, Template, TextTransformation, + Underline, WProofreader + ], + toolbar: { + items: [ + 'accessibilityHelp', + '|', + 'undo', 'redo', + '|', + 'heading', + '|', + 'fontSize', 'fontFamily', + { + label: 'Font color', + icon: 'plus', + items: [ 'fontColor', 'fontBackgroundColor' ] + }, + '|', + 'bold', 'italic', 'underline', + { + label: 'Formatting', + icon: 'text', + items: [ 'strikethrough', 'subscript', 'superscript', 'code', 'horizontalLine', '|', 'removeFormat' ] + }, + 'specialCharacters', 'pageBreak', + '|', + 'link', 'insertImage', 'ckbox', 'insertTable', 'tableOfContents', 'insertTemplate', + { + label: 'Insert', + icon: 'plus', + items: [ 'highlight', 'blockQuote', 'mediaEmbed', 'codeBlock', 'htmlEmbed' ] + }, + '|', + 'alignment', + '|', + 'bulletedList', 'numberedList', 'todoList', 'outdent', 'indent' + ] + }, + htmlSupport: { + allow: [ + { + name: /^.*$/, + styles: true, + attributes: true, + classes: true + } + ] + }, + style: { + definitions: [ + { + name: 'Article category', + element: 'h3', + classes: [ 'category' ] + }, + { + name: 'Title', + element: 'h2', + classes: [ 'document-title' ] + }, + { + name: 'Subtitle', + element: 'h3', + classes: [ 'document-subtitle' ] + }, + { + name: 'Info box', + element: 'p', + classes: [ 'info-box' ] + }, + { + name: 'Side quote', + element: 'blockquote', + classes: [ 'side-quote' ] + }, + { + name: 'Marker', + element: 'span', + classes: [ 'marker' ] + }, + { + name: 'Spoiler', + element: 'span', + classes: [ 'spoiler' ] + }, + { + name: 'Code (dark)', + element: 'pre', + classes: [ 'fancy-code', 'fancy-code-dark' ] + }, + { + name: 'Code (bright)', + element: 'pre', + classes: [ 'fancy-code', 'fancy-code-bright' ] + } + ] + }, + exportPdf: { + stylesheets: [ + '../../assets/pagination-fonts.css', + 'EDITOR_STYLES', + '../../snippets/features/pagination/snippet.css', + '../../assets/pagination.css' + ], + fileName: 'export-pdf-demo.pdf', + appID: 'cke5-docs', + converterOptions: { + format: 'Tabloid', + margin_top: '20mm', + margin_bottom: '20mm', + margin_right: '24mm', + margin_left: '24mm', + page_orientation: 'portrait' + }, + tokenUrl: false + }, + exportWord: { + stylesheets: [ 'EDITOR_STYLES' ], + fileName: 'export-word-demo.docx', + appID: 'cke5-docs', + converterOptions: { + format: 'B4', + margin_top: '20mm', + margin_bottom: '20mm', + margin_right: '12mm', + margin_left: '12mm', + page_orientation: 'portrait' + }, + tokenUrl: false + }, + fontFamily: { + supportAllValues: true + }, + fontSize: { + options: [ 10, 12, 14, 'default', 18, 20, 22 ], + supportAllValues: true + }, + htmlEmbed: { + showPreviews: true + }, + image: { + styles: [ + 'alignCenter', + 'alignLeft', + 'alignRight' + ], + resizeOptions: [ + { + name: 'resizeImage:original', + label: 'Original', + value: null + }, + { + name: 'resizeImage:50', + label: '50%', + value: '50' + }, + { + name: 'resizeImage:75', + label: '75%', + value: '75' + } + ], + toolbar: [ + 'imageTextAlternative', 'toggleImageCaption', '|', + 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', + 'resizeImage', '|', 'ckboxImageEdit' + ] + }, + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + }, + link: { + decorators: { + addTargetToExternalLinks: true, + defaultProtocol: 'https://', + toggleDownloadable: { + mode: 'manual', + label: 'Downloadable', + attributes: { + download: 'file' + } + } + } + }, + mention: { + feeds: [ + { + marker: '@', + feed: [ + '@apple', '@bears', '@brownie', '@cake', '@cake', '@candy', '@canes', '@chocolate', '@cookie', '@cotton', '@cream', + '@cupcake', '@danish', '@donut', '@dragée', '@fruitcake', '@gingerbread', '@gummi', '@ice', '@jelly-o', + '@liquorice', '@macaroon', '@marzipan', '@oat', '@pie', '@plum', '@pudding', '@sesame', '@snaps', '@soufflé', + '@sugar', '@sweet', '@topping', '@wafer' + ], + minimumCharacters: 0 + } + ] + }, + importWord: { + tokenUrl: false, + defaultStyles: true + }, + placeholder: 'Type or paste your content here!', + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties', 'toggleTableCaption' + ] + }, + template: { + definitions: [ + { + title: 'Document with an image', + description: 'Simple heading with text and and and image.', + icon: articleImageRightIcon, + data: `

Title of the document

+
+ +
A caption of the image.
+
+

The content of the document. 

` + }, + { + title: 'Annual financial report', + description: 'A report that spells out the company\'s financial condition.', + icon: financialReportIcon, + data: `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Metric nameYear
2019202020212022
Revenue$100,000.00$120,000.00$130,000.00$180,000.00
Operating expenses    
Interest    
Net profit    
+
` + }, + { + title: 'Resume', + description: 'A quick overview of candidate\'s professional qualifications.', + icon: resumeIcon, + data: `
+ + + +
+

John Doe

+

Address, Phone, e-mail, social media

+

Profile

+

A quick summary of who you are and what you specialize in.

+

Employment history

+ +

Skills

+ +

Education

+ ` + }, + { + title: 'Formal business letter', + description: 'A clear letter template for business communication.', + icon: formalLetterIcon, + data: () => `

${ new Date().toLocaleDateString() }

+

Company name,
Street Name, Number
Post code, City

+

 

+

Dear [First name],

+

Content of the letter. Content of the letter. Content of the letter. Content of the letter. + Content of the letter. + Content of the letter. Content of the letter. Content of the letter. Content of the letter. Content of the letter. + Content of the letter. Content of the letter. Content of the letter. Content of the letter. Content of the letter. + Content of the letter. Content of the letter. Content of the letter. Content of the letter. Content of the letter. + Content of the letter. Content of the letter. Content of the letter. Content of the letter. Content of the letter. + Content of the letter. Content of the letter. Content of the letter. Content of the letter. Content of the letter. + Content of the letter. Content of the letter. Content of the letter. Content of the letter. 

+

Kind regards,

+

Name Surname
Position, Company
Phone, E-mail

` + }, + { + title: 'Rich table', + description: 'A table with a colorful header.', + icon: richTableIcon, + data: `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Column 1Column 2Column 3Column 4Column 5
     
     
     
     
     
+
Caption of the table
+
` + } + ] + }, + wproofreader: { + serviceId: '1:Eebp63-lWHbt2-ASpHy4-AYUpy2-fo3mk4-sKrza1-NsuXy4-I1XZC2-0u2F54-aqYWd1-l3Qf14-umd', + lang: 'auto', + srcUrl: 'https://svc.webspellchecker.net/spellcheck31/wscbundle/wscbundle.js' + }, + ckbox: { + tokenUrl: TOKEN_URL, + forceDemoLabel: true, + allowExternalImagesEditing: [ /^data:/, 'origin' ] + }, + licenseKey: LICENSE_KEY + } ) + .then( editor => { + window.editor = editor; + // Prevent showing a warning notification when user is pasting a content from MS Word or Google Docs. + window.preventPasteFromOfficeNotification = true; + + window.attachTourBalloon( { + target: window.findToolbarItem( editor.ui.view.toolbar, + item => item.label && item.label === 'Accessibility help' ), + text: 'Click to display keyboard shortcuts.', + editor, + tippyOptions: { + placement: 'top-start' + } + } ); + } ) + .catch( err => { + console.error( err ); + } ); diff --git a/docs/features/keyboard-support.md b/docs/features/keyboard-support.md index 4ca2014c735..f307b002720 100644 --- a/docs/features/keyboard-support.md +++ b/docs/features/keyboard-support.md @@ -221,6 +221,16 @@ Use the following keystrokes for more efficient navigation in the CKEditor  } +## Displaying keyboard shortcuts in the editor + +CKEditor 5 offers a dedicated {@link module:ui/editorui/accessibilityhelp/accessibilityhelp~AccessibilityHelp Accessibility help} plugin that displays a list of all available keyboard shortcuts in a dialog. It can be opened by pressing Alt + 0 (on Windows) or ⌥0 (on macOS). Alternatively, you can use the toolbar button to open the dialog. + +{@snippet features/keyboard-support} + +The Accessibility help plugin is enabled by the {@link module:essentials/essentials~Essentials} plugin from the {@link api/essentials `@ckeditor/ckeditor5-essentials`} package (which also enables other fundamental editing features). + +Learn how to {@link tutorials/crash-course/keystrokes#adding-shortcut-information-to-the-accessibility-help-dialog add your own keyboard shortcuts} to the Accessibility help dialog. + ## Related productivity features Besides using keyboard shortcuts, you may want to check the following productivity features: diff --git a/docs/tutorials/crash-course/keystrokes.md b/docs/tutorials/crash-course/keystrokes.md index 1719b530d25..f4f0178a60e 100644 --- a/docs/tutorials/crash-course/keystrokes.md +++ b/docs/tutorials/crash-course/keystrokes.md @@ -30,6 +30,29 @@ editor.keystrokes.set( 'Ctrl+Alt+H', ( event, cancel ) => { } ); ``` +## Adding shortcut information to the Accessibility help dialog + +The {@link features/keyboard-support#displaying-keyboard-shortcuts-in-the-editor Accessibility help} dialog displays a complete list of available keyboard shortcuts with their descriptions. It does not know about the [shortcut we just added](#adding-keyboard-shortcuts), though. + +The dialog reads from the {@link module:core/accessibility~Accessibility `editor.accessibility`} namespace where all the information about keystrokes and their accessible labels is stored. There is an API to add new entries ({@link module:core/accessibility~Accessibility#addKeystrokeInfos}, {@link module:core/accessibility~Accessibility#addKeystrokeInfoGroup}, and {@link module:core/accessibility~Accessibility#addKeystrokeInfoCategory} methods). + +In this case, a simple `editor.accessibility.addKeystrokeInfos( ... )` is all you need for the Accessibility help dialog to learn about the new shortcut: + +```js +const t = editor.t; + +editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Highlight text' ), + keystroke: 'Ctrl+Alt+H' + } + ] +} ); +``` + +You can learn more about the {@link module:ui/editorui/accessibilityhelp/accessibilityhelp~AccessibilityHelp} plugin and the {@link module:core/accessibility~Accessibility `editor.accessibility`} namespace in the API reference. + ## Updating button tooltip When you hover over the "Undo" and "Redo" buttons, you will see a tooltip containing the name of the operation and their respective keyboard shortcuts. However, when hovering over the "Highlight" button, the keyboard shortcut is missing. diff --git a/packages/ckeditor5-autoformat/lang/contexts.json b/packages/ckeditor5-autoformat/lang/contexts.json new file mode 100644 index 00000000000..011e96880d3 --- /dev/null +++ b/packages/ckeditor5-autoformat/lang/contexts.json @@ -0,0 +1,3 @@ +{ + "Revert autoformatting action": "Keystroke description for assistive technologies: keystroke for reverting autoformatting action." +} diff --git a/packages/ckeditor5-autoformat/src/autoformat.ts b/packages/ckeditor5-autoformat/src/autoformat.ts index d24b056f890..f3afeb42b7d 100644 --- a/packages/ckeditor5-autoformat/src/autoformat.ts +++ b/packages/ckeditor5-autoformat/src/autoformat.ts @@ -40,12 +40,25 @@ export default class Autoformat extends Plugin { * @inheritDoc */ public afterInit(): void { + const editor = this.editor; + const t = this.editor.t; + this._addListAutoformats(); this._addBasicStylesAutoformats(); this._addHeadingAutoformats(); this._addBlockQuoteAutoformats(); this._addCodeBlockAutoformats(); this._addHorizontalLineAutoformats(); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Revert autoformatting action' ), + keystroke: 'Backspace' + } + ] + } ); } /** diff --git a/packages/ckeditor5-autoformat/tests/autoformat.js b/packages/ckeditor5-autoformat/tests/autoformat.js index f5749ee10f0..2ff76503aab 100644 --- a/packages/ckeditor5-autoformat/tests/autoformat.js +++ b/packages/ckeditor5-autoformat/tests/autoformat.js @@ -62,6 +62,17 @@ describe( 'Autoformat', () => { return editor.destroy(); } ); + it( 'should have pluginName', () => { + expect( Autoformat.pluginName ).to.equal( 'Autoformat' ); + } ); + + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Revert autoformatting action', + keystroke: 'Backspace' + } ); + } ); + describe( 'Bulleted list', () => { it( 'should replace asterisk with bulleted list item', () => { setData( model, '*[]' ); diff --git a/packages/ckeditor5-basic-styles/lang/contexts.json b/packages/ckeditor5-basic-styles/lang/contexts.json index 6f0d9a8e64f..581582cc737 100644 --- a/packages/ckeditor5-basic-styles/lang/contexts.json +++ b/packages/ckeditor5-basic-styles/lang/contexts.json @@ -5,5 +5,10 @@ "Code": "Toolbar button tooltip for the Code feature.", "Strikethrough": "Toolbar button tooltip for the Strikethrough feature.", "Subscript": "Toolbar button tooltip for the Subscript feature.", - "Superscript": "Toolbar button tooltip for the Superscript feature." + "Superscript": "Toolbar button tooltip for the Superscript feature.", + "Italic text": "Keystroke description for assistive technologies: keystroke for making text italic.", + "Move out of an inline code style": "Keystroke description for assistive technologies: keystroke for moving selection out of an inline code style.", + "Bold text": "Keystroke description for assistive technologies: keystroke for making text bold.", + "Underline text": "Keystroke description for assistive technologies: keystroke for making text underlined.", + "Strikethrough text": "Keystroke description for assistive technologies: keystroke for making text strikethrough." } diff --git a/packages/ckeditor5-basic-styles/src/bold/boldediting.ts b/packages/ckeditor5-basic-styles/src/bold/boldediting.ts index f330eb539f4..1727a4d3396 100644 --- a/packages/ckeditor5-basic-styles/src/bold/boldediting.ts +++ b/packages/ckeditor5-basic-styles/src/bold/boldediting.ts @@ -31,6 +31,8 @@ export default class BoldEditing extends Plugin { */ public init(): void { const editor = this.editor; + const t = this.editor.t; + // Allow bold attribute on text nodes. editor.model.schema.extend( '$text', { allowAttributes: BOLD } ); editor.model.schema.setAttributeProperties( BOLD, { @@ -69,5 +71,15 @@ export default class BoldEditing extends Plugin { // Set the Ctrl+B keystroke. editor.keystrokes.set( 'CTRL+B', BOLD ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Bold text' ), + keystroke: 'CTRL+B' + } + ] + } ); } } diff --git a/packages/ckeditor5-basic-styles/src/code/codeediting.ts b/packages/ckeditor5-basic-styles/src/code/codeediting.ts index bf113d7c2aa..0e84a7f65fa 100644 --- a/packages/ckeditor5-basic-styles/src/code/codeediting.ts +++ b/packages/ckeditor5-basic-styles/src/code/codeediting.ts @@ -41,6 +41,7 @@ export default class CodeEditing extends Plugin { */ public init(): void { const editor = this.editor; + const t = this.editor.t; // Allow code attribute on text nodes. editor.model.schema.extend( '$text', { allowAttributes: CODE } ); @@ -67,5 +68,18 @@ export default class CodeEditing extends Plugin { // Setup highlight over selected element. inlineHighlight( editor, CODE, 'code', HIGHLIGHT_CLASS ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Move out of an inline code style' ), + keystroke: [ + [ 'arrowleft', 'arrowleft' ], + [ 'arrowright', 'arrowright' ] + ] + } + ] + } ); } } diff --git a/packages/ckeditor5-basic-styles/src/italic/italicediting.ts b/packages/ckeditor5-basic-styles/src/italic/italicediting.ts index a3a6cdb2848..d23efc6d4a8 100644 --- a/packages/ckeditor5-basic-styles/src/italic/italicediting.ts +++ b/packages/ckeditor5-basic-styles/src/italic/italicediting.ts @@ -31,6 +31,7 @@ export default class ItalicEditing extends Plugin { */ public init(): void { const editor = this.editor; + const t = this.editor.t; // Allow italic attribute on text nodes. editor.model.schema.extend( '$text', { allowAttributes: ITALIC } ); @@ -57,5 +58,15 @@ export default class ItalicEditing extends Plugin { // Set the Ctrl+I keystroke. editor.keystrokes.set( 'CTRL+I', ITALIC ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Italic text' ), + keystroke: 'CTRL+I' + } + ] + } ); } } diff --git a/packages/ckeditor5-basic-styles/src/strikethrough/strikethroughediting.ts b/packages/ckeditor5-basic-styles/src/strikethrough/strikethroughediting.ts index 21425c4c344..557b712301e 100644 --- a/packages/ckeditor5-basic-styles/src/strikethrough/strikethroughediting.ts +++ b/packages/ckeditor5-basic-styles/src/strikethrough/strikethroughediting.ts @@ -32,6 +32,7 @@ export default class StrikethroughEditing extends Plugin { */ public init(): void { const editor = this.editor; + const t = this.editor.t; // Allow strikethrough attribute on text nodes. editor.model.schema.extend( '$text', { allowAttributes: STRIKETHROUGH } ); @@ -59,5 +60,15 @@ export default class StrikethroughEditing extends Plugin { // Set the Ctrl+Shift+X keystroke. editor.keystrokes.set( 'CTRL+SHIFT+X', 'strikethrough' ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Strikethrough text' ), + keystroke: 'CTRL+SHIFT+X' + } + ] + } ); } } diff --git a/packages/ckeditor5-basic-styles/src/underline/underlineediting.ts b/packages/ckeditor5-basic-styles/src/underline/underlineediting.ts index f4d649c766e..4a992a8ea49 100644 --- a/packages/ckeditor5-basic-styles/src/underline/underlineediting.ts +++ b/packages/ckeditor5-basic-styles/src/underline/underlineediting.ts @@ -31,6 +31,7 @@ export default class UnderlineEditing extends Plugin { */ public init(): void { const editor = this.editor; + const t = this.editor.t; // Allow strikethrough attribute on text nodes. editor.model.schema.extend( '$text', { allowAttributes: UNDERLINE } ); @@ -54,5 +55,15 @@ export default class UnderlineEditing extends Plugin { // Set the Ctrl+U keystroke. editor.keystrokes.set( 'CTRL+U', 'underline' ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Underline text' ), + keystroke: 'CTRL+U' + } + ] + } ); } } diff --git a/packages/ckeditor5-basic-styles/tests/bold/boldediting.js b/packages/ckeditor5-basic-styles/tests/bold/boldediting.js index 5fad45b4bb3..44a91de313c 100644 --- a/packages/ckeditor5-basic-styles/tests/bold/boldediting.js +++ b/packages/ckeditor5-basic-styles/tests/bold/boldediting.js @@ -40,6 +40,13 @@ describe( 'BoldEditing', () => { expect( editor.plugins.get( BoldEditing ) ).to.be.instanceOf( BoldEditing ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Bold text', + keystroke: 'CTRL+B' + } ); + } ); + it( 'should set proper schema rules', () => { expect( model.schema.checkAttribute( [ '$root', '$block', '$text' ], 'bold' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'bold' ) ).to.be.true; diff --git a/packages/ckeditor5-basic-styles/tests/code/codeediting.js b/packages/ckeditor5-basic-styles/tests/code/codeediting.js index 33ff0c9aa38..1c6c1d4666f 100644 --- a/packages/ckeditor5-basic-styles/tests/code/codeediting.js +++ b/packages/ckeditor5-basic-styles/tests/code/codeediting.js @@ -41,6 +41,16 @@ describe( 'CodeEditing', () => { expect( editor.plugins.get( CodeEditing ) ).to.be.instanceOf( CodeEditing ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Move out of an inline code style', + keystroke: [ + [ 'arrowleft', 'arrowleft' ], + [ 'arrowright', 'arrowright' ] + ] + } ); + } ); + it( 'should set proper schema rules', () => { expect( model.schema.checkAttribute( [ '$root', '$block', '$text' ], 'code' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'code' ) ).to.be.true; diff --git a/packages/ckeditor5-basic-styles/tests/italic/italicediting.js b/packages/ckeditor5-basic-styles/tests/italic/italicediting.js index 2900bc67eec..cbeeb7d164f 100644 --- a/packages/ckeditor5-basic-styles/tests/italic/italicediting.js +++ b/packages/ckeditor5-basic-styles/tests/italic/italicediting.js @@ -38,6 +38,13 @@ describe( 'ItalicEditing', () => { expect( editor.plugins.get( ItalicEditing ) ).to.be.instanceOf( ItalicEditing ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Italic text', + keystroke: 'CTRL+I' + } ); + } ); + it( 'should set proper schema rules', () => { expect( model.schema.checkAttribute( [ '$root', '$block', '$text' ], 'italic' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'italic' ) ).to.be.true; diff --git a/packages/ckeditor5-basic-styles/tests/strikethrough/strikethroughediting.js b/packages/ckeditor5-basic-styles/tests/strikethrough/strikethroughediting.js index df6ed0dca94..a9673b978c2 100644 --- a/packages/ckeditor5-basic-styles/tests/strikethrough/strikethroughediting.js +++ b/packages/ckeditor5-basic-styles/tests/strikethrough/strikethroughediting.js @@ -38,6 +38,13 @@ describe( 'StrikethroughEditing', () => { expect( editor.plugins.get( StrikethroughEditing ) ).to.be.instanceOf( StrikethroughEditing ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Strikethrough text', + keystroke: 'CTRL+SHIFT+X' + } ); + } ); + it( 'should set proper schema rules', () => { expect( model.schema.checkAttribute( [ '$root', '$block', '$text' ], 'strikethrough' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'strikethrough' ) ).to.be.true; diff --git a/packages/ckeditor5-basic-styles/tests/underline/underlineediting.js b/packages/ckeditor5-basic-styles/tests/underline/underlineediting.js index c6950a769e6..af92f65398f 100644 --- a/packages/ckeditor5-basic-styles/tests/underline/underlineediting.js +++ b/packages/ckeditor5-basic-styles/tests/underline/underlineediting.js @@ -38,6 +38,13 @@ describe( 'UnderlineEditing', () => { expect( editor.plugins.get( UnderlineEditing ) ).to.be.instanceOf( UnderlineEditing ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Underline text', + keystroke: 'CTRL+U' + } ); + } ); + it( 'should set proper schema rules', () => { expect( model.schema.checkAttribute( [ '$root', '$block', '$text' ], 'underline' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$clipboardHolder', '$text' ], 'underline' ) ).to.be.true; diff --git a/packages/ckeditor5-clipboard/lang/contexts.json b/packages/ckeditor5-clipboard/lang/contexts.json new file mode 100644 index 00000000000..b216dee3e56 --- /dev/null +++ b/packages/ckeditor5-clipboard/lang/contexts.json @@ -0,0 +1,5 @@ +{ + "Copy selected content": "Keystroke description for assistive technologies: keystroke for copying selected content.", + "Paste content": "Keystroke description for assistive technologies: keystroke for pasting content.", + "Paste content as plain text": "Keystroke description for assistive technologies: keystroke for pasting content as plain text." +} diff --git a/packages/ckeditor5-clipboard/src/clipboard.ts b/packages/ckeditor5-clipboard/src/clipboard.ts index cccf61abf60..486c848ae2d 100644 --- a/packages/ckeditor5-clipboard/src/clipboard.ts +++ b/packages/ckeditor5-clipboard/src/clipboard.ts @@ -37,4 +37,30 @@ export default class Clipboard extends Plugin { public static get requires() { return [ ClipboardPipeline, DragDrop, PastePlainText ] as const; } + + /** + * @inheritDoc + */ + public init(): void { + const editor = this.editor; + const t = this.editor.t; + + // Add the information about the keystrokes to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Copy selected content' ), + keystroke: 'CTRL+C' + }, + { + label: t( 'Paste content' ), + keystroke: 'CTRL+V' + }, + { + label: t( 'Paste content as plain text' ), + keystroke: 'CTRL+SHIFT+V' + } + ] + } ); + } } diff --git a/packages/ckeditor5-clipboard/tests/clipboard.js b/packages/ckeditor5-clipboard/tests/clipboard.js index 71424a0788b..ef85d0e5163 100644 --- a/packages/ckeditor5-clipboard/tests/clipboard.js +++ b/packages/ckeditor5-clipboard/tests/clipboard.js @@ -7,8 +7,28 @@ import Clipboard from '../src/clipboard.js'; import ClipboardPipeline from '../src/clipboardpipeline.js'; import DragDrop from '../src/dragdrop.js'; import PastePlainText from '../src/pasteplaintext.js'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import { global } from '@ckeditor/ckeditor5-utils'; describe( 'Clipboard Feature', () => { + let editor, domElement; + + beforeEach( async () => { + domElement = global.document.createElement( 'div' ); + global.document.body.appendChild( domElement ); + + editor = await ClassicTestEditor.create( domElement, { + plugins: [ + Clipboard + ] + } ); + } ); + + afterEach( async () => { + domElement.remove(); + await editor.destroy(); + } ); + it( 'requires ClipboardPipeline, DragDrop and PastePlainText', () => { expect( Clipboard.requires ).to.deep.equal( [ ClipboardPipeline, DragDrop, PastePlainText ] ); } ); @@ -16,4 +36,21 @@ describe( 'Clipboard Feature', () => { it( 'has proper name', () => { expect( Clipboard.pluginName ).to.equal( 'Clipboard' ); } ); + + it( 'should provide keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Copy selected content', + keystroke: 'CTRL+C' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Paste content', + keystroke: 'CTRL+V' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Paste content as plain text', + keystroke: 'CTRL+SHIFT+V' + } ); + } ); } ); diff --git a/packages/ckeditor5-core/src/accessibility.ts b/packages/ckeditor5-core/src/accessibility.ts new file mode 100644 index 00000000000..c8a08d33f96 --- /dev/null +++ b/packages/ckeditor5-core/src/accessibility.ts @@ -0,0 +1,525 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module core/accessibility + */ + +import { CKEditorError } from '@ckeditor/ckeditor5-utils'; +import type Editor from './editor/editor.js'; + +const DEFAULT_CATEGORY_ID = 'contentEditing' as const; +export const DEFAULT_GROUP_ID = 'common' as const; + +/** + * A common namespace for various accessibility features of the editor. + * + * **Information about editor keystrokes** + * + * * The information about keystrokes available in the editor is stored in the {@link #keystrokeInfos} property. + * * New info entries can be added using the {@link #addKeystrokeInfoCategory}, {@link #addKeystrokeInfoGroup}, + * and {@link #addKeystrokeInfos} methods. + */ +export default class Accessibility { + /** + * Stores information about keystrokes brought by editor features for the users to interact with the editor, mainly + * keystroke combinations and their accessible labels. + * + * This information is particularly useful for screen reader and other assistive technology users. It gets displayed + * by the {@link module:ui/editorui/accessibilityhelp/accessibilityhelp~AccessibilityHelp Accessibility help} dialog. + * + * Keystrokes are organized in categories and groups. They can be added using ({@link #addKeystrokeInfoCategory}, + * {@link #addKeystrokeInfoGroup}, and {@link #addKeystrokeInfos}) methods. + * + * Please note that: + * * two categories are always available: + * * `'contentEditing'` for keystrokes related to content creation, + * * `'navigation'` for keystrokes related to navigation in the UI and the content. + * * unless specified otherwise, new keystrokes are added into the `'contentEditing'` category and the `'common'` + * keystroke group within that category while using the {@link #addKeystrokeInfos} method. + */ + public readonly keystrokeInfos: KeystrokeInfos = new Map(); + + /** + * The editor instance. + */ + private readonly _editor: Editor; + + /** + * @inheritDoc + */ + constructor( editor: Editor ) { + this._editor = editor; + + const t = editor.locale.t; + + this.addKeystrokeInfoCategory( { + id: DEFAULT_CATEGORY_ID, + label: t( 'Content editing keystrokes' ), + description: t( 'These keyboard shortcuts allow for quick access to content editing features.' ) + } ); + + this.addKeystrokeInfoCategory( { + id: 'navigation', + label: t( 'User interface and content navigation keystrokes' ), + description: t( 'Use the following keystrokes for more efficient navigation in the CKEditor 5 user interface.' ), + groups: [ + { + id: 'common', + keystrokes: [ + { + label: t( 'Close contextual balloons, dropdowns, and dialogs' ), + keystroke: 'Esc' + }, + { + label: t( 'Open the accessibility help dialog' ), + keystroke: 'Alt+0' + }, + { + label: t( 'Move focus between form fields (inputs, buttons, etc.)' ), + keystroke: [ [ 'Tab' ], [ 'Shift+Tab' ] ] + }, + { + label: t( 'Move focus to the toolbar, navigate between toolbars' ), + keystroke: 'Alt+F10', + mayRequireFn: true + }, + { + label: t( 'Navigate through the toolbar' ), + keystroke: [ [ 'arrowup' ], [ 'arrowright' ], [ 'arrowdown' ], [ 'arrowleft' ] ] + }, + { + label: t( 'Execute the currently focused button' ), + keystroke: [ [ 'Enter' ], [ 'Space' ] ] + } + ] + } + ] + } ); + } + + /** + * Adds a top-level category in the {@link #keystrokeInfos keystroke information database} with a label and optional description. + * + * Categories organize keystrokes and help users to find the right keystroke. Each category can have multiple groups + * of keystrokes that narrow down the context in which the keystrokes are available. Every keystroke category comes + * with a `'common'` group by default. + * + * By default, two categories are available: + * * `'contentEditing'` for keystrokes related to content creation, + * * `'navigation'` for keystrokes related to navigation in the UI and the content. + * + * To create a new keystroke category with new groups, use the following code: + * + * ```js + * class MyPlugin extends Plugin { + * // ... + * init() { + * const editor = this.editor; + * const t = editor.t; + * + * // ... + * + * editor.accessibility.addKeystrokeInfoCategory( { + * id: 'myCategory', + * label: t( 'My category' ), + * description: t( 'My category description.' ), + * groups: [ + * { + * id: 'myGroup', + * label: t( 'My keystroke group' ), + * keystrokes: [ + * { + * label: t( 'Keystroke label 1' ), + * keystroke: 'Ctrl+Shift+N' + * }, + * { + * label: t( 'Keystroke label 2' ), + * keystroke: 'Ctrl+Shift+M' + * } + * ] + * } + * ] + * }; + * } + * } + * ``` + * + * See {@link #keystrokeInfos}, {@link #addKeystrokeInfoGroup}, and {@link #addKeystrokeInfos}. + */ + public addKeystrokeInfoCategory( { id, label, description, groups }: AddKeystrokeInfoCategoryData ): void { + this.keystrokeInfos.set( id, { + id, + label, + description, + groups: new Map() + } ); + + this.addKeystrokeInfoGroup( { + categoryId: id, + id: DEFAULT_GROUP_ID + } ); + + if ( groups ) { + groups.forEach( group => { + this.addKeystrokeInfoGroup( { + categoryId: id, + ...group + } ); + } ); + } + } + + /** + * Adds a group of keystrokes in a specific category to the {@link #keystrokeInfos keystroke information database}. + * + * Groups narrow down the context in which the keystrokes are available. When `categoryId` is not specified, + * the group goes to the `'contentEditing'` category (default). + * + * To create a new group within an existing category, use the following code: + * + * ```js + * class MyPlugin extends Plugin { + * // ... + * init() { + * const editor = this.editor; + * const t = editor.t; + * + * // ... + * + * editor.accessibility.addKeystrokeInfoGroup( { + * id: 'myGroup', + * categoryId: 'navigation', + * label: t( 'My keystroke group' ), + * keystrokes: [ + * { + * label: t( 'Keystroke label 1' ), + * keystroke: 'Ctrl+Shift+N' + * }, + * { + * label: t( 'Keystroke label 2' ), + * keystroke: 'Ctrl+Shift+M' + * } + * ] + * } ); + * } + * } + * ``` + * + * See {@link #keystrokeInfos}, {@link #addKeystrokeInfoCategory}, and {@link #addKeystrokeInfos}. + */ + public addKeystrokeInfoGroup( { + categoryId = DEFAULT_CATEGORY_ID, + id, + label, + keystrokes + }: AddKeystrokeInfoGroupData ): void { + const category = this.keystrokeInfos.get( categoryId ); + + if ( !category ) { + throw new CKEditorError( 'accessibility-unknown-keystroke-info-category', this._editor, { groupId: id, categoryId } ); + } + + category.groups.set( id, { + id, + label, + keystrokes: keystrokes || [] + } ); + } + + /** + * Adds information about keystrokes to the {@link #keystrokeInfos keystroke information database}. + * + * Keystrokes without specified `groupId` or `categoryId` go to the `'common'` group in the `'contentEditing'` category (default). + * + * To add a keystroke brought by your plugin (using default group and category), use the following code: + * + * ```js + * class MyPlugin extends Plugin { + * // ... + * init() { + * const editor = this.editor; + * const t = editor.t; + * + * // ... + * + * editor.accessibility.addKeystrokeInfos( { + * keystrokes: [ + * { + * label: t( 'Keystroke label' ), + * keystroke: 'CTRL+B' + * } + * ] + * } ); + * } + * } + * ``` + * To add a keystroke in a specific existing `'widget'` group in the default `'contentEditing'` category: + * + * ```js + * class MyPlugin extends Plugin { + * // ... + * init() { + * const editor = this.editor; + * const t = editor.t; + * + * // ... + * + * editor.accessibility.addKeystrokeInfos( { + * // Add a keystroke to the existing "widget" group. + * groupId: 'widget', + * keystrokes: [ + * { + * label: t( 'A an action on a selected widget' ), + * keystroke: 'Ctrl+D', + * } + * ] + * } ); + * } + * } + * ``` + * + * To add a keystroke to another existing category (using default group): + * + * ```js + * class MyPlugin extends Plugin { + * // ... + * init() { + * const editor = this.editor; + * const t = editor.t; + * + * // ... + * + * editor.accessibility.addKeystrokeInfos( { + * // Add keystrokes to the "navigation" category (one of defaults). + * categoryId: 'navigation', + * keystrokes: [ + * { + * label: t( 'Keystroke label' ), + * keystroke: 'CTRL+B' + * } + * ] + * } ); + * } + * } + * ``` + * + * See {@link #keystrokeInfos}, {@link #addKeystrokeInfoGroup}, and {@link #addKeystrokeInfoCategory}. + */ + public addKeystrokeInfos( { + categoryId = DEFAULT_CATEGORY_ID, + groupId = DEFAULT_GROUP_ID, + keystrokes + }: AddKeystrokeInfosData ): void { + if ( !this.keystrokeInfos.has( categoryId ) ) { + /** + * Cannot add keystrokes in an unknown category. Use + * {@link module:core/accessibility~Accessibility#addKeystrokeInfoCategory} + * to add a new category or make sure the specified category exists. + * + * @error accessibility-unknown-keystroke-info-category + * @param categoryId The id of the unknown keystroke category. + * @param keystrokes Keystroke definitions about to be added. + */ + throw new CKEditorError( 'accessibility-unknown-keystroke-info-category', this._editor, { categoryId, keystrokes } ); + } + + const category = this.keystrokeInfos.get( categoryId )!; + + if ( !category.groups.has( groupId ) ) { + /** + * Cannot add keystrokes to an unknown group. + * + * Use {@link module:core/accessibility~Accessibility#addKeystrokeInfoGroup} + * to add a new group or make sure the specified group exists. + * + * @error accessibility-unknown-keystroke-info-group + * @param groupId The id of the unknown keystroke group. + * @param categoryId The id of category the unknown group should belong to. + * @param keystrokes Keystroke definitions about to be added. + */ + throw new CKEditorError( 'accessibility-unknown-keystroke-info-group', this._editor, { groupId, categoryId, keystrokes } ); + } + + category.groups.get( groupId )!.keystrokes.push( ...keystrokes ); + } +} + +/** + * A description of category of keystrokes accepted by the {@link module:core/accessibility~Accessibility#addKeystrokeInfoCategory} method. + * + * Top-level categories organize keystrokes and help users to find the right keystroke. Each category can have multiple groups of + * keystrokes that narrow down the context in which the keystrokes are available. + * + * See {@link module:core/accessibility~Accessibility#addKeystrokeInfoGroup} and + * {@link module:core/accessibility~Accessibility#addKeystrokeInfos}. + */ +export interface AddKeystrokeInfoCategoryData { + + /** + * The unique id of the category. + */ + id: string; + + /** + * The label of the category. + */ + label: string; + + /** + * The description of the category (optional). + */ + description?: string; + + /** + * Groups of keystrokes within the category. + */ + groups?: Array; +} + +/** + * A description of keystroke group accepted by the {@link module:core/accessibility~Accessibility#addKeystrokeInfoGroup} method. + * + * Groups narrow down the context in which the keystrokes are available. When `categoryId` is not specified, the group goes + * to the `'contentEditing'` category (default). + * + * See {@link module:core/accessibility~Accessibility#addKeystrokeInfoCategory} and + * {@link module:core/accessibility~Accessibility#addKeystrokeInfos}. + */ +export interface AddKeystrokeInfoGroupData { + + /** + * The category id the group belongs to. + */ + categoryId?: string; + + /** + * The unique id of the group. + */ + id: string; + + /** + * The label of the group (optional). + */ + label?: string; + + /** + * Keystroke definitions within the group. + */ + keystrokes?: Array; +} + +/** + * Description of keystrokes accepted by the {@link module:core/accessibility~Accessibility#addKeystrokeInfos} method. + * + * Keystrokes without specified `groupId` or `categoryId` go to the `'common'` group in the `'contentEditing'` category (default). + * + * See {@link module:core/accessibility~Accessibility#addKeystrokeInfoCategory} and + * {@link module:core/accessibility~Accessibility#addKeystrokeInfoGroup}. + */ +export interface AddKeystrokeInfosData { + + /** + * The category id the keystrokes belong to. + */ + categoryId?: string; + + /** + * The group id the keystrokes belong to. + */ + groupId?: string; + + /** + * An array of keystroke definitions. + */ + keystrokes: Array; +} + +export type KeystrokeInfos = Map; + +/** + * A category of keystrokes in {@link module:core/accessibility~Accessibility#keystrokeInfos}. + */ +export type KeystrokeInfoCategory = { + + /** + * The unique id of the category. + */ + id: string; + + /** + * The label of the category. + */ + label: string; + + /** + * The description of the category (optional). + */ + description?: string; + + /** + * Groups of keystrokes within the category. + */ + groups: Map; +}; + +/** + * A group of keystrokes in {@link module:core/accessibility~Accessibility#keystrokeInfos}. + */ +export type KeystrokeInfoGroup = { + + /** + * The unique id of the group. + */ + id: string; + + /** + * The label of the group (optional). + */ + label?: string; + + /** + * Keystroke definitions within the group. + */ + keystrokes: Array; +}; + +/** + * A keystroke info definition in {@link module:core/accessibility~Accessibility#keystrokeInfos} + */ +export interface KeystrokeInfoDefinition { + + /** + * The label of the keystroke. It should briefly describe the action that the keystroke performs. It may contain HTML. + */ + label: string; + + /** + * The keystroke string. In its basic form, it must be a combination of {@link module:utils/keyboard#keyCodes known key names} + * joined by the `+` sign, the same as the keystroke format accepted by the + * {@link module:utils/keystrokehandler~KeystrokeHandler#set `KeystrokeHandler#set()`} method used to register most of the + * keystroke interactions in the editor. + * + * * The keystroke string can represent a single keystroke, for instance: `keystroke: 'Ctrl+B'`, `keystroke: 'Shift+Enter'`, + * `keystroke: 'Alt+F10'`, etc. + * * The keystroke can be activated by successive press of multiple keys. For instance `keystroke: [ [ 'arrowleft', 'arrowleft' ] ]` + * will indicate that a specific action will be performed by pressing twice in a row. + * * Keystrokes can have alternatives. For instance `keystroke: [ [ 'Ctrl+Y' ], [ 'Ctrl+Shift+Z' ] ]` will indicate that + * a specific action can be performed by pressing either Ctrl + Y or + * Ctrl + Shift + Z. + * + * Please note that the keystrokes are automatically translated to the environment-specific form. For example, `Ctrl+A` + * will be rendered as `⌘A` in the {@link module:utils/env~EnvType#isMac Mac environment}. Always use the IBM PC keyboard + * syntax, for instance `Ctrl` instead of `⌘`, `Alt` instead of `⌥`, etc. + */ + keystroke: string | Array | Array>; + + /** + * This (optional) flag suggests that the keystroke(s) may require a function (Fn) key to be pressed + * in order to work in the {@link module:utils/env~EnvType#isMac Mac environment}. If set `true`, an additional + * information will be displayed next to the keystroke. + */ + mayRequireFn?: boolean; +} diff --git a/packages/ckeditor5-core/src/editor/editor.ts b/packages/ckeditor5-core/src/editor/editor.ts index 15536182984..2b914f6bcd5 100644 --- a/packages/ckeditor5-core/src/editor/editor.ts +++ b/packages/ckeditor5-core/src/editor/editor.ts @@ -30,6 +30,7 @@ import Context from '../context.js'; import PluginCollection from '../plugincollection.js'; import CommandCollection, { type CommandsMap } from '../commandcollection.js'; import EditingKeystrokeHandler from '../editingkeystrokehandler.js'; +import Accessibility from '../accessibility.js'; import type { LoadedPlugins, PluginConstructor } from '../plugin.js'; import type { EditorConfig } from './editorconfig.js'; @@ -53,6 +54,11 @@ import type { EditorConfig } from './editorconfig.js'; * (as most editor implementations do). */ export default abstract class Editor extends ObservableMixin() { + /** + * A namespace for the accessibility features of the editor. + */ + public readonly accessibility: Accessibility; + /** * Commands registered to the editor. * @@ -326,6 +332,8 @@ export default abstract class Editor extends ObservableMixin() { this.keystrokes = new EditingKeystrokeHandler( this ); this.keystrokes.listenTo( this.editing.view.document ); + + this.accessibility = new Accessibility( this ); } /** diff --git a/packages/ckeditor5-core/src/index.ts b/packages/ckeditor5-core/src/index.ts index 97a5ef8cf46..9f365cf8925 100644 --- a/packages/ckeditor5-core/src/index.ts +++ b/packages/ckeditor5-core/src/index.ts @@ -33,6 +33,13 @@ export { default as secureSourceElement } from './editor/utils/securesourceeleme export { default as PendingActions, type PendingAction } from './pendingactions.js'; +export type { + KeystrokeInfos as KeystrokeInfoDefinitions, + KeystrokeInfoGroup as KeystrokeInfoGroupDefinition, + KeystrokeInfoCategory as KeystrokeInfoCategoryDefinition, + KeystrokeInfoDefinition as KeystrokeInfoDefinition +} from './accessibility.js'; + import cancel from './../theme/icons/cancel.svg'; import caption from './../theme/icons/caption.svg'; import check from './../theme/icons/check.svg'; diff --git a/packages/ckeditor5-core/tests/accessibility.js b/packages/ckeditor5-core/tests/accessibility.js new file mode 100644 index 00000000000..068c51b8a3c --- /dev/null +++ b/packages/ckeditor5-core/tests/accessibility.js @@ -0,0 +1,443 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import { Editor } from '@ckeditor/ckeditor5-core'; +import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils.js'; +import { cloneDeep } from 'lodash-es'; + +describe( 'Accessibility', () => { + let editor, accessibility; + + beforeEach( () => { + editor = new Editor(); + accessibility = editor.accessibility; + } ); + + afterEach( async () => { + editor.destroy(); + } ); + + it( 'should provide default categories, groups, and keystrokes', () => { + expect( serializeKeystrokes( accessibility.keystrokeInfos ) ).to.deep.equal( [ + [ + 'contentEditing', + { + description: 'These keyboard shortcuts allow for quick access to content editing features.', + groups: [ + [ + 'common', + { + id: 'common', + keystrokes: [], + label: undefined + } + ] + ], + id: 'contentEditing', + label: 'Content editing keystrokes' + } + ], + [ + 'navigation', + { + description: 'Use the following keystrokes for more efficient navigation in the CKEditor 5 user interface.', + groups: [ + [ + 'common', + { + id: 'common', + keystrokes: [ + { + keystroke: 'Esc', + label: 'Close contextual balloons, dropdowns, and dialogs' + }, + { + keystroke: 'Alt+0', + label: 'Open the accessibility help dialog' + }, + { + keystroke: [ [ 'Tab' ], [ 'Shift+Tab' ] ], + label: 'Move focus between form fields (inputs, buttons, etc.)' + }, + { + keystroke: 'Alt+F10', + label: 'Move focus to the toolbar, navigate between toolbars', + mayRequireFn: true + }, + { + keystroke: [ + [ 'arrowup' ], + [ 'arrowright' ], + [ 'arrowdown' ], + [ 'arrowleft' ] + ], + label: 'Navigate through the toolbar' + }, + { + keystroke: [ + [ 'Enter' ], + [ 'Space' ] + ], + label: 'Execute the currently focused button' + } + ], + label: undefined + } + ] + ], + id: 'navigation', + label: 'User interface and content navigation keystrokes' + } + ] + ] ); + } ); + + describe( 'addKeystrokeInfoCategory()', () => { + it( 'should add a new category', () => { + accessibility.addKeystrokeInfoCategory( { + id: 'test', + label: 'Test category', + description: 'Test category description' + } ); + + const keystrokes = serializeKeystrokes( accessibility.keystrokeInfos ); + + expect( keystrokes ).to.deep.include( [ + 'test', + { + id: 'test', + label: 'Test category', + description: 'Test category description', + groups: [ + [ + 'common', + { + id: 'common', + label: undefined, + keystrokes: [] + } + ] + ] + } + ] ); + } ); + + it( 'should add child groups with keystrokes when specified', () => { + accessibility.addKeystrokeInfoCategory( { + id: 'testcat', + label: 'Test category', + description: 'Test category description', + groups: [ + { + id: 'testgroup', + label: 'Test group', + keystrokes: [ + { + label: 'Foo', + keystroke: 'Alt+C' + } + ] + } + ] + } ); + + expect( serializeKeystrokes( accessibility.keystrokeInfos ) ).to.deep.include( [ + 'testcat', + { + id: 'testcat', + label: 'Test category', + description: 'Test category description', + groups: [ + [ + 'common', + { + id: 'common', + label: undefined, + keystrokes: [] + } + ], + [ + 'testgroup', + { + id: 'testgroup', + label: 'Test group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } + ] + ] + } + ] ); + } ); + } ); + + describe( 'addKeystrokeInfoGroup()', () => { + it( 'should add a new group in the default category', () => { + accessibility.addKeystrokeInfoGroup( { + id: 'testgroup', + label: 'Test group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + + expect( serializeKeystrokes( accessibility.keystrokeInfos ) ).to.deep.include( [ + 'contentEditing', + { + id: 'contentEditing', + label: 'Content editing keystrokes', + description: 'These keyboard shortcuts allow for quick access to content editing features.', + groups: [ + [ + 'common', + { + id: 'common', + label: undefined, + keystrokes: [] + } + ], + [ + 'testgroup', + { + id: 'testgroup', + label: 'Test group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } + ] + ] + } + ] ); + } ); + + it( 'should throw if the category was not found', () => { + expectToThrowCKEditorError( () => { + accessibility.addKeystrokeInfoGroup( { + id: 'testgroup', + categoryId: 'unknown-category', + label: 'Test group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + }, /^accessibility-unknown-keystroke-info-category/, editor, { groupId: 'testgroup', categoryId: 'unknown-category' } ); + } ); + + it( 'should add group to a specific category', () => { + accessibility.addKeystrokeInfoCategory( { + id: 'testcat', + label: 'Test category', + description: 'Test category description' + } ); + + accessibility.addKeystrokeInfoGroup( { + id: 'testgroup', + categoryId: 'testcat', + label: 'Test group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + + expect( serializeKeystrokes( accessibility.keystrokeInfos ) ).to.deep.include( [ + 'testcat', + { + id: 'testcat', + label: 'Test category', + description: 'Test category description', + groups: [ + [ + 'common', + { + id: 'common', + label: undefined, + keystrokes: [] + } + ], + [ + 'testgroup', + { + id: 'testgroup', + label: 'Test group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } + ] + ] + } + ] ); + } ); + } ); + + describe( 'addKeystrokeInfos()', () => { + it( 'should throw if the category does not exist', () => { + expectToThrowCKEditorError( () => { + accessibility.addKeystrokeInfos( { + categoryId: 'unknown-category', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + }, /^accessibility-unknown-keystroke-info-category/, editor, { + categoryId: 'unknown-category', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + } ); + + it( 'should throw if the group does not exist', () => { + expectToThrowCKEditorError( () => { + accessibility.addKeystrokeInfos( { + groupId: 'unknown-group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + }, /^accessibility-unknown-keystroke-info-group/, editor, { + categoryId: 'contentEditing', + groupId: 'unknown-group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + } ); + + it( 'should add keystrokes to a specific group in a specific category', () => { + accessibility.addKeystrokeInfoCategory( { + id: 'testcat', + label: 'Test category', + description: 'Test category description' + } ); + + accessibility.addKeystrokeInfoGroup( { + id: 'testgroup', + categoryId: 'testcat', + label: 'Test group' + } ); + + accessibility.addKeystrokeInfos( { + categoryId: 'testcat', + groupId: 'testgroup', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + + expect( serializeKeystrokes( accessibility.keystrokeInfos ) ).to.deep.include( [ + 'testcat', + { + id: 'testcat', + label: 'Test category', + description: 'Test category description', + groups: [ + [ + 'common', + { + id: 'common', + label: undefined, + keystrokes: [] + } + ], + [ + 'testgroup', + { + id: 'testgroup', + label: 'Test group', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } + ] + ] + } + ] ); + } ); + + it( 'should add keystrokes to the default group and category ', () => { + accessibility.addKeystrokeInfos( { + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ] + } ); + + expect( serializeKeystrokes( accessibility.keystrokeInfos ) ).to.deep.include( [ + 'contentEditing', + { + description: 'These keyboard shortcuts allow for quick access to content editing features.', + groups: [ + [ + 'common', + { + id: 'common', + keystrokes: [ + { + keystroke: 'Alt+C', + label: 'Foo' + } + ], + label: undefined + } + ] + ], + id: 'contentEditing', + label: 'Content editing keystrokes' + } + ] ); + } ); + } ); + + function serializeKeystrokes( keystrokes ) { + const serialized = Array.from( cloneDeep( keystrokes ).entries() ); + + for ( const [ , categoryDef ] of serialized ) { + categoryDef.groups = Array.from( categoryDef.groups.entries() ); + } + + return serialized; + } +} ); diff --git a/packages/ckeditor5-core/tests/editor/editor.js b/packages/ckeditor5-core/tests/editor/editor.js index 95f466e8804..4882f449c00 100644 --- a/packages/ckeditor5-core/tests/editor/editor.js +++ b/packages/ckeditor5-core/tests/editor/editor.js @@ -19,6 +19,7 @@ import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_uti import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror.js'; import testUtils from '../../tests/_utils/utils.js'; import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model.js'; +import Accessibility from '../../src/accessibility.js'; class TestEditor extends Editor { static create( config ) { @@ -130,6 +131,7 @@ describe( 'Editor', () => { it( 'should create a new editor instance', () => { const editor = new TestEditor(); + expect( editor.accessibility ).to.be.an.instanceof( Accessibility ); expect( editor.config ).to.be.an.instanceof( Config ); expect( editor.commands ).to.be.an.instanceof( CommandCollection ); expect( editor.editing ).to.be.instanceof( EditingController ); diff --git a/packages/ckeditor5-editor-multi-root/src/index.ts b/packages/ckeditor5-editor-multi-root/src/index.ts index 3f17c8657f4..22d98f8d8f0 100644 --- a/packages/ckeditor5-editor-multi-root/src/index.ts +++ b/packages/ckeditor5-editor-multi-root/src/index.ts @@ -9,6 +9,10 @@ export { default as MultiRootEditor } from './multirooteditor.js'; -export type { RootAttributes } from './multirooteditor.js'; +export type { + RootAttributes, + AddRootEvent, + DetachRootEvent +} from './multirooteditor.js'; import './augmentation.js'; diff --git a/packages/ckeditor5-enter/lang/contexts.json b/packages/ckeditor5-enter/lang/contexts.json new file mode 100644 index 00000000000..5d051ce2f7f --- /dev/null +++ b/packages/ckeditor5-enter/lang/contexts.json @@ -0,0 +1,4 @@ +{ + "Insert a soft break (a <br> element)": "Keystroke description for assistive technologies: keystroke for inserting a soft break.", + "Insert a hard break (a new paragraph)": "Keystroke description for assistive technologies: keystroke for inserting a hard break." +} diff --git a/packages/ckeditor5-enter/src/enter.ts b/packages/ckeditor5-enter/src/enter.ts index f5d5e5c3e7e..bb668128060 100644 --- a/packages/ckeditor5-enter/src/enter.ts +++ b/packages/ckeditor5-enter/src/enter.ts @@ -30,6 +30,7 @@ export default class Enter extends Plugin { const editor = this.editor; const view = editor.editing.view; const viewDocument = view.document; + const t = this.editor.t; view.addObserver( EnterObserver ); @@ -51,5 +52,15 @@ export default class Enter extends Plugin { view.scrollToTheSelection(); }, { priority: 'low' } ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Insert a hard break (a new paragraph)' ), + keystroke: 'Enter' + } + ] + } ); } } diff --git a/packages/ckeditor5-enter/src/shiftenter.ts b/packages/ckeditor5-enter/src/shiftenter.ts index 011d47c9677..2c1d478f163 100644 --- a/packages/ckeditor5-enter/src/shiftenter.ts +++ b/packages/ckeditor5-enter/src/shiftenter.ts @@ -32,6 +32,7 @@ export default class ShiftEnter extends Plugin { const conversion = editor.conversion; const view = editor.editing.view; const viewDocument = view.document; + const t = this.editor.t; // Configure the schema. schema.register( 'softBreak', { @@ -71,5 +72,15 @@ export default class ShiftEnter extends Plugin { editor.execute( 'shiftEnter' ); view.scrollToTheSelection(); }, { priority: 'low' } ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Insert a soft break (a <br> element)' ), + keystroke: 'Shift+Enter' + } + ] + } ); } } diff --git a/packages/ckeditor5-enter/tests/enter.js b/packages/ckeditor5-enter/tests/enter.js index f907d4e8297..6480d996eb9 100644 --- a/packages/ckeditor5-enter/tests/enter.js +++ b/packages/ckeditor5-enter/tests/enter.js @@ -35,6 +35,17 @@ describe( 'Enter feature', () => { return editor.destroy(); } ); + it( 'should have pluginName', () => { + expect( Enter.pluginName ).to.equal( 'Enter' ); + } ); + + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Insert a hard break (a new paragraph)', + keystroke: 'Enter' + } ); + } ); + it( 'creates the commands', () => { expect( editor.commands.get( 'enter' ) ).to.be.instanceof( EnterCommand ); } ); diff --git a/packages/ckeditor5-enter/tests/shiftenter.js b/packages/ckeditor5-enter/tests/shiftenter.js index e42da088651..55f3b7f9546 100644 --- a/packages/ckeditor5-enter/tests/shiftenter.js +++ b/packages/ckeditor5-enter/tests/shiftenter.js @@ -35,6 +35,17 @@ describe( 'ShiftEnter feature', () => { return editor.destroy(); } ); + it( 'should have pluginName', () => { + expect( ShiftEnter.pluginName ).to.equal( 'ShiftEnter' ); + } ); + + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Insert a soft break (a <br> element)', + keystroke: 'Shift+Enter' + } ); + } ); + it( 'creates the commands', () => { expect( editor.commands.get( 'shiftEnter' ) ).to.be.instanceof( ShiftEnterCommand ); } ); diff --git a/packages/ckeditor5-essentials/package.json b/packages/ckeditor5-essentials/package.json index be51c464e3e..20984d93a2a 100644 --- a/packages/ckeditor5-essentials/package.json +++ b/packages/ckeditor5-essentials/package.json @@ -25,6 +25,7 @@ "@ckeditor/ckeditor5-select-all": "41.1.0", "@ckeditor/ckeditor5-theme-lark": "41.1.0", "@ckeditor/ckeditor5-typing": "41.1.0", + "@ckeditor/ckeditor5-ui": "41.1.0", "@ckeditor/ckeditor5-undo": "41.1.0", "typescript": "5.0.4", "webpack": "^5.58.1", diff --git a/packages/ckeditor5-essentials/src/essentials.ts b/packages/ckeditor5-essentials/src/essentials.ts index 64b0e7d961e..f6d93b04cbb 100644 --- a/packages/ckeditor5-essentials/src/essentials.ts +++ b/packages/ckeditor5-essentials/src/essentials.ts @@ -14,6 +14,7 @@ import { Enter, ShiftEnter } from 'ckeditor5/src/enter.js'; import { SelectAll } from 'ckeditor5/src/select-all.js'; import { Typing } from 'ckeditor5/src/typing.js'; import { Undo } from 'ckeditor5/src/undo.js'; +import { AccessibilityHelp } from 'ckeditor5/src/ui.js'; /** * A plugin including all essential editing features. It represents a set of features that enables similar functionalities @@ -36,7 +37,7 @@ export default class Essentials extends Plugin { * @inheritDoc */ public static get requires() { - return [ Clipboard, Enter, SelectAll, ShiftEnter, Typing, Undo ] as const; + return [ AccessibilityHelp, Clipboard, Enter, SelectAll, ShiftEnter, Typing, Undo ] as const; } /** diff --git a/packages/ckeditor5-essentials/tests/essentials.js b/packages/ckeditor5-essentials/tests/essentials.js index da826d8cefa..1ce0f5d72fe 100644 --- a/packages/ckeditor5-essentials/tests/essentials.js +++ b/packages/ckeditor5-essentials/tests/essentials.js @@ -14,6 +14,7 @@ import SelectAll from '@ckeditor/ckeditor5-select-all/src/selectall.js'; import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter.js'; import Typing from '@ckeditor/ckeditor5-typing/src/typing.js'; import Undo from '@ckeditor/ckeditor5-undo/src/undo.js'; +import { AccessibilityHelp } from '@ckeditor/ckeditor5-ui'; describe( 'Essentials preset', () => { let editor, editorElement; @@ -39,6 +40,7 @@ describe( 'Essentials preset', () => { } ); it( 'should load all its dependencies', () => { + expect( editor.plugins.get( AccessibilityHelp ) ).to.be.instanceOf( AccessibilityHelp ); expect( editor.plugins.get( Clipboard ) ).to.be.instanceOf( Clipboard ); expect( editor.plugins.get( Enter ) ).to.be.instanceOf( Enter ); expect( editor.plugins.get( SelectAll ) ).to.be.instanceOf( SelectAll ); diff --git a/packages/ckeditor5-find-and-replace/lang/contexts.json b/packages/ckeditor5-find-and-replace/lang/contexts.json index a5a53472bc1..cbded05dcb9 100644 --- a/packages/ckeditor5-find-and-replace/lang/contexts.json +++ b/packages/ckeditor5-find-and-replace/lang/contexts.json @@ -11,5 +11,6 @@ "Replace with…": "The label for the text replacement in the find and replace dropdown.", "Text to find must not be empty.": "An error text displayed when user attempted to find an empty text.", "Tip: Find some text first in order to replace it.": "A message displayed next to the replace field when disabled but user tries to use it.", - "Advanced options": "The label and the tooltip of the options dropdown button in the find and replace form." + "Advanced options": "The label and the tooltip of the options dropdown button in the find and replace form.", + "Find in the document": "Keystroke description for assistive technologies: keystroke for opening the find and replace UI." } diff --git a/packages/ckeditor5-find-and-replace/src/findandreplaceui.ts b/packages/ckeditor5-find-and-replace/src/findandreplaceui.ts index 567f1de4ff8..e59689754a2 100644 --- a/packages/ckeditor5-find-and-replace/src/findandreplaceui.ts +++ b/packages/ckeditor5-find-and-replace/src/findandreplaceui.ts @@ -70,6 +70,7 @@ export default class FindAndReplaceUI extends Plugin { const editor = this.editor; const isUiUsingDropdown = editor.config.get( 'findAndReplace.uiType' ) === 'dropdown'; const findCommand = editor.commands.get( 'find' )!; + const t = this.editor.t; // Register the toolbar component: dropdown or button (that opens a dialog). editor.ui.componentFactory.add( 'findAndReplace', () => { @@ -115,6 +116,16 @@ export default class FindAndReplaceUI extends Plugin { return view; } ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Find in the document' ), + keystroke: 'CTRL+F' + } + ] + } ); } /** diff --git a/packages/ckeditor5-find-and-replace/tests/findandreplaceui.js b/packages/ckeditor5-find-and-replace/tests/findandreplaceui.js index feb22650e84..3bb3bc75b19 100644 --- a/packages/ckeditor5-find-and-replace/tests/findandreplaceui.js +++ b/packages/ckeditor5-find-and-replace/tests/findandreplaceui.js @@ -63,6 +63,13 @@ describe( 'FindAndReplaceUI', () => { return editor.destroy(); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Find in the document', + keystroke: 'CTRL+F' + } ); + } ); + it( 'should register a button UI compontent', () => { expect( toolbarButtonView ).to.be.instanceOf( ButtonView ); } ); @@ -475,6 +482,13 @@ describe( 'FindAndReplaceUI', () => { return editor.destroy(); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Find in the document', + keystroke: 'CTRL+F' + } ); + } ); + it( 'should create a dropdown UI component', () => { expect( dropdown ).to.be.instanceOf( DropdownView ); } ); diff --git a/packages/ckeditor5-link/lang/contexts.json b/packages/ckeditor5-link/lang/contexts.json index 0d1422d412e..6634c6d1a06 100644 --- a/packages/ckeditor5-link/lang/contexts.json +++ b/packages/ckeditor5-link/lang/contexts.json @@ -7,5 +7,7 @@ "Open link in new tab": "Button opening the link in new browser tab.", "This link has no URL": "Label explaining that a link has no URL set (the URL is empty).", "Open in a new tab": "The label of the switch button that controls whether the edited link will open in a new tab.", - "Downloadable": "The label of the switch button that controls whether the edited link refers to downloadable resource." + "Downloadable": "The label of the switch button that controls whether the edited link refers to downloadable resource.", + "Create link": "Keystroke description for assistive technologies: keystroke for creating a link.", + "Move out of a link": "Keystroke description for assistive technologies: keystroke for moving out of a link." } diff --git a/packages/ckeditor5-link/src/linkui.ts b/packages/ckeditor5-link/src/linkui.ts index 1c20161d38a..acef29027d2 100644 --- a/packages/ckeditor5-link/src/linkui.ts +++ b/packages/ckeditor5-link/src/linkui.ts @@ -76,6 +76,7 @@ export default class LinkUI extends Plugin { */ public init(): void { const editor = this.editor; + const t = this.editor.t; editor.editing.view.addObserver( ClickObserver ); @@ -101,6 +102,23 @@ export default class LinkUI extends Plugin { classes: [ 'ck-fake-link-selection', 'ck-fake-link-selection_collapsed' ] } } ); + + // Add the information about the keystrokes to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Create link' ), + keystroke: LINK_KEYSTROKE + }, + { + label: t( 'Move out of a link' ), + keystroke: [ + [ 'arrowleft', 'arrowleft' ], + [ 'arrowright', 'arrowright' ] + ] + } + ] + } ); } /** diff --git a/packages/ckeditor5-link/tests/linkui.js b/packages/ckeditor5-link/tests/linkui.js index f1469bee14c..d2e49b64493 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -67,6 +67,21 @@ describe( 'LinkUI', () => { expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Create link', + keystroke: 'Ctrl+K' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Move out of a link', + keystroke: [ + [ 'arrowleft', 'arrowleft' ], + [ 'arrowright', 'arrowright' ] + ] + } ); + } ); + describe( 'init', () => { it( 'should register click observer', () => { expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver ); diff --git a/packages/ckeditor5-list/lang/contexts.json b/packages/ckeditor5-list/lang/contexts.json index ad142911006..c3c21c8f54d 100644 --- a/packages/ckeditor5-list/lang/contexts.json +++ b/packages/ckeditor5-list/lang/contexts.json @@ -26,5 +26,8 @@ "Start at": "The label of the input allowing to change the start index of a numbered list.", "Invalid start index value.": "The error message displayed when the numbered list start index input value is not a valid number.", "Start index must be greater than 0.": "The error message displayed when the numbered list start index input value is invalid.", - "Reversed order": "The label of the switch button that reverses the order of the numbered list." + "Reversed order": "The label of the switch button that reverses the order of the numbered list.", + "Keystrokes that can be used in a list": "Accessibility help dialog header text displayed before the list of keystrokes that can be used in a list.", + "Increase list item indent": "Keystroke description for assistive technologies: keystroke for increasing list item indentation.", + "Decrease list item indent": "Keystroke description for assistive technologies: keystroke for decreasing list item indentation." } diff --git a/packages/ckeditor5-list/src/list/listediting.ts b/packages/ckeditor5-list/src/list/listediting.ts index eb79e6c033d..8edde75fce1 100644 --- a/packages/ckeditor5-list/src/list/listediting.ts +++ b/packages/ckeditor5-list/src/list/listediting.ts @@ -177,6 +177,7 @@ export default class ListEditing extends Plugin { this._setupEnterIntegration(); this._setupTabIntegration(); this._setupClipboardIntegration(); + this._setupAccessibilityIntegration(); } /** @@ -598,6 +599,29 @@ export default class ListEditing extends Plugin { } ); } ); } + + /** + * Informs editor accessibility features about keystrokes brought by the plugin. + */ + private _setupAccessibilityIntegration() { + const editor = this.editor; + const t = editor.t; + + editor.accessibility.addKeystrokeInfoGroup( { + id: 'list', + label: t( 'Keystrokes that can be used in a list' ), + keystrokes: [ + { + label: t( 'Increase list item indent' ), + keystroke: 'Tab' + }, + { + label: t( 'Decrease list item indent' ), + keystroke: 'Shift+Tab' + } + ] + } ); + } } /** diff --git a/packages/ckeditor5-list/tests/list/listediting.js b/packages/ckeditor5-list/tests/list/listediting.js index ae279e281d4..2c6e538a643 100644 --- a/packages/ckeditor5-list/tests/list/listediting.js +++ b/packages/ckeditor5-list/tests/list/listediting.js @@ -72,6 +72,22 @@ describe( 'ListEditing', () => { expect( ListEditing.pluginName ).to.equal( 'ListEditing' ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'list' ).label ).to.equal( + 'Keystrokes that can be used in a list' + ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'list' ).keystrokes ).to.deep.include( { + label: 'Increase list item indent', + keystroke: 'Tab' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'list' ).keystrokes ).to.deep.include( { + label: 'Decrease list item indent', + keystroke: 'Shift+Tab' + } ); + } ); + it( 'should be loaded', () => { expect( editor.plugins.get( ListEditing ) ).to.be.instanceOf( ListEditing ); } ); diff --git a/packages/ckeditor5-select-all/src/selectallediting.ts b/packages/ckeditor5-select-all/src/selectallediting.ts index 024e27d47f9..57d9e76f41e 100644 --- a/packages/ckeditor5-select-all/src/selectallediting.ts +++ b/packages/ckeditor5-select-all/src/selectallediting.ts @@ -33,6 +33,7 @@ export default class SelectAllEditing extends Plugin { */ public init(): void { const editor = this.editor; + const t = editor.t; const view = editor.editing.view; const viewDocument = view.document; @@ -44,5 +45,15 @@ export default class SelectAllEditing extends Plugin { domEventData.preventDefault(); } } ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Select all' ), + keystroke: 'CTRL+A' + } + ] + } ); } } diff --git a/packages/ckeditor5-select-all/tests/selectallediting.js b/packages/ckeditor5-select-all/tests/selectallediting.js index cc40a9ec997..3b02fbd0710 100644 --- a/packages/ckeditor5-select-all/tests/selectallediting.js +++ b/packages/ckeditor5-select-all/tests/selectallediting.js @@ -33,6 +33,13 @@ describe( 'SelectAllEditing', () => { expect( SelectAllEditing.pluginName ).to.equal( 'SelectAllEditing' ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Select all', + keystroke: 'CTRL+A' + } ); + } ); + it( 'should register the "selectAll" command', () => { const command = editor.commands.get( 'selectAll' ); diff --git a/packages/ckeditor5-table/lang/contexts.json b/packages/ckeditor5-table/lang/contexts.json index f9bd28969d1..5eac201663d 100644 --- a/packages/ckeditor5-table/lang/contexts.json +++ b/packages/ckeditor5-table/lang/contexts.json @@ -57,5 +57,10 @@ "The color is invalid. Try \"#FF0000\" or \"rgb(255,0,0)\" or \"red\".": "The localized error string that can be displayed next to color (background, border) fields that have an invalid value", "The value is invalid. Try \"10px\" or \"2em\" or simply \"2\".": "The localized error string that can be displayed next to length (padding, border width) fields that have an invalid value.", "Color picker": "The label used by assistive technologies describing a button that opens a color picker, where user can choose a configured color for a certain properties (eg.: background color, color, border-color etc.).", - "Enter table caption": "The placeholder text for the table caption displayed when the caption is empty." + "Enter table caption": "The placeholder text for the table caption displayed when the caption is empty.", + "Keystrokes that can be used in a table cell": "Accessibility help dialog header text displayed before the list of keystrokes that can be used in a table cell.", + "Move the selection to the next cell": "Keystroke description for assistive technologies: keystroke for moving the selection to the next cell.", + "Move the selection to the previous cell": "Keystroke description for assistive technologies: keystroke for moving the selection to the previous cell.", + "Insert a new table row (when in the last cell of a table)": "Keystroke description for assistive technologies: keystroke for inserting a new table row.", + "Navigate through the table": "Keystroke description for assistive technologies: keystroke for navigating through the table." } diff --git a/packages/ckeditor5-table/src/tablekeyboard.ts b/packages/ckeditor5-table/src/tablekeyboard.ts index e762a3ec0e7..92d935bdf9b 100644 --- a/packages/ckeditor5-table/src/tablekeyboard.ts +++ b/packages/ckeditor5-table/src/tablekeyboard.ts @@ -52,8 +52,10 @@ export default class TableKeyboard extends Plugin { * @inheritDoc */ public init(): void { - const view = this.editor.editing.view; + const editor = this.editor; + const view = editor.editing.view; const viewDocument = view.document; + const t = editor.t; this.listenTo( viewDocument, @@ -75,6 +77,30 @@ export default class TableKeyboard extends Plugin { ( ...args ) => this._handleTab( ...args ), { context: [ 'th', 'td' ] } ); + + // Add the information about the keystrokes to the accessibility database. + editor.accessibility.addKeystrokeInfoGroup( { + id: 'table', + label: t( 'Keystrokes that can be used in a table cell' ), + keystrokes: [ + { + label: t( 'Move the selection to the next cell' ), + keystroke: 'Tab' + }, + { + label: t( 'Move the selection to the previous cell' ), + keystroke: 'Shift+Tab' + }, + { + label: t( 'Insert a new table row (when in the last cell of a table)' ), + keystroke: 'Tab' + }, + { + label: t( 'Navigate through the table' ), + keystroke: [ [ 'arrowup' ], [ 'arrowright' ], [ 'arrowdown' ], [ 'arrowleft' ] ] + } + ] + } ); } /** diff --git a/packages/ckeditor5-table/tests/tablekeyboard.js b/packages/ckeditor5-table/tests/tablekeyboard.js index a7189172107..65ed7ee96fe 100644 --- a/packages/ckeditor5-table/tests/tablekeyboard.js +++ b/packages/ckeditor5-table/tests/tablekeyboard.js @@ -54,6 +54,32 @@ describe( 'TableKeyboard', () => { expect( TableKeyboard.pluginName ).to.equal( 'TableKeyboard' ); } ); + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'table' ).label ).to.equal( + 'Keystrokes that can be used in a table cell' + ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'table' ).keystrokes ).to.deep.include( { + label: 'Move the selection to the next cell', + keystroke: 'Tab' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'table' ).keystrokes ).to.deep.include( { + label: 'Move the selection to the previous cell', + keystroke: 'Shift+Tab' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'table' ).keystrokes ).to.deep.include( { + label: 'Insert a new table row (when in the last cell of a table)', + keystroke: 'Tab' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'table' ).keystrokes ).to.deep.include( { + label: 'Navigate through the table', + keystroke: [ [ 'arrowup' ], [ 'arrowright' ], [ 'arrowdown' ], [ 'arrowleft' ] ] + } ); + } ); + describe( 'Tab key handling', () => { let domEvtDataStub; diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/editorui/accessibilityhelp.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/editorui/accessibilityhelp.css new file mode 100644 index 00000000000..70865719148 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/editorui/accessibilityhelp.css @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +@import "../../../mixins/_focus.css"; +@import "../../../mixins/_shadow.css"; + +:root { + --ck-accessibility-help-dialog-max-width: 600px; + --ck-accessibility-help-dialog-max-height: 400px; + --ck-accessibility-help-dialog-border-color: hsl(220, 6%, 81%); + --ck-accessibility-help-dialog-code-background-color: hsl(0deg 0% 92.94%); + --ck-accessibility-help-dialog-kbd-shadow-color: hsl(0deg 0% 61%); +} + +.ck.ck-accessibility-help-dialog .ck-accessibility-help-dialog__content { + padding: var(--ck-spacing-large); + max-width: var(--ck-accessibility-help-dialog-max-width); + max-height: var(--ck-accessibility-help-dialog-max-height); + overflow: auto; + user-select: text; + + border: 1px solid transparent; + + &:focus { + @mixin ck-focus-ring; + @mixin ck-box-shadow var(--ck-focus-outer-shadow); + } + + * { + white-space: normal; + } + + /* Hide the main label of the content container. */ + & .ck-label { + display: none; + } + + & h3 { + font-weight: bold; + font-size: 1.2em; + } + + & h4 { + font-weight: bold; + font-size: 1em; + } + + & p, + & h3, + & h4, + & table { + margin: 1em 0; + } + + & dl { + display: grid; + grid-template-columns: 2fr 1fr; + border-top: 1px solid var(--ck-accessibility-help-dialog-border-color); + border-bottom: none; + + & dt, & dd { + border-bottom: 1px solid var(--ck-accessibility-help-dialog-border-color); + padding: .4em 0; + } + + & dt { + grid-column-start: 1; + } + + & dd { + grid-column-start: 2; + text-align: right; + } + } + + & kbd, & code { + display: inline-block; + background: var(--ck-accessibility-help-dialog-code-background-color); + padding: .4em; + vertical-align: middle; + line-height: 1; + border-radius: 2px; + text-align: center; + font-size: .9em; + } + + & code { + font-family: monospace; + } + + & kbd { + min-width: 1.8em; + box-shadow: 0px 1px 1px var(--ck-accessibility-help-dialog-kbd-shadow-color); + margin: 0 1px; + + & + kbd { + margin-left: 2px; + } + } +} + diff --git a/packages/ckeditor5-ui/ckeditor5-metadata.json b/packages/ckeditor5-ui/ckeditor5-metadata.json index f68952bd8a3..322388fef9b 100644 --- a/packages/ckeditor5-ui/ckeditor5-metadata.json +++ b/packages/ckeditor5-ui/ckeditor5-metadata.json @@ -6,6 +6,20 @@ "description": "Provides an additional configurable toolbar on the left-hand side of the content area, next to the selected block element. It comes in handy when the main editor toolbar cannot be accessed.", "docs": "features/toolbar/blocktoolbar.html", "path": "src/toolbar/block/blocktoolbar.js" + }, + { + "name": "Accessibility help", + "className": "AccessibilityHelp", + "description": "Displays all editor keyboard shortcuts in a dialog window.", + "docs": "features/keyboard-support.html", + "path": "src/editorui/accessibilityhelp.js", + "uiComponents": [ + { + "type": "Button", + "name": "accessibilityHelp", + "iconPath": "theme/icons/accessibility.svg" + } + ] } ] } diff --git a/packages/ckeditor5-ui/lang/contexts.json b/packages/ckeditor5-ui/lang/contexts.json index cc167270c05..79790a671b2 100644 --- a/packages/ckeditor5-ui/lang/contexts.json +++ b/packages/ckeditor5-ui/lang/contexts.json @@ -30,5 +30,21 @@ "No results found": "The main text of the message shown to the user when given query does not match any results.", "No searchable items": "The main text of the message shown to the user when no results are available.", "Editor dialog": "A default label of a dialog window displayed on top the editor.", - "Close": "The label and the tooltip for the close button in the dialog header." + "Close": "The label and the tooltip for the close button in the dialog header.", + "Help Contents. To close this dialog press ESC.": "Accessibility help dialog assistive technologies label telling users how to exit the dialog.", + "Below, you can find a list of keyboard shortcuts that can be used in the editor.": "Accessibility help dialog text explaining what can be found in that dialog.", + "(may require Fn)": "Accessibility help dialog text displayed next to keystrokes that may require the Fn key on Mac.", + "Accessibility help": "Accessibility help dialog title.", + "Content editing keystrokes": "Accessibility help dialog category header text for keystrokes related to content creation.", + "These keyboard shortcuts allow for quick access to content editing features.": "Accessibility help dialog text further explaining the purpose of the \"Content editing keystrokes\" category.", + "User interface and content navigation keystrokes": "Accessibility help dialog category header text for keystrokes related to navigation in the user interface.", + "Use the following keystrokes for more efficient navigation in the CKEditor 5 user interface.": "Accessibility help dialog text further explaining the purpose of the \"User interface and content navigation keystrokes\" category.", + "Close contextual balloons, dropdowns, and dialogs": "Keystroke description for assistive technologies: keystroke for closing contextual balloons, dropdowns, and dialogs.", + "Open the accessibility help dialog": "Keystroke description for assistive technologies: keystroke for opening the accessibility help dialog.", + "Move focus between form fields (inputs, buttons, etc.)": "Keystroke description for assistive technologies: keystroke for moving between fields.", + "Move focus to the toolbar, navigate between toolbars": "Keystroke description for assistive technologies: keystroke for moving focus to the toolbar.", + "Navigate through the toolbar": "Keystroke description for assistive technologies: keystroke for navigating through the toolbar.", + "Execute the currently focused button": "Keystroke description for assistive technologies: keystroke for executing currently focused button.", + "Press %0 for help.": "Assistive technologies label added to each editor editing area informing users about the possibility of opening the accessibility help dialog.", + "Move focus in and out of an active dialog window": "Keystroke description for assistive technologies: keystroke for moving focus out of an active dialog window." } diff --git a/packages/ckeditor5-ui/package.json b/packages/ckeditor5-ui/package.json index a022aaecdcc..554ed44dbf7 100644 --- a/packages/ckeditor5-ui/package.json +++ b/packages/ckeditor5-ui/package.json @@ -47,6 +47,7 @@ "@ckeditor/ckeditor5-special-characters": "41.1.0", "@ckeditor/ckeditor5-table": "41.1.0", "@ckeditor/ckeditor5-typing": "41.1.0", + "@ckeditor/ckeditor5-undo": "41.1.0", "@types/color-convert": "2.0.0", "typescript": "5.0.4", "webpack": "^5.58.1", diff --git a/packages/ckeditor5-ui/src/augmentation.ts b/packages/ckeditor5-ui/src/augmentation.ts index e26c5f84378..fc414d295b0 100644 --- a/packages/ckeditor5-ui/src/augmentation.ts +++ b/packages/ckeditor5-ui/src/augmentation.ts @@ -8,7 +8,8 @@ import type { BlockToolbar, ContextualBalloon, Notification, - Dialog + Dialog, + AccessibilityHelp } from './index.js'; import type { @@ -97,5 +98,6 @@ declare module '@ckeditor/ckeditor5-core' { [ ContextualBalloon.pluginName ]: ContextualBalloon; [ Dialog.pluginName ]: Dialog; [ Notification.pluginName ]: Notification; + [ AccessibilityHelp.pluginName ]: AccessibilityHelp; } } diff --git a/packages/ckeditor5-ui/src/dialog/dialog.ts b/packages/ckeditor5-ui/src/dialog/dialog.ts index 6ac2ec81ef6..eba7c685acc 100644 --- a/packages/ckeditor5-ui/src/dialog/dialog.ts +++ b/packages/ckeditor5-ui/src/dialog/dialog.ts @@ -65,11 +65,23 @@ export default class Dialog extends Plugin { constructor( editor: Editor ) { super( editor ); + const t = editor.t; + this._initShowHideListeners(); this._initFocusToggler(); this._initMultiRootIntegration(); this.set( 'id', null ); + + // Add the information about the keystroke to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + categoryId: 'navigation', + keystrokes: [ { + label: t( 'Move focus in and out of an active dialog window' ), + keystroke: 'Ctrl+F6', + mayRequireFn: true + } ] + } ); } /** diff --git a/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelp.ts b/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelp.ts new file mode 100644 index 00000000000..27b050a3ca1 --- /dev/null +++ b/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelp.ts @@ -0,0 +1,132 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/editorui/accessibilityhelp/accessibilityhelp + */ + +import { Plugin } from '@ckeditor/ckeditor5-core'; +import { ButtonView, Dialog, type EditorUIReadyEvent } from '../../index.js'; +import AccessibilityHelpContentView from './accessibilityhelpcontentview.js'; +import { getEnvKeystrokeText } from '@ckeditor/ckeditor5-utils'; +import type { AddRootEvent } from '@ckeditor/ckeditor5-editor-multi-root'; +import type { DowncastWriter, ViewRootEditableElement } from '@ckeditor/ckeditor5-engine'; + +import accessibilityIcon from '../../../theme/icons/accessibility.svg'; +import '../../../theme/components/editorui/accessibilityhelp.css'; + +/** + * A plugin that brings the accessibility help dialog to the editor available under the Alt+0 + * keystroke and via the "Accessibility help" toolbar button. The dialog displays a list of keystrokes that can be used + * by the user to perform various actions in the editor. + * + * Keystroke information is loaded from {@link module:core/accessibility~Accessibility#keystrokeInfos}. New entries can be + * added using the API provided by the {@link module:core/accessibility~Accessibility} class. + */ +export default class AccessibilityHelp extends Plugin { + /** + * The view that displays the dialog content (list of keystrokes). + * Created when the dialog is opened for the first time. + */ + public contentView: AccessibilityHelpContentView | null = null; + + /** + * @inheritDoc + */ + public static get requires() { + return [ Dialog ] as const; + } + + /** + * @inheritDoc + */ + public static get pluginName() { + return 'AccessibilityHelp' as const; + } + + /** + * @inheritDoc + */ + public init(): void { + const editor = this.editor; + const t = editor.locale.t; + + editor.ui.componentFactory.add( 'accessibilityHelp', locale => { + const buttonView = new ButtonView( locale ); + + buttonView.set( { + label: t( 'Accessibility help' ), + tooltip: true, + withText: false, + keystroke: 'Alt+0', + icon: accessibilityIcon + } ); + + buttonView.on( 'execute', () => this._showDialog() ); + + return buttonView; + } ); + + editor.keystrokes.set( 'Alt+0', ( evt, cancel ) => { + this._showDialog(); + cancel(); + } ); + + this._setupRootLabels(); + } + + /** + * Injects a help text into each editing root's `aria-label` attribute allowing assistive technology users + * to discover the availability of the Accessibility help dialog. + */ + private _setupRootLabels() { + const editor = this.editor; + const editingView = editor.editing.view; + const t = editor.t; + + editor.ui.on( 'ready', () => { + editingView.change( writer => { + for ( const root of editingView.document.roots ) { + addAriaLabelTextToRoot( writer, root ); + } + } ); + + editor.on( 'addRoot', ( evt, modelRoot ) => { + const viewRoot = editor.editing.view.document.getRoot( modelRoot.rootName )!; + + editingView.change( writer => addAriaLabelTextToRoot( writer, viewRoot ) ); + }, { priority: 'low' } ); + } ); + + function addAriaLabelTextToRoot( writer: DowncastWriter, viewRoot: ViewRootEditableElement ) { + const currentAriaLabel = viewRoot.getAttribute( 'aria-label' ); + const newAriaLabel = `${ currentAriaLabel }. ${ t( 'Press %0 for help.', [ getEnvKeystrokeText( 'Alt+0' ) ] ) }`; + + writer.setAttribute( 'aria-label', newAriaLabel, viewRoot ); + } + } + + /** + * Shows the accessibility help dialog. Also, creates {@link #contentView} on demand. + */ + private _showDialog() { + const editor = this.editor; + const dialog = editor.plugins.get( 'Dialog' ); + const t = editor.locale.t; + + if ( !this.contentView ) { + this.contentView = new AccessibilityHelpContentView( editor.locale, editor.accessibility.keystrokeInfos ); + } + + dialog.show( { + id: 'accessibilityHelp', + className: 'ck-accessibility-help-dialog', + title: t( 'Accessibility help' ), + icon: accessibilityIcon, + hasCloseButton: true, + content: this.contentView + } ); + } +} diff --git a/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelpcontentview.ts b/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelpcontentview.ts new file mode 100644 index 00000000000..d809fe186db --- /dev/null +++ b/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelpcontentview.ts @@ -0,0 +1,147 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/editorui/accessibilityhelp/accessibilityhelpcontentview + */ + +import { + createElement, + env, + getEnvKeystrokeText, + type Locale +} from '@ckeditor/ckeditor5-utils'; + +import View from '../../view.js'; +import LabelView from '../../label/labelview.js'; +import type { + KeystrokeInfoCategoryDefinition, + KeystrokeInfoDefinition, + KeystrokeInfoDefinitions, + KeystrokeInfoGroupDefinition +} from '@ckeditor/ckeditor5-core'; + +/** + * The view displaying keystrokes in the Accessibility help dialog. + */ +export default class AccessibilityHelpContentView extends View { + /** + * @inheritDoc + */ + constructor( locale: Locale, keystrokes: KeystrokeInfoDefinitions ) { + super( locale ); + + const t = locale.t; + const helpLabel = new LabelView(); + + helpLabel.text = t( 'Help Contents. To close this dialog press ESC.' ); + + this.setTemplate( { + tag: 'div', + attributes: { + class: [ 'ck', 'ck-accessibility-help-dialog__content' ], + 'aria-labelledby': helpLabel.id, + role: 'document', + tabindex: -1 + }, + children: [ + createElement( document, 'p', {}, t( 'Below, you can find a list of keyboard shortcuts that can be used in the editor.' ) ), + ...this._createCategories( Array.from( keystrokes.values() ) ), + helpLabel + ] + } ); + } + + /** + * @inheritDoc + */ + public focus(): void { + this.element!.focus(); + } + + /** + * Creates `

Category label

...
` elements for each category of keystrokes. + */ + private _createCategories( categories: Array ): Array { + return categories.map( categoryDefinition => { + const elements: Array = [ + // Category header. + createElement( document, 'h3', {}, categoryDefinition.label ), + + // Category definitions (
) and their optional headers (

). + ...Array.from( categoryDefinition.groups.values() ) + .map( groupDefinition => this._createGroup( groupDefinition ) ) + .flat() + ]; + + // Category description (

). + if ( categoryDefinition.description ) { + elements.splice( 1, 0, createElement( document, 'p', {}, categoryDefinition.description ) ); + } + + return createElement( document, 'section', {}, elements ); + } ); + } + + /** + * Creates `[

Optional label

]
...
` elements for each group of keystrokes in a category. + */ + private _createGroup( groupDefinition: KeystrokeInfoGroupDefinition ): Array { + const definitionAndDescriptionElements = groupDefinition.keystrokes + .sort( ( a, b ) => a.label.localeCompare( b.label ) ) + .map( keystrokeDefinition => this._createGroupRow( keystrokeDefinition ) ) + .flat(); + + const elements: Array = [ + createElement( document, 'dl', {}, definitionAndDescriptionElements ) + ]; + + if ( groupDefinition.label ) { + elements.unshift( createElement( document, 'h4', {}, groupDefinition.label ) ); + } + + return elements; + } + + /** + * Creates `
Keystroke label
Keystroke definition
` elements for each keystroke in a group. + */ + private _createGroupRow( keystrokeDefinition: KeystrokeInfoDefinition ): [ HTMLElement, HTMLElement ] { + const t = this.locale!.t; + const dt = createElement( document, 'dt' ); + const dd = createElement( document, 'dd' ); + const normalizedKeystrokeDefinition = normalizeKeystrokeDefinition( keystrokeDefinition.keystroke ); + const keystrokeAlternativeHTMLs = []; + + for ( const keystrokeAlternative of normalizedKeystrokeDefinition ) { + keystrokeAlternativeHTMLs.push( keystrokeAlternative.map( keystrokeToEnvKbd ).join( '' ) ); + } + + dt.innerHTML = keystrokeDefinition.label; + dd.innerHTML = keystrokeAlternativeHTMLs.join( ', ' ) + + ( keystrokeDefinition.mayRequireFn && env.isMac ? ` ${ t( '(may require Fn)' ) }` : '' ); + + return [ dt, dd ]; + } +} + +function keystrokeToEnvKbd( keystroke: string ): string { + return getEnvKeystrokeText( keystroke ) + .split( '+' ) + .map( part => `${ part }` ) + .join( '+' ); +} + +function normalizeKeystrokeDefinition( definition: KeystrokeInfoDefinition[ 'keystroke' ] ): Array> { + if ( typeof definition === 'string' ) { + return [ [ definition ] ]; + } + + if ( typeof definition[ 0 ] === 'string' ) { + return [ definition as Array ]; + } + + return definition as Array>; +} diff --git a/packages/ckeditor5-ui/src/formheader/formheaderview.ts b/packages/ckeditor5-ui/src/formheader/formheaderview.ts index d783329de72..6141c570b98 100644 --- a/packages/ckeditor5-ui/src/formheader/formheaderview.ts +++ b/packages/ckeditor5-ui/src/formheader/formheaderview.ts @@ -104,7 +104,8 @@ export default class FormHeaderView extends View { class: [ 'ck', 'ck-form__header__label' - ] + ], + role: 'presentation' }, children: [ { text: bind.to( 'label' ) } diff --git a/packages/ckeditor5-ui/src/index.ts b/packages/ckeditor5-ui/src/index.ts index f5648fc905b..a492d8d5d28 100644 --- a/packages/ckeditor5-ui/src/index.ts +++ b/packages/ckeditor5-ui/src/index.ts @@ -13,6 +13,8 @@ export { default as CssTransitionDisablerMixin, type ViewWithCssTransitionDisabl export { default as submitHandler } from './bindings/submithandler.js'; export { default as addKeyboardHandlingForGrid } from './bindings/addkeyboardhandlingforgrid.js'; +export { default as AccessibilityHelp } from './editorui/accessibilityhelp/accessibilityhelp.js'; + export { default as BodyCollection } from './editorui/bodycollection.js'; export { type ButtonExecuteEvent } from './button/button.js'; diff --git a/packages/ckeditor5-ui/tests/dialog/dialog.js b/packages/ckeditor5-ui/tests/dialog/dialog.js index e37cd4b44f9..6c64b9cb1ed 100644 --- a/packages/ckeditor5-ui/tests/dialog/dialog.js +++ b/packages/ckeditor5-ui/tests/dialog/dialog.js @@ -37,6 +37,18 @@ describe( 'Dialog', () => { Dialog._visibleDialogPlugin = undefined; } ); + it( 'should have a name', () => { + expect( Dialog.pluginName ).to.equal( 'Dialog' ); + } ); + + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'navigation' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Move focus in and out of an active dialog window', + keystroke: 'Ctrl+F6', + mayRequireFn: true + } ); + } ); + it( 'should initialise without #_visibleDialogPlugin set', () => { expect( Dialog._visibleDialogPlugin ).to.be.undefined; } ); diff --git a/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelp.js b/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelp.js new file mode 100644 index 00000000000..c18b13d9996 --- /dev/null +++ b/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelp.js @@ -0,0 +1,178 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; +import { AccessibilityHelp, ButtonView } from '../../../src/index.js'; +import { env, global, keyCodes } from '@ckeditor/ckeditor5-utils'; +import { MultiRootEditor } from '@ckeditor/ckeditor5-editor-multi-root'; +import AccessibilityHelpContentView from '../../../src/editorui/accessibilityhelp/accessibilityhelpcontentview.js'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; +import { UndoEditing } from '@ckeditor/ckeditor5-undo'; + +describe( 'AccessibilityHelp', () => { + let editor, plugin, dialogPlugin, domElement; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + testUtils.sinon.stub( env, 'isMac' ).value( false ); + domElement = global.document.createElement( 'div' ); + global.document.body.appendChild( domElement ); + + editor = await ClassicTestEditor.create( domElement, { + plugins: [ + AccessibilityHelp + ] + } ); + + plugin = editor.plugins.get( AccessibilityHelp ); + dialogPlugin = editor.plugins.get( 'Dialog' ); + } ); + + afterEach( async () => { + domElement.remove(); + await editor.destroy(); + } ); + + it( 'should have a name', () => { + expect( AccessibilityHelp.pluginName ).to.equal( 'AccessibilityHelp' ); + } ); + + describe( 'constructor()', () => { + it( 'should have #contentView', () => { + expect( plugin.contentView ).to.be.null; + } ); + } ); + + describe( 'init()', () => { + it( 'should register the "accessibilityHelp" button in the factory that opens the dialog', () => { + const buttonView = editor.ui.componentFactory.create( 'accessibilityHelp' ); + const dialogShowSpy = sinon.spy(); + dialogPlugin.on( 'show:accessibilityHelp', dialogShowSpy ); + + expect( buttonView ).to.be.instanceOf( ButtonView ); + expect( buttonView.isOn ).to.be.false; + expect( buttonView.label ).to.equal( 'Accessibility help' ); + expect( buttonView.icon ).to.match( / { + const dialogShowSpy = sinon.spy(); + const keyEventData = { + keyCode: keyCodes[ '0' ], + altKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + dialogPlugin.on( 'show:accessibilityHelp', dialogShowSpy ); + + const wasHandled = editor.keystrokes.press( keyEventData ); + + expect( wasHandled ).to.be.true; + expect( keyEventData.preventDefault.calledOnce ).to.be.true; + + sinon.assert.calledOnce( dialogShowSpy ); + } ); + + describe( 'editor editing view root integration', () => { + it( 'should inject label into a single root', () => { + const viewRoot = editor.editing.view.document.getRoot( 'main' ); + const ariaLabel = viewRoot.getAttribute( 'aria-label' ); + + expect( ariaLabel ).to.equal( 'Editor editing area: main. Press Alt+0 for help.' ); + } ); + + it( 'should work for multiple roots (MultiRootEditor)', async () => { + const rootElA = global.document.createElement( 'div' ); + const rootElB = global.document.createElement( 'div' ); + const rootElC = global.document.createElement( 'div' ); + + global.document.body.appendChild( rootElA ); + global.document.body.appendChild( rootElB ); + global.document.body.appendChild( rootElC ); + + const multiRootEditor = await MultiRootEditor.create( { rootElA, rootElB, rootElC }, { + plugins: [ AccessibilityHelp ] + } ); + + assertEditorRootLabels( multiRootEditor ); + + await multiRootEditor.destroy(); + + for ( const editable of Object.values( multiRootEditor.ui.view.editables ) ) { + editable.element.remove(); + } + } ); + + it( 'should work for dynamic roots', async () => { + const rootElA = global.document.createElement( 'div' ); + const rootElB = global.document.createElement( 'div' ); + const rootElC = global.document.createElement( 'div' ); + + global.document.body.appendChild( rootElA ); + global.document.body.appendChild( rootElB ); + global.document.body.appendChild( rootElC ); + + const multiRootEditor = await MultiRootEditor.create( { rootElA, rootElB, rootElC }, { + plugins: [ AccessibilityHelp, UndoEditing ] + } ); + + multiRootEditor.on( 'addRoot', ( evt, root ) => { + const domElement = multiRootEditor.createEditable( root ); + global.document.body.appendChild( domElement ); + } ); + + multiRootEditor.on( 'detachRoot', ( evt, root ) => { + const domElement = multiRootEditor.detachEditable( root ); + domElement.remove(); + } ); + + multiRootEditor.addRoot( 'dynamicRoot', { isUndoable: true } ); + + assertEditorRootLabels( multiRootEditor ); + + multiRootEditor.detachRoot( multiRootEditor.model.document.getRoot( 'dynamicRoot' ), true ); + + assertEditorRootLabels( multiRootEditor ); + + multiRootEditor.execute( 'undo' ); + + assertEditorRootLabels( multiRootEditor ); + + await multiRootEditor.destroy(); + + for ( const editable of Object.values( multiRootEditor.ui.view.editables ) ) { + editable.element.remove(); + } + } ); + + function assertEditorRootLabels( editor ) { + for ( const rootName of editor.model.document.getRootNames() ) { + const viewRoot = editor.editing.view.document.getRoot( rootName ); + const ariaLabel = viewRoot.getAttribute( 'aria-label' ); + + expect( ariaLabel ).to.equal( `Rich Text Editor. Editing area: ${ rootName }. Press Alt+0 for help.` ); + } + } + } ); + } ); + + describe( 'showing the dialog for the first time', () => { + it( 'should create #contentView', () => { + expect( plugin.contentView ).to.be.null; + + plugin._showDialog(); + + expect( plugin.contentView ).to.be.instanceof( AccessibilityHelpContentView ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelpcontentview.js b/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelpcontentview.js new file mode 100644 index 00000000000..e3470cf3c62 --- /dev/null +++ b/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelpcontentview.js @@ -0,0 +1,429 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import { Locale, env } from '@ckeditor/ckeditor5-utils'; +import AccessibilityHelpContentView from '../../../src/editorui/accessibilityhelp/accessibilityhelpcontentview.js'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; + +describe( 'AccessibilityHelpContentView', () => { + const defaultKeystrokes = new Map( [ + [ + 'testCat', + { + id: 'testCat', + label: 'Test cat', + groups: new Map( [ + [ + 'testGroup', + { + id: 'testGroup', + label: 'Test group', + keystrokes: [] + } + ] + ] ) + } + ] + ] ); + + testUtils.createSinonSandbox(); + + describe( 'constructor()', () => { + let view; + + beforeEach( () => { + view = getView( defaultKeystrokes ); + view.render(); + } ); + + afterEach( () => { + view.destroy(); + } ); + + it( 'should have label', () => { + expect( view.element.lastChild.classList.contains( 'ck-label' ) ).to.be.true; + expect( view.element.lastChild.id ).to.equal( view.element.getAttribute( 'aria-labelledby' ) ); + } ); + + it( 'should have CSS class', () => { + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-accessibility-help-dialog__content' ) ).to.be.true; + } ); + + it( 'should have the role attribute', () => { + expect( view.element.getAttribute( 'role' ) ).to.equal( 'document' ); + } ); + + it( 'should have tabindex', () => { + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'should render an intro paragraph', () => { + expect( view.element.firstChild.outerHTML ).to.match( /^

Below, .+<\/p>$/ ); + } ); + } ); + + describe( 'lists of keystrokes', () => { + let view; + + beforeEach( () => { + testUtils.sinon.stub( env, 'isMac' ).value( false ); + } ); + + afterEach( () => { + view.destroy(); + } ); + + it( 'should render for multiple categories, groups, and keystrokes', () => { + view = getView( new Map( [ + [ + 'catA', + { + id: 'catA', + label: 'Cat A', + groups: new Map( [ + [ + 'groupAA', + { + id: 'groupAA', + label: 'Group AA', + keystrokes: [ + { + keystroke: 'Ctrl+A', + label: 'Foo' + }, + { + keystroke: 'Ctrl+B', + label: 'Bar' + } + ] + } + ], + [ + 'groupAB', + { + id: 'groupAB', + label: 'Group AB', + keystrokes: [ + { + keystroke: 'Ctrl+C', + label: 'Baz' + } + ] + } + ] + ] ) + } + ], + [ + 'catB', + { + id: 'catB', + label: 'Cat B', + description: 'Cat B description', + groups: new Map( [ + [ + 'groupBA', + { + id: 'groupBA', + label: 'Group BA', + keystrokes: [ + { + keystroke: 'Ctrl+D', + label: 'Qux' + } + ] + } + ] + ] ) + } + ] + ] ) ); + + view.render(); + + expect( view.element.childNodes[ 1 ].outerHTML ).to.deep.equal( + '

' + + '

Cat A

' + + '

Group AA

' + + '
' + + '
Bar
Ctrl+B
' + + '
Foo
Ctrl+A
' + + '
' + + '

Group AB

' + + '
' + + '
Baz
Ctrl+C
' + + '
' + + '
' + ); + + expect( view.element.childNodes[ 2 ].outerHTML ).to.deep.equal( + '
' + + '

Cat B

' + + '

Cat B description

' + + '

Group BA

' + + '
' + + '
Qux
Ctrl+D
' + + '
' + + '
' + ); + } ); + + it( 'should sort keystrokes alphabetically', () => { + view = getView( new Map( [ + [ + 'catA', + { + id: 'catA', + label: 'Cat A', + groups: new Map( [ + [ + 'groupAA', + { + id: 'groupAA', + label: 'Group AA', + keystrokes: [ + { + keystroke: 'Ctrl+C', + label: 'C' + }, + { + keystroke: 'Ctrl+A', + label: 'A' + }, + { + keystroke: 'Ctrl+B', + label: 'B' + } + ] + } + ] + ] ) + } + ] + + ] ) ); + + view.render(); + + expect( view.element.childNodes[ 1 ].outerHTML ).to.deep.equal( + '
' + + '

Cat A

' + + '

Group AA

' + + '
' + + '
A
Ctrl+A
' + + '
B
Ctrl+B
' + + '
C
Ctrl+C
' + + '
' + + '
' + ); + } ); + + it( 'should use env-specific keystroke rendering', () => { + testUtils.sinon.stub( env, 'isMac' ).value( true ); + + view = getView( new Map( [ + [ + 'catA', + { + id: 'catA', + label: 'Cat A', + groups: new Map( [ + [ + 'groupAA', + { + id: 'groupAA', + label: 'Group AA', + keystrokes: [ + { + keystroke: 'Ctrl+C', + label: 'C' + }, + { + keystroke: 'Alt+A', + label: 'A' + }, + { + keystroke: 'Shift+B', + label: 'B' + } + ] + } + ] + ] ) + } + ] + + ] ) ); + + view.render(); + + expect( view.element.childNodes[ 1 ].outerHTML ).to.deep.equal( + '
' + + '

Cat A

' + + '

Group AA

' + + '
' + + '
A
⌥A
' + + '
B
⇧B
' + + '
C
⌘C
' + + '
' + + '
' + ); + } ); + + it( 'should support the "mayRequireFn" flag in keystroke definition', () => { + testUtils.sinon.stub( env, 'isMac' ).value( true ); + + view = getView( new Map( [ + [ + 'catA', + { + id: 'catA', + label: 'Cat A', + groups: new Map( [ + [ + 'groupAA', + { + id: 'groupAA', + label: 'Group AA', + keystrokes: [ + { + keystroke: 'Alt+A', + label: 'A', + mayRequireFn: true + } + ] + } + ] + ] ) + } + ] + + ] ) ); + + view.render(); + + expect( view.element.childNodes[ 1 ].outerHTML ).to.deep.equal( + '
' + + '

Cat A

' + + '

Group AA

' + + '
' + + '
A
⌥A (may require Fn)
' + + '
' + + '
' + ); + } ); + + it( 'should support keystroke sequences', () => { + view = getView( new Map( [ + [ + 'catA', + { + id: 'catA', + label: 'Cat A', + groups: new Map( [ + [ + 'groupAA', + { + id: 'groupAA', + label: 'Group AA', + keystrokes: [ + { + keystroke: [ 'Alt+A', 'Alt+B' ], + label: 'A' + } + ] + } + ] + ] ) + } + ] + + ] ) ); + + view.render(); + + expect( view.element.childNodes[ 1 ].outerHTML ).to.deep.equal( + '
' + + '

Cat A

' + + '

Group AA

' + + '
' + + '
A
' + + '
' + + 'Alt+A' + + 'Alt+B' + + '
' + + '
' + + '
' + ); + } ); + + it( 'should support keystroke alternatives', () => { + view = getView( new Map( [ + [ + 'catA', + { + id: 'catA', + label: 'Cat A', + groups: new Map( [ + [ + 'groupAA', + { + id: 'groupAA', + label: 'Group AA', + keystrokes: [ + { + keystroke: [ [ 'Alt+A', 'Alt+B' ], [ 'Alt+C', 'Alt+D' ] ], + label: 'A' + } + ] + } + ] + ] ) + } + ] + + ] ) ); + + view.render(); + + expect( view.element.childNodes[ 1 ].outerHTML ).to.deep.equal( + '
' + + '

Cat A

' + + '

Group AA

' + + '
' + + '
A
' + + '
' + + 'Alt+AAlt+B, ' + + 'Alt+CAlt+D' + + '
' + + '
' + + '
' + ); + } ); + } ); + + describe( 'focus()', () => { + it( 'should focus the view', () => { + const view = getView( defaultKeystrokes ); + view.render(); + const focusSpy = sinon.spy( view.element, 'focus' ); + + document.body.appendChild( view.element ); + + view.focus(); + + sinon.assert.calledOnce( focusSpy ); + + view.element.remove(); + } ); + } ); + + function getView( keystrokes ) { + return new AccessibilityHelpContentView( new Locale(), keystrokes ); + } +} ); diff --git a/packages/ckeditor5-ui/tests/formheader/formheaderview.js b/packages/ckeditor5-ui/tests/formheader/formheaderview.js index 486a171b3fd..2b3b023e0f2 100644 --- a/packages/ckeditor5-ui/tests/formheader/formheaderview.js +++ b/packages/ckeditor5-ui/tests/formheader/formheaderview.js @@ -69,6 +69,7 @@ describe( 'FormHeaderView', () => { expect( view.element.firstChild.classList.contains( 'ck' ) ).to.be.true; expect( view.element.firstChild.classList.contains( 'ck-form__header__label' ) ).to.be.true; + expect( view.element.firstChild.role ).to.equal( 'presentation' ); expect( view.element.firstChild.textContent ).to.equal( 'foo' ); view.destroy(); diff --git a/packages/ckeditor5-ui/tests/manual/dialog/dialog.ts b/packages/ckeditor5-ui/tests/manual/dialog/dialog.ts index 5a0228008da..1d15932328e 100644 --- a/packages/ckeditor5-ui/tests/manual/dialog/dialog.ts +++ b/packages/ckeditor5-ui/tests/manual/dialog/dialog.ts @@ -284,6 +284,8 @@ function initEditor( editorName, editorClass, direction = 'ltr', customCallback? ], toolbar: { items: [ + 'accessibilityHelp', + '|', 'heading', 'bold', 'italic', 'link', 'sourceediting', '-', 'findAndReplace', 'modalWithText', 'yesNoModal', ...POSSIBLE_DIALOG_POSITIONS diff --git a/packages/ckeditor5-ui/theme/components/editorui/accessibilityhelp.css b/packages/ckeditor5-ui/theme/components/editorui/accessibilityhelp.css new file mode 100644 index 00000000000..061e05951c9 --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/editorui/accessibilityhelp.css @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* + * Note: This file should contain the wireframe styles only. But since there are no such styles, + * it acts as a message to the builder telling that it should look for the corresponding styles + * **in the theme** when compiling the editor. + */ diff --git a/packages/ckeditor5-ui/theme/icons/accessibility.svg b/packages/ckeditor5-ui/theme/icons/accessibility.svg new file mode 100644 index 00000000000..bbd7c7d3354 --- /dev/null +++ b/packages/ckeditor5-ui/theme/icons/accessibility.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ckeditor5-undo/src/undoediting.ts b/packages/ckeditor5-undo/src/undoediting.ts index 9109ce412e7..b2cd1476e34 100644 --- a/packages/ckeditor5-undo/src/undoediting.ts +++ b/packages/ckeditor5-undo/src/undoediting.ts @@ -7,7 +7,7 @@ * @module undo/undoediting */ -import { Plugin, type Editor } from '@ckeditor/ckeditor5-core'; +import { Plugin } from '@ckeditor/ckeditor5-core'; import UndoCommand, { type UndoCommandRevertEvent } from './undocommand.js'; import RedoCommand from './redocommand.js'; @@ -52,6 +52,7 @@ export default class UndoEditing extends Plugin { */ public init(): void { const editor = this.editor; + const t = editor.t; // Create commands. this._undoCommand = new UndoCommand( editor ); @@ -109,5 +110,19 @@ export default class UndoEditing extends Plugin { editor.keystrokes.set( 'CTRL+Z', 'undo' ); editor.keystrokes.set( 'CTRL+Y', 'redo' ); editor.keystrokes.set( 'CTRL+SHIFT+Z', 'redo' ); + + // Add the information about the keystrokes to the accessibility database. + editor.accessibility.addKeystrokeInfos( { + keystrokes: [ + { + label: t( 'Undo' ), + keystroke: 'CTRL+Z' + }, + { + label: t( 'Redo' ), + keystroke: [ [ 'CTRL+Y' ], [ 'CTRL+SHIFT+Z' ] ] + } + ] + } ); } } diff --git a/packages/ckeditor5-undo/tests/undoediting.js b/packages/ckeditor5-undo/tests/undoediting.js index 5fe7f783e3e..a452f90f335 100644 --- a/packages/ckeditor5-undo/tests/undoediting.js +++ b/packages/ckeditor5-undo/tests/undoediting.js @@ -26,6 +26,22 @@ describe( 'UndoEditing', () => { undo.destroy(); } ); + it( 'should have a name', () => { + expect( UndoEditing.pluginName ).to.equal( 'UndoEditing' ); + } ); + + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Undo', + keystroke: 'CTRL+Z' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'common' ).keystrokes ).to.deep.include( { + label: 'Redo', + keystroke: [ [ 'CTRL+Y' ], [ 'CTRL+SHIFT+Z' ] ] + } ); + } ); + it( 'should register undo command and redo command', () => { expect( editor.commands.get( 'undo' ) ).to.equal( undo._undoCommand ); expect( editor.commands.get( 'redo' ) ).to.equal( undo._redoCommand ); diff --git a/packages/ckeditor5-utils/src/keyboard.ts b/packages/ckeditor5-utils/src/keyboard.ts index deac4b438bc..401b1d3c217 100644 --- a/packages/ckeditor5-utils/src/keyboard.ts +++ b/packages/ckeditor5-utils/src/keyboard.ts @@ -26,6 +26,14 @@ const modifiersToGlyphsNonMac = { shift: 'Shift+' } as const; +const keyCodesToGlyphs: { [key: number]: string } = { + 37: '←', + 38: '↑', + 39: '→', + 40: '↓', + 9: '⇥' +} as const; + /** * An object with `keyName => keyCode` pairs for a set of known keys. * @@ -41,8 +49,18 @@ const modifiersToGlyphsNonMac = { */ export const keyCodes = generateKnownKeyCodes(); -const keyCodeNames = Object.fromEntries( - Object.entries( keyCodes ).map( ( [ name, code ] ) => [ code, name.charAt( 0 ).toUpperCase() + name.slice( 1 ) ] ) +const keyCodeNames: { readonly [ keyCode: number ]: string } = Object.fromEntries( + Object.entries( keyCodes ).map( ( [ name, code ] ) => { + let prettyKeyName; + + if ( code in keyCodesToGlyphs ) { + prettyKeyName = keyCodesToGlyphs[ code ]; + } else { + prettyKeyName = name.charAt( 0 ).toUpperCase() + name.slice( 1 ); + } + + return [ code, prettyKeyName ]; + } ) ); /** @@ -264,9 +282,19 @@ function generateKnownKeyCodes(): { readonly [ keyCode: string ]: number } { } // other characters - for ( const char of '`-=[];\',./\\' ) { - keyCodes[ char ] = char.charCodeAt( 0 ); - } + Object.assign( keyCodes, { + '\'': 222, + ',': 108, + '-': 109, + '.': 110, + '/': 111, + ';': 186, + '=': 187, + '[': 219, + '\\': 220, + ']': 221, + '`': 223 + } ); return keyCodes; } diff --git a/packages/ckeditor5-utils/tests/keyboard.js b/packages/ckeditor5-utils/tests/keyboard.js index 90ad642f7c5..64cb0999d8d 100644 --- a/packages/ckeditor5-utils/tests/keyboard.js +++ b/packages/ckeditor5-utils/tests/keyboard.js @@ -42,6 +42,26 @@ describe( 'Keyboard', () => { '`', '-', '=', '[', ']', ';', '\'', ',', '.', '/', '\\' ); } ); + + it( 'should provide correct codes for interpunction characters, brackets, slashes, etc.', () => { + const charactersToCodes = { + '\'': 222, + ',': 108, + '-': 109, + '.': 110, + '/': 111, + ';': 186, + '=': 187, + '[': 219, + '\\': 220, + ']': 221, + '`': 223 + }; + + for ( const character in charactersToCodes ) { + expect( keyCodes[ character ] ).to.equal( charactersToCodes[ character ] ); + } + } ); } ); describe( 'getCode', () => { @@ -58,7 +78,7 @@ describe( 'Keyboard', () => { } ); it( 'gets code of a punctuation character', () => { - expect( getCode( ']' ) ).to.equal( 93 ); + expect( getCode( ']' ) ).to.equal( 221 ); } ); it( 'is case insensitive', () => { @@ -103,7 +123,7 @@ describe( 'Keyboard', () => { } ); it( 'parses string without modifier', () => { - expect( parseKeystroke( '[' ) ).to.equal( 91 ); + expect( parseKeystroke( '[' ) ).to.equal( 219 ); } ); it( 'allows spacing', () => { @@ -148,7 +168,7 @@ describe( 'Keyboard', () => { } ); it( 'parses string without modifier', () => { - expect( parseKeystroke( '[' ) ).to.equal( 91 ); + expect( parseKeystroke( '[' ) ).to.equal( 219 ); } ); it( 'allows spacing', () => { @@ -192,7 +212,7 @@ describe( 'Keyboard', () => { } ); it( 'parses string without modifier', () => { - expect( parseKeystroke( '[' ) ).to.equal( 91 ); + expect( parseKeystroke( '[' ) ).to.equal( 219 ); } ); it( 'allows spacing', () => { @@ -273,7 +293,7 @@ describe( 'Keyboard', () => { it( 'normalizes value', () => { expect( getEnvKeystrokeText( 'ESC' ) ).to.equal( 'Esc' ); - expect( getEnvKeystrokeText( 'TAB' ) ).to.equal( 'Tab' ); + expect( getEnvKeystrokeText( 'TAB' ) ).to.equal( '⇥' ); expect( getEnvKeystrokeText( 'A' ) ).to.equal( 'A' ); expect( getEnvKeystrokeText( 'a' ) ).to.equal( 'A' ); expect( getEnvKeystrokeText( 'CTRL+a' ) ).to.equal( '⌘A' ); @@ -281,6 +301,13 @@ describe( 'Keyboard', () => { expect( getEnvKeystrokeText( 'CTRL+[' ) ).to.equal( '⌘[' ); expect( getEnvKeystrokeText( 'CTRL+]' ) ).to.equal( '⌘]' ); } ); + + it( 'uses pretty glyphs for arrows', () => { + expect( getEnvKeystrokeText( 'Arrowleft' ) ).to.equal( '←' ); + expect( getEnvKeystrokeText( 'Arrowup' ) ).to.equal( '↑' ); + expect( getEnvKeystrokeText( 'Arrowright' ) ).to.equal( '→' ); + expect( getEnvKeystrokeText( 'Arrowdown' ) ).to.equal( '↓' ); + } ); } ); describe( 'on iOS', () => { @@ -320,7 +347,7 @@ describe( 'Keyboard', () => { it( 'normalizes value', () => { expect( getEnvKeystrokeText( 'ESC' ) ).to.equal( 'Esc' ); - expect( getEnvKeystrokeText( 'TAB' ) ).to.equal( 'Tab' ); + expect( getEnvKeystrokeText( 'TAB' ) ).to.equal( '⇥' ); expect( getEnvKeystrokeText( 'A' ) ).to.equal( 'A' ); expect( getEnvKeystrokeText( 'a' ) ).to.equal( 'A' ); expect( getEnvKeystrokeText( 'CTRL+a' ) ).to.equal( '⌘A' ); @@ -328,6 +355,13 @@ describe( 'Keyboard', () => { expect( getEnvKeystrokeText( 'CTRL+[' ) ).to.equal( '⌘[' ); expect( getEnvKeystrokeText( 'CTRL+]' ) ).to.equal( '⌘]' ); } ); + + it( 'uses pretty glyphs for arrows', () => { + expect( getEnvKeystrokeText( 'Arrowleft' ) ).to.equal( '←' ); + expect( getEnvKeystrokeText( 'Arrowup' ) ).to.equal( '↑' ); + expect( getEnvKeystrokeText( 'Arrowright' ) ).to.equal( '→' ); + expect( getEnvKeystrokeText( 'Arrowdown' ) ).to.equal( '↓' ); + } ); } ); describe( 'on non–Macintosh', () => { @@ -337,7 +371,7 @@ describe( 'Keyboard', () => { it( 'normalizes value', () => { expect( getEnvKeystrokeText( 'ESC' ) ).to.equal( 'Esc' ); - expect( getEnvKeystrokeText( 'TAB' ) ).to.equal( 'Tab' ); + expect( getEnvKeystrokeText( 'TAB' ) ).to.equal( '⇥' ); expect( getEnvKeystrokeText( 'A' ) ).to.equal( 'A' ); expect( getEnvKeystrokeText( 'a' ) ).to.equal( 'A' ); expect( getEnvKeystrokeText( 'CTRL+a' ) ).to.equal( 'Ctrl+A' ); @@ -350,6 +384,13 @@ describe( 'Keyboard', () => { expect( getEnvKeystrokeText( 'CTRL+[' ) ).to.equal( 'Ctrl+[' ); expect( getEnvKeystrokeText( 'CTRL+]' ) ).to.equal( 'Ctrl+]' ); } ); + + it( 'uses pretty glyphs for arrows', () => { + expect( getEnvKeystrokeText( 'Arrowleft' ) ).to.equal( '←' ); + expect( getEnvKeystrokeText( 'Arrowup' ) ).to.equal( '↑' ); + expect( getEnvKeystrokeText( 'Arrowright' ) ).to.equal( '→' ); + expect( getEnvKeystrokeText( 'Arrowdown' ) ).to.equal( '↓' ); + } ); } ); } ); diff --git a/packages/ckeditor5-widget/lang/contexts.json b/packages/ckeditor5-widget/lang/contexts.json index 71615ef972e..5131b3e6c31 100644 --- a/packages/ckeditor5-widget/lang/contexts.json +++ b/packages/ckeditor5-widget/lang/contexts.json @@ -2,5 +2,10 @@ "Widget toolbar": "The label used by assistive technologies describing a toolbar attached to a widget.", "Insert paragraph before block": "The title displayed when a mouse is over a button that inserts a paragraph before a block.", "Insert paragraph after block": "The title displayed when a mouse is over a button that inserts a paragraph after a block.", - "Press Enter to type after or press Shift + Enter to type before the widget": "Information to be read by screen reader about shortcuts to type around a widget" + "Press Enter to type after or press Shift + Enter to type before the widget": "Information to be read by screen reader about shortcuts to type around a widget.", + "Keystrokes that can be used when a widget is selected (for example: image, table, etc.)": "Accessibility help dialog section title for widget plugin keystrokes.", + "Insert a new paragraph directly after a widget": "Accessibility help dialog entry explaining the meaning of the keystroke that inserts a paragraph after a widget.", + "Insert a new paragraph directly before a widget": "Accessibility help dialog entry explaining the meaning of the keystroke that inserts a paragraph before a widget.", + "Move the caret to allow typing directly before a widget": "Accessibility help dialog entry explaining the meaning of the keystroke that moves the caret before a widget.", + "Move the caret to allow typing directly after a widget": "Accessibility help dialog entry explaining the meaning of the keystroke that moves the caret after a widget." } diff --git a/packages/ckeditor5-widget/src/widget.ts b/packages/ckeditor5-widget/src/widget.ts index 3ffcf1c9651..0b985a1ca46 100644 --- a/packages/ckeditor5-widget/src/widget.ts +++ b/packages/ckeditor5-widget/src/widget.ts @@ -80,6 +80,7 @@ export default class Widget extends Plugin { const editor = this.editor; const view = editor.editing.view; const viewDocument = view.document; + const t = editor.t; // Model to view selection converter. // Converts selection placed over widget element to fake selection. @@ -194,6 +195,30 @@ export default class Widget extends Plugin { evt.stop(); } }, { context: '$root' } ); + + // Add the information about the keystrokes to the accessibility database. + editor.accessibility.addKeystrokeInfoGroup( { + id: 'widget', + label: t( 'Keystrokes that can be used when a widget is selected (for example: image, table, etc.)' ), + keystrokes: [ + { + label: t( 'Insert a new paragraph directly after a widget' ), + keystroke: 'Enter' + }, + { + label: t( 'Insert a new paragraph directly before a widget' ), + keystroke: 'Shift+Enter' + }, + { + label: t( 'Move the caret to allow typing directly before a widget' ), + keystroke: [ [ 'arrowup' ], [ 'arrowleft' ] ] + }, + { + label: t( 'Move the caret to allow typing directly after a widget' ), + keystroke: [ [ 'arrowdown' ], [ 'arrowright' ] ] + } + ] + } ); } /** diff --git a/packages/ckeditor5-widget/tests/widget.js b/packages/ckeditor5-widget/tests/widget.js index 0de545ac1e7..31347f28adc 100644 --- a/packages/ckeditor5-widget/tests/widget.js +++ b/packages/ckeditor5-widget/tests/widget.js @@ -137,6 +137,36 @@ describe( 'Widget', () => { return editor.destroy(); } ); + it( 'should have a name', () => { + expect( Widget.pluginName ).to.equal( 'Widget' ); + } ); + + it( 'should add keystroke accessibility info', () => { + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'widget' ).label ).to.equal( + 'Keystrokes that can be used when a widget is selected (for example: image, table, etc.)' + ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'widget' ).keystrokes ).to.deep.include( { + label: 'Insert a new paragraph directly after a widget', + keystroke: 'Enter' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'widget' ).keystrokes ).to.deep.include( { + label: 'Insert a new paragraph directly before a widget', + keystroke: 'Shift+Enter' + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'widget' ).keystrokes ).to.deep.include( { + label: 'Move the caret to allow typing directly before a widget', + keystroke: [ [ 'arrowup' ], [ 'arrowleft' ] ] + } ); + + expect( editor.accessibility.keystrokeInfos.get( 'contentEditing' ).groups.get( 'widget' ).keystrokes ).to.deep.include( { + label: 'Move the caret to allow typing directly after a widget', + keystroke: [ [ 'arrowdown' ], [ 'arrowright' ] ] + } ); + } ); + it( 'should be loaded', () => { expect( editor.plugins.get( Widget ) ).to.be.instanceOf( Widget ); } );