diff --git a/packages/ckeditor5-core/src/editor/editorconfig.ts b/packages/ckeditor5-core/src/editor/editorconfig.ts index 17944353516..c923d5ab6d5 100644 --- a/packages/ckeditor5-core/src/editor/editorconfig.ts +++ b/packages/ckeditor5-core/src/editor/editorconfig.ts @@ -821,6 +821,48 @@ export interface EditorConfig { * Translations to be used in the editor. */ translations?: ArrayOrItem; + + /** + * Label text for the `aria-label` attribute set on editor editing area. Used by assistive technologies + * to tell apart multiple editor instances (editing areas) on the page. If not set, a default + * "Rich Text Editor. Editing area [name of the area]" is used instead. + * + * ```ts + * ClassicEditor + * .create( document.querySelector( '#editor' ), { + * label: 'My editor' + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * If your editor implementation uses multiple roots, you should pass an object with keys corresponding to the editor + * roots names and values equal to the label that should be used for each root: + * + * ```ts + * MultiRootEditor.create( + * // Roots for the editor: + * { + * header: document.querySelector( '#header' ), + * content: document.querySelector( '#content' ), + * leftSide: document.querySelector( '#left-side' ), + * rightSide: document.querySelector( '#right-side' ) + * }, + * // Config: + * { + * label: { + * header: 'Header label', + * content: 'Content label', + * leftSide: 'Left side label', + * rightSide: 'Right side label' + * } + * } + * ) + * .then( ... ) + * .catch( ... ); + * ``` + */ + label?: string | Record; } /** diff --git a/packages/ckeditor5-core/tests/_utils/classictesteditor.js b/packages/ckeditor5-core/tests/_utils/classictesteditor.js index 42f8b7bcf85..e52c456dfe2 100644 --- a/packages/ckeditor5-core/tests/_utils/classictesteditor.js +++ b/packages/ckeditor5-core/tests/_utils/classictesteditor.js @@ -52,7 +52,9 @@ export default class ClassicTestEditor extends ElementApiMixin( Editor ) { this.ui = new ClassicTestEditorUI( this, new BoxedEditorUIView( this.locale ) ); // Expose properties normally exposed by the ClassicEditorUI. - this.ui.view.editable = new InlineEditableUIView( this.ui.view.locale, this.editing.view ); + this.ui.view.editable = new InlineEditableUIView( this.ui.view.locale, this.editing.view, undefined, { + label: this.config.get( 'label' ) + } ); } /** diff --git a/packages/ckeditor5-editor-balloon/src/ballooneditor.ts b/packages/ckeditor5-editor-balloon/src/ballooneditor.ts index e8ce61c801c..d6b1f6319d2 100644 --- a/packages/ckeditor5-editor-balloon/src/ballooneditor.ts +++ b/packages/ckeditor5-editor-balloon/src/ballooneditor.ts @@ -77,7 +77,7 @@ export default class BalloonEditor extends /* #__PURE__ */ ElementApiMixin( Edit this.model.document.createRoot(); - const view = new BalloonEditorUIView( this.locale, this.editing.view, this.sourceElement ); + const view = new BalloonEditorUIView( this.locale, this.editing.view, this.sourceElement, this.config.get( 'label' ) ); this.ui = new BalloonEditorUI( this, view ); attachToForm( this ); diff --git a/packages/ckeditor5-editor-balloon/src/ballooneditoruiview.ts b/packages/ckeditor5-editor-balloon/src/ballooneditoruiview.ts index 009b1ea2678..e07e5e5f0ca 100644 --- a/packages/ckeditor5-editor-balloon/src/ballooneditoruiview.ts +++ b/packages/ckeditor5-editor-balloon/src/ballooneditoruiview.ts @@ -32,20 +32,19 @@ export default class BalloonEditorUIView extends EditorUIView { * @param editingView The editing view instance this view is related to. * @param editableElement The editable element. If not specified, it will be automatically created by * {@link module:ui/editableui/editableuiview~EditableUIView}. Otherwise, the given element will be used. + * @param label When set, this value will be used as an accessible `aria-label` of the + * {@link module:ui/editableui/editableuiview~EditableUIView editable view}. */ constructor( locale: Locale, editingView: EditingView, - editableElement?: HTMLElement + editableElement?: HTMLElement, + label?: string | Record ) { super( locale ); - const t = locale.t; - this.editable = new InlineEditableUIView( locale, editingView, editableElement, { - label: editableView => { - return t( 'Rich Text Editor. Editing area: %0', editableView.name! ); - } + label } ); this.menuBarView = new MenuBarView( locale ); diff --git a/packages/ckeditor5-editor-balloon/tests/ballooneditor.js b/packages/ckeditor5-editor-balloon/tests/ballooneditor.js index 2a8732d2efa..d135e08b09f 100644 --- a/packages/ckeditor5-editor-balloon/tests/ballooneditor.js +++ b/packages/ckeditor5-editor-balloon/tests/ballooneditor.js @@ -146,8 +146,10 @@ describe( 'BalloonEditor', () => { } ); } ); - afterEach( () => { - return editor.destroy(); + afterEach( async () => { + if ( editor.state !== 'destroyed' ) { + await editor.destroy(); + } } ); it( 'creates an instance which inherits from the BalloonEditor', () => { @@ -275,6 +277,106 @@ describe( 'BalloonEditor', () => { .then( done ) .catch( done ); } ); + + describe( 'configurable editor label (aria-label)', () => { + it( 'should be set to the defaut value if not configured', () => { + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Rich Text Editor. Editing area: main' + ); + } ); + + it( 'should support the string format', async () => { + await editor.destroy(); + + editor = await BalloonEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + } ); + + it( 'should support object format', async () => { + await editor.destroy(); + + editor = await BalloonEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: { + main: 'Custom label' + } + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + } ); + + it( 'should keep an existing value from the source DOM element', async () => { + await editor.destroy(); + + editorElement.setAttribute( 'aria-label', 'Pre-existing value' ); + const newEditor = await BalloonEditor.create( editorElement, { + plugins: [ Paragraph, Bold ] + } ); + + expect( newEditor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Keep value' ).to.equal( + 'Pre-existing value' + ); + + await newEditor.destroy(); + + expect( editorElement.getAttribute( 'aria-label' ), 'Restore value' ).to.equal( 'Pre-existing value' ); + } ); + + it( 'should override the existing value from the source DOM element', async () => { + await editor.destroy(); + + editorElement.setAttribute( 'aria-label', 'Pre-existing value' ); + editor = await BalloonEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + + expect( editorElement.getAttribute( 'aria-label' ), 'Restore value' ).to.equal( 'Pre-existing value' ); + } ); + + it( 'should use default label when creating an editor from initial data rather than a DOM element', async () => { + await editor.destroy(); + + editor = await BalloonEditor.create( '

Initial data

', { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Rich Text Editor. Editing area: main' + ); + + await editor.destroy(); + } ); + + it( 'should set custom label when creating an editor from initial data rather than a DOM element', async () => { + await editor.destroy(); + + editor = await BalloonEditor.create( '

Initial data

', { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + } ); + } ); } ); describe( 'create - events', () => { diff --git a/packages/ckeditor5-editor-balloon/tests/ballooneditoruiview.js b/packages/ckeditor5-editor-balloon/tests/ballooneditoruiview.js index 559bfdefbb1..82eb22b8f4d 100644 --- a/packages/ckeditor5-editor-balloon/tests/ballooneditoruiview.js +++ b/packages/ckeditor5-editor-balloon/tests/ballooneditoruiview.js @@ -39,13 +39,39 @@ describe( 'BalloonEditorUIView', () => { expect( view.editable.isRendered ).to.be.false; } ); - it( 'is given an accessible aria label', () => { + it( 'creates an editing root with the default aria-label', () => { view.render(); expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Rich Text Editor. Editing area: main' ); view.destroy(); } ); + + it( 'creates an editing root with the configured aria-label (string format)', () => { + const editingView = new EditingView(); + const editingViewRoot = createRoot( editingView.document ); + const view = new BalloonEditorUIView( locale, editingView, undefined, 'Foo' ); + view.editable.name = editingViewRoot.rootName; + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); + + it( 'creates an editing root with the configured aria-label (object format)', () => { + const editingView = new EditingView(); + const editingViewRoot = createRoot( editingView.document ); + const view = new BalloonEditorUIView( locale, editingView, undefined, { + main: 'Foo' + } ); + view.editable.name = editingViewRoot.rootName; + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); } ); describe( '#menuBarView', () => { diff --git a/packages/ckeditor5-editor-classic/src/classiceditor.ts b/packages/ckeditor5-editor-classic/src/classiceditor.ts index 2280a25fff7..fd33031f994 100644 --- a/packages/ckeditor5-editor-classic/src/classiceditor.ts +++ b/packages/ckeditor5-editor-classic/src/classiceditor.ts @@ -73,7 +73,8 @@ export default class ClassicEditor extends /* #__PURE__ */ ElementApiMixin( Edit const view = new ClassicEditorUIView( this.locale, this.editing.view, { shouldToolbarGroupWhenFull, - useMenuBar: menuBarConfig.isVisible + useMenuBar: menuBarConfig.isVisible, + label: this.config.get( 'label' ) } ); this.ui = new ClassicEditorUI( this, view ); diff --git a/packages/ckeditor5-editor-classic/src/classiceditoruiview.ts b/packages/ckeditor5-editor-classic/src/classiceditoruiview.ts index c5ac6ce1362..18c7bedde3b 100644 --- a/packages/ckeditor5-editor-classic/src/classiceditoruiview.ts +++ b/packages/ckeditor5-editor-classic/src/classiceditoruiview.ts @@ -43,6 +43,8 @@ export default class ClassicEditorUIView extends BoxedEditorUIView { * @param options.shouldToolbarGroupWhenFull When set `true` enables automatic items grouping * in the main {@link module:editor-classic/classiceditoruiview~ClassicEditorUIView#toolbar toolbar}. * See {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull} to learn more. + * @param options.label When set, this value will be used as an accessible `aria-label` of the + * {@link module:ui/editableui/editableuiview~EditableUIView editable view}. */ constructor( locale: Locale, @@ -50,6 +52,7 @@ export default class ClassicEditorUIView extends BoxedEditorUIView { options: { shouldToolbarGroupWhenFull?: boolean; useMenuBar?: boolean; + label?: string | Record; } = {} ) { super( locale ); @@ -64,7 +67,9 @@ export default class ClassicEditorUIView extends BoxedEditorUIView { this.menuBarView = new MenuBarView( locale ); } - this.editable = new InlineEditableUIView( locale, editingView ); + this.editable = new InlineEditableUIView( locale, editingView, undefined, { + label: options.label + } ); } /** diff --git a/packages/ckeditor5-editor-classic/tests/classiceditor.js b/packages/ckeditor5-editor-classic/tests/classiceditor.js index 2b1d005e3bf..a73fc5a64d6 100644 --- a/packages/ckeditor5-editor-classic/tests/classiceditor.js +++ b/packages/ckeditor5-editor-classic/tests/classiceditor.js @@ -171,8 +171,10 @@ describe( 'ClassicEditor', () => { } ); } ); - afterEach( () => { - return editor.destroy(); + afterEach( async () => { + if ( editor.state !== 'destroyed' ) { + await editor.destroy(); + } } ); it( 'creates an instance which inherits from the ClassicEditor', () => { @@ -267,6 +269,71 @@ describe( 'ClassicEditor', () => { it( 'attaches editable UI as view\'s DOM root', () => { expect( editor.editing.view.getDomRoot() ).to.equal( editor.ui.view.editable.element ); } ); + + describe( 'configurable editor label (aria-label)', () => { + it( 'should be set to the defaut value if not configured', () => { + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Rich Text Editor. Editing area: main' + ); + } ); + + it( 'should support the string format', async () => { + await editor.destroy(); + + editor = await ClassicEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + } ); + + it( 'should support object format', async () => { + await editor.destroy(); + + editor = await ClassicEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: { + main: 'Custom label' + } + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + } ); + + it( 'should use default label when creating an editor from initial data rather than a DOM element', async () => { + await editor.destroy(); + + editor = await ClassicEditor.create( '

Initial data

', { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Rich Text Editor. Editing area: main' + ); + + await editor.destroy(); + } ); + + it( 'should set custom label when creating an editor from initial data rather than a DOM element', async () => { + await editor.destroy(); + + editor = await ClassicEditor.create( '

Initial data

', { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + } ); + } ); } ); } ); diff --git a/packages/ckeditor5-editor-classic/tests/classiceditorui.js b/packages/ckeditor5-editor-classic/tests/classiceditorui.js index 81e5ec05c62..24723aa56e7 100644 --- a/packages/ckeditor5-editor-classic/tests/classiceditorui.js +++ b/packages/ckeditor5-editor-classic/tests/classiceditorui.js @@ -325,7 +325,7 @@ describe( 'ClassicEditorUI', () => { editorWithUi.ui.view.stickyPanel.isSticky = true; dialogPlugin.show( { - title: 'Foo', + label: 'Foo', content: dialogContentView, position: DialogViewPosition.EDITOR_TOP_SIDE } ); @@ -350,7 +350,7 @@ describe( 'ClassicEditorUI', () => { editorWithUi.ui.view.stickyPanel.isSticky = false; dialogPlugin.show( { - title: 'Foo', + label: 'Foo', content: dialogContentView, position: DialogViewPosition.EDITOR_TOP_SIDE } ); @@ -375,7 +375,7 @@ describe( 'ClassicEditorUI', () => { editorWithUi.ui.view.stickyPanel.isSticky = true; dialogPlugin.show( { - title: 'Foo', + label: 'Foo', content: dialogContentView, position: DialogViewPosition.EDITOR_TOP_SIDE } ); diff --git a/packages/ckeditor5-editor-classic/tests/classiceditoruiview.js b/packages/ckeditor5-editor-classic/tests/classiceditoruiview.js index b5b89de7a99..7e4a1122b7b 100644 --- a/packages/ckeditor5-editor-classic/tests/classiceditoruiview.js +++ b/packages/ckeditor5-editor-classic/tests/classiceditoruiview.js @@ -82,6 +82,42 @@ describe( 'ClassicEditorUIView', () => { } ); } ); + describe( '#editable', () => { + it( 'creates an editing root with the default aria-label', () => { + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Rich Text Editor. Editing area: main' ); + } ); + + it( 'creates an editing root with the configured aria-label (string format)', () => { + const editingView = new EditingView(); + const editingViewRoot = createRoot( editingView.document ); + const view = new ClassicEditorUIView( locale, editingView, { + label: 'Foo' + } ); + view.editable.name = editingViewRoot.rootName; + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); + + it( 'creates an editing root with the configured aria-label (object format)', () => { + const editingView = new EditingView(); + const editingViewRoot = createRoot( editingView.document ); + const view = new ClassicEditorUIView( locale, editingView, { + label: { + main: 'Foo' + } + } ); + view.editable.name = editingViewRoot.rootName; + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); + } ); + describe( '#menuBarView', () => { it( 'is not created', () => { expect( view.menuBarView ).to.be.undefined; diff --git a/packages/ckeditor5-editor-decoupled/src/decouplededitor.ts b/packages/ckeditor5-editor-decoupled/src/decouplededitor.ts index 6bdc7a4b4b4..21f81ecb3c7 100644 --- a/packages/ckeditor5-editor-decoupled/src/decouplededitor.ts +++ b/packages/ckeditor5-editor-decoupled/src/decouplededitor.ts @@ -81,7 +81,8 @@ export default class DecoupledEditor extends /* #__PURE__ */ ElementApiMixin( Ed const shouldToolbarGroupWhenFull = !this.config.get( 'toolbar.shouldNotGroupWhenFull' ); const view = new DecoupledEditorUIView( this.locale, this.editing.view, { editableElement: this.sourceElement, - shouldToolbarGroupWhenFull + shouldToolbarGroupWhenFull, + label: this.config.get( 'label' ) } ); this.ui = new DecoupledEditorUI( this, view ); diff --git a/packages/ckeditor5-editor-decoupled/src/decouplededitoruiview.ts b/packages/ckeditor5-editor-decoupled/src/decouplededitoruiview.ts index 1b608f55e6e..e38de377545 100644 --- a/packages/ckeditor5-editor-decoupled/src/decouplededitoruiview.ts +++ b/packages/ckeditor5-editor-decoupled/src/decouplededitoruiview.ts @@ -48,6 +48,8 @@ export default class DecoupledEditorUIView extends EditorUIView { * @param options.shouldToolbarGroupWhenFull When set `true` enables automatic items grouping * in the main {@link module:editor-decoupled/decouplededitoruiview~DecoupledEditorUIView#toolbar toolbar}. * See {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull} to learn more. + * @param options.label When set, this value will be used as an accessible `aria-label` of the + * {@link module:ui/editableui/editableuiview~EditableUIView editable view}. */ constructor( locale: Locale, @@ -55,12 +57,11 @@ export default class DecoupledEditorUIView extends EditorUIView { options: { editableElement?: HTMLElement; shouldToolbarGroupWhenFull?: boolean; + label?: string | Record; } = {} ) { super( locale ); - const t = locale.t; - this.toolbar = new ToolbarView( locale, { shouldGroupWhenFull: options.shouldToolbarGroupWhenFull } ); @@ -68,9 +69,7 @@ export default class DecoupledEditorUIView extends EditorUIView { this.menuBarView = new MenuBarView( locale ); this.editable = new InlineEditableUIView( locale, editingView, options.editableElement, { - label: editableView => { - return t( 'Rich Text Editor. Editing area: %0', editableView.name! ); - } + label: options.label } ); // This toolbar may be placed anywhere in the page so things like font size need to be reset in it. diff --git a/packages/ckeditor5-editor-decoupled/tests/decouplededitor.js b/packages/ckeditor5-editor-decoupled/tests/decouplededitor.js index 29f47b82f45..6ddebdd17b8 100644 --- a/packages/ckeditor5-editor-decoupled/tests/decouplededitor.js +++ b/packages/ckeditor5-editor-decoupled/tests/decouplededitor.js @@ -88,6 +88,116 @@ describe( 'DecoupledEditor', () => { editorElement.remove(); } ); } ); + + describe( 'configurable editor label (aria-label)', () => { + let editorElement; + + beforeEach( () => { + editorElement = document.createElement( 'div' ); + + document.body.appendChild( editorElement ); + } ); + + afterEach( () => { + editorElement.remove(); + } ); + + it( 'should be set to the defaut value if not configured', async () => { + const editor = await DecoupledEditor.create( editorElement, { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Rich Text Editor. Editing area: main' + ); + + await editor.destroy(); + } ); + + it( 'should support the string format', async () => { + const editor = await DecoupledEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + } ); + + it( 'should support object format', async () => { + const editor = await DecoupledEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: { + main: 'Custom label' + } + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + } ); + + it( 'should keep an existing value from the source DOM element', async () => { + editorElement.setAttribute( 'aria-label', 'Pre-existing value' ); + const editor = await DecoupledEditor.create( editorElement, { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Keep value' ).to.equal( + 'Pre-existing value' + ); + + await editor.destroy(); + + expect( editorElement.getAttribute( 'aria-label' ), 'Restore value' ).to.equal( 'Pre-existing value' ); + } ); + + it( 'should override the existing value from the source DOM element', async () => { + editorElement.setAttribute( 'aria-label', 'Pre-existing value' ); + const editor = await DecoupledEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + + expect( editorElement.getAttribute( 'aria-label' ), 'Restore value' ).to.equal( 'Pre-existing value' ); + } ); + + it( 'should use default label when creating an editor from initial data rather than a DOM element', async () => { + const editor = await DecoupledEditor.create( '

Initial data

', { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Rich Text Editor. Editing area: main' + ); + + await editor.destroy(); + } ); + + it( 'should set custom label when creating an editor from initial data rather than a DOM element', async () => { + const editor = await DecoupledEditor.create( '

Initial data

', { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + } ); + } ); } ); describe( 'config.initialData', () => { diff --git a/packages/ckeditor5-editor-decoupled/tests/decouplededitoruiview.js b/packages/ckeditor5-editor-decoupled/tests/decouplededitoruiview.js index cef63efb85e..d23f1097252 100644 --- a/packages/ckeditor5-editor-decoupled/tests/decouplededitoruiview.js +++ b/packages/ckeditor5-editor-decoupled/tests/decouplededitoruiview.js @@ -111,13 +111,43 @@ describe( 'DecoupledEditorUIView', () => { testView.destroy(); } ); - it( 'is given an accessible aria label', () => { + it( 'creates an editing root with the default aria-label', () => { view.render(); expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Rich Text Editor. Editing area: main' ); view.destroy(); } ); + + it( 'creates an editing root with the configured aria-label (string format)', () => { + const editingView = new EditingView(); + const editingViewRoot = createRoot( editingView.document ); + const view = new DecoupledEditorUIView( locale, editingView, { + label: 'Foo' + } ); + view.editable.name = editingViewRoot.rootName; + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); + + it( 'creates an editing root with the configured aria-label (object format)', () => { + const editingView = new EditingView(); + const editingViewRoot = createRoot( editingView.document ); + const view = new DecoupledEditorUIView( locale, editingView, { + label: { + main: 'Foo' + } + } ); + view.editable.name = editingViewRoot.rootName; + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); } ); } ); diff --git a/packages/ckeditor5-editor-inline/src/inlineeditor.ts b/packages/ckeditor5-editor-inline/src/inlineeditor.ts index a6cc9ef4f18..abc466fb284 100644 --- a/packages/ckeditor5-editor-inline/src/inlineeditor.ts +++ b/packages/ckeditor5-editor-inline/src/inlineeditor.ts @@ -75,7 +75,8 @@ export default class InlineEditor extends /* #__PURE__ */ ElementApiMixin( Edito const view = new InlineEditorUIView( this.locale, this.editing.view, this.sourceElement, { shouldToolbarGroupWhenFull, - useMenuBar: menuBarConfig.isVisible + useMenuBar: menuBarConfig.isVisible, + label: this.config.get( 'label' ) } ); this.ui = new InlineEditorUI( this, view ); diff --git a/packages/ckeditor5-editor-inline/src/inlineeditoruiview.ts b/packages/ckeditor5-editor-inline/src/inlineeditoruiview.ts index 94a2a3442d0..0e0bfc0c3ba 100644 --- a/packages/ckeditor5-editor-inline/src/inlineeditoruiview.ts +++ b/packages/ckeditor5-editor-inline/src/inlineeditoruiview.ts @@ -134,6 +134,8 @@ export default class InlineEditorUIView extends EditorUIView { * @param options.shouldToolbarGroupWhenFull When set `true` enables automatic items grouping * in the main {@link module:editor-inline/inlineeditoruiview~InlineEditorUIView#toolbar toolbar}. * See {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull} to learn more. + * @param options.label When set, this value will be used as an accessible `aria-label` of the + * {@link module:ui/editableui/editableuiview~EditableUIView editable view}. */ constructor( locale: Locale, @@ -142,12 +144,11 @@ export default class InlineEditorUIView extends EditorUIView { options: { shouldToolbarGroupWhenFull?: boolean; useMenuBar?: boolean; + label?: string | Record; } = {} ) { super( locale ); - const t = locale.t; - this.toolbar = new ToolbarView( locale, { shouldGroupWhenFull: options.shouldToolbarGroupWhenFull, isFloating: true @@ -169,9 +170,7 @@ export default class InlineEditorUIView extends EditorUIView { } ); this.editable = new InlineEditableUIView( locale, editingView, editableElement, { - label: editableView => { - return t( 'Rich Text Editor. Editing area: %0', editableView.name! ); - } + label: options.label } ); this._resizeObserver = null; diff --git a/packages/ckeditor5-editor-inline/tests/inlineeditor.js b/packages/ckeditor5-editor-inline/tests/inlineeditor.js index b6581a8303c..8c860031c4b 100644 --- a/packages/ckeditor5-editor-inline/tests/inlineeditor.js +++ b/packages/ckeditor5-editor-inline/tests/inlineeditor.js @@ -146,8 +146,10 @@ describe( 'InlineEditor', () => { } ); } ); - afterEach( () => { - return editor.destroy(); + afterEach( async () => { + if ( editor.state !== 'destroyed' ) { + await editor.destroy(); + } } ); it( 'creates an instance which inherits from the InlineEditor', () => { @@ -284,6 +286,106 @@ describe( 'InlineEditor', () => { .then( done ) .catch( done ); } ); + + describe( 'configurable editor label (aria-label)', () => { + it( 'should be set to the defaut value if not configured', () => { + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Rich Text Editor. Editing area: main' + ); + } ); + + it( 'should support the string format', async () => { + await editor.destroy(); + + editor = await InlineEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + } ); + + it( 'should support object format', async () => { + await editor.destroy(); + + editor = await InlineEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: { + main: 'Custom label' + } + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + } ); + + it( 'should keep an existing value from the source DOM element', async () => { + await editor.destroy(); + + editorElement.setAttribute( 'aria-label', 'Pre-existing value' ); + editor = await InlineEditor.create( editorElement, { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Keep value' ).to.equal( + 'Pre-existing value' + ); + + await editor.destroy(); + + expect( editorElement.getAttribute( 'aria-label' ), 'Restore value' ).to.equal( 'Pre-existing value' ); + } ); + + it( 'should override the existing value from the source DOM element', async () => { + await editor.destroy(); + + editorElement.setAttribute( 'aria-label', 'Pre-existing value' ); + editor = await InlineEditor.create( editorElement, { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + + expect( editorElement.getAttribute( 'aria-label' ), 'Restore value' ).to.equal( 'Pre-existing value' ); + } ); + + it( 'should use default label when creating an editor from initial data rather than a DOM element', async () => { + await editor.destroy(); + + editor = await InlineEditor.create( '

Initial data

', { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Rich Text Editor. Editing area: main' + ); + + await editor.destroy(); + } ); + + it( 'should set custom label when creating an editor from initial data rather than a DOM element', async () => { + await editor.destroy(); + + editor = await InlineEditor.create( '

Initial data

', { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot().getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + } ); + } ); } ); describe( 'create - events', () => { diff --git a/packages/ckeditor5-editor-inline/tests/inlineeditoruiview.js b/packages/ckeditor5-editor-inline/tests/inlineeditoruiview.js index c5d3bf8c488..9235e2e0896 100644 --- a/packages/ckeditor5-editor-inline/tests/inlineeditoruiview.js +++ b/packages/ckeditor5-editor-inline/tests/inlineeditoruiview.js @@ -108,13 +108,43 @@ describe( 'InlineEditorUIView', () => { expect( view.editable.isRendered ).to.be.false; } ); - it( 'is given an accessible aria label', () => { + it( 'creates an editing root with the default aria-label', () => { view.render(); expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Rich Text Editor. Editing area: main' ); view.destroy(); } ); + + it( 'creates an editing root with the configured aria-label (string format)', () => { + const editingView = new EditingView(); + const editingViewRoot = createRoot( editingView.document ); + const view = new InlineEditorUIView( locale, editingView, undefined, { + label: 'Foo' + } ); + view.editable.name = editingViewRoot.rootName; + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); + + it( 'creates an editing root with the configured aria-label (object format)', () => { + const editingView = new EditingView(); + const editingViewRoot = createRoot( editingView.document ); + const view = new InlineEditorUIView( locale, editingView, undefined, { + label: { + main: 'Foo' + } + } ); + view.editable.name = editingViewRoot.rootName; + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); } ); describe( '#menuBarView', () => { diff --git a/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts b/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts index 47ed4e173f1..2eeca033a17 100644 --- a/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts +++ b/packages/ckeditor5-editor-multi-root/src/multirooteditor.ts @@ -189,7 +189,8 @@ export default class MultiRootEditor extends Editor { const options = { shouldToolbarGroupWhenFull: !this.config.get( 'toolbar.shouldNotGroupWhenFull' ), - editableElements: sourceIsData ? undefined : sourceElementsOrData as Record + editableElements: sourceIsData ? undefined : sourceElementsOrData as Record, + label: this.config.get( 'label' ) }; const view = new MultiRootEditorUIView( this.locale, this.editing.view, rootNames, options ); @@ -485,10 +486,11 @@ export default class MultiRootEditor extends Editor { * @param root Root for which the editable element should be created. * @param placeholder Placeholder for the editable element. If not set, placeholder value from the * {@link module:core/editor/editorconfig~EditorConfig#placeholder editor configuration} will be used (if it was provided). + * @param label The accessible label text describing the editable to the assistive technologies. * @returns The created DOM element. Append it in a desired place in your application. */ - public createEditable( root: RootElement, placeholder?: string ): HTMLElement { - const editable = this.ui.view.createEditable( root.rootName ); + public createEditable( root: RootElement, placeholder?: string, label?: string ): HTMLElement { + const editable = this.ui.view.createEditable( root.rootName, undefined, label ); this.ui.addEditable( editable, placeholder ); diff --git a/packages/ckeditor5-editor-multi-root/src/multirooteditoruiview.ts b/packages/ckeditor5-editor-multi-root/src/multirooteditoruiview.ts index 7215f1f5065..a92f303778a 100644 --- a/packages/ckeditor5-editor-multi-root/src/multirooteditoruiview.ts +++ b/packages/ckeditor5-editor-multi-root/src/multirooteditoruiview.ts @@ -57,6 +57,8 @@ export default class MultiRootEditorUIView extends EditorUIView { * @param options.shouldToolbarGroupWhenFull When set to `true` enables automatic items grouping * in the main {@link module:editor-multi-root/multirooteditoruiview~MultiRootEditorUIView#toolbar toolbar}. * See {@link module:ui/toolbar/toolbarview~ToolbarOptions#shouldGroupWhenFull} to learn more. + * @param options.label When set, this value will be used as an accessible `aria-label` of the + * {@link module:ui/editableui/editableuiview~EditableUIView editable view} elements. */ constructor( locale: Locale, @@ -65,6 +67,7 @@ export default class MultiRootEditorUIView extends EditorUIView { options: { editableElements?: Record; shouldToolbarGroupWhenFull?: boolean; + label?: string | Record; } = {} ) { super( locale ); @@ -82,8 +85,13 @@ export default class MultiRootEditorUIView extends EditorUIView { // Create `InlineEditableUIView` instance for each editable. for ( const editableName of editableNames ) { const editableElement = options.editableElements ? options.editableElements[ editableName ] : undefined; + let { label } = options; - this.createEditable( editableName, editableElement ); + if ( typeof label === 'object' ) { + label = label[ editableName ]; + } + + this.createEditable( editableName, editableElement, label ); } this.editable = Object.values( this.editables )[ 0 ]; @@ -121,15 +129,12 @@ export default class MultiRootEditorUIView extends EditorUIView { * * @param editableName The name for the editable. * @param editableElement DOM element for which the editable should be created. + * @param label The accessible editable label used by assistive technologies. * @returns The created editable instance. */ - public createEditable( editableName: string, editableElement?: HTMLElement ): InlineEditableUIView { - const t = this.locale.t; - + public createEditable( editableName: string, editableElement?: HTMLElement, label?: string ): InlineEditableUIView { const editable = new InlineEditableUIView( this.locale, this._editingView, editableElement, { - label: editable => { - return t( 'Rich Text Editor. Editing area: %0', editable.name! ); - } + label } ); this.editables[ editableName ] = editable; diff --git a/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js b/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js index aacb46c6949..20c19f0d7e8 100644 --- a/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js +++ b/packages/ckeditor5-editor-multi-root/tests/multirooteditor.js @@ -325,6 +325,188 @@ describe( 'MultiRootEditor', () => { } ); } ); + describe( 'configurable editor label (aria-label)', () => { + it( 'should be set to the defaut value if not configured', async () => { + const editor = await MultiRootEditor.create( { + foo: document.createElement( 'div' ), + bar: document.createElement( 'div' ) + }, { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot( 'foo' ).getAttribute( 'aria-label' ) ).to.equal( + 'Rich Text Editor. Editing area: foo' + ); + + expect( editor.editing.view.getDomRoot( 'bar' ).getAttribute( 'aria-label' ) ).to.equal( + 'Rich Text Editor. Editing area: bar' + ); + + await editor.destroy(); + } ); + + it( 'should support string format', async () => { + const editor = await MultiRootEditor.create( { + foo: document.createElement( 'div' ), + bar: document.createElement( 'div' ) + }, { + plugins: [ Paragraph, Bold ], + label: 'Custom label' + } ); + + expect( editor.editing.view.getDomRoot( 'foo' ).getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + + expect( editor.editing.view.getDomRoot( 'bar' ).getAttribute( 'aria-label' ) ).to.equal( + 'Custom label' + ); + + await editor.destroy(); + } ); + + it( 'should support object format', async () => { + const editor = await MultiRootEditor.create( { + foo: document.createElement( 'div' ), + bar: document.createElement( 'div' ) + }, { + plugins: [ Paragraph, Bold ], + label: { + foo: 'Foo custom label', + bar: 'Bar custom label' + } + } ); + + expect( editor.editing.view.getDomRoot( 'foo' ).getAttribute( 'aria-label' ) ).to.equal( + 'Foo custom label' + ); + + expect( editor.editing.view.getDomRoot( 'bar' ).getAttribute( 'aria-label' ) ).to.equal( + 'Bar custom label' + ); + + await editor.destroy(); + } ); + + it( 'should support object format (mix default and custom label)', async () => { + const editor = await MultiRootEditor.create( { + foo: document.createElement( 'div' ), + bar: document.createElement( 'div' ) + }, { + plugins: [ Paragraph, Bold ], + label: { + bar: 'Bar custom label' + } + } ); + + expect( editor.editing.view.getDomRoot( 'foo' ).getAttribute( 'aria-label' ) ).to.equal( + 'Rich Text Editor. Editing area: foo' + ); + + expect( editor.editing.view.getDomRoot( 'bar' ).getAttribute( 'aria-label' ) ).to.equal( + 'Bar custom label' + ); + + await editor.destroy(); + } ); + + it( 'should keep an existing value from the source DOM element', async () => { + const fooElement = document.createElement( 'div' ); + fooElement.setAttribute( 'aria-label', 'Foo pre-existing value' ); + + const barElement = document.createElement( 'div' ); + barElement.setAttribute( 'aria-label', 'Bar pre-existing value' ); + + const editor = await MultiRootEditor.create( { + foo: fooElement, + bar: barElement + }, { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot( 'foo' ).getAttribute( 'aria-label' ) ).to.equal( + 'Foo pre-existing value' + ); + + expect( editor.editing.view.getDomRoot( 'bar' ).getAttribute( 'aria-label' ) ).to.equal( + 'Bar pre-existing value' + ); + + await editor.destroy(); + } ); + + it( 'should override the existing value from the source DOM element', async () => { + const fooElement = document.createElement( 'div' ); + fooElement.setAttribute( 'aria-label', 'Foo pre-existing value' ); + + const barElement = document.createElement( 'div' ); + barElement.setAttribute( 'aria-label', 'Bar pre-existing value' ); + + const editor = await MultiRootEditor.create( { + foo: fooElement, + bar: barElement + }, { + plugins: [ Paragraph, Bold ], + label: { + foo: 'Foo override', + bar: 'Bar override' + } + } ); + + expect( editor.editing.view.getDomRoot( 'foo' ).getAttribute( 'aria-label' ) ).to.equal( + 'Foo override' + ); + + expect( editor.editing.view.getDomRoot( 'bar' ).getAttribute( 'aria-label' ) ).to.equal( + 'Bar override' + ); + + await editor.destroy(); + } ); + + it( 'should use default label when creating an editor from initial data rather than a DOM element', async () => { + const editor = await MultiRootEditor.create( { + foo: 'Foo content', + bar: 'Bar content' + }, { + plugins: [ Paragraph, Bold ] + } ); + + expect( editor.editing.view.getDomRoot( 'foo' ).getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Rich Text Editor. Editing area: foo' + ); + + expect( editor.editing.view.getDomRoot( 'bar' ).getAttribute( 'aria-label' ), 'Override value' ).to.equal( + 'Rich Text Editor. Editing area: bar' + ); + + await editor.destroy(); + } ); + + it( 'should set custom label when creating an editor from initial data rather than a DOM element', async () => { + const editor = await MultiRootEditor.create( { + foo: 'Foo content', + bar: 'Bar content' + }, { + plugins: [ Paragraph, Bold ], + label: { + foo: 'Foo override', + bar: 'Bar override' + } + } ); + + expect( editor.editing.view.getDomRoot( 'foo' ).getAttribute( 'aria-label' ) ).to.equal( + 'Foo override' + ); + + expect( editor.editing.view.getDomRoot( 'bar' ).getAttribute( 'aria-label' ) ).to.equal( + 'Bar override' + ); + + await editor.destroy(); + } ); + } ); + function test( getElementOrData ) { it( 'creates an instance which inherits from the MultiRootEditor', () => { return MultiRootEditor @@ -935,6 +1117,16 @@ describe( 'MultiRootEditor', () => { expect( editableElement.children[ 0 ].dataset.placeholder ).to.equal( 'new' ); } ); + + it( 'should alow for setting a custom label to the editable', () => { + editor.addRoot( 'new' ); + + editor.createEditable( editor.model.document.getRoot( 'new' ), undefined, 'Custom label' ); + + const editableElement = editor.ui.view.editables.new.element; + + expect( editableElement.getAttribute( 'aria-label' ) ).to.equal( 'Custom label' ); + } ); } ); describe( 'detachEditable()', () => { diff --git a/packages/ckeditor5-editor-multi-root/tests/multirooteditoruiview.js b/packages/ckeditor5-editor-multi-root/tests/multirooteditoruiview.js index 0807769d26f..00233471026 100644 --- a/packages/ckeditor5-editor-multi-root/tests/multirooteditoruiview.js +++ b/packages/ckeditor5-editor-multi-root/tests/multirooteditoruiview.js @@ -119,7 +119,7 @@ describe( 'MultiRootEditorUIView', () => { testView.destroy(); } ); - it( 'is given an accessible aria label', () => { + it( 'creates an editing root with the default aria-label', () => { view.render(); expect( fooViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Rich Text Editor. Editing area: foo' ); @@ -127,6 +127,45 @@ describe( 'MultiRootEditorUIView', () => { view.destroy(); } ); + + it( 'creates an editing root with the configured aria-label (string format)', () => { + const editingView = new EditingView(); + const fooViewRoot = createRoot( editingView.document, 'div', 'foo' ); + const barViewRoot = createRoot( editingView.document, 'div', 'bar' ); + const view = new MultiRootEditorUIView( locale, editingView, [ 'foo', 'bar' ], { + label: 'Foo' + } ); + + view.editables.foo.name = 'foo'; + view.editables.bar.name = 'bar'; + view.render(); + + expect( fooViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + expect( barViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + + view.destroy(); + } ); + + it( 'creates an editing root with the configured aria-label (object format)', () => { + const editingView = new EditingView(); + const fooViewRoot = createRoot( editingView.document, 'div', 'foo' ); + const barViewRoot = createRoot( editingView.document, 'div', 'bar' ); + const view = new MultiRootEditorUIView( locale, editingView, [ 'foo', 'bar' ], { + label: { + foo: 'Foo', + bar: 'Bar' + } + } ); + + view.editables.foo.name = 'foo'; + view.editables.bar.name = 'bar'; + view.render(); + + expect( fooViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + expect( barViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Bar' ); + + view.destroy(); + } ); } ); } ); @@ -176,6 +215,19 @@ describe( 'MultiRootEditorUIView', () => { view.destroy(); } ); + + it( 'new editable is given an accessible aria label (custom)', () => { + const newViewRoot = createRoot( editingView.document, 'div', 'new' ); + + view.createEditable( 'new', undefined, 'Custom label' ); + view.editables.new.name = 'new'; + + view.render(); + + expect( newViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Custom label' ); + + view.destroy(); + } ); } ); describe( 'removeEditable()', () => { diff --git a/packages/ckeditor5-engine/src/view/view.ts b/packages/ckeditor5-engine/src/view/view.ts index 1fd9b84586d..0a5812dd94f 100644 --- a/packages/ckeditor5-engine/src/view/view.ts +++ b/packages/ckeditor5-engine/src/view/view.ts @@ -279,7 +279,12 @@ export default class View extends /* #__PURE__ */ ObservableMixin() { if ( name === 'class' ) { this._writer.addClass( value.split( ' ' ), viewRoot ); } else { - this._writer.setAttribute( name, value, viewRoot ); + // There is a chance that some attributes have already been set on the view root before attaching + // the DOM root and should be preserved. This is a similar case to the "class" attribute except + // this time there is no workaround using a some low-level API. + if ( !viewRoot.hasAttribute( name ) ) { + this._writer.setAttribute( name, value, viewRoot ); + } } } diff --git a/packages/ckeditor5-engine/tests/view/view/view.js b/packages/ckeditor5-engine/tests/view/view/view.js index 39072491e46..02f68f65625 100644 --- a/packages/ckeditor5-engine/tests/view/view/view.js +++ b/packages/ckeditor5-engine/tests/view/view/view.js @@ -180,6 +180,36 @@ describe( 'view', () => { sinon.assert.calledOnce( observerMock.observe ); sinon.assert.calledOnce( observerMockGlobalCount.observe ); } ); + + it( 'should transfer all DOM attributes to the root element', () => { + const domDiv = document.createElement( 'div' ); + const viewRoot = createViewRoot( viewDocument, 'div', 'main' ); + + domDiv.setAttribute( 'foo', 'bar' ); + domDiv.setAttribute( 'baz', 'qux' ); + + view.attachDomRoot( domDiv ); + + expect( viewRoot.getAttribute( 'foo' ) ).to.equal( 'bar' ); + expect( viewRoot.getAttribute( 'baz' ) ).to.equal( 'qux' ); + } ); + + it( 'should not transfer a DOM attribute to the root element if already exists before attaching', () => { + const domDiv = document.createElement( 'div' ); + const viewRoot = createViewRoot( viewDocument, 'div', 'main' ); + + domDiv.setAttribute( 'foo', 'bar' ); + domDiv.setAttribute( 'baz', 'qux' ); + + view.change( writer => { + writer.setAttribute( 'foo', 'pre-existing', viewRoot ); + } ); + + view.attachDomRoot( domDiv ); + + expect( viewRoot.getAttribute( 'foo' ) ).to.equal( 'pre-existing' ); + expect( viewRoot.getAttribute( 'baz' ) ).to.equal( 'qux' ); + } ); } ); describe( 'detachDomRoot()', () => { @@ -231,6 +261,28 @@ describe( 'view', () => { domDiv.remove(); } ); + it( 'should restore the DOM root attributes that have been set on the view root before attaching', () => { + const domDiv = document.createElement( 'div' ); + const viewRoot = createViewRoot( viewDocument, 'div', 'main' ); + + domDiv.setAttribute( 'foo', 'bar' ); + domDiv.setAttribute( 'baz', 'qux' ); + + view.change( writer => { + writer.setAttribute( 'foo', 'pre-existing', viewRoot ); + } ); + + view.attachDomRoot( domDiv ); + + expect( viewRoot.getAttribute( 'foo' ) ).to.equal( 'pre-existing' ); + expect( viewRoot.getAttribute( 'baz' ) ).to.equal( 'qux' ); + + view.detachDomRoot( 'main' ); + + expect( domDiv.getAttribute( 'foo' ) ).to.equal( 'bar' ); + expect( domDiv.getAttribute( 'baz' ) ).to.equal( 'qux' ); + } ); + it( 'should remove the "contenteditable" attribute from the DOM root', () => { const domDiv = document.createElement( 'div' ); const viewRoot = createViewRoot( viewDocument, 'div', 'main' ); diff --git a/packages/ckeditor5-ui/lang/contexts.json b/packages/ckeditor5-ui/lang/contexts.json index a397f262e03..ebe09dc69d3 100644 --- a/packages/ckeditor5-ui/lang/contexts.json +++ b/packages/ckeditor5-ui/lang/contexts.json @@ -1,6 +1,5 @@ { "Rich Text Editor": "Title of the CKEditor5 editor.", - "Editor editing area: %0": "Accessible label of the specific editing area belonging to a container with an ARIA application role.", "Edit block": "Label of the block toolbar icon (a block toolbar is displayed next to each paragraph, heading, list item, etc. and contains e.g. block formatting options)", "Click to edit block": "First part of the label of the block toolbar icon when functionality of drag and drop is available (a block toolbar is displayed next to each paragraph, heading, list item, etc. and contains e.g. block formatting options)", "Drag to move": "Second part of the label of the block toolbar icon when functionality of drag and drop is available (a block toolbar is displayed next to each paragraph, heading, list item, etc. and contains e.g. block formatting options)", diff --git a/packages/ckeditor5-ui/src/editableui/editableuiview.ts b/packages/ckeditor5-ui/src/editableui/editableuiview.ts index a214e969a7e..28530db57e0 100644 --- a/packages/ckeditor5-ui/src/editableui/editableuiview.ts +++ b/packages/ckeditor5-ui/src/editableui/editableuiview.ts @@ -41,7 +41,7 @@ export default class EditableUIView extends View { /** * The element which is the main editable element (usually the one with `contentEditable="true"`). */ - private _editableElement: HTMLElement | null | undefined; + protected _editableElement: HTMLElement | null | undefined; /** * Whether an external {@link #_editableElement} was passed into the constructor, which also means diff --git a/packages/ckeditor5-ui/src/editableui/inline/inlineeditableuiview.ts b/packages/ckeditor5-ui/src/editableui/inline/inlineeditableuiview.ts index ecd4bcb1485..7a50223fe57 100644 --- a/packages/ckeditor5-ui/src/editableui/inline/inlineeditableuiview.ts +++ b/packages/ckeditor5-ui/src/editableui/inline/inlineeditableuiview.ts @@ -17,10 +17,9 @@ import type { Locale } from '@ckeditor/ckeditor5-utils'; */ export default class InlineEditableUIView extends EditableUIView { /** - * A function that gets called with the instance of this view as an argument and should return a string that - * represents the label of the editable for assistive technologies. + * The cached options object passed to the constructor. */ - private readonly _generateLabel: ( view: InlineEditableUIView ) => string; + private readonly _options: InlineEditableUIViewOptions; /** * Creates an instance of the InlineEditableUIView class. @@ -31,19 +30,18 @@ export default class InlineEditableUIView extends EditableUIView { * {@link module:ui/editableui/editableuiview~EditableUIView} * will create it. Otherwise, the existing element will be used. * @param options Additional configuration of the view. - * @param options.label A function that gets called with the instance of this view as an argument - * and should return a string that represents the label of the editable for assistive technologies. If not provided, - * a default label generator is used. + * @param options.label The label of the editable for assistive technologies. If not provided, a default label is used or, + * the existing `aria-label` attribute value from the specified `editableElement` is preserved. */ constructor( locale: Locale, editingView: EditingView, editableElement?: HTMLElement, - options: { label?: ( view: InlineEditableUIView ) => string } = {} + options: InlineEditableUIViewOptions = {} ) { super( locale, editingView, editableElement ); - const t = locale.t; + this._options = options; this.extendTemplate( { attributes: { @@ -51,8 +49,6 @@ export default class InlineEditableUIView extends EditableUIView { class: 'ck-editor__editable_inline' } } ); - - this._generateLabel = options.label || ( () => t( 'Editor editing area: %0', this.name! ) ); } /** @@ -66,7 +62,37 @@ export default class InlineEditableUIView extends EditableUIView { editingView.change( writer => { const viewRoot = editingView.document.getRoot( this.name! ); - writer.setAttribute( 'aria-label', this._generateLabel( this ), viewRoot! ); + writer.setAttribute( 'aria-label', this.getEditableAriaLabel(), viewRoot! ); } ); } + + /** + * Returns a normalized label for the editable view based on the environment. + */ + public getEditableAriaLabel(): string { + const t = this.locale!.t; + const label = this._options.label; + const editableElement = this._editableElement; + const editableName = this.name!; + + if ( typeof label == 'string' ) { + return label; + } else if ( typeof label === 'object' ) { + return label[ editableName ]; + } else if ( typeof label === 'function' ) { + return label( this ); + } else if ( editableElement ) { + const existingLabel = editableElement.getAttribute( 'aria-label' ); + + if ( existingLabel ) { + return existingLabel; + } + } + + return t( 'Rich Text Editor. Editing area: %0', editableName ); + } } + +type InlineEditableUIViewOptions = { + label?: ( ( view: InlineEditableUIView ) => string ) | string | Record; +}; diff --git a/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelp.ts b/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelp.ts index 030718cbe12..cd8708e8916 100644 --- a/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelp.ts +++ b/packages/ckeditor5-ui/src/editorui/accessibilityhelp/accessibilityhelp.ts @@ -130,7 +130,9 @@ export default class AccessibilityHelp extends Plugin { function addAriaLabelTextToRoot( writer: DowncastWriter, viewRoot: ViewRootEditableElement ) { const currentAriaLabel = viewRoot.getAttribute( 'aria-label' ); - const newAriaLabel = `${ currentAriaLabel }. ${ t( 'Press %0 for help.', [ getEnvKeystrokeText( 'Alt+0' ) ] ) }`; + const newAriaLabel = [ currentAriaLabel, t( 'Press %0 for help.', [ getEnvKeystrokeText( 'Alt+0' ) ] ) ] + .filter( segment => segment ) + .join( '. ' ); writer.setAttribute( 'aria-label', newAriaLabel, viewRoot ); } diff --git a/packages/ckeditor5-ui/tests/editableui/inline/inlineeditableuiview.js b/packages/ckeditor5-ui/tests/editableui/inline/inlineeditableuiview.js index 1172dece9a6..5ef0821537b 100644 --- a/packages/ckeditor5-ui/tests/editableui/inline/inlineeditableuiview.js +++ b/packages/ckeditor5-ui/tests/editableui/inline/inlineeditableuiview.js @@ -59,10 +59,85 @@ describe( 'InlineEditableUIView', () => { describe( 'aria-label', () => { it( 'should fall back to the default value when no option was provided', () => { - expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Editor editing area: main' ); + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Rich Text Editor. Editing area: main' ); } ); - it( 'should be set via options.label passed into constructor()', () => { + it( 'should use the existing aria-label value of the editable element (no configured value)', () => { + const editingViewRoot = new ViewRootEditableElement( editingView.document, 'div' ); + editingViewRoot.rootName = 'custom-name'; + editingView.document.roots.add( editingViewRoot ); + const editableElement = document.createElement( 'div' ); + + editableElement.setAttribute( 'aria-label', 'Existing label' ); + + const view = new InlineEditableUIView( locale, editingView, editableElement ); + + view.name = editingViewRoot.rootName; + + view.render(); + + expect( editableElement.getAttribute( 'aria-label' ) ).to.equal( 'Existing label' ); + + view.destroy(); + } ); + + it( 'should be set via options.label passed into constructor (callback)', () => { + const editingViewRoot = new ViewRootEditableElement( editingView.document, 'div' ); + editingViewRoot.rootName = 'custom-name'; + editingView.document.roots.add( editingViewRoot ); + + const view = new InlineEditableUIView( locale, editingView, null, { + label: view => `Custom label: ${ view.name }` + } ); + + view.name = editingViewRoot.rootName; + + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Custom label: custom-name' ); + + view.destroy(); + } ); + + it( 'should be set via options.label passed into constructor (string)', () => { + const editingViewRoot = new ViewRootEditableElement( editingView.document, 'div' ); + editingViewRoot.rootName = 'custom-name'; + editingView.document.roots.add( editingViewRoot ); + + const view = new InlineEditableUIView( locale, editingView, null, { + label: 'Custom label' + } ); + + view.name = editingViewRoot.rootName; + + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Custom label' ); + + view.destroy(); + } ); + + it( 'should be set via options.label passed into constructor (object)', () => { + const editingViewRoot = new ViewRootEditableElement( editingView.document, 'div' ); + editingViewRoot.rootName = 'custom-name'; + editingView.document.roots.add( editingViewRoot ); + + const view = new InlineEditableUIView( locale, editingView, null, { + label: { + 'custom-name': 'Custom label' + } + } ); + + view.name = editingViewRoot.rootName; + + view.render(); + + expect( editingViewRoot.getAttribute( 'aria-label' ) ).to.equal( 'Custom label' ); + + view.destroy(); + } ); + + it( 'should be set via options.label passed into constructor (empty string)', () => { const editingViewRoot = new ViewRootEditableElement( editingView.document, 'div' ); editingViewRoot.rootName = 'custom-name'; editingView.document.roots.add( editingViewRoot ); diff --git a/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelp.js b/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelp.js index 07eb5a53d38..e3a64c00322 100644 --- a/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelp.js +++ b/packages/ckeditor5-ui/tests/editorui/accessibilityhelp/accessibilityhelp.js @@ -132,7 +132,23 @@ describe( 'AccessibilityHelp', () => { 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.' ); + expect( ariaLabel ).to.equal( 'Rich Text Editor. Editing area: main. Press Alt+0 for help.' ); + } ); + + it( 'should inject a label into a root with no aria-label', async () => { + const editor = await ClassicTestEditor.create( domElement, { + plugins: [ + AccessibilityHelp + ], + label: '' + } ); + + const viewRoot = editor.editing.view.document.getRoot( 'main' ); + const ariaLabel = viewRoot.getAttribute( 'aria-label' ); + + expect( ariaLabel ).to.equal( 'Press Alt+0 for help.' ); + + await editor.destroy(); } ); it( 'should work for multiple roots (MultiRootEditor)', async () => {