Browse Source

Merge branch 'master' into t/ckeditor5/1404

Aleksander Nowodzinski 6 years ago
parent
commit
3db5f5c9aa
26 changed files with 377 additions and 119 deletions
  1. 10 2
      packages/ckeditor5-ui/docs/features/blocktoolbar.md
  2. 8 0
      packages/ckeditor5-ui/src/button/button.jsdoc
  3. 2 1
      packages/ckeditor5-ui/src/button/buttonview.js
  4. 2 0
      packages/ckeditor5-ui/src/button/switchbuttonview.js
  5. 4 0
      packages/ckeditor5-ui/src/colorgrid/colorgridview.js
  6. 5 1
      packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.js
  7. 28 9
      packages/ckeditor5-ui/src/dropdown/dropdownview.js
  8. 3 1
      packages/ckeditor5-ui/src/editableui/editableuiview.js
  9. 2 2
      packages/ckeditor5-ui/src/editorui/boxed/boxededitoruiview.js
  10. 3 1
      packages/ckeditor5-ui/src/editorui/editoruiview.js
  11. 3 3
      packages/ckeditor5-ui/src/template.js
  12. 2 0
      packages/ckeditor5-ui/src/toolbar/block/blockbuttonview.js
  13. 18 2
      packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js
  14. 4 4
      packages/ckeditor5-ui/src/viewcollection.js
  15. 10 1
      packages/ckeditor5-ui/tests/button/buttonview.js
  16. 4 0
      packages/ckeditor5-ui/tests/button/switchbuttonview.js
  17. 17 0
      packages/ckeditor5-ui/tests/colorgrid/colorgridview.js
  18. 16 0
      packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js
  19. 88 82
      packages/ckeditor5-ui/tests/dropdown/dropdownview.js
  20. 58 5
      packages/ckeditor5-ui/tests/editableui/editableuiview.js
  21. 12 1
      packages/ckeditor5-ui/tests/editorui/boxed/boxededitoruiview.js
  22. 20 1
      packages/ckeditor5-ui/tests/editorui/editoruiview.js
  23. 4 0
      packages/ckeditor5-ui/tests/manual/blocktoolbar/blocktoolbar.html
  24. 4 0
      packages/ckeditor5-ui/tests/toolbar/block/blockbuttonview.js
  25. 40 3
      packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js
  26. 10 0
      packages/ckeditor5-ui/theme/mixins/_dir.css

+ 10 - 2
packages/ckeditor5-ui/docs/features/blocktoolbar.md

@@ -5,12 +5,12 @@ category: features
 
 The {@link module:ui/toolbar/block/blocktoolbar~BlockToolbar} plugin provides an additional [configurable](#configuration) toolbar on the left-hand side of the content area (the gutter). The toolbar is represented by a button with a pilcrow (or a paragraph mark — ¶). It is positioned next to the block element that the selection is anchored to (e.g. a paragraph), following the caret as the user edits the content and navigates the document.
 
-The block toolbar comes in handy when the main editor toolbar cannot be accessed. It complements the {@link builds/guides/overview#balloon-editor Balloon editor} when it falls short, for example when some content must be inserted (like an image) but the selection is collapsed, leaving the user unable to access the toolbar.
+The block toolbar comes in handy when the main editor toolbar cannot be accessed. It complements the {@link builds/guides/overview#balloon-editor Balloon editor} when it falls short, for example when some content must be inserted (like an image) but the selection is collapsed, leaving the user unable to access the toolbar. You can read more about it in the {@link builds/guides/overview#balloon-block-editor balloon block editor overview}.
 
 ## Example
 
 <info-box hint>
-	Move the caret around the content with the balloon editor below to see the block toolbar button following the selection. Click the button (&#182;) to use the toolbar, for example to create a header or insert an image.
+	Move the caret around the content with the balloon editor below to see the block toolbar button following the selection. Click the button (&#182;) to use the toolbar, for example, to create a header or insert an image. See the {@link examples/builds/balloon-block-editor balloon block editor example} page, too.
 </info-box>
 
 {@snippet features/blocktoolbar}
@@ -31,6 +31,14 @@ To adjust the position of the block toolbar button to match the styles of your w
 }
 ```
 
+If you plan to run the editor in a right–to–left (RTL) language, keep in mind the button will be attached to the **right** boundary of the editable area. In that case, make sure the CSS position adjustment works properly by adding the following styles:
+
+```css
+.ck[dir="rtl"] .ck-block-toolbar-button {
+	transform: translateX( 10px );
+}
+```
+
 ## Installation
 
 <info-box hint>

+ 8 - 0
packages/ckeditor5-ui/src/button/button.jsdoc

@@ -96,6 +96,14 @@
  */
 
 /**
+ * Controls whether the button view is a toggle button (two–state) for assistive technologies.
+ *
+ * @observable
+ * @default false
+ * @member {Boolean} #isToggleable
+ */
+
+/**
  * (Optional) Controls whether the label of the button is hidden (e.g. an icon–only button).
  *
  * @observable

+ 2 - 1
packages/ckeditor5-ui/src/button/buttonview.js

@@ -52,6 +52,7 @@ export default class ButtonView extends View {
 		this.set( 'isEnabled', true );
 		this.set( 'isOn', false );
 		this.set( 'isVisible', true );
+		this.set( 'isToggleable', false );
 		this.set( 'keystroke' );
 		this.set( 'label' );
 		this.set( 'tabindex', -1 );
@@ -132,7 +133,7 @@ export default class ButtonView extends View {
 				tabindex: bind.to( 'tabindex' ),
 				'aria-labelledby': `ck-editor__aria-label_${ ariaLabelUid }`,
 				'aria-disabled': bind.if( 'isEnabled', true, value => !value ),
-				'aria-pressed': bind.if( 'isOn', true )
+				'aria-pressed': bind.to( 'isOn', value => this.isToggleable ? String( value ) : false )
 			},
 
 			children: this.children,

+ 2 - 0
packages/ckeditor5-ui/src/button/switchbuttonview.js

@@ -35,6 +35,8 @@ export default class SwitchButtonView extends ButtonView {
 	constructor( locale ) {
 		super( locale );
 
+		this.isToggleable = true;
+
 		/**
 		 * The toggle switch of the button.
 		 *

+ 4 - 0
packages/ckeditor5-ui/src/colorgrid/colorgridview.js

@@ -90,6 +90,10 @@ export default class ColorGridView extends View {
 			}
 		} );
 
+		this.items.on( 'add', ( evt, colorTile ) => {
+			colorTile.isOn = colorTile.color === this.selectedColor;
+		} );
+
 		colorDefinitions.forEach( item => {
 			const colorTile = new ColorTileView();
 

+ 5 - 1
packages/ckeditor5-ui/src/dropdown/button/splitbuttonview.js

@@ -50,6 +50,7 @@ export default class SplitButtonView extends View {
 		this.set( 'icon' );
 		this.set( 'isEnabled', true );
 		this.set( 'isOn', false );
+		this.set( 'isToggleable', false );
 		this.set( 'isVisible', true );
 		this.set( 'keystroke' );
 		this.set( 'label' );
@@ -173,6 +174,7 @@ export default class SplitButtonView extends View {
 			'icon',
 			'isEnabled',
 			'isOn',
+			'isToggleable',
 			'keystroke',
 			'label',
 			'tabindex',
@@ -202,13 +204,15 @@ export default class SplitButtonView extends View {
 	 */
 	_createArrowView() {
 		const arrowView = new ButtonView();
+		const bind = arrowView.bindTemplate;
 
 		arrowView.icon = dropdownArrowIcon;
 
 		arrowView.extendTemplate( {
 			attributes: {
 				class: 'ck-splitbutton__arrow',
-				'aria-haspopup': true
+				'aria-haspopup': true,
+				'aria-expanded': bind.to( 'isOn', value => String( value ) )
 			}
 		} );
 

+ 28 - 9
packages/ckeditor5-ui/src/dropdown/dropdownview.js

@@ -253,18 +253,11 @@ export default class DropdownView extends View {
 			// If "auto", find the best position of the panel to fit into the viewport.
 			// Otherwise, simply assign the static position.
 			if ( this.panelPosition === 'auto' ) {
-				const defaultPanelPositions = DropdownView.defaultPanelPositions;
-
-				this.panelView.position = getOptimalPosition( {
+				this.panelView.position = DropdownView._getOptimalPosition( {
 					element: this.panelView.element,
 					target: this.buttonView.element,
 					fitInViewport: true,
-					positions: [
-						defaultPanelPositions.southEast,
-						defaultPanelPositions.southWest,
-						defaultPanelPositions.northEast,
-						defaultPanelPositions.northWest
-					]
+					positions: this._panelPositions
 				} ).name;
 			} else {
 				this.panelView.position = this.panelPosition;
@@ -312,6 +305,24 @@ export default class DropdownView extends View {
 	focus() {
 		this.buttonView.focus();
 	}
+
+	/**
+	 * Returns {@link #panelView panel} positions to be used by the
+	 * {@link module:utils/dom/position~getOptimalPosition `getOptimalPosition()`}
+	 * utility considering the direction of the language the UI of the editor is displayed in.
+	 *
+	 * @type {module:utils/dom/position~Options#positions}
+	 * @private
+	 */
+	get _panelPositions() {
+		const { southEast, southWest, northEast, northWest } = DropdownView.defaultPanelPositions;
+
+		if ( this.locale.uiLanguageDirection === 'ltr' ) {
+			return [ southEast, southWest, northEast, northWest ];
+		} else {
+			return [ southWest, southEast, northWest, northEast ];
+		}
+	}
 }
 
 /**
@@ -392,3 +403,11 @@ DropdownView.defaultPanelPositions = {
 		};
 	}
 };
+
+/**
+ * A function used to calculate the optimal position for the dropdown panel.
+ *
+ * @protected
+ * @member {Function} module:ui/dropdown/dropdownview~DropdownView._getOptimalPosition
+ */
+DropdownView._getOptimalPosition = getOptimalPosition;

+ 3 - 1
packages/ckeditor5-ui/src/editableui/editableuiview.js

@@ -34,7 +34,9 @@ export default class EditableUIView extends View {
 					'ck-content',
 					'ck-editor__editable',
 					'ck-rounded-corners'
-				]
+				],
+				lang: locale.contentLanguage,
+				dir: locale.contentLanguageDirection
 			}
 		} );
 

+ 2 - 2
packages/ckeditor5-ui/src/editorui/boxed/boxededitoruiview.js

@@ -66,8 +66,8 @@ export default class BoxedEditorUIView extends EditorUIView {
 					'ck-rounded-corners'
 				],
 				role: 'application',
-				dir: 'ltr',
-				lang: locale.language,
+				dir: locale.uiLanguageDirection,
+				lang: locale.uiLanguage,
 				'aria-labelledby': `ck-editor__aria-label_${ ariaLabelUid }`
 			},
 

+ 3 - 1
packages/ckeditor5-ui/src/editorui/editoruiview.js

@@ -69,6 +69,7 @@ export default class EditorUIView extends View {
 	 * @private
 	 */
 	_renderBodyCollection() {
+		const locale = this.locale;
 		const bodyElement = this._bodyCollectionContainer = new Template( {
 			tag: 'div',
 			attributes: {
@@ -77,7 +78,8 @@ export default class EditorUIView extends View {
 					'ck-reset_all',
 					'ck-body',
 					'ck-rounded-corners'
-				]
+				],
+				dir: locale.uiLanguageDirection,
 			},
 			children: this.body
 		} ).render();

+ 3 - 3
packages/ckeditor5-ui/src/template.js

@@ -38,7 +38,7 @@ const xhtmlNs = 'http://www.w3.org/1999/xhtml';
  *			},
  *			on: {
  *				click: bind.to( 'clicked' )
- *			}
+ *			},
  *			children: [
  *				'A paragraph.'
  *			]
@@ -802,8 +802,8 @@ export default class Template {
 	 *
 	 * @protected
 	 * @param {HTMLElement|Text} node A node to be reverted.
-	 * @param {module:ui/template~RenderData#revertData} revertData Stores information about
-	 * what changes have been made by {@link #apply} to the node.
+	 * @param {Object} revertData An object that stores information about what changes have been made by
+	 * {@link #apply} to the node. See {@link module:ui/template~RenderData#revertData} for more information.
 	 */
 	_revertTemplateFromNode( node, revertData ) {
 		for ( const binding of revertData.bindings ) {

+ 2 - 0
packages/ckeditor5-ui/src/toolbar/block/blockbuttonview.js

@@ -34,6 +34,8 @@ export default class BlockButtonView extends ButtonView {
 		// Hide button on init.
 		this.isVisible = false;
 
+		this.isToggleable = true;
+
 		/**
 		 * Top offset.
 		 *

+ 18 - 2
packages/ckeditor5-ui/src/toolbar/block/blocktoolbar.js

@@ -53,6 +53,14 @@ import iconPilcrow from '@ckeditor/ckeditor5-core/theme/icons/pilcrow.svg';
  * 		      |  block of content that the button is
  * 		      |  attached to.
  *
+ * **Note**: If you plan to run the editor in a right–to–left (RTL) language, keep in mind the button
+ * will be attached to the **right** boundary of the editable area. In that case, make sure the
+ * CSS position adjustment works properly by adding the following styles:
+ *
+ * 		.ck[dir="rtl"] .ck-block-toolbar-button {
+ * 			transform: translateX( 10px );
+ * 		}
+ *
  * @extends module:core/plugin~Plugin
  */
 export default class BlockToolbar extends Plugin {
@@ -361,9 +369,17 @@ export default class BlockToolbar extends Plugin {
 			target: targetElement,
 			positions: [
 				( contentRect, buttonRect ) => {
+					let left;
+
+					if ( this.editor.locale.uiLanguageDirection === 'ltr' ) {
+						left = editableRect.left - buttonRect.width;
+					} else {
+						left = editableRect.right;
+					}
+
 					return {
-						top: contentRect.top + contentPaddingTop + ( ( contentLineHeight - buttonRect.height ) / 2 ),
-						left: editableRect.left - buttonRect.width
+						top: contentRect.top + contentPaddingTop + ( contentLineHeight - buttonRect.height ) / 2,
+						left
 					};
 				}
 			]

+ 4 - 4
packages/ckeditor5-ui/src/viewcollection.js

@@ -139,13 +139,13 @@ export default class ViewCollection extends Collection {
 	 *
 	 *		viewA.fire( 'eventY', customData );
 	 *
-	 * See {@link module:utils/emittermixin~EmitterMixin#delegate}.
+	 * See {@link module:utils/emittermixin~Emitter#delegate}.
 	 *
 	 * @param {...String} events {@link module:ui/view~View} event names to be delegated to another
 	 * {@link module:utils/emittermixin~Emitter}.
 	 * @returns {Object}
 	 * @returns {Function} return.to A function which accepts the destination of
-	 * {@link module:utils/emittermixin~EmitterMixin#delegate delegated} events.
+	 * {@link module:utils/emittermixin~Emitter#delegate delegated} events.
 	 */
 	delegate( ...events ) {
 		if ( !events.length || !isStringArray( events ) ) {
@@ -162,11 +162,11 @@ export default class ViewCollection extends Collection {
 
 		return {
 			/**
-			 * Selects destination for {@link module:utils/emittermixin~EmitterMixin#delegate} events.
+			 * Selects destination for {@link module:utils/emittermixin~Emitter#delegate} events.
 			 *
 			 * @memberOf module:ui/viewcollection~ViewCollection#delegate
 			 * @function module:ui/viewcollection~ViewCollection#delegate.to
-			 * @param {module:utils/emittermixin~EmitterMixin} dest An `EmitterMixin` instance which is
+			 * @param {module:utils/emittermixin~Emitter} dest An `Emitter` instance which is
 			 * the destination for delegated events.
 			 */
 			to: dest => {

+ 10 - 1
packages/ckeditor5-ui/tests/button/buttonview.js

@@ -245,11 +245,20 @@ describe( 'ButtonView', () => {
 			} );
 
 			it( '-pressed reacts to #isOn', () => {
+				view.isToggleable = true;
 				view.isOn = true;
 				expect( view.element.attributes[ 'aria-pressed' ].value ).to.equal( 'true' );
 
 				view.isOn = false;
-				expect( view.element.attributes[ 'aria-pressed' ] ).to.be.undefined;
+				expect( view.element.attributes[ 'aria-pressed' ].value ).to.equal( 'false' );
+			} );
+
+			it( '-pressed is not present for non–toggleable button', () => {
+				view.isOn = true;
+				expect( view.element.hasAttribute( 'aria-pressed' ) ).to.be.false;
+
+				view.isOn = false;
+				expect( view.element.hasAttribute( 'aria-pressed' ) ).to.be.false;
 			} );
 		} );
 

+ 4 - 0
packages/ckeditor5-ui/tests/button/switchbuttonview.js

@@ -24,6 +24,10 @@ describe( 'SwitchButtonView', () => {
 		it( 'sets CSS class', () => {
 			expect( view.element.classList.contains( 'ck-switchbutton' ) ).to.be.true;
 		} );
+
+		it( 'sets isToggleable flag to true', () => {
+			expect( view.isToggleable ).to.be.true;
+		} );
 	} );
 
 	describe( 'render', () => {

+ 17 - 0
packages/ckeditor5-ui/tests/colorgrid/colorgridview.js

@@ -106,6 +106,23 @@ describe( 'ColorGridView', () => {
 			expect( view.items.get( 2 ).isOn ).to.be.false;
 		} );
 
+		it( 'should determine #isOn value when a ColorTileView is added', () => {
+			view.selectedColor = 'gold';
+
+			const tile = new ColorTileView();
+			tile.set( {
+				color: 'gold',
+				label: 'Gold',
+				options: {
+					hasBorder: false
+				}
+			} );
+
+			view.items.add( tile );
+
+			expect( view.items.get( 3 ).isOn ).to.be.true;
+		} );
+
 		describe( 'add colors from definition as child items', () => {
 			it( 'has proper number of elements', () => {
 				expect( view.items.length ).to.equal( 3 );

+ 16 - 0
packages/ckeditor5-ui/tests/dropdown/button/splitbuttonview.js

@@ -30,6 +30,14 @@ describe( 'SplitButtonView', () => {
 			expect( view.actionView.element.classList.contains( 'ck-splitbutton__action' ) ).to.be.true;
 		} );
 
+		it( 'adds isToggleable to view#actionView', () => {
+			expect( view.actionView.isToggleable ).to.be.false;
+
+			view.isToggleable = true;
+
+			expect( view.actionView.isToggleable ).to.be.true;
+		} );
+
 		it( 'creates view#arrowView', () => {
 			expect( view.arrowView ).to.be.instanceOf( ButtonView );
 			expect( view.arrowView.element.classList.contains( 'ck-splitbutton__arrow' ) ).to.be.true;
@@ -62,6 +70,14 @@ describe( 'SplitButtonView', () => {
 			expect( view.element.classList.contains( 'ck-splitbutton_open' ) ).to.be.false;
 		} );
 
+		it( 'binds arrowView aria-expanded attribute to #isOn', () => {
+			view.arrowView.isOn = true;
+			expect( view.arrowView.element.getAttribute( 'aria-expanded' ) ).to.equal( 'true' );
+
+			view.arrowView.isOn = false;
+			expect( view.arrowView.element.getAttribute( 'aria-expanded' ) ).to.equal( 'false' );
+		} );
+
 		describe( 'activates keyboard navigation for the toolbar', () => {
 			it( 'so "arrowright" on view#arrowView does nothing', () => {
 				const keyEvtData = {

+ 88 - 82
packages/ckeditor5-ui/tests/dropdown/dropdownview.js

@@ -10,7 +10,6 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import ButtonView from '../../src/button/buttonview';
 import DropdownPanelView from '../../src/dropdown/dropdownpanelview';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
 describe( 'DropdownView', () => {
@@ -19,7 +18,10 @@ describe( 'DropdownView', () => {
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
-		locale = { t() {} };
+		locale = {
+			uiLanguageDirection: 'ltr',
+			t() {}
+		};
 
 		buttonView = new ButtonView( locale );
 		panelView = new DropdownPanelView( locale );
@@ -116,7 +118,7 @@ describe( 'DropdownView', () => {
 			} );
 
 			describe( 'view.panelView#position to view#panelPosition', () => {
-				it( 'does not update until the dropdown is opened', () => {
+				it( 'does not update until the dropdown is open', () => {
 					view.isOpen = false;
 					view.panelPosition = 'nw';
 
@@ -128,86 +130,37 @@ describe( 'DropdownView', () => {
 				} );
 
 				describe( 'in "auto" mode', () => {
-					beforeEach( () => {
-						// Bloat the panel a little to give the positioning algorithm something to
-						// work with. If the panel was empty, any smart positioning is pointless.
-						// Placing an empty element in the viewport isn't that hard, right?
-						panelView.element.style.width = '200px';
-						panelView.element.style.height = '200px';
-					} );
-
-					it( 'defaults to "south-east" when there is a plenty of space around', () => {
-						const windowRect = new Rect( global.window );
-
-						// "Put" the dropdown in the middle of the viewport.
-						stubElementClientRect( view.buttonView.element, {
-							top: windowRect.height / 2,
-							left: windowRect.width / 2,
-							width: 10,
-							height: 10
-						} );
-
-						view.isOpen = true;
-
-						expect( panelView.position ).to.equal( 'se' );
-					} );
-
-					it( 'when the dropdown in the north-west corner of the viewport', () => {
-						stubElementClientRect( view.buttonView.element, {
-							top: 0,
-							left: 0,
-							width: 100,
-							height: 10
-						} );
+					it( 'uses _getOptimalPosition() and a dedicated set of positions (LTR)', () => {
+						const spy = testUtils.sinon.spy( DropdownView, '_getOptimalPosition' );
+						const { southEast, southWest, northEast, northWest } = DropdownView.defaultPanelPositions;
 
 						view.isOpen = true;
 
-						expect( panelView.position ).to.equal( 'se' );
+						sinon.assert.calledWithExactly( spy, sinon.match( {
+							element: panelView.element,
+							target: buttonView.element,
+							positions: [
+								southEast, southWest, northEast, northWest
+							],
+							fitInViewport: true
+						} ) );
 					} );
 
-					it( 'when the dropdown in the north-east corner of the viewport', () => {
-						const windowRect = new Rect( global.window );
-
-						stubElementClientRect( view.buttonView.element, {
-							top: 0,
-							left: windowRect.right - 100,
-							width: 100,
-							height: 10
-						} );
+					it( 'uses _getOptimalPosition() and a dedicated set of positions (RTL)', () => {
+						const spy = testUtils.sinon.spy( DropdownView, '_getOptimalPosition' );
+						const { southEast, southWest, northEast, northWest } = DropdownView.defaultPanelPositions;
 
+						view.locale.uiLanguageDirection = 'rtl';
 						view.isOpen = true;
 
-						expect( panelView.position ).to.equal( 'sw' );
-					} );
-
-					it( 'when the dropdown in the south-west corner of the viewport', () => {
-						const windowRect = new Rect( global.window );
-
-						stubElementClientRect( view.buttonView.element, {
-							top: windowRect.bottom - 10,
-							left: 0,
-							width: 100,
-							height: 10
-						} );
-
-						view.isOpen = true;
-
-						expect( panelView.position ).to.equal( 'ne' );
-					} );
-
-					it( 'when the dropdown in the south-east corner of the viewport', () => {
-						const windowRect = new Rect( global.window );
-
-						stubElementClientRect( view.buttonView.element, {
-							top: windowRect.bottom - 10,
-							left: windowRect.right - 100,
-							width: 100,
-							height: 10
-						} );
-
-						view.isOpen = true;
-
-						expect( panelView.position ).to.equal( 'nw' );
+						sinon.assert.calledWithExactly( spy, sinon.match( {
+							element: panelView.element,
+							target: buttonView.element,
+							positions: [
+								southWest, southEast, northWest, northEast
+							],
+							fitInViewport: true
+						} ) );
 					} );
 				} );
 			} );
@@ -372,13 +325,66 @@ describe( 'DropdownView', () => {
 			sinon.assert.calledOnce( spy );
 		} );
 	} );
-} );
 
-function stubElementClientRect( element, data ) {
-	const clientRect = Object.assign( {}, data );
+	describe( 'DropdownView.defaultPanelPositions', () => {
+		let positions, buttonRect, panelRect;
+
+		beforeEach( () => {
+			positions = DropdownView.defaultPanelPositions;
+
+			buttonRect = {
+				top: 100,
+				bottom: 200,
+				left: 100,
+				right: 200,
+				width: 100,
+				height: 100
+			};
+
+			panelRect = {
+				top: 0,
+				bottom: 0,
+				left: 0,
+				right: 0,
+				width: 50,
+				height: 50
+			};
+		} );
+
+		it( 'should have a proper length', () => {
+			expect( Object.keys( positions ) ).to.have.length( 4 );
+		} );
+
+		it( 'should define the "southEast" position', () => {
+			expect( positions.southEast( buttonRect, panelRect ) ).to.deep.equal( {
+				top: 200,
+				left: 100,
+				name: 'se'
+			} );
+		} );
+
+		it( 'should define the "southWest" position', () => {
+			expect( positions.southWest( buttonRect, panelRect ) ).to.deep.equal( {
+				top: 200,
+				left: 150,
+				name: 'sw'
+			} );
+		} );
 
-	clientRect.right = clientRect.left + clientRect.width;
-	clientRect.bottom = clientRect.top + clientRect.height;
+		it( 'should define the "northEast" position', () => {
+			expect( positions.northEast( buttonRect, panelRect ) ).to.deep.equal( {
+				top: 50,
+				left: 100,
+				name: 'ne'
+			} );
+		} );
 
-	testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( clientRect );
-}
+		it( 'should define the "northWest" position', () => {
+			expect( positions.northWest( buttonRect, panelRect ) ).to.deep.equal( {
+				top: 150,
+				left: 150,
+				name: 'nw'
+			} );
+		} );
+	} );
+} );

+ 58 - 5
packages/ckeditor5-ui/tests/editableui/editableuiview.js

@@ -18,7 +18,7 @@ describe( 'EditableUIView', () => {
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
-		locale = new Locale( 'en' );
+		locale = new Locale();
 		editableElement = document.createElement( 'div' );
 
 		editingView = new EditingView();
@@ -31,14 +31,21 @@ describe( 'EditableUIView', () => {
 		view.render();
 	} );
 
+	afterEach( () => {
+		view.destroy();
+		editableElement.remove();
+	} );
+
 	describe( 'constructor()', () => {
 		it( 'sets initial values of attributes', () => {
-			view = new EditableUIView( locale, editingView );
+			const view = new EditableUIView( locale, editingView );
 
 			expect( view.isFocused ).to.be.false;
 			expect( view.name ).to.be.null;
 			expect( view._externalElement ).to.be.undefined;
 			expect( view._editingView ).to.equal( editingView );
+
+			view.destroy();
 		} );
 
 		it( 'renders element from template when no editableElement', () => {
@@ -47,22 +54,58 @@ describe( 'EditableUIView', () => {
 			expect( view.element.classList.contains( 'ck-content' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-editor__editable' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
+			expect( view.element.getAttribute( 'lang' ) ).to.equal( 'en' );
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'ltr' );
 			expect( view._externalElement ).to.be.undefined;
 			expect( view.isRendered ).to.be.true;
 		} );
 
 		it( 'accepts editableElement as an argument', () => {
-			view = new EditableUIView( locale, editingView, editableElement );
+			const view = new EditableUIView( locale, editingView, editableElement );
 			view.name = editingViewRoot.rootName;
 
 			view.render();
+
 			expect( view.element ).to.equal( editableElement );
 			expect( view.element ).to.equal( view._editableElement );
 			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-editor__editable' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
+			expect( view.element.getAttribute( 'lang' ) ).to.equal( 'en' );
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'ltr' );
 			expect( view._hasExternalElement ).to.be.true;
 			expect( view.isRendered ).to.be.true;
+
+			view.destroy();
+		} );
+
+		it( 'sets proper lang and dir attributes (implicit content language)', () => {
+			const locale = new Locale( { uiLanguage: 'ar' } );
+			const view = new EditableUIView( locale, editingView );
+			view.name = editingViewRoot.rootName;
+
+			view.render();
+
+			expect( view.element.getAttribute( 'lang' ) ).to.equal( 'ar' );
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'rtl' );
+
+			view.destroy();
+		} );
+
+		it( 'sets proper lang and dir attributes (explicit content language)', () => {
+			const locale = new Locale( {
+				uiLanguage: 'pl',
+				contentLanguage: 'ar'
+			} );
+			const view = new EditableUIView( locale, editingView );
+			view.name = editingViewRoot.rootName;
+
+			view.render();
+
+			expect( view.element.getAttribute( 'lang' ) ).to.equal( 'ar' );
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'rtl' );
+
+			view.destroy();
 		} );
 	} );
 
@@ -123,6 +166,7 @@ describe( 'EditableUIView', () => {
 				expect( secondEditingViewRoot.hasClass( 'ck-blurred' ), 12 ).to.be.false;
 
 				secondEditableElement.remove();
+				secondView.destroy();
 			} );
 		} );
 	} );
@@ -130,12 +174,21 @@ describe( 'EditableUIView', () => {
 	describe( 'destroy()', () => {
 		it( 'calls super#destroy()', () => {
 			const spy = testUtils.sinon.spy( View.prototype, 'destroy' );
+			const view = new EditableUIView( locale, editingView );
+			view.name = editingViewRoot.rootName;
 
+			view.render();
 			view.destroy();
+
 			sinon.assert.calledOnce( spy );
 		} );
 
 		it( 'can be called multiple times', () => {
+			const view = new EditableUIView( locale, editingView );
+			view.name = editingViewRoot.rootName;
+
+			view.render();
+
 			expect( () => {
 				view.destroy();
 				view.destroy();
@@ -144,11 +197,11 @@ describe( 'EditableUIView', () => {
 
 		describe( 'when #editableElement as an argument', () => {
 			it( 'reverts the template of editableElement', () => {
-				editableElement = document.createElement( 'div' );
+				const editableElement = document.createElement( 'div' );
 				editableElement.classList.add( 'foo' );
 				editableElement.contentEditable = false;
 
-				view = new EditableUIView( locale, editingView, editableElement );
+				const view = new EditableUIView( locale, editingView, editableElement );
 				view.name = editingViewRoot.rootName;
 
 				view.render();

+ 12 - 1
packages/ckeditor5-ui/tests/editorui/boxed/boxededitoruiview.js

@@ -11,7 +11,7 @@ describe( 'BoxedEditorUIView', () => {
 	let view, element;
 
 	beforeEach( () => {
-		view = new BoxedEditorUIView( new Locale( 'en' ) );
+		view = new BoxedEditorUIView( new Locale() );
 		view.render();
 		element = view.element;
 	} );
@@ -31,6 +31,7 @@ describe( 'BoxedEditorUIView', () => {
 			expect( view.element.classList.contains( 'ck-editor' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-reset' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'ltr' );
 			expect( element.attributes[ 'aria-labelledby' ].value )
 				.to.equal( view.element.firstChild.id )
 				.to.match( /^ck-editor__aria-label_\w+$/ );
@@ -59,5 +60,15 @@ describe( 'BoxedEditorUIView', () => {
 			expect( element.childNodes[ 1 ].attributes.getNamedItem( 'role' ).value ).to.equal( 'presentation' );
 			expect( element.childNodes[ 2 ].attributes.getNamedItem( 'role' ).value ).to.equal( 'presentation' );
 		} );
+
+		it( 'sets the proper "dir" attribute value when using RTL language', () => {
+			const view = new BoxedEditorUIView( new Locale( { uiLanguage: 'ar' } ) );
+
+			view.render();
+
+			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'rtl' );
+
+			view.destroy();
+		} );
 	} );
 } );

+ 20 - 1
packages/ckeditor5-ui/tests/editorui/editoruiview.js

@@ -16,7 +16,7 @@ describe( 'EditorUIView', () => {
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
-		locale = new Locale( 'en' );
+		locale = new Locale();
 		view = new EditorUIView( locale );
 
 		view.render();
@@ -46,6 +46,25 @@ describe( 'EditorUIView', () => {
 			expect( el.classList.contains( 'ck-rounded-corners' ) ).to.be.true;
 			expect( el.classList.contains( 'ck-reset_all' ) ).to.be.true;
 		} );
+
+		it( 'sets the right dir attribute to the body region (LTR)', () => {
+			const el = view._bodyCollectionContainer;
+
+			expect( el.getAttribute( 'dir' ) ).to.equal( 'ltr' );
+		} );
+
+		it( 'sets the right dir attribute to the body region (RTL)', () => {
+			const locale = new Locale( { uiLanguage: 'ar' } );
+			const view = new EditorUIView( locale );
+
+			view.render();
+
+			const el = view._bodyCollectionContainer;
+
+			expect( el.getAttribute( 'dir' ) ).to.equal( 'rtl' );
+
+			view.destroy();
+		} );
 	} );
 
 	describe( 'destroy()', () => {

+ 4 - 0
packages/ckeditor5-ui/tests/manual/blocktoolbar/blocktoolbar.html

@@ -56,4 +56,8 @@
 	.ck-block-toolbar-button {
 		transform: translateX( -10px );
 	}
+
+	[dir="rtl"] .ck-block-toolbar-button {
+		transform: translateX( 10px );
+	}
 </style>

+ 4 - 0
packages/ckeditor5-ui/tests/toolbar/block/blockbuttonview.js

@@ -22,6 +22,10 @@ describe( 'BlockButtonView', () => {
 		expect( view.element.classList.contains( 'ck-block-toolbar-button' ) ).to.be.true;
 	} );
 
+	it( 'should be initialized as toggleable button', () => {
+		expect( view.isToggleable ).to.be.true;
+	} );
+
 	describe( 'DOM binding', () => {
 		it( 'should react on `view#top` change', () => {
 			view.top = 0;

+ 40 - 3
packages/ckeditor5-ui/tests/toolbar/block/blocktoolbar.js

@@ -227,14 +227,14 @@ describe( 'BlockToolbar', () => {
 		} );
 
 		it( 'should display the button when the first selected block is an object', () => {
-			setData( editor.model, '[<image src="foo.jpg"><caption>foo</caption></image>]' );
+			setData( editor.model, '[<image src="/assets/sample.png"><caption>foo</caption></image>]' );
 
 			expect( blockToolbar.buttonView.isVisible ).to.be.true;
 		} );
 
 		// This test makes no sense now, but so do all other tests here (see https://github.com/ckeditor/ckeditor5/issues/1522).
 		it( 'should not display the button when the selection is inside a limit element', () => {
-			setData( editor.model, '<image src="foo.jpg"><caption>f[]oo</caption></image>' );
+			setData( editor.model, '<image src="/assets/sample.png"><caption>f[]oo</caption></image>' );
 
 			expect( blockToolbar.buttonView.isVisible ).to.be.false;
 		} );
@@ -258,7 +258,7 @@ describe( 'BlockToolbar', () => {
 			} ).then( editor => {
 				const blockToolbar = editor.plugins.get( BlockToolbar );
 
-				setData( editor.model, '[<image src="foo.jpg"></image>]' );
+				setData( editor.model, '[<image src="/assets/sample.png"></image>]' );
 
 				expect( blockToolbar.buttonView.isVisible ).to.be.false;
 
@@ -345,6 +345,43 @@ describe( 'BlockToolbar', () => {
 			expect( blockToolbar.buttonView.left ).to.equal( 100 );
 		} );
 
+		it( 'should attach the left side of the button to the right side of the editable when language direction is RTL', () => {
+			editor.locale.uiLanguageDirection = 'rtl';
+
+			setData( editor.model, '<paragraph>foo[]bar</paragraph>' );
+
+			const target = editor.ui.getEditableElement().querySelector( 'p' );
+			const styleMock = testUtils.sinon.stub( window, 'getComputedStyle' );
+
+			styleMock.withArgs( target ).returns( {
+				lineHeight: 'normal',
+				fontSize: '20px',
+				paddingTop: '10px'
+			} );
+
+			styleMock.callThrough();
+
+			testUtils.sinon.stub( editor.ui.getEditableElement(), 'getBoundingClientRect' ).returns( {
+				left: 200,
+				right: 600
+			} );
+
+			testUtils.sinon.stub( target, 'getBoundingClientRect' ).returns( {
+				top: 500,
+				left: 300
+			} );
+
+			testUtils.sinon.stub( blockToolbar.buttonView.element, 'getBoundingClientRect' ).returns( {
+				width: 100,
+				height: 100
+			} );
+
+			editor.ui.fire( 'update' );
+
+			expect( blockToolbar.buttonView.top ).to.equal( 472 );
+			expect( blockToolbar.buttonView.left ).to.equal( 600 );
+		} );
+
 		it( 'should reposition the #panelView when open on ui#update', () => {
 			blockToolbar.panelView.isVisible = false;
 

+ 10 - 0
packages/ckeditor5-ui/theme/mixins/_dir.css

@@ -0,0 +1,10 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+@define-mixin ck-dir $direction {
+	@nest [dir="$(direction)"] & {
+		@mixin-content;
+	}
+}