Преглед на файлове

Merge pull request #232 from ckeditor/i/6112

Feature: Implemented the table properties form. Closes ckeditor/ckeditor5#6112.
Maciej преди 5 години
родител
ревизия
534872217c
променени са 24 файла, в които са добавени 2578 реда и са изтрити 315 реда
  1. 7 1
      packages/ckeditor5-table/lang/contexts.json
  2. 1 1
      packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js
  3. 41 124
      packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js
  4. 3 2
      packages/ckeditor5-table/src/tableproperties.js
  5. 280 0
      packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js
  6. 636 0
      packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js
  7. 90 0
      packages/ckeditor5-table/src/ui/formheaderview.js
  8. 2 0
      packages/ckeditor5-table/src/ui/formrowview.js
  9. 133 11
      packages/ckeditor5-table/src/ui/utils.js
  10. 1 1
      packages/ckeditor5-table/tests/manual/tableproperties.js
  11. 6 0
      packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js
  12. 4 3
      packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js
  13. 3 3
      packages/ckeditor5-table/tests/tableproperties/tablepropertiesediting.js
  14. 354 0
      packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js
  15. 547 0
      packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js
  16. 98 0
      packages/ckeditor5-table/tests/ui/formheaderview.js
  17. 4 0
      packages/ckeditor5-table/tests/ui/formrowview.js
  18. 253 37
      packages/ckeditor5-table/tests/ui/utils.js
  19. 5 65
      packages/ckeditor5-table/theme/form.css
  20. 10 0
      packages/ckeditor5-table/theme/formheader.css
  21. 23 0
      packages/ckeditor5-table/theme/formrow.css
  22. 0 67
      packages/ckeditor5-table/theme/tablecellproperties.css
  23. 36 0
      packages/ckeditor5-table/theme/tableform.css
  24. 41 0
      packages/ckeditor5-table/theme/tableproperties.css

+ 7 - 1
packages/ckeditor5-table/lang/contexts.json

@@ -25,9 +25,12 @@
 	"Color": "The label for the input that allows configuring the border color of a table or a table cell.",
 	"Background": "The label for the input that allows configuring the background color of a table or a table cell.",
 	"Padding": "The label for the input that allows configuring the padding of a table cell.",
+	"Dimensions": "The label describing a group of form fields that allows setting dimensions of a table or a table cell.",
 	"Text alignment": "The label for the group of toolbars that allows configuring the text alignment in a table cell.",
+	"Alignment": "The label for the toolbar that allows configuring the alignment of a table.",
 	"Horizontal text alignment toolbar": "The label used by assistive technologies describing a toolbar that allows configuring the horizontal text alignment in a table cell.",
 	"Vertical text alignment toolbar": "The label used by assistive technologies describing a toolbar that allows configuring the vertical text alignment in a table cell.",
+	"Table alignment toolbar": "The label used by assistive technologies describing a toolbar that allows configuring the alignment of a table.",
 	"Save": "The label for the button that saves the changes made to the table or table cell properties.",
 	"Cancel": "The label for the button that rejects the changes made to the table or table cell properties.",
 	"None": "The label for the border style dropdown when no style is applied to a table or a table cell.",
@@ -45,5 +48,8 @@
 	"Justify cell text": "The label used by assistive technologies describing a button that justifies the table cell text.",
 	"Align cell text to the top": "The label used by assistive technologies describing a button that aligns the table cell text to the top.",
 	"Align cell text to the middle": "The label used by assistive technologies describing a button that aligns the table cell text to the middle.",
-	"Align cell text to the bottom": "The label used by assistive technologies describing a button that aligns the table cell text to the bottom."
+	"Align cell text to the bottom": "The label used by assistive technologies describing a button that aligns the table cell text to the bottom.",
+	"Align table to the left": "The label used by assistive technologies describing a button that aligns the table to the left.",
+	"Center table": "The label used by assistive technologies describing a button that centers the table.",
+	"Align table to the right": "The label used by assistive technologies describing a button that aligns the table to the right."
 }

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js

@@ -136,7 +136,7 @@ export default class TableCellPropertiesUI extends Plugin {
 			if ( !getTableWidgetAncestor( viewDocument.selection ) ) {
 				this._hideView();
 			} else if ( this._isViewVisible ) {
-				repositionContextualBalloon( editor );
+				repositionContextualBalloon( editor, 'cell' );
 			}
 		} );
 

+ 41 - 124
packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js

@@ -8,8 +8,6 @@
  */
 
 import View from '@ckeditor/ckeditor5-ui/src/view';
-import Model from '@ckeditor/ckeditor5-ui/src/model';
-import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
 import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
 
@@ -18,11 +16,14 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
 
 import LabeledView from '@ckeditor/ckeditor5-ui/src/labeledview/labeledview';
-import { createLabeledInputText, createLabeledDropdown } from '@ckeditor/ckeditor5-ui/src/labeledview/utils';
+import { createLabeledDropdown, createLabeledInputText } from '@ckeditor/ckeditor5-ui/src/labeledview/utils';
 import LabelView from '@ckeditor/ckeditor5-ui/src/label/labelview';
 import { addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
 import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import { fillToolbar, getBorderStyleDefinitions, getBorderStyleLabels } from '../../ui/utils';
+import FormRowView from '../../ui/formrowview';
+import FormHeaderView from '../../ui/formheaderview';
 
 import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
 import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
@@ -35,8 +36,8 @@ import alignMiddleIcon from '@ckeditor/ckeditor5-core/theme/icons/align-middle.s
 import alignBottomIcon from '@ckeditor/ckeditor5-core/theme/icons/align-bottom.svg';
 
 import '../../../theme/form.css';
+import '../../../theme/tableform.css';
 import '../../../theme/tablecellproperties.css';
-import FormRowView from '../../ui/formrowview';
 
 const ALIGNMENT_ICONS = {
 	left: alignLeftIcon,
@@ -254,7 +255,9 @@ export default class TableCellPropertiesView extends View {
 		} );
 
 		// Form header.
-		this.children.add( this._createHeaderView() );
+		this.children.add( new FormHeaderView( locale, {
+			label: this.t( 'Cell properties' )
+		} ) );
 
 		// Border row.
 		this.children.add( new FormRowView( locale, {
@@ -265,7 +268,7 @@ export default class TableCellPropertiesView extends View {
 				borderColorInput,
 				borderWidthInput
 			],
-			class: 'ck-table-cell-properties-form__border-row'
+			class: 'ck-table-form__border-row'
 		} ) );
 
 		// Background and padding row.
@@ -302,6 +305,7 @@ export default class TableCellPropertiesView extends View {
 				class: [
 					'ck',
 					'ck-form',
+					'ck-table-form',
 					'ck-table-cell-properties-form'
 				],
 				// https://github.com/ckeditor/ckeditor5-link/issues/90
@@ -353,34 +357,6 @@ export default class TableCellPropertiesView extends View {
 	}
 
 	/**
-	 * Creates the header of the form with a localized label.
-	 *
-	 * @private
-	 * @returns {module:ui/view~View}
-	 */
-	_createHeaderView() {
-		const locale = this.locale;
-		const t = this.t;
-
-		const headerView = new View( locale );
-
-		headerView.setTemplate( {
-			tag: 'div',
-			attributes: {
-				class: [
-					'ck',
-					'ck-form__header'
-				]
-			},
-			children: [
-				t( 'Cell properties' )
-			]
-		} );
-
-		return headerView;
-	}
-
-	/**
 	 * Creates the following form fields:
 	 *
 	 * * {@link #borderStyleDropdown},
@@ -401,10 +377,11 @@ export default class TableCellPropertiesView extends View {
 
 		// -- Style ---------------------------------------------------
 
+		const styleLabels = getBorderStyleLabels( t );
 		const borderStyleDropdown = new LabeledView( locale, createLabeledDropdown );
 		borderStyleDropdown.set( {
 			label: t( 'Style' ),
-			class: 'ck-table-cell-properties-form__border-style'
+			class: 'ck-table-form__border-style'
 		} );
 
 		borderStyleDropdown.view.buttonView.set( {
@@ -414,14 +391,14 @@ export default class TableCellPropertiesView extends View {
 		} );
 
 		borderStyleDropdown.view.buttonView.bind( 'label' ).to( this, 'borderStyle', value => {
-			return this._borderStyleLabels[ value ];
+			return styleLabels[ value ];
 		} );
 
 		borderStyleDropdown.view.on( 'execute', evt => {
 			this.borderStyle = evt.source._borderStyleValue;
 		} );
 
-		addListToDropdown( borderStyleDropdown.view, this._getBorderStyleDefinitions() );
+		addListToDropdown( borderStyleDropdown.view, getBorderStyleDefinitions( this ) );
 
 		// -- Width ---------------------------------------------------
 
@@ -429,7 +406,7 @@ export default class TableCellPropertiesView extends View {
 
 		borderWidthInput.set( {
 			label: t( 'Width' ),
-			class: 'ck-table-cell-properties-form__border-width',
+			class: 'ck-table-form__border-width',
 		} );
 
 		borderWidthInput.view.bind( 'value' ).to( this, 'borderWidth' );
@@ -535,14 +512,36 @@ export default class TableCellPropertiesView extends View {
 		// -- Horizontal ---------------------------------------------------
 
 		const horizontalAlignmentToolbar = new ToolbarView( locale );
-		horizontalAlignmentToolbar.ariaLabel = t( 'Horizontal text alignment toolbar' );
-		this._fillAlignmentToolbar( horizontalAlignmentToolbar, this._horizontalAlignmentLabels, 'horizontalAlignment' );
+
+		horizontalAlignmentToolbar.set( {
+			isCompact: true,
+			ariaLabel: t( 'Horizontal text alignment toolbar' )
+		} );
+
+		fillToolbar( {
+			view: this,
+			icons: ALIGNMENT_ICONS,
+			toolbar: horizontalAlignmentToolbar,
+			labels: this._horizontalAlignmentLabels,
+			propertyName: 'horizontalAlignment'
+		} );
 
 		// -- Vertical -----------------------------------------------------
 
 		const verticalAlignmentToolbar = new ToolbarView( locale );
-		verticalAlignmentToolbar.ariaLabel = t( 'Vertical text alignment toolbar' );
-		this._fillAlignmentToolbar( verticalAlignmentToolbar, this._verticalAlignmentLabels, 'verticalAlignment' );
+
+		verticalAlignmentToolbar.set( {
+			isCompact: true,
+			ariaLabel: t( 'Vertical text alignment toolbar' )
+		} );
+
+		fillToolbar( {
+			view: this,
+			icons: ALIGNMENT_ICONS,
+			toolbar: verticalAlignmentToolbar,
+			labels: this._verticalAlignmentLabels,
+			propertyName: 'verticalAlignment'
+		} );
 
 		return {
 			horizontalAlignmentToolbar,
@@ -591,88 +590,6 @@ export default class TableCellPropertiesView extends View {
 	}
 
 	/**
-	 * Provides a set of {@link #borderStyleDropdown} item definitions.
-	 *
-	 * @private
-	 * @returns {Iterable.<module:ui/dropdown/utils~ListDropdownItemDefinition>}
-	 */
-	_getBorderStyleDefinitions() {
-		const itemDefinitions = new Collection();
-
-		for ( const style in this._borderStyleLabels ) {
-			const definition = {
-				type: 'button',
-				model: new Model( {
-					_borderStyleValue: style,
-					label: this._borderStyleLabels[ style ],
-					withText: true,
-				} )
-			};
-
-			definition.model.bind( 'isOn' ).to( this, 'borderStyle', value => {
-				return value === style;
-			} );
-
-			itemDefinitions.add( definition );
-		}
-
-		return itemDefinitions;
-	}
-
-	/**
-	 * Fills an alignment (either horizontal or vertical) with buttons
-	 * that have certain labels and interact with a certain view property
-	 * upon execution.
-	 *
-	 * @private
-	 * @param {module:ui/toolbar/toolbarview~ToolbarView} toolbar
-	 * @param {Array.<String>} labels
-	 * @param {String} propertyName
-	 */
-	_fillAlignmentToolbar( toolbar, labels, propertyName ) {
-		for ( const alignment in labels ) {
-			const button = new ButtonView( this.locale );
-
-			button.set( {
-				label: labels[ alignment ],
-				icon: ALIGNMENT_ICONS[ alignment ],
-			} );
-
-			button.bind( 'isOn' ).to( this, propertyName, value => {
-				return value === alignment;
-			} );
-
-			button.on( 'execute', () => {
-				this[ propertyName ] = alignment;
-			} );
-
-			toolbar.items.add( button );
-		}
-	}
-
-	/**
-	 * Provides localized labels for {@link #borderStyleDropdown} items.
-	 *
-	 * @private
-	 * @type {Object.<String,String>}
-	 */
-	get _borderStyleLabels() {
-		const t = this.t;
-
-		return {
-			none: t( 'None' ),
-			solid: t( 'Solid' ),
-			dotted: t( 'Dotted' ),
-			dashed: t( 'Dashed' ),
-			double: t( 'Double' ),
-			groove: t( 'Groove' ),
-			ridge: t( 'Ridge' ),
-			inset: t( 'Inset' ),
-			outset: t( 'Outset' ),
-		};
-	}
-
-	/**
 	 * Provides localized labels for {@link #horizontalAlignmentToolbar} buttons.
 	 *
 	 * @private

+ 3 - 2
packages/ckeditor5-table/src/tableproperties.js

@@ -10,13 +10,14 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 import TablePropertiesEditing from './tableproperties/tablepropertiesediting';
+import TablePropertiesUI from './tableproperties/tablepropertiesui';
 
 /**
  * The table properties feature.
  *
  * This is a "glue" plugin which loads the
  * {@link module:table/tableproperties/tablepropertiesediting~TablePropertiesEditing table editing feature} and
- * table UI feature.
+ * the {@link module:table/tableproperties/tablepropertiesui~TablePropertiesUI table properties UI feature}.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -32,6 +33,6 @@ export default class TableProperties extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ TablePropertiesEditing ];
+		return [ TablePropertiesEditing, TablePropertiesUI ];
 	}
 }

+ 280 - 0
packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js

@@ -0,0 +1,280 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/tablepropertiesui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import { getTableWidgetAncestor } from '../utils';
+import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+import TablePropertiesView from './ui/tablepropertiesview';
+import tableProperties from './../../theme/icons/table-properties.svg';
+import {
+	repositionContextualBalloon,
+	getBalloonTablePositionData
+} from '../ui/utils';
+
+const DEFAULT_BORDER_STYLE = 'none';
+const DEFAULT_ALIGNMENT = 'center';
+
+/**
+ * The table properties UI plugin. It introduces the `'tableProperties'` button
+ * that opens a form allowing to specify visual styling of an entire table.
+ *
+ * It uses the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TablePropertiesUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TablePropertiesUI';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		/**
+		 * The contextual balloon plugin instance.
+		 *
+		 * @private
+		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
+		 */
+		this._balloon = editor.plugins.get( ContextualBalloon );
+
+		/**
+		 * The properties form view displayed inside the balloon.
+		 *
+		 * @member {module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView}
+		 */
+		this.view = this._createPropertiesView();
+
+		/**
+		 * The batch used to undo all changes made by the form (which are live, as the user types)
+		 * when "Cancel" was pressed. Each time the view is shown, a new batch is created.
+		 *
+		 * @protected
+		 * @member {module:engine/model/batch~Batch}
+		 */
+		this._undoStepBatch = null;
+
+		editor.ui.componentFactory.add( 'tableProperties', locale => {
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: t( 'Table properties' ),
+				icon: tableProperties,
+				tooltip: true
+			} );
+
+			this.listenTo( view, 'execute', () => this._showView() );
+
+			return view;
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		// Destroy created UI components as they are not automatically destroyed.
+		// See https://github.com/ckeditor/ckeditor5/issues/1341.
+		this.view.destroy();
+	}
+
+	/**
+	 * Creates the {@link module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView} instance.
+	 *
+	 * @private
+	 * @returns {module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView} The table
+	 * properties form view instance.
+	 */
+	_createPropertiesView() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view.document;
+		const view = new TablePropertiesView( editor.locale );
+
+		// Render the view so its #element is available for the clickOutsideHandler.
+		view.render();
+
+		this.listenTo( view, 'submit', () => {
+			this._hideView();
+		} );
+
+		this.listenTo( view, 'cancel', () => {
+			editor.execute( 'undo', this._undoStepBatch );
+			this._hideView();
+		} );
+
+		// Close the balloon on Esc key press.
+		view.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hideView();
+			cancel();
+		} );
+
+		// Reposition the balloon or hide the form if a table is no longer selected.
+		this.listenTo( editor.ui, 'update', () => {
+			if ( !getTableWidgetAncestor( viewDocument.selection ) ) {
+				this._hideView();
+			} else if ( this._isViewVisible ) {
+				repositionContextualBalloon( editor, 'table' );
+			}
+		} );
+
+		// Close on click outside of balloon panel element.
+		clickOutsideHandler( {
+			emitter: view,
+			activator: () => this._isViewInBalloon,
+			contextElements: [ this._balloon.view.element ],
+			callback: () => this._hideView()
+		} );
+
+		// Create the "UI -> editor data" binding.
+		// These listeners update the editor data (via table commands) when any observable
+		// property of the view has changed. This makes the view live, which means the changes are
+		// visible in the editing as soon as the user types or changes fields' values.
+		view.on( 'change:borderStyle', this._getPropertyChangeCallback( 'tableBorderStyle' ) );
+		view.on( 'change:borderColor', this._getPropertyChangeCallback( 'tableBorderColor' ) );
+		view.on( 'change:borderWidth', this._getPropertyChangeCallback( 'tableBorderWidth' ) );
+		view.on( 'change:backgroundColor', this._getPropertyChangeCallback( 'tableBackgroundColor' ) );
+		view.on( 'change:width', this._getPropertyChangeCallback( 'tableWidth' ) );
+		view.on( 'change:height', this._getPropertyChangeCallback( 'tableHeight' ) );
+		view.on( 'change:alignment', this._getPropertyChangeCallback( 'tableAlignment' ) );
+
+		return view;
+	}
+
+	/**
+	 * In this method the "editor data -> UI" binding is happening.
+	 *
+	 * When executed, this method obtains selected table property values from various table commands
+	 * and passes them to the {@link #view}.
+	 *
+	 * This way, the UI stays up–to–date with the editor data.
+	 *
+	 * @private
+	 */
+	_fillViewFormFromCommandValues() {
+		const commands = this.editor.commands;
+
+		this.view.set( {
+			borderStyle: commands.get( 'tableBorderStyle' ).value || DEFAULT_BORDER_STYLE,
+			borderColor: commands.get( 'tableBorderColor' ).value || '',
+			borderWidth: commands.get( 'tableBorderWidth' ).value || '',
+			backgroundColor: commands.get( 'tableBackgroundColor' ).value || '',
+			width: commands.get( 'tableWidth' ).value || '',
+			height: commands.get( 'tableHeight' ).value || '',
+			alignment: commands.get( 'tableAlignment' ).value || DEFAULT_ALIGNMENT,
+		} );
+	}
+
+	/**
+	 * Shows the {@link #view} in the {@link #_balloon}.
+	 *
+	 * **Note**: Each time a view is shown, the new {@link #_undoStepBatch} is created that contains
+	 * all changes made to the document when the view is visible, allowing a single undo step
+	 * for all of them.
+	 *
+	 * @protected
+	 */
+	_showView() {
+		const editor = this.editor;
+
+		this._balloon.add( {
+			view: this.view,
+			position: getBalloonTablePositionData( editor )
+		} );
+
+		// Create a new batch. Clicking "Cancel" will undo this batch.
+		this._undoStepBatch = editor.model.createBatch();
+
+		// Update the view with the model values.
+		this._fillViewFormFromCommandValues();
+
+		// Basic a11y.
+		this.view.focus();
+	}
+
+	/**
+	 * Removes the {@link #view} from the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_hideView() {
+		if ( !this._isViewInBalloon ) {
+			return;
+		}
+
+		const editor = this.editor;
+
+		this.stopListening( editor.ui, 'update' );
+
+		// Blur any input element before removing it from DOM to prevent issues in some browsers.
+		// See https://github.com/ckeditor/ckeditor5/issues/1501.
+		this.view.saveButtonView.focus();
+
+		this._balloon.remove( this.view );
+
+		// Make sure the focus is not lost in the process by putting it directly
+		// into the editing view.
+		this.editor.editing.view.focus();
+	}
+
+	/**
+	 * Returns `true` when the {@link #view} is the visible in the {@link #_balloon}.
+	 *
+	 * @private
+	 * @type {Boolean}
+	 */
+	get _isViewVisible() {
+		return this._balloon.visibleView === this.view;
+	}
+
+	/**
+	 * Returns `true` when the {@link #view} is in the {@link #_balloon}.
+	 *
+	 * @private
+	 * @type {Boolean}
+	 */
+	get _isViewInBalloon() {
+		return this._balloon.hasView( this.view );
+	}
+
+	/**
+	 * Creates a callback that when executed upon {@link #view view's} property change
+	 * executes a related editor command with the new property value.
+	 *
+	 * @private
+	 * @param {String} commandName
+	 * @returns {Function}
+	 */
+	_getPropertyChangeCallback( commandName ) {
+		return ( evt, propertyName, newValue ) => {
+			this.editor.execute( commandName, {
+				value: newValue,
+				batch: this._undoStepBatch
+			} );
+		};
+	}
+}

+ 636 - 0
packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js

@@ -0,0 +1,636 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/ui/tablepropertiesview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
+
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+
+import LabeledView from '@ckeditor/ckeditor5-ui/src/labeledview/labeledview';
+import { createLabeledDropdown, createLabeledInputText } from '@ckeditor/ckeditor5-ui/src/labeledview/utils';
+import LabelView from '@ckeditor/ckeditor5-ui/src/label/labelview';
+import { addListToDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import { fillToolbar, getBorderStyleDefinitions, getBorderStyleLabels } from '../../ui/utils';
+import FormRowView from '../../ui/formrowview';
+import FormHeaderView from '../../ui/formheaderview';
+
+import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
+import objectLeftIcon from '@ckeditor/ckeditor5-core/theme/icons/object-left.svg';
+import objectRightIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg';
+import objectCenterIcon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg';
+
+import '../../../theme/form.css';
+import '../../../theme/tableform.css';
+import '../../../theme/tableproperties.css';
+
+const ALIGNMENT_ICONS = {
+	left: objectLeftIcon,
+	center: objectCenterIcon,
+	right: objectRightIcon
+};
+
+/**
+ * The class representing a table properties form, allowing users to customize
+ * certain style aspects of a table, for instance, border, background color, alignment, etc..
+ *
+ * @extends module:ui/view~View
+ */
+export default class TablePropertiesView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const { borderStyleDropdown, borderWidthInput, borderColorInput, borderRowLabel } = this._createBorderFields();
+		const { widthInput, operatorLabel, heightInput, dimensionsLabel } = this._createDimensionFields();
+		const { alignmentToolbar, alignmentLabel } = this._createAlignmentFields();
+		const { saveButtonView, cancelButtonView } = this._createActionButtons();
+
+		/**
+		 * Tracks information about the DOM focus in the form.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * A collection of child views in the form.
+		 *
+		 * @readonly
+		 * @type {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this.createCollection();
+
+		this.set( {
+			/**
+			 * The value of the border style.
+			 *
+			 * @observable
+			 * @default 'none'
+			 * @member #borderStyle
+			 */
+			borderStyle: 'none',
+
+			/**
+			 * The value of the border width style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #borderWidth
+			 */
+			borderWidth: '',
+
+			/**
+			 * The value of the border color style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #borderColor
+			 */
+			borderColor: '',
+
+			/**
+			 * The value of the background color style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #backgroundColor
+			 */
+			backgroundColor: '',
+
+			/**
+			 * The value of the table width style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #width
+			 */
+			width: '',
+
+			/**
+			 * The value of the table height style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #height
+			 */
+			height: '',
+
+			/**
+			 * The value of the table alignment style.
+			 *
+			 * @observable
+			 * @default 'center'
+			 * @member #alignment
+			 */
+			alignment: 'center',
+		} );
+
+		/**
+		 * A dropdown that allows selecting the style of the table border.
+		 *
+		 * @readonly
+		 * @member {module:ui/dropdown/dropdownview~DropdownView}
+		 */
+		this.borderStyleDropdown = borderStyleDropdown;
+
+		/**
+		 * An input that allows specifying the width of the table border.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.borderWidthInput = borderWidthInput;
+
+		/**
+		 * An input that allows specifying the color of the table border.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.borderColorInput = borderColorInput;
+
+		/**
+		 * An input that allows specifying the table background color.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.backgroundInput = this._createBackgroundField();
+
+		/**
+		 * An input that allows specifying the table width.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.widthInput = widthInput;
+
+		/**
+		 * An input that allows specifying the table height.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.heightInput = heightInput;
+
+		/**
+		 * A toolbar with buttons that allow changing the alignment of an entire table.
+		 *
+		 * @readonly
+		 * @member {module:ui/toolbar/toolbar~ToolbarView}
+		 */
+		this.alignmentToolbar = alignmentToolbar;
+
+		/**
+		 * The "Save" button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.saveButtonView = saveButtonView;
+
+		/**
+		 * The "Cancel" button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.cancelButtonView = cancelButtonView;
+
+		/**
+		 * A collection of views that can be focused in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate form fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate form fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		// Form header.
+		this.children.add( new FormHeaderView( locale, {
+			label: this.t( 'Table properties' )
+		} ) );
+
+		// Border row.
+		this.children.add( new FormRowView( locale, {
+			labelView: borderRowLabel,
+			children: [
+				borderRowLabel,
+				borderStyleDropdown,
+				borderColorInput,
+				borderWidthInput
+			],
+			class: 'ck-table-form__border-row'
+		} ) );
+
+		// Background row.
+		this.children.add( new FormRowView( locale, {
+			children: [
+				this.backgroundInput
+			]
+		} ) );
+
+		this.children.add( new FormRowView( locale, {
+			children: [
+				// Dimensions row.
+				new FormRowView( locale, {
+					labelView: dimensionsLabel,
+					children: [
+						dimensionsLabel,
+						widthInput,
+						operatorLabel,
+						heightInput
+					],
+					class: 'ck-table-properties-form__dimensions-row'
+				} ),
+				// Alignment row.
+				new FormRowView( locale, {
+					labelView: alignmentLabel,
+					children: [
+						alignmentLabel,
+						alignmentToolbar
+					],
+					class: 'ck-table-properties-form__alignment-row'
+				} )
+			]
+		} ) );
+
+		// Action row.
+		this.children.add( new FormRowView( locale, {
+			children: [
+				this.saveButtonView,
+				this.cancelButtonView,
+			],
+			class: 'ck-table-form__action-row'
+		} ) );
+
+		this.setTemplate( {
+			tag: 'form',
+			attributes: {
+				class: [
+					'ck',
+					'ck-form',
+					'ck-table-form',
+					'ck-table-properties-form'
+				],
+				// https://github.com/ckeditor/ckeditor5-link/issues/90
+				tabindex: '-1'
+			},
+			children: this.children
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		// Enable the "submit" event for this view. It can be triggered by the #saveButtonView
+		// which is of the "submit" DOM "type".
+		submitHandler( {
+			view: this
+		} );
+
+		[
+			this.borderStyleDropdown,
+			this.borderColorInput,
+			this.borderWidthInput,
+			this.backgroundInput,
+			this.widthInput,
+			this.heightInput,
+			this.alignmentToolbar,
+			this.saveButtonView,
+			this.cancelButtonView,
+		].forEach( view => {
+			// Register the view as focusable.
+			this._focusables.add( view );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( view.element );
+		} );
+
+		// Mainly for closing using "Esc" and navigation using "Tab".
+		this.keystrokes.listenTo( this.element );
+	}
+
+	/**
+	 * Focuses the fist focusable field in the form.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+
+	/**
+	 * Creates the following form fields:
+	 *
+	 * * {@link #borderStyleDropdown},
+	 * * {@link #borderWidthInput},
+	 * * {@link #borderColorInput}.
+	 *
+	 * @private
+	 * @returns {Object.<String,module:ui/view~View>}
+	 */
+	_createBorderFields() {
+		const locale = this.locale;
+		const t = this.t;
+
+		// -- Group label ---------------------------------------------
+
+		const borderRowLabel = new LabelView( locale );
+		borderRowLabel.text = t( 'Border' );
+
+		// -- Style ---------------------------------------------------
+
+		const styleLabels = getBorderStyleLabels( this.t );
+		const borderStyleDropdown = new LabeledView( locale, createLabeledDropdown );
+		borderStyleDropdown.set( {
+			label: t( 'Style' ),
+			class: 'ck-table-form__border-style'
+		} );
+
+		borderStyleDropdown.view.buttonView.set( {
+			isOn: false,
+			withText: true,
+			tooltip: t( 'Style' )
+		} );
+
+		borderStyleDropdown.view.buttonView.bind( 'label' ).to( this, 'borderStyle', value => {
+			return styleLabels[ value ];
+		} );
+
+		borderStyleDropdown.view.on( 'execute', evt => {
+			this.borderStyle = evt.source._borderStyleValue;
+		} );
+
+		addListToDropdown( borderStyleDropdown.view, getBorderStyleDefinitions( this ) );
+
+		// -- Width ---------------------------------------------------
+
+		const borderWidthInput = new LabeledView( locale, createLabeledInputText );
+
+		borderWidthInput.set( {
+			label: t( 'Width' ),
+			class: 'ck-table-form__border-width',
+		} );
+
+		borderWidthInput.view.bind( 'value' ).to( this, 'borderWidth' );
+		borderWidthInput.bind( 'isEnabled' ).to( this, 'borderStyle', value => {
+			return value !== 'none';
+		} );
+		borderWidthInput.view.on( 'input', () => {
+			this.borderWidth = borderWidthInput.view.element.value;
+		} );
+
+		// -- Color ---------------------------------------------------
+
+		const borderColorInput = new LabeledView( locale, createLabeledInputText );
+		borderColorInput.label = t( 'Color' );
+		borderColorInput.view.bind( 'value' ).to( this, 'borderColor' );
+		borderColorInput.bind( 'isEnabled' ).to( this, 'borderStyle', value => {
+			return value !== 'none';
+		} );
+
+		borderColorInput.view.on( 'input', () => {
+			this.borderColor = borderColorInput.view.element.value;
+		} );
+
+		return {
+			borderRowLabel,
+			borderStyleDropdown,
+			borderColorInput,
+			borderWidthInput,
+		};
+	}
+
+	/**
+	 * Creates the following form fields:
+	 *
+	 * * {@link #backgroundInput}.
+	 *
+	 * @private
+	 * @returns {module:ui/labeledview/labeledview~LabeledView}
+	 */
+	_createBackgroundField() {
+		const locale = this.locale;
+		const t = this.t;
+
+		const backgroundInput = new LabeledView( locale, createLabeledInputText );
+
+		backgroundInput.set( {
+			label: t( 'Background' ),
+			class: 'ck-table-properties-form__background',
+		} );
+
+		backgroundInput.view.bind( 'value' ).to( this, 'backgroundColor' );
+		backgroundInput.view.on( 'input', () => {
+			this.backgroundColor = backgroundInput.view.element.value;
+		} );
+
+		return backgroundInput;
+	}
+
+	/**
+	 * Creates the following form fields:
+	 *
+	 * * {@link #widthInput}.
+	 * * {@link #heightInput}.
+	 *
+	 * @private
+	 * @returns {module:ui/labeledview/labeledview~LabeledView}
+	 */
+	_createDimensionFields() {
+		const locale = this.locale;
+		const t = this.t;
+
+		// -- Label ---------------------------------------------------
+
+		const dimensionsLabel = new LabelView( locale );
+		dimensionsLabel.text = t( 'Dimensions' );
+
+		// -- Width ---------------------------------------------------
+
+		const widthInput = new LabeledView( locale, createLabeledInputText );
+
+		widthInput.set( {
+			label: t( 'Width' ),
+			class: 'ck-table-properties-form__width',
+		} );
+
+		widthInput.view.bind( 'value' ).to( this, 'width' );
+		widthInput.view.on( 'input', () => {
+			this.width = widthInput.view.element.value;
+		} );
+
+		// -- Operator ---------------------------------------------------
+
+		const operatorLabel = new View( locale );
+		operatorLabel.setTemplate( {
+			tag: 'span',
+			attributes: {
+				class: [
+					'ck-table-properties-form__dimension-operator'
+				]
+			},
+			children: [
+				{ text: '×' }
+			]
+		} );
+
+		// -- Height ---------------------------------------------------
+
+		const heightInput = new LabeledView( locale, createLabeledInputText );
+
+		heightInput.set( {
+			label: t( 'Height' ),
+			class: 'ck-table-properties-form__height',
+		} );
+
+		heightInput.view.bind( 'value' ).to( this, 'height' );
+		heightInput.view.on( 'input', () => {
+			this.height = heightInput.view.element.value;
+		} );
+
+		return {
+			dimensionsLabel,
+			widthInput,
+			operatorLabel,
+			heightInput
+		};
+	}
+
+	/**
+	 * Creates the following form fields:
+	 *
+	 * * {@link #alignmentToolbar},
+	 *
+	 * @private
+	 * @returns {Object.<String,module:ui/view~View>}
+	 */
+	_createAlignmentFields() {
+		const locale = this.locale;
+		const t = this.t;
+
+		// -- Label ---------------------------------------------------
+
+		const alignmentLabel = new LabelView( locale );
+		alignmentLabel.text = t( 'Alignment' );
+
+		// -- Toolbar ---------------------------------------------------
+
+		const alignmentToolbar = new ToolbarView( locale );
+		alignmentToolbar.set( {
+			isCompact: true,
+			ariaLabel: t( 'Table alignment toolbar' )
+		} );
+
+		fillToolbar( {
+			view: this,
+			icons: ALIGNMENT_ICONS,
+			toolbar: alignmentToolbar,
+			labels: this._alignmentLabels,
+			propertyName: 'alignment'
+		} );
+
+		return {
+			alignmentLabel,
+			alignmentToolbar
+		};
+	}
+
+	/**
+	 * Creates the following form controls:
+	 *
+	 * * {@link #saveButtonView},
+	 * * {@link #cancelButtonView}.
+	 *
+	 * @private
+	 * @returns {Object.<String,module:ui/view~View>}
+	 */
+	_createActionButtons() {
+		const locale = this.locale;
+		const t = this.t;
+
+		const saveButtonView = new ButtonView( locale );
+		const cancelButtonView = new ButtonView( locale );
+
+		saveButtonView.set( {
+			label: t( 'Save' ),
+			icon: checkIcon,
+			class: 'ck-button-save',
+			type: 'submit',
+			withText: true,
+		} );
+
+		cancelButtonView.set( {
+			label: t( 'Cancel' ),
+			icon: cancelIcon,
+			class: 'ck-button-cancel',
+			type: 'cancel',
+			withText: true,
+		} );
+
+		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
+
+		return {
+			saveButtonView, cancelButtonView
+		};
+	}
+
+	/**
+	 * Provides localized labels for {@link #alignmentToolbar} buttons.
+	 *
+	 * @private
+	 * @type {Object.<String,String>}
+	 */
+	get _alignmentLabels() {
+		const t = this.t;
+
+		return {
+			left: t( 'Align table to the left' ),
+			center: t( 'Center table' ),
+			right: t( 'Align table to the right' )
+		};
+	}
+}

+ 90 - 0
packages/ckeditor5-table/src/ui/formheaderview.js

@@ -0,0 +1,90 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/ui/formheaderview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+import '../../theme/formheader.css';
+
+/**
+ * The class representing a header in a complex form.
+ *
+ * **Note**: For now this class is private. When more use cases arrive (beyond ckeditor5-table),
+ * it will become a component in ckeditor5-ui.
+ *
+ * @private
+ * @extends module:ui/view~View
+ */
+export default class FormHeaderView extends View {
+	/**
+	 * Creates an instance of the form header view class.
+	 *
+	 * @param {module:utils/locale~Locale} locale The locale instance.
+	 * @param {Object} [options]
+	 * @param {String} [options.class] When passed, the class will be set on the header element.
+	 * @param {String} [options.label] When passed, the label will be used for the header.
+	 */
+	constructor( locale, options = {} ) {
+		super( locale );
+
+		const bind = this.bindTemplate;
+
+		/**
+		 * A collection of header items.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this.createCollection();
+
+		/**
+		 * An additional CSS class added to the {@link #element}.
+		 *
+		 * @observable
+		 * @member {String} #class
+		 */
+		this.set( 'class', options.class || null );
+
+		/**
+		 * Label of the header.
+		 *
+		 * @readonly
+		 * @member {String} #label
+		 */
+		this.set( 'label', options.label || '' );
+
+		const label = new View( locale );
+
+		label.setTemplate( {
+			tag: 'span',
+			attributes: {
+				class: [
+					'ck',
+					'ck-form__header__label'
+				],
+			},
+			children: [
+				{ text: bind.to( 'label' ) }
+			]
+		} );
+
+		this.children.add( label );
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-form__header',
+					bind.to( 'class' )
+				]
+			},
+			children: this.children
+		} );
+	}
+}

+ 2 - 0
packages/ckeditor5-table/src/ui/formrowview.js

@@ -9,6 +9,8 @@
 
 import View from '@ckeditor/ckeditor5-ui/src/view';
 
+import '../../theme/formrow.css';
+
 /**
  * The class representing a single row in the complex form,
  * used by {@link module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView}.

+ 133 - 11
packages/ckeditor5-table/src/ui/utils.js

@@ -8,21 +8,42 @@
  */
 
 import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import Model from '@ckeditor/ckeditor5-ui/src/model';
 import { getTableWidgetAncestor } from '../utils';
 import { findAncestor } from '../commands/utils';
 
+const DEFAULT_BALLOON_POSITIONS = BalloonPanelView.defaultPositions;
+const BALLOON_POSITIONS = [
+	DEFAULT_BALLOON_POSITIONS.northArrowSouth,
+	DEFAULT_BALLOON_POSITIONS.northArrowSouthWest,
+	DEFAULT_BALLOON_POSITIONS.northArrowSouthEast,
+	DEFAULT_BALLOON_POSITIONS.southArrowNorth,
+	DEFAULT_BALLOON_POSITIONS.southArrowNorthWest,
+	DEFAULT_BALLOON_POSITIONS.southArrowNorthEast
+];
+
 /**
  * A helper utility that positions the
  * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} instance
  * with respect to the table in the editor content, if one is selected.
  *
  * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @param {String} target Either "cell" or "table". Determines the the target the balloon will
+ * be attached to.
  */
-export function repositionContextualBalloon( editor ) {
+export function repositionContextualBalloon( editor, target ) {
 	const balloon = editor.plugins.get( 'ContextualBalloon' );
 
 	if ( getTableWidgetAncestor( editor.editing.view.document.selection ) ) {
-		const position = getBalloonCellPositionData( editor );
+		let position;
+
+		if ( target === 'cell' ) {
+			position = getBalloonCellPositionData( editor );
+		} else {
+			position = getBalloonTablePositionData( editor );
+		}
 
 		balloon.updatePosition( position );
 	}
@@ -31,6 +52,25 @@ export function repositionContextualBalloon( editor ) {
 /**
  * Returns the positioning options that control the geometry of the
  * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect
+ * to the selected table in the editor content.
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @returns {module:utils/dom/position~Options}
+ */
+export function getBalloonTablePositionData( editor ) {
+	const firstPosition = editor.model.document.selection.getFirstPosition();
+	const modelTable = findAncestor( 'table', firstPosition );
+	const viewTable = editor.editing.mapper.toViewElement( modelTable );
+
+	return {
+		target: editor.editing.view.domConverter.viewToDom( viewTable ),
+		positions: BALLOON_POSITIONS
+	};
+}
+
+/**
+ * Returns the positioning options that control the geometry of the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect
  * to the selected table cell in the editor content.
  *
  * @param {module:core/editor/editor~Editor} editor The editor instance.
@@ -40,17 +80,99 @@ export function getBalloonCellPositionData( editor ) {
 	const firstPosition = editor.model.document.selection.getFirstPosition();
 	const modelTableCell = findAncestor( 'tableCell', firstPosition );
 	const viewTableCell = editor.editing.mapper.toViewElement( modelTableCell );
-	const defaultPositions = BalloonPanelView.defaultPositions;
 
 	return {
 		target: editor.editing.view.domConverter.viewToDom( viewTableCell ),
-		positions: [
-			defaultPositions.northArrowSouth,
-			defaultPositions.northArrowSouthWest,
-			defaultPositions.northArrowSouthEast,
-			defaultPositions.southArrowNorth,
-			defaultPositions.southArrowNorthWest,
-			defaultPositions.southArrowNorthEast
-		]
+		positions: BALLOON_POSITIONS
+	};
+}
+
+/**
+ * Returns an object containing pairs of CSS border style values and their localized UI
+ * labels. Used by {@link module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView}
+ * and {@link module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView}.
+ *
+ * @param {module:utils/locale~Locale#t} t The "t" function provided by the editor
+ * that is used to localize strings.
+ * @returns {Object.<String,String>}
+ */
+export function getBorderStyleLabels( t ) {
+	return {
+		none: t( 'None' ),
+		solid: t( 'Solid' ),
+		dotted: t( 'Dotted' ),
+		dashed: t( 'Dashed' ),
+		double: t( 'Double' ),
+		groove: t( 'Groove' ),
+		ridge: t( 'Ridge' ),
+		inset: t( 'Inset' ),
+		outset: t( 'Outset' ),
 	};
 }
+
+/**
+ * Generates item definitions for a UI dropdown that allows changing the border style of a table or a table cell.
+ *
+ * @param {module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView|
+ * module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView}
+ * @returns {Iterable.<module:ui/dropdown/utils~ListDropdownItemDefinition>}
+ */
+export function getBorderStyleDefinitions( view ) {
+	const itemDefinitions = new Collection();
+	const styleLabels = getBorderStyleLabels( view.t );
+
+	for ( const style in styleLabels ) {
+		const definition = {
+			type: 'button',
+			model: new Model( {
+				_borderStyleValue: style,
+				label: styleLabels[ style ],
+				withText: true,
+			} )
+		};
+
+		definition.model.bind( 'isOn' ).to( view, 'borderStyle', value => {
+			return value === style;
+		} );
+
+		itemDefinitions.add( definition );
+	}
+
+	return itemDefinitions;
+}
+
+/**
+ * A helper that fills a toolbar toolbar with buttons that:
+ *
+ * * have some labels,
+ * * have some icons,
+ * * set a certain UI view property value upon execution.
+ *
+ * @param {Object} options
+ * @param {module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView|
+ * module:table/tableproperties/ui/tablepropertiesview~TablePropertiesView} options.view
+ * @param {Array.<String>} options.icons
+ * @param {module:ui/toolbar/toolbarview~ToolbarView} options.toolbar
+ * @param {Object.<String,String>} labels
+ * @param {String} propertyName
+ */
+export function fillToolbar( { view, icons, toolbar, labels, propertyName } ) {
+	for ( const name in labels ) {
+		const button = new ButtonView( view.locale );
+
+		button.set( {
+			label: labels[ name ],
+			icon: icons[ name ],
+		} );
+
+		button.bind( 'isOn' ).to( view, propertyName, value => {
+			return value === name;
+		} );
+
+		button.on( 'execute', () => {
+			view[ propertyName ] = name;
+		} );
+
+		toolbar.items.add( button );
+	}
+}

+ 1 - 1
packages/ckeditor5-table/tests/manual/tableproperties.js

@@ -26,7 +26,7 @@ ClassicEditor
 			'heading', '|', 'insertTable', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo'
 		],
 		table: {
-			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableCellProperties' ],
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ],
 			tableToolbar: [ 'bold', 'italic' ]
 		}
 	} )

+ 6 - 0
packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js

@@ -103,7 +103,13 @@ describe( 'table cell properties', () => {
 		} );
 
 		describe( 'destroy()', () => {
+			it( 'should destroy the #view', () => {
+				const spy = sinon.spy( tableCellPropertiesView, 'destroy' );
 
+				tableCellPropertiesUI.destroy();
+
+				sinon.assert.calledOnce( spy );
+			} );
 		} );
 
 		describe( 'Properties #view', () => {

+ 4 - 3
packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js

@@ -57,6 +57,7 @@ describe( 'table cell properties', () => {
 			it( 'should create element from template', () => {
 				expect( view.element.classList.contains( 'ck' ) ).to.be.true;
 				expect( view.element.classList.contains( 'ck-form' ) ).to.be.true;
+				expect( view.element.classList.contains( 'ck-table-form' ) ).to.be.true;
 				expect( view.element.classList.contains( 'ck-table-cell-properties-form' ) ).to.be.true;
 				expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
 			} );
@@ -88,7 +89,7 @@ describe( 'table cell properties', () => {
 						const row = view.element.childNodes[ 1 ];
 
 						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
-						expect( row.classList.contains( 'ck-table-cell-properties-form__border-row' ) ).to.be.true;
+						expect( row.classList.contains( 'ck-table-form__border-row' ) ).to.be.true;
 						expect( row.childNodes[ 0 ].textContent ).to.equal( 'Border' );
 						expect( row.childNodes[ 1 ] ).to.equal( view.borderStyleDropdown.element );
 						expect( row.childNodes[ 2 ] ).to.equal( view.borderColorInput.element );
@@ -104,7 +105,7 @@ describe( 'table cell properties', () => {
 
 						it( 'should have properties set', () => {
 							expect(	labeledDropdown.label ).to.equal( 'Style' );
-							expect(	labeledDropdown.class ).to.equal( 'ck-table-cell-properties-form__border-style' );
+							expect(	labeledDropdown.class ).to.equal( 'ck-table-form__border-style' );
 						} );
 
 						it( 'should have a button with properties set', () => {
@@ -148,7 +149,7 @@ describe( 'table cell properties', () => {
 						it( 'should be created', () => {
 							expect( labeledInput.view ).to.be.instanceOf( InputTextView );
 							expect( labeledInput.label ).to.equal( 'Width' );
-							expect( labeledInput.class ).to.equal( 'ck-table-cell-properties-form__border-width' );
+							expect( labeledInput.class ).to.equal( 'ck-table-form__border-width' );
 						} );
 
 						it( 'should reflect #borderWidth property', () => {

+ 3 - 3
packages/ckeditor5-table/tests/tableproperties/tablepropertiesediting.js

@@ -7,7 +7,7 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 
 import TableEditing from '../../src/tableediting';
-import TableProperties from '../../src/tableproperties';
+import TablePropertiesEditing from '../../src/tableproperties/tablepropertiesediting';
 
 import TableBorderColorCommand from '../../src/tableproperties/commands/tablebordercolorcommand';
 import TableBorderStyleCommand from '../../src/tableproperties/commands/tableborderstylecommand';
@@ -28,7 +28,7 @@ describe( 'table properties', () => {
 		beforeEach( () => {
 			return VirtualTestEditor
 				.create( {
-					plugins: [ TableProperties, Paragraph, TableEditing ]
+					plugins: [ TablePropertiesEditing, Paragraph, TableEditing ]
 				} )
 				.then( newEditor => {
 					editor = newEditor;
@@ -42,7 +42,7 @@ describe( 'table properties', () => {
 		} );
 
 		it( 'should have pluginName', () => {
-			expect( TableProperties.pluginName ).to.equal( 'TableProperties' );
+			expect( TablePropertiesEditing.pluginName ).to.equal( 'TablePropertiesEditing' );
 		} );
 
 		it( 'adds tableBorderColor command', () => {

+ 354 - 0
packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js

@@ -0,0 +1,354 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document, Event */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Batch from '@ckeditor/ckeditor5-engine/src/model/batch';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+
+import Table from '../../src/table';
+import TablePropertiesEditing from '../../src/tableproperties/tablepropertiesediting';
+import TablePropertiesUI from '../../src/tableproperties/tablepropertiesui';
+import TablePropertiesUIView from '../../src/tableproperties/ui/tablepropertiesview';
+
+describe( 'table properties', () => {
+	describe( 'TablePropertiesUI', () => {
+		let editor, editorElement, contextualBalloon,
+			tablePropertiesUI, tablePropertiesView, tablePropertiesButton;
+
+		testUtils.createSinonSandbox();
+
+		beforeEach( () => {
+			editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			return ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Table, TablePropertiesEditing, TablePropertiesUI, Paragraph, Undo ],
+					initialData: '<table><tr><td>foo</td></tr></table><p>bar</p>'
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+
+					tablePropertiesUI = editor.plugins.get( TablePropertiesUI );
+					tablePropertiesButton = editor.ui.componentFactory.create( 'tableProperties' );
+					contextualBalloon = editor.plugins.get( ContextualBalloon );
+					tablePropertiesView = tablePropertiesUI.view;
+
+					// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
+					testUtils.sinon.stub( contextualBalloon.view, 'attachTo' ).returns( {} );
+					testUtils.sinon.stub( contextualBalloon.view, 'pin' ).returns( {} );
+				} );
+		} );
+
+		afterEach( () => {
+			editorElement.remove();
+
+			return editor.destroy();
+		} );
+
+		it( 'should be named', () => {
+			expect( TablePropertiesUI.pluginName ).to.equal( 'TablePropertiesUI' );
+		} );
+
+		it( 'should load ContextualBalloon', () => {
+			expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon );
+		} );
+
+		describe( 'init()', () => {
+			it( 'should set a batch', () => {
+				expect( tablePropertiesUI._undoStepBatch ).to.be.null;
+			} );
+
+			describe( '#view', () => {
+				it( 'should be created', () => {
+					expect( tablePropertiesUI.view ).to.be.instanceOf( TablePropertiesUIView );
+				} );
+
+				it( 'should be rendered', () => {
+					expect( tablePropertiesUI.view.isRendered ).to.be.true;
+				} );
+			} );
+
+			describe( 'toolbar button', () => {
+				it( 'should be registered', () => {
+					expect( tablePropertiesButton ).to.be.instanceOf( ButtonView );
+				} );
+
+				it( 'should have a label', () => {
+					expect( tablePropertiesButton.label ).to.equal( 'Table properties' );
+				} );
+
+				it( 'should have a tooltip', () => {
+					expect( tablePropertiesButton.tooltip ).to.be.true;
+				} );
+
+				it( 'should call #_showView upon #execute', () => {
+					const spy = testUtils.sinon.stub( tablePropertiesUI, '_showView' ).returns( {} );
+
+					tablePropertiesButton.fire( 'execute' );
+					sinon.assert.calledOnce( spy );
+				} );
+			} );
+		} );
+
+		describe( 'destroy()', () => {
+			it( 'should destroy the #view', () => {
+				const spy = sinon.spy( tablePropertiesView, 'destroy' );
+
+				tablePropertiesUI.destroy();
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+
+		describe( 'Properties #view', () => {
+			beforeEach( () => {
+				editor.model.change( writer => {
+					writer.setSelection( editor.model.document.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 ), 0 );
+				} );
+			} );
+
+			it( 'should hide on #submit', () => {
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				tablePropertiesView.fire( 'submit' );
+				expect( contextualBalloon.visibleView ).to.be.null;
+			} );
+
+			it( 'should undo the entire batch of changes on #cancel', () => {
+				// Show the view. New batch will be created.
+				tablePropertiesButton.fire( 'execute' );
+
+				// Do the changes like a user.
+				tablePropertiesView.borderStyle = 'dotted';
+				tablePropertiesView.backgroundColor = 'red';
+
+				expect( getModelData( editor.model ) ).to.equal(
+					'<table backgroundColor="red" borderStyle="dotted">' +
+						'<tableRow>' +
+							'<tableCell>' +
+								'<paragraph>[]foo</paragraph>' +
+							'</tableCell>' +
+						'</tableRow>' +
+					'</table>' +
+					'<paragraph>bar</paragraph>'
+				);
+
+				tablePropertiesView.fire( 'cancel' );
+
+				expect( getModelData( editor.model ) ).to.equal(
+					'<table>' +
+						'<tableRow>' +
+							'<tableCell>' +
+								'<paragraph>[]foo</paragraph>' +
+							'</tableCell>' +
+						'</tableRow>' +
+					'</table>' +
+					'<paragraph>bar</paragraph>'
+				);
+			} );
+
+			it( 'should hide on #cancel', () => {
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				tablePropertiesView.fire( 'cancel' );
+				expect( contextualBalloon.visibleView ).to.be.null;
+			} );
+
+			it( 'should hide on the Esc key press', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				tablePropertiesView.keystrokes.press( keyEvtData );
+				expect( contextualBalloon.visibleView ).to.be.null;
+			} );
+
+			it( 'should hide if the table is no longer selected on EditorUI#update', () => {
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				editor.model.change( writer => {
+					// Set selection in the paragraph.
+					writer.setSelection( editor.model.document.getRoot().getChild( 1 ), 0 );
+				} );
+
+				expect( contextualBalloon.visibleView ).to.be.null;
+			} );
+
+			it( 'should reposition if table is still selected on on EditorUI#update', () => {
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				editor.model.change( writer => {
+					writer.insertText( 'qux', editor.model.document.selection.getFirstPosition() );
+				} );
+
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+			} );
+
+			it( 'should hide if clicked outside the balloon', () => {
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+				expect( contextualBalloon.visibleView ).to.be.null;
+			} );
+
+			describe( 'property changes', () => {
+				it( 'should affect the editor state', () => {
+					const spy = testUtils.sinon.stub( editor, 'execute' );
+
+					tablePropertiesUI._undoStepBatch = 'foo';
+					tablePropertiesView.borderStyle = 'dotted';
+
+					sinon.assert.calledOnce( spy );
+					sinon.assert.calledWithExactly( spy, 'tableBorderStyle', { value: 'dotted', batch: 'foo' } );
+				} );
+
+				it( 'should not affect the editor state if internal property has changed', () => {
+					const spy = testUtils.sinon.stub( editor, 'execute' );
+
+					tablePropertiesView.set( 'internalProp', 'foo' );
+
+					tablePropertiesUI._undoStepBatch = 'foo';
+					tablePropertiesView.internalProp = 'bar';
+
+					sinon.assert.notCalled( spy );
+				} );
+			} );
+		} );
+
+		describe( 'Showing the #view', () => {
+			beforeEach( () => {
+				editor.model.change( writer => {
+					writer.setSelection( editor.model.document.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 ), 0 );
+				} );
+			} );
+
+			it( 'should create a new undoable batch for further #view cancel', () => {
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				const firstBatch = tablePropertiesUI._undoStepBatch;
+				expect( firstBatch ).to.be.instanceOf( Batch );
+
+				tablePropertiesView.fire( 'submit' );
+				expect( contextualBalloon.visibleView ).to.be.null;
+
+				tablePropertiesButton.fire( 'execute' );
+
+				const secondBatch = tablePropertiesUI._undoStepBatch;
+				expect( secondBatch ).to.be.instanceOf( Batch );
+				expect( firstBatch ).to.not.equal( secondBatch );
+			} );
+
+			describe( 'initial data', () => {
+				it( 'should be set from the command values', () => {
+					editor.commands.get( 'tableBorderStyle' ).value = 'a';
+					editor.commands.get( 'tableBorderColor' ).value = 'b';
+					editor.commands.get( 'tableBorderWidth' ).value = 'c';
+					editor.commands.get( 'tableBackgroundColor' ).value = 'd';
+					editor.commands.get( 'tableWidth' ).value = 'e';
+					editor.commands.get( 'tableHeight' ).value = 'f';
+					editor.commands.get( 'tableAlignment' ).value = 'g';
+
+					tablePropertiesButton.fire( 'execute' );
+
+					expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+					expect( tablePropertiesView ).to.include( {
+						borderStyle: 'a',
+						borderColor: 'b',
+						borderWidth: 'c',
+						backgroundColor: 'd',
+						width: 'e',
+						height: 'f',
+						alignment: 'g'
+					} );
+				} );
+
+				it( 'should use default values when command has no value', () => {
+					editor.commands.get( 'tableBorderStyle' ).value = null;
+					editor.commands.get( 'tableBorderColor' ).value = null;
+					editor.commands.get( 'tableBorderWidth' ).value = null;
+					editor.commands.get( 'tableBackgroundColor' ).value = null;
+					editor.commands.get( 'tableWidth' ).value = null;
+					editor.commands.get( 'tableHeight' ).value = null;
+					editor.commands.get( 'tableAlignment' ).value = null;
+
+					tablePropertiesButton.fire( 'execute' );
+
+					expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+					expect( tablePropertiesView ).to.include( {
+						borderStyle: 'none',
+						borderColor: '',
+						borderWidth: '',
+						backgroundColor: '',
+						width: '',
+						height: '',
+						alignment: 'center'
+					} );
+				} );
+			} );
+
+			it( 'should focus the form view', () => {
+				const spy = testUtils.sinon.spy( tablePropertiesView, 'focus' );
+
+				tablePropertiesButton.fire( 'execute' );
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+
+		describe( 'Hiding the #view', () => {
+			beforeEach( () => {
+				editor.model.change( writer => {
+					writer.setSelection( editor.model.document.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 ), 0 );
+				} );
+			} );
+
+			it( 'should stop listening to EditorUI#update', () => {
+				const spy = testUtils.sinon.spy( tablePropertiesUI, 'stopListening' );
+
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				tablePropertiesView.fire( 'submit' );
+				expect( contextualBalloon.visibleView ).to.be.null;
+
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, editor.ui, 'update' );
+			} );
+
+			it( 'should focus the editing view so the focus is not lost', () => {
+				const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+
+				tablePropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+
+				tablePropertiesView.fire( 'submit' );
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+} );

+ 547 - 0
packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js

@@ -0,0 +1,547 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals Event */
+
+import TablePropertiesView from '../../../src/tableproperties/ui/tablepropertiesview';
+import LabeledView from '@ckeditor/ckeditor5-ui/src/labeledview/labeledview';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
+
+describe( 'table properties', () => {
+	describe( 'TablePropertiesView', () => {
+		let view, locale;
+
+		testUtils.createSinonSandbox();
+
+		beforeEach( () => {
+			locale = { t: val => val };
+			view = new TablePropertiesView( locale );
+			view.render();
+		} );
+
+		afterEach( () => {
+			view.destroy();
+		} );
+
+		describe( 'constructor()', () => {
+			it( 'should set view#locale', () => {
+				expect( view.locale ).to.equal( locale );
+			} );
+
+			it( 'should create view#children collection', () => {
+				expect( view.children ).to.be.instanceOf( ViewCollection );
+			} );
+
+			it( 'should define the public data interface (observable properties)', () => {
+				expect( view ).to.include( {
+					borderStyle: 'none',
+					borderWidth: '',
+					borderColor: '',
+					backgroundColor: '',
+					width: '',
+					height: '',
+					alignment: 'center'
+				} );
+			} );
+
+			it( 'should create element from template', () => {
+				expect( view.element.classList.contains( 'ck' ) ).to.be.true;
+				expect( view.element.classList.contains( 'ck-form' ) ).to.be.true;
+				expect( view.element.classList.contains( 'ck-table-form' ) ).to.be.true;
+				expect( view.element.classList.contains( 'ck-table-properties-form' ) ).to.be.true;
+				expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
+			} );
+
+			it( 'should create child views (and references)', () => {
+				expect( view.borderStyleDropdown ).to.be.instanceOf( LabeledView );
+				expect( view.borderWidthInput ).to.be.instanceOf( LabeledView );
+				expect( view.borderColorInput ).to.be.instanceOf( LabeledView );
+				expect( view.backgroundInput ).to.be.instanceOf( LabeledView );
+				expect( view.widthInput ).to.be.instanceOf( LabeledView );
+				expect( view.heightInput ).to.be.instanceOf( LabeledView );
+				expect( view.alignmentToolbar ).to.be.instanceOf( ToolbarView );
+
+				expect( view.saveButtonView ).to.be.instanceOf( ButtonView );
+				expect( view.cancelButtonView ).to.be.instanceOf( ButtonView );
+			} );
+
+			it( 'should have a header', () => {
+				const header = view.element.firstChild;
+
+				expect( header.classList.contains( 'ck' ) ).to.be.true;
+				expect( header.classList.contains( 'ck-form__header' ) ).to.be.true;
+				expect( header.textContent ).to.equal( 'Table properties' );
+			} );
+
+			describe( 'form rows', () => {
+				describe( 'border row', () => {
+					it( 'should be defined', () => {
+						const row = view.element.childNodes[ 1 ];
+
+						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
+						expect( row.classList.contains( 'ck-table-form__border-row' ) ).to.be.true;
+						expect( row.childNodes[ 0 ].textContent ).to.equal( 'Border' );
+						expect( row.childNodes[ 1 ] ).to.equal( view.borderStyleDropdown.element );
+						expect( row.childNodes[ 2 ] ).to.equal( view.borderColorInput.element );
+						expect( row.childNodes[ 3 ] ).to.equal( view.borderWidthInput.element );
+					} );
+
+					describe( 'border style labeled dropdown', () => {
+						let labeledDropdown;
+
+						beforeEach( () => {
+							labeledDropdown = view.borderStyleDropdown;
+						} );
+
+						it( 'should have properties set', () => {
+							expect(	labeledDropdown.label ).to.equal( 'Style' );
+							expect(	labeledDropdown.class ).to.equal( 'ck-table-form__border-style' );
+						} );
+
+						it( 'should have a button with properties set', () => {
+							expect(	labeledDropdown.view.buttonView.isOn ).to.be.false;
+							expect(	labeledDropdown.view.buttonView.withText ).to.be.true;
+							expect(	labeledDropdown.view.buttonView.tooltip ).to.equal( 'Style' );
+						} );
+
+						it( 'should bind button\'s label to #borderStyle property', () => {
+							view.borderStyle = 'dotted';
+							expect( labeledDropdown.view.buttonView.label ).to.equal( 'Dotted' );
+
+							view.borderStyle = 'dashed';
+							expect( labeledDropdown.view.buttonView.label ).to.equal( 'Dashed' );
+						} );
+
+						it( 'should change #borderStyle when executed', () => {
+							labeledDropdown.view.listView.items.first.children.first.fire( 'execute' );
+							expect( view.borderStyle ).to.equal( 'none' );
+
+							labeledDropdown.view.listView.items.last.children.first.fire( 'execute' );
+							expect( view.borderStyle ).to.equal( 'outset' );
+						} );
+
+						it( 'should come with a set of pre–defined border styles', () => {
+							expect( labeledDropdown.view.listView.items.map( item => {
+								return item.children.first.label;
+							} ) ).to.have.ordered.members( [
+								'None', 'Solid', 'Dotted', 'Dashed', 'Double', 'Groove', 'Ridge', 'Inset', 'Outset',
+							] );
+						} );
+					} );
+
+					describe( 'border width input', () => {
+						let labeledInput;
+
+						beforeEach( () => {
+							labeledInput = view.borderWidthInput;
+						} );
+
+						it( 'should be created', () => {
+							expect( labeledInput.view ).to.be.instanceOf( InputTextView );
+							expect( labeledInput.label ).to.equal( 'Width' );
+							expect( labeledInput.class ).to.equal( 'ck-table-form__border-width' );
+						} );
+
+						it( 'should reflect #borderWidth property', () => {
+							view.borderWidth = 'foo';
+							expect( labeledInput.view.value ).to.equal( 'foo' );
+
+							view.borderWidth = 'bar';
+							expect( labeledInput.view.value ).to.equal( 'bar' );
+						} );
+
+						it( 'should be enabled only when #borderStyle is different than "none"', () => {
+							view.borderStyle = 'none';
+							expect( labeledInput.isEnabled ).to.be.false;
+
+							view.borderStyle = 'dotted';
+							expect( labeledInput.isEnabled ).to.be.true;
+						} );
+
+						it( 'should update #borderWidth on DOM "input" event', () => {
+							labeledInput.view.element.value = 'foo';
+							labeledInput.view.fire( 'input' );
+							expect( view.borderWidth ).to.equal( 'foo' );
+
+							labeledInput.view.element.value = 'bar';
+							labeledInput.view.fire( 'input' );
+							expect( view.borderWidth ).to.equal( 'bar' );
+						} );
+					} );
+
+					describe( 'border color input', () => {
+						let labeledInput;
+
+						beforeEach( () => {
+							labeledInput = view.borderColorInput;
+						} );
+
+						it( 'should be created', () => {
+							expect( labeledInput.view ).to.be.instanceOf( InputTextView );
+							expect( labeledInput.label ).to.equal( 'Color' );
+						} );
+
+						it( 'should reflect #borderColor property', () => {
+							view.borderColor = 'foo';
+							expect( labeledInput.view.value ).to.equal( 'foo' );
+
+							view.borderColor = 'bar';
+							expect( labeledInput.view.value ).to.equal( 'bar' );
+						} );
+
+						it( 'should be enabled only when #borderStyle is different than "none"', () => {
+							view.borderStyle = 'none';
+							expect( labeledInput.isEnabled ).to.be.false;
+
+							view.borderStyle = 'dotted';
+							expect( labeledInput.isEnabled ).to.be.true;
+						} );
+
+						it( 'should update #borderColor on DOM "input" event', () => {
+							labeledInput.view.element.value = 'foo';
+							labeledInput.view.fire( 'input' );
+							expect( view.borderColor ).to.equal( 'foo' );
+
+							labeledInput.view.element.value = 'bar';
+							labeledInput.view.fire( 'input' );
+							expect( view.borderColor ).to.equal( 'bar' );
+						} );
+					} );
+				} );
+
+				describe( 'background row', () => {
+					it( 'should be defined', () => {
+						const row = view.element.childNodes[ 2 ];
+
+						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
+						expect( row.childNodes[ 0 ] ).to.equal( view.backgroundInput.element );
+					} );
+
+					describe( 'background color input', () => {
+						let labeledInput;
+
+						beforeEach( () => {
+							labeledInput = view.backgroundInput;
+						} );
+
+						it( 'should be created', () => {
+							expect( labeledInput.view ).to.be.instanceOf( InputTextView );
+							expect( labeledInput.label ).to.equal( 'Background' );
+							expect( labeledInput.class ).to.equal( 'ck-table-properties-form__background' );
+						} );
+
+						it( 'should reflect #backgroundColor property', () => {
+							view.backgroundColor = 'foo';
+							expect( labeledInput.view.value ).to.equal( 'foo' );
+
+							view.backgroundColor = 'bar';
+							expect( labeledInput.view.value ).to.equal( 'bar' );
+						} );
+
+						it( 'should update #backgroundColor on DOM "input" event', () => {
+							labeledInput.view.element.value = 'foo';
+							labeledInput.view.fire( 'input' );
+							expect( view.backgroundColor ).to.equal( 'foo' );
+
+							labeledInput.view.element.value = 'bar';
+							labeledInput.view.fire( 'input' );
+							expect( view.backgroundColor ).to.equal( 'bar' );
+						} );
+					} );
+				} );
+
+				describe( 'dimensions row', () => {
+					it( 'should be defined', () => {
+						const row = view.element.childNodes[ 3 ].childNodes[ 0 ];
+
+						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
+						expect( row.classList.contains( 'ck-table-properties-form__dimensions-row' ) ).to.be.true;
+						expect( row.childNodes[ 0 ].textContent ).to.equal( 'Dimensions' );
+						expect( row.childNodes[ 1 ] ).to.equal( view.widthInput.element );
+						expect( row.childNodes[ 2 ].textContent ).to.equal( '×' );
+						expect( row.childNodes[ 3 ] ).to.equal( view.heightInput.element );
+					} );
+
+					describe( 'width input', () => {
+						let labeledInput;
+
+						beforeEach( () => {
+							labeledInput = view.widthInput;
+						} );
+
+						it( 'should be created', () => {
+							expect( labeledInput.view ).to.be.instanceOf( InputTextView );
+							expect( labeledInput.label ).to.equal( 'Width' );
+							expect( labeledInput.class ).to.equal( 'ck-table-properties-form__width' );
+						} );
+
+						it( 'should reflect #width property', () => {
+							view.width = 'foo';
+							expect( labeledInput.view.value ).to.equal( 'foo' );
+
+							view.width = 'bar';
+							expect( labeledInput.view.value ).to.equal( 'bar' );
+						} );
+
+						it( 'should update #width on DOM "input" event', () => {
+							labeledInput.view.element.value = 'foo';
+							labeledInput.view.fire( 'input' );
+							expect( view.width ).to.equal( 'foo' );
+
+							labeledInput.view.element.value = 'bar';
+							labeledInput.view.fire( 'input' );
+							expect( view.width ).to.equal( 'bar' );
+						} );
+					} );
+
+					describe( 'height input', () => {
+						let labeledInput;
+
+						beforeEach( () => {
+							labeledInput = view.heightInput;
+						} );
+
+						it( 'should be created', () => {
+							expect( labeledInput.view ).to.be.instanceOf( InputTextView );
+							expect( labeledInput.label ).to.equal( 'Height' );
+							expect( labeledInput.class ).to.equal( 'ck-table-properties-form__height' );
+						} );
+
+						it( 'should reflect #height property', () => {
+							view.height = 'foo';
+							expect( labeledInput.view.value ).to.equal( 'foo' );
+
+							view.height = 'bar';
+							expect( labeledInput.view.value ).to.equal( 'bar' );
+						} );
+
+						it( 'should update #height on DOM "input" event', () => {
+							labeledInput.view.element.value = 'foo';
+							labeledInput.view.fire( 'input' );
+							expect( view.height ).to.equal( 'foo' );
+
+							labeledInput.view.element.value = 'bar';
+							labeledInput.view.fire( 'input' );
+							expect( view.height ).to.equal( 'bar' );
+						} );
+					} );
+				} );
+
+				describe( 'dimensions alignment row', () => {
+					it( 'should be defined', () => {
+						const row = view.element.childNodes[ 3 ].childNodes[ 1 ];
+
+						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
+						expect( row.classList.contains( 'ck-table-properties-form__alignment-row' ) ).to.be.true;
+						expect( row.childNodes[ 0 ].textContent ).to.equal( 'Alignment' );
+						expect( row.childNodes[ 1 ] ).to.equal( view.alignmentToolbar.element );
+					} );
+
+					describe( 'alignment toolbar', () => {
+						let toolbar;
+
+						beforeEach( () => {
+							toolbar = view.alignmentToolbar;
+						} );
+
+						it( 'should be defined', () => {
+							expect( toolbar ).to.be.instanceOf( ToolbarView );
+						} );
+
+						it( 'should have an ARIA label', () => {
+							expect( toolbar.ariaLabel ).to.equal( 'Table alignment toolbar' );
+						} );
+
+						it( 'should bring alignment buttons', () => {
+							expect( toolbar.items.map( ( { label } ) => label ) ).to.have.ordered.members( [
+								'Align table to the left',
+								'Center table',
+								'Align table to the right'
+							] );
+
+							expect( toolbar.items.map( ( { isOn } ) => isOn ) ).to.have.ordered.members( [
+								false, true, false
+							] );
+						} );
+
+						it( 'should change the #horizontalAlignment value', () => {
+							toolbar.items.last.fire( 'execute' );
+							expect( view.alignment ).to.equal( 'right' );
+							expect( toolbar.items.last.isOn ).to.be.true;
+
+							toolbar.items.first.fire( 'execute' );
+							expect( view.alignment ).to.equal( 'left' );
+							expect( toolbar.items.last.isOn ).to.be.false;
+							expect( toolbar.items.first.isOn ).to.be.true;
+						} );
+					} );
+				} );
+
+				describe( 'action row', () => {
+					it( 'should be defined', () => {
+						const row = view.element.childNodes[ 4 ];
+
+						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
+						expect( row.classList.contains( 'ck-table-form__action-row' ) ).to.be.true;
+						expect( row.childNodes[ 0 ] ).to.equal( view.saveButtonView.element );
+						expect( row.childNodes[ 1 ] ).to.equal( view.cancelButtonView.element );
+					} );
+
+					it( 'should have buttons with right properties', () => {
+						expect( view.saveButtonView.label ).to.equal( 'Save' );
+						expect( view.saveButtonView.type ).to.equal( 'submit' );
+						expect( view.saveButtonView.withText ).to.be.true;
+						expect( view.saveButtonView.class ).to.equal( 'ck-button-save' );
+
+						expect( view.cancelButtonView.label ).to.equal( 'Cancel' );
+						expect( view.cancelButtonView.withText ).to.be.true;
+						expect( view.cancelButtonView.class ).to.equal( 'ck-button-cancel' );
+					} );
+
+					it( 'should make the cancel button fire the #cancel event when executed', () => {
+						const spy = sinon.spy();
+
+						view.on( 'cancel', spy );
+
+						view.cancelButtonView.fire( 'execute' );
+
+						expect( spy.calledOnce ).to.be.true;
+					} );
+				} );
+			} );
+
+			it( 'should create #focusTracker instance', () => {
+				expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+			} );
+
+			it( 'should create #keystrokes instance', () => {
+				expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+			} );
+
+			it( 'should create #_focusCycler instance', () => {
+				expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
+			} );
+
+			it( 'should create #_focusables view collection', () => {
+				expect( view._focusables ).to.be.instanceOf( ViewCollection );
+			} );
+		} );
+
+		describe( 'render()', () => {
+			it( 'should register child views in #_focusables', () => {
+				expect( view._focusables.map( f => f ) ).to.have.members( [
+					view.borderStyleDropdown,
+					view.borderColorInput,
+					view.borderWidthInput,
+					view.backgroundInput,
+					view.widthInput,
+					view.heightInput,
+					view.alignmentToolbar,
+					view.saveButtonView,
+					view.cancelButtonView,
+				] );
+			} );
+
+			it( 'should register child views\' #element in #focusTracker', () => {
+				const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+				const view = new TablePropertiesView( { t: val => val } );
+				view.render();
+
+				sinon.assert.calledWith( spy, view.borderStyleDropdown.element );
+				sinon.assert.calledWith( spy, view.borderColorInput.element );
+				sinon.assert.calledWith( spy, view.borderWidthInput.element );
+				sinon.assert.calledWith( spy, view.backgroundInput.element );
+				sinon.assert.calledWith( spy, view.widthInput.element );
+				sinon.assert.calledWith( spy, view.heightInput.element );
+				sinon.assert.calledWith( spy, view.alignmentToolbar.element );
+				sinon.assert.calledWith( spy, view.saveButtonView.element );
+				sinon.assert.calledWith( spy, view.cancelButtonView.element );
+
+				view.destroy();
+			} );
+
+			it( 'starts listening for #keystrokes coming from #element', () => {
+				const view = new TablePropertiesView( { t: val => val } );
+				const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+				view.render();
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, view.element );
+			} );
+
+			describe( 'activates keyboard navigation for the form', () => {
+				it( 'so "tab" focuses the next focusable item', () => {
+					const keyEvtData = {
+						keyCode: keyCodes.tab,
+						preventDefault: sinon.spy(),
+						stopPropagation: sinon.spy()
+					};
+
+					// Mock the border style dropdown button is focused.
+					view.focusTracker.isFocused = true;
+					view.focusTracker.focusedElement = view.borderStyleDropdown.element;
+
+					const spy = sinon.spy( view.borderColorInput, 'focus' );
+
+					view.keystrokes.press( keyEvtData );
+					sinon.assert.calledOnce( keyEvtData.preventDefault );
+					sinon.assert.calledOnce( keyEvtData.stopPropagation );
+					sinon.assert.calledOnce( spy );
+				} );
+
+				it( 'so "shift + tab" focuses the previous focusable item', () => {
+					const keyEvtData = {
+						keyCode: keyCodes.tab,
+						shiftKey: true,
+						preventDefault: sinon.spy(),
+						stopPropagation: sinon.spy()
+					};
+
+					// Mock the border style dropdown button is focused.
+					view.focusTracker.isFocused = true;
+					view.focusTracker.focusedElement = view.borderStyleDropdown.element;
+
+					const spy = sinon.spy( view.cancelButtonView, 'focus' );
+
+					view.keystrokes.press( keyEvtData );
+					sinon.assert.calledOnce( keyEvtData.preventDefault );
+					sinon.assert.calledOnce( keyEvtData.stopPropagation );
+					sinon.assert.calledOnce( spy );
+				} );
+			} );
+		} );
+
+		describe( 'DOM bindings', () => {
+			describe( 'submit event', () => {
+				it( 'should trigger submit event', () => {
+					const spy = sinon.spy();
+
+					view.on( 'submit', spy );
+					view.element.dispatchEvent( new Event( 'submit' ) );
+
+					expect( spy.calledOnce ).to.be.true;
+				} );
+			} );
+		} );
+
+		describe( 'focus()', () => {
+			it( 'focuses the #borderStyleDropdown', () => {
+				const spy = sinon.spy( view.borderStyleDropdown, 'focus' );
+
+				view.focus();
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+} );

+ 98 - 0
packages/ckeditor5-table/tests/ui/formheaderview.js

@@ -0,0 +1,98 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import FormHeaderView from '../../src/ui/formheaderview';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+
+describe( 'FormHeaderView', () => {
+	let view, locale;
+
+	beforeEach( () => {
+		locale = { t: val => val };
+		view = new FormHeaderView( locale );
+		view.render();
+	} );
+
+	afterEach( () => {
+		view.element.remove();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should set view#locale', () => {
+			expect( view.locale ).to.equal( locale );
+		} );
+
+		it( 'should create view#children collection', () => {
+			expect( view.children ).to.be.instanceOf( ViewCollection );
+			expect( view.children ).to.have.length( 1 );
+		} );
+
+		it( 'should set view#label', () => {
+			expect( view.label ).to.equal( '' );
+		} );
+
+		it( 'should set view#class', () => {
+			expect( view.class ).to.be.null;
+		} );
+
+		it( 'should set the template', () => {
+			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
+			expect( view.element.classList.contains( 'ck-form__header' ) ).to.be.true;
+		} );
+
+		describe( 'options', () => {
+			it( 'should set view#class when class was passed', () => {
+				const view = new FormHeaderView( locale, {
+					class: 'foo'
+				} );
+
+				expect( view.class ).to.equal( 'foo' );
+
+				view.destroy();
+			} );
+
+			it( 'should use a label text when passed', () => {
+				const view = new FormHeaderView( locale, {
+					label: 'foo'
+				} );
+
+				view.render();
+
+				expect( view.element.firstChild.classList.contains( 'ck' ) ).to.be.true;
+				expect( view.element.firstChild.classList.contains( 'ck-form__header__label' ) ).to.be.true;
+				expect( view.element.firstChild.textContent ).to.equal( 'foo' );
+
+				view.destroy();
+			} );
+		} );
+
+		describe( 'template bindings', () => {
+			it( 'should bind #class to the template', () => {
+				view.class = 'foo';
+				expect( view.element.classList.contains( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should bind #label to the template', () => {
+				view.label = 'bar';
+				expect( view.element.firstChild.textContent ).to.equal( 'bar' );
+
+				view.label = 'baz';
+				expect( view.element.firstChild.textContent ).to.equal( 'baz' );
+			} );
+
+			it( 'should bind #children to the template', () => {
+				const child = new View();
+				child.setTemplate( { tag: 'div' } );
+
+				view.children.add( child );
+
+				expect( view.element.childNodes[ 1 ] ).to.equal( child.element );
+
+				view.destroy();
+			} );
+		} );
+	} );
+} );

+ 4 - 0
packages/ckeditor5-table/tests/ui/formrowview.js

@@ -74,6 +74,8 @@ describe( 'FormRowView', () => {
 
 				expect( view.element.getAttribute( 'role' ) ).to.equal( 'group' );
 				expect( view.element.getAttribute( 'aria-labelledby' ) ).to.equal( '123' );
+
+				view.destroy();
 			} );
 		} );
 
@@ -90,6 +92,8 @@ describe( 'FormRowView', () => {
 				view.children.add( child );
 
 				expect( view.element.firstChild ).to.equal( child.element );
+
+				view.destroy();
 			} );
 		} );
 	} );

+ 253 - 37
packages/ckeditor5-table/tests/ui/utils.js

@@ -6,15 +6,24 @@
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import Table from '../../src/table';
 import TableCellProperties from '../../src/tablecellproperties';
+import { findAncestor } from '../../src/commands/utils';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import View from '@ckeditor/ckeditor5-ui/src/view';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { repositionContextualBalloon, getBalloonCellPositionData } from '../../src/ui/utils';
-import { findAncestor } from '../../src/commands/utils';
+import {
+	repositionContextualBalloon,
+	getBalloonCellPositionData,
+	getBorderStyleDefinitions,
+	getBorderStyleLabels,
+	fillToolbar
+} from '../../src/ui/utils';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 
-describe( 'Utils', () => {
+describe( 'UI Utils', () => {
 	let editor, editingView, balloon, editorElement;
 
 	beforeEach( () => {
@@ -38,51 +47,101 @@ describe( 'Utils', () => {
 		return editor.destroy();
 	} );
 
-	describe( 'repositionContextualBalloon', () => {
-		it( 'should re-position the ContextualBalloon when the table cell is selected', () => {
-			const spy = sinon.spy( balloon, 'updatePosition' );
-			const defaultPositions = BalloonPanelView.defaultPositions;
-			const view = new View();
+	describe( 'repositionContextualBalloon()', () => {
+		describe( 'with respect to the table cell', () => {
+			it( 'should re-position the ContextualBalloon when the table cell is selected', () => {
+				const spy = sinon.spy( balloon, 'updatePosition' );
+				const defaultPositions = BalloonPanelView.defaultPositions;
+				const view = new View();
+
+				view.element = global.document.createElement( 'div' );
+
+				balloon.add( {
+					view,
+					position: {
+						target: global.document.body
+					}
+				} );
 
-			view.element = global.document.createElement( 'div' );
+				setData( editor.model,
+					'<table><tableRow>' +
+						'<tableCell><paragraph>foo</paragraph></tableCell>' +
+						'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
+					'</tableRow></table>' );
+				repositionContextualBalloon( editor, 'cell' );
 
-			balloon.add( {
-				view,
-				position: {
-					target: global.document.body
-				}
+				const modelCell = findAncestor( 'tableCell', editor.model.document.selection.getFirstPosition() );
+				const viewCell = editor.editing.mapper.toViewElement( modelCell );
+
+				sinon.assert.calledWithExactly( spy, {
+					target: editingView.domConverter.viewToDom( viewCell ),
+					positions: [
+						defaultPositions.northArrowSouth,
+						defaultPositions.northArrowSouthWest,
+						defaultPositions.northArrowSouthEast,
+						defaultPositions.southArrowNorth,
+						defaultPositions.southArrowNorthWest,
+						defaultPositions.southArrowNorthEast
+					]
+				} );
 			} );
 
-			setData( editor.model,
-				'<table><tableRow>' +
-					'<tableCell><paragraph>foo</paragraph></tableCell>' +
-					'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
-				'</tableRow></table>' );
-			repositionContextualBalloon( editor );
+			it( 'should not engage with no table is selected', () => {
+				const spy = sinon.spy( balloon, 'updatePosition' );
 
-			const modelCell = findAncestor( 'tableCell', editor.model.document.selection.getFirstPosition() );
-			const viewCell = editor.editing.mapper.toViewElement( modelCell );
+				setData( editor.model, '<paragraph>foo</paragraph>' );
 
-			sinon.assert.calledWithExactly( spy, {
-				target: editingView.domConverter.viewToDom( viewCell ),
-				positions: [
-					defaultPositions.northArrowSouth,
-					defaultPositions.northArrowSouthWest,
-					defaultPositions.northArrowSouthEast,
-					defaultPositions.southArrowNorth,
-					defaultPositions.southArrowNorthWest,
-					defaultPositions.southArrowNorthEast
-				]
+				repositionContextualBalloon( editor, 'cell' );
+				sinon.assert.notCalled( spy );
 			} );
 		} );
 
-		it( 'should not engage with no table is selected', () => {
-			const spy = sinon.spy( balloon, 'updatePosition' );
+		describe( 'with respect to the entire table', () => {
+			it( 'should re-position the ContextualBalloon when the table is selected', () => {
+				const spy = sinon.spy( balloon, 'updatePosition' );
+				const defaultPositions = BalloonPanelView.defaultPositions;
+				const view = new View();
+
+				view.element = global.document.createElement( 'div' );
+
+				balloon.add( {
+					view,
+					position: {
+						target: global.document.body
+					}
+				} );
 
-			setData( editor.model, '<paragraph>foo</paragraph>' );
+				setData( editor.model,
+					'<table><tableRow>' +
+						'<tableCell><paragraph>foo</paragraph></tableCell>' +
+						'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
+					'</tableRow></table>' );
+				repositionContextualBalloon( editor, 'table' );
 
-			repositionContextualBalloon( editor );
-			sinon.assert.notCalled( spy );
+				const modelTable = findAncestor( 'table', editor.model.document.selection.getFirstPosition() );
+				const viewTable = editor.editing.mapper.toViewElement( modelTable );
+
+				sinon.assert.calledWithExactly( spy, {
+					target: editingView.domConverter.viewToDom( viewTable ),
+					positions: [
+						defaultPositions.northArrowSouth,
+						defaultPositions.northArrowSouthWest,
+						defaultPositions.northArrowSouthEast,
+						defaultPositions.southArrowNorth,
+						defaultPositions.southArrowNorthWest,
+						defaultPositions.southArrowNorthEast
+					]
+				} );
+			} );
+
+			it( 'should not engage with no table is selected', () => {
+				const spy = sinon.spy( balloon, 'updatePosition' );
+
+				setData( editor.model, '<paragraph>foo</paragraph>' );
+
+				repositionContextualBalloon( editor, 'table' );
+				sinon.assert.notCalled( spy );
+			} );
 		} );
 	} );
 
@@ -112,4 +171,161 @@ describe( 'Utils', () => {
 			} );
 		} );
 	} );
+
+	describe( 'getBorderStyleLabels()', () => {
+		it( 'should return labels for different border styles', () => {
+			const t = string => string;
+
+			expect( getBorderStyleLabels( t ) ).to.deep.equal( {
+				none: 'None',
+				solid: 'Solid',
+				dotted: 'Dotted',
+				dashed: 'Dashed',
+				double: 'Double',
+				groove: 'Groove',
+				ridge: 'Ridge',
+				inset: 'Inset',
+				outset: 'Outset',
+			} );
+		} );
+	} );
+
+	describe( 'getBorderStyleDefinitions()', () => {
+		let view, locale, definitions;
+
+		beforeEach( () => {
+			locale = { t: val => val };
+			view = new View( locale );
+			view.set( 'borderStyle', 'none' );
+
+			definitions = getBorderStyleDefinitions( view );
+		} );
+
+		it( 'should return a collection', () => {
+			expect( definitions ).to.be.instanceOf( Collection );
+		} );
+
+		it( 'should create a button definition for each style', () => {
+			expect( definitions.map( ( { type } ) => type ).every( item => item === 'button' ) ).to.be.true;
+		} );
+
+		it( 'should set label of a button for each style', () => {
+			expect( definitions.map( ( { model: { label } } ) => label ) ).to.have.ordered.members( [
+				'None',
+				'Solid',
+				'Dotted',
+				'Dashed',
+				'Double',
+				'Groove',
+				'Ridge',
+				'Inset',
+				'Outset',
+			] );
+		} );
+
+		it( 'should set type of a button for each style', () => {
+			expect( definitions.map( ( { model: { withText } } ) => withText ).every( item => item === true ) ).to.be.true;
+		} );
+
+		it( 'should bind button\'s #isOn to the view #borderStyle property', () => {
+			view.borderStyle = 'dotted';
+
+			expect( definitions.map( ( { model: { isOn } } ) => isOn ) ).to.have.ordered.members( [
+				false,
+				false,
+				true,
+				false,
+				false,
+				false,
+				false,
+				false,
+				false,
+			] );
+
+			view.borderStyle = 'inset';
+
+			expect( definitions.map( ( { model: { isOn } } ) => isOn ) ).to.have.ordered.members( [
+				false,
+				false,
+				false,
+				false,
+				false,
+				false,
+				false,
+				true,
+				false,
+			] );
+		} );
+	} );
+
+	describe( 'fillToolbar()', () => {
+		let view, locale, toolbar;
+
+		const labels = {
+			first: 'Do something',
+			second: 'Do something else'
+		};
+
+		const icons = {
+			first: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path /></svg>',
+			second: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path /></svg>'
+		};
+
+		beforeEach( () => {
+			locale = { t: val => val };
+			view = new View( locale );
+			view.set( 'someProperty', 'foo' );
+			toolbar = new ToolbarView( locale );
+
+			fillToolbar( {
+				view, toolbar, icons, labels,
+				propertyName: 'someProperty'
+			} );
+		} );
+
+		afterEach( () => {
+			view.destroy();
+		} );
+
+		it( 'should create buttons', () => {
+			expect( toolbar.items ).to.have.length( 2 );
+			expect( toolbar.items.first ).to.be.instanceOf( ButtonView );
+			expect( toolbar.items.last ).to.be.instanceOf( ButtonView );
+		} );
+
+		it( 'should set button labels', () => {
+			expect( toolbar.items.first.label ).to.equal( 'Do something' );
+			expect( toolbar.items.last.label ).to.equal( 'Do something else' );
+		} );
+
+		it( 'should set button icons', () => {
+			expect( toolbar.items.first.icon ).to.equal( icons.first );
+			expect( toolbar.items.last.icon ).to.equal( icons.second );
+		} );
+
+		it( 'should bind button #isOn to an observable property', () => {
+			expect( toolbar.items.first.isOn ).to.be.false;
+			expect( toolbar.items.last.isOn ).to.be.false;
+
+			view.someProperty = 'first';
+
+			expect( toolbar.items.first.isOn ).to.be.true;
+			expect( toolbar.items.last.isOn ).to.be.false;
+
+			view.someProperty = 'second';
+
+			expect( toolbar.items.first.isOn ).to.be.false;
+			expect( toolbar.items.last.isOn ).to.be.true;
+		} );
+
+		it( 'should make the buttons change the property value upon execution', () => {
+			toolbar.items.first.fire( 'execute' );
+
+			expect( view.someProperty ).to.equal( 'first' );
+
+			toolbar.items.last.fire( 'execute' );
+
+			expect( view.someProperty ).to.equal( 'second' );
+		} );
+	} );
 } );

+ 5 - 65
packages/ckeditor5-table/theme/form.css

@@ -3,69 +3,9 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-.ck.ck-form {
-	padding: 0 0 var(--ck-spacing-large);
-
-	&:focus {
-		/* https://github.com/ckeditor/ckeditor5-link/issues/90 */
-		outline: none;
-	}
-
-	& .ck.ck-form__header {
-		font-weight: bold;
-		padding: 0 var(--ck-spacing-large);
-		height: 38px;
-		line-height: 38px;
-		border-bottom: 1px solid var(--ck-color-base-border);
-	}
-
-	& .ck.ck-input-text {
-		min-width: 100%;
-		width: 0;
-	}
-
-	& .ck.ck-dropdown {
-		min-width: 100%;
-
-		& .ck-dropdown__button {
-			&:not(:focus) {
-				border: 1px solid var(--ck-color-base-border);
-			}
-
-			& .ck-button__label {
-				width: 100%;
-			}
-		}
-	}
-
-	& .ck-form__row {
-		display: flex;
-		flex-direction: row;
-		flex-wrap: nowrap;
-		justify-content: space-between;
-
-		padding: var(--ck-spacing-standard) var(--ck-spacing-large) 0;
-
-		/* Ignore labels that work as fieldset legends */
-		& > *:not(.ck-label) {
-			flex-grow: 1;
-
-			& + * {
-				padding-left: var(--ck-spacing-large);
-			}
-		}
-
-		&.ck-table-form__action-row {
-			margin-top: var(--ck-spacing-large);
-
-			& .ck-button-save,
-			& .ck-button-cancel {
-				justify-content: center;
-			}
+/*
+ * Note: This file should contain the wireframe styles only. But since there are no such styles,
+ * it acts as a message to the builder telling that it should look for the corresponding styles
+ * **in the theme** when compiling the editor.
+ */
 
-			& .ck-button .ck-button__label {
-				color: var(--ck-color-text);
-			}
-		}
-	}
-}

+ 10 - 0
packages/ckeditor5-table/theme/formheader.css

@@ -0,0 +1,10 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/*
+ * Note: This file should contain the wireframe styles only. But since there are no such styles,
+ * it acts as a message to the builder telling that it should look for the corresponding styles
+ * **in the theme** when compiling the editor.
+ */

+ 23 - 0
packages/ckeditor5-table/theme/formrow.css

@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-form__row {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: nowrap;
+	justify-content: space-between;
+
+	/* Ignore labels that work as fieldset legends */
+	& > *:not(.ck-label) {
+		flex-grow: 1;
+	}
+
+	&.ck-table-form__action-row {
+		& .ck-button-save,
+		& .ck-button-cancel {
+			justify-content: center;
+		}
+	}
+}

+ 0 - 67
packages/ckeditor5-table/theme/tablecellproperties.css

@@ -4,79 +4,12 @@
  */
 
 .ck.ck-table-cell-properties-form {
-	width: 320px;
-
 	& .ck-form__row {
-		&.ck-table-cell-properties-form__border-row {
-			flex-wrap: wrap;
-
-			& > .ck.ck-label {
-				width: 100%;
-				min-width: 100%;
-			}
-
-			& .ck-labeled-view {
-				display: flex;
-				flex-direction: column-reverse;
-				align-items: center;
-
-				& > .ck-label {
-					font-size: 10px;
-				}
-
-				& .ck.ck-dropdown {
-					flex-grow: 0;
-				}
-			}
-
-			& .ck-table-cell-properties-form__border-style {
-				width: 80px;
-				min-width: 80px;
-				flex-grow: 0;
-			}
-
-			& .ck-table-cell-properties-form__border-width {
-				width: 80px;
-				min-width: 80px;
-				flex-grow: 0;
-			}
-		}
-
-		& .ck-table-cell-properties-form__background,
-		& .ck-table-cell-properties-form__padding {
-			width: 50%;
-		}
-
 		&.ck-table-cell-properties-form__alignment-row {
 			flex-wrap: wrap;
 
-			& > .ck.ck-label {
-				width: 100%;
-				min-width: 100%;
-			}
-
 			& .ck.ck-toolbar {
-				padding: 0;
-				background: 0;
 				flex-grow: 0;
-
-				& .ck-toolbar__items > * {
-					margin: 0;
-
-					&:first-child {
-						border-top-right-radius: 0;
-						border-bottom-right-radius: 0;
-					}
-
-					&:last-child {
-						border-top-left-radius: 0;
-						border-bottom-left-radius: 0;
-					}
-
-					&:not(:first-child):not(:last-child) {
-						border-radius: 0;
-					}
-				}
 			}
 		}
 	}

+ 36 - 0
packages/ckeditor5-table/theme/tableform.css

@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-table-form {
+	& .ck-table-form__border-row {
+		flex-wrap: wrap;
+
+		& .ck-labeled-view {
+			display: flex;
+			flex-direction: column-reverse;
+			align-items: center;
+
+			& > .ck-label {
+				font-size: 10px;
+			}
+
+			& .ck.ck-dropdown {
+				flex-grow: 0;
+			}
+		}
+
+		& .ck-table-form__border-style {
+			width: 80px;
+			min-width: 80px;
+			flex-grow: 0;
+		}
+
+		& .ck-table-form__border-width {
+			width: 80px;
+			min-width: 80px;
+			flex-grow: 0;
+		}
+	}
+}

+ 41 - 0
packages/ckeditor5-table/theme/tableproperties.css

@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck.ck-table-properties-form {
+	& .ck-form__row {
+		&.ck-table-properties-form__dimensions-row,
+		&.ck-table-properties-form__alignment-row {
+			flex-wrap: wrap;
+		}
+
+		&.ck-table-properties-form__dimensions-row {
+			align-items: center;
+
+			& .ck-table-properties-form__dimension-operator {
+				flex-grow: 0;
+			}
+
+			& .ck-labeled-view {
+				display: flex;
+				flex-direction: column-reverse;
+				align-items: center;
+
+				& .ck.ck-dropdown {
+					flex-grow: 0;
+				}
+			}
+		}
+
+		&.ck-table-properties-form__alignment-row {
+			flex-wrap: wrap;
+			flex-basis: 0;
+			align-content: baseline;
+
+			& .ck.ck-toolbar .ck-toolbar__items {
+				flex-wrap: nowrap;
+			}
+		}
+	}
+}