瀏覽代碼

Merge branch 'master' into i/6098

Aleksander Nowodzinski 5 年之前
父節點
當前提交
071e4fd904

+ 100 - 7
packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js

@@ -14,11 +14,20 @@ import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsid
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import TableCellPropertiesView from './ui/tablecellpropertiesview';
 import tableCellProperties from './../../theme/icons/table-cell-properties.svg';
-import { repositionContextualBalloon, getBalloonCellPositionData } from '../ui/utils';
+import {
+	repositionContextualBalloon,
+	getBalloonCellPositionData,
+	getLocalizedColorErrorText,
+	getLocalizedLengthErrorText,
+	colorFieldValidator,
+	lengthFieldValidator
+} from '../ui/utils';
+import { debounce } from 'lodash-es';
 
 const DEFAULT_BORDER_STYLE = 'none';
 const DEFAULT_HORIZONTAL_ALIGNMENT = 'left';
 const DEFAULT_VERTICAL_ALIGNMENT = 'middle';
+const ERROR_TEXT_TIMEOUT = 500;
 
 /**
  * The table cell properties UI plugin. It introduces the `'tableCellProperties'` button
@@ -112,6 +121,7 @@ export default class TableCellPropertiesUI extends Plugin {
 		const editor = this.editor;
 		const viewDocument = editor.editing.view.document;
 		const view = new TableCellPropertiesView( editor.locale );
+		const t = editor.t;
 
 		// Render the view so its #element is available for the clickOutsideHandler.
 		view.render();
@@ -121,7 +131,11 @@ export default class TableCellPropertiesUI extends Plugin {
 		} );
 
 		this.listenTo( view, 'cancel', () => {
-			editor.execute( 'undo', this._undoStepBatch );
+			// https://github.com/ckeditor/ckeditor5/issues/6180
+			if ( this._undoStepBatch.operations.length ) {
+				editor.execute( 'undo', this._undoStepBatch );
+			}
+
 			this._hideView();
 		} );
 
@@ -148,15 +162,58 @@ export default class TableCellPropertiesUI extends Plugin {
 			callback: () => this._hideView()
 		} );
 
+		const colorErrorText = getLocalizedColorErrorText( t );
+		const lengthErrorText = getLocalizedLengthErrorText( t );
+
 		// 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
+		// property of the view has changed. They also validate the value and display errors in the UI
+		// when necessary. 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( 'tableCellBorderStyle' ) );
-		view.on( 'change:borderColor', this._getPropertyChangeCallback( 'tableCellBorderColor' ) );
-		view.on( 'change:borderWidth', this._getPropertyChangeCallback( 'tableCellBorderWidth' ) );
-		view.on( 'change:padding', this._getPropertyChangeCallback( 'tableCellPadding' ) );
-		view.on( 'change:backgroundColor', this._getPropertyChangeCallback( 'tableCellBackgroundColor' ) );
+
+		view.on( 'change:borderColor', this._getValidatedPropertyChangeCallback( {
+			viewField: view.borderColorInput,
+			commandName: 'tableCellBorderColor',
+			errorText: colorErrorText,
+			validator: colorFieldValidator
+		} ) );
+
+		view.on( 'change:borderWidth', this._getValidatedPropertyChangeCallback( {
+			viewField: view.borderWidthInput,
+			commandName: 'tableCellBorderWidth',
+			errorText: lengthErrorText,
+			validator: lengthFieldValidator
+		} ) );
+
+		view.on( 'change:padding', this._getValidatedPropertyChangeCallback( {
+			viewField: view.paddingInput,
+			commandName: 'tableCellPadding',
+			errorText: lengthErrorText,
+			validator: lengthFieldValidator
+		} ) );
+
+		view.on( 'change:width', this._getValidatedPropertyChangeCallback( {
+			viewField: view.widthInput,
+			commandName: 'tableCellWidth',
+			errorText: lengthErrorText,
+			validator: lengthFieldValidator
+		} ) );
+
+		view.on( 'change:height', this._getValidatedPropertyChangeCallback( {
+			viewField: view.heightInput,
+			commandName: 'tableCellHeight',
+			errorText: lengthErrorText,
+			validator: lengthFieldValidator
+		} ) );
+
+		view.on( 'change:backgroundColor', this._getValidatedPropertyChangeCallback( {
+			viewField: view.backgroundInput,
+			commandName: 'tableCellBackgroundColor',
+			errorText: colorErrorText,
+			validator: colorFieldValidator
+		} ) );
+
 		view.on( 'change:horizontalAlignment', this._getPropertyChangeCallback( 'tableCellHorizontalAlignment' ) );
 		view.on( 'change:verticalAlignment', this._getPropertyChangeCallback( 'tableCellVerticalAlignment' ) );
 
@@ -180,6 +237,8 @@ export default class TableCellPropertiesUI extends Plugin {
 			borderStyle: commands.get( 'tableCellBorderStyle' ).value || DEFAULT_BORDER_STYLE,
 			borderColor: commands.get( 'tableCellBorderColor' ).value || '',
 			borderWidth: commands.get( 'tableCellBorderWidth' ).value || '',
+			width: commands.get( 'tableCellWidth' ).value || '',
+			height: commands.get( 'tableCellHeight' ).value || '',
 			padding: commands.get( 'tableCellPadding' ).value || '',
 			backgroundColor: commands.get( 'tableCellBackgroundColor' ).value || '',
 			horizontalAlignment: commands.get( 'tableCellHorizontalAlignment' ).value || DEFAULT_HORIZONTAL_ALIGNMENT,
@@ -275,4 +334,38 @@ export default class TableCellPropertiesUI extends Plugin {
 			} );
 		};
 	}
+
+	/**
+	 * Creates a callback that when executed upon {@link #view view's} property change:
+	 * * executes a related editor command with the new property value if the value is valid,
+	 * * or sets the error text next to the invalid field, if the value did not pass the validation.
+	 *
+	 * @private
+	 * @param {Object} options
+	 * @param {String} options.commandName
+	 * @param {module:ui/view~View} options.viewField
+	 * @param {Function} options.validator
+	 * @param {String} options.errorText
+	 * @returns {Function}
+	 */
+	_getValidatedPropertyChangeCallback( { commandName, viewField, validator, errorText } ) {
+		const setErrorTextDebounced = debounce( () => {
+			viewField.errorText = errorText;
+		}, ERROR_TEXT_TIMEOUT );
+
+		return ( evt, propertyName, newValue ) => {
+			setErrorTextDebounced.cancel();
+
+			if ( validator( newValue ) ) {
+				this.editor.execute( commandName, {
+					value: newValue,
+					batch: this._undoStepBatch
+				} );
+
+				viewField.errorText = null;
+			} else {
+				setErrorTextDebounced();
+			}
+		};
+	}
 }

+ 147 - 5
packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js

@@ -63,8 +63,8 @@ export default class TableCellPropertiesView extends View {
 		super( locale );
 
 		const { borderStyleDropdown, borderWidthInput, borderColorInput, borderRowLabel } = this._createBorderFields();
+		const { widthInput, operatorLabel, heightInput, dimensionsLabel } = this._createDimensionFields();
 		const { horizontalAlignmentToolbar, verticalAlignmentToolbar, alignmentLabel } = this._createAlignmentFields();
-		const { saveButtonView, cancelButtonView } = this._createActionButtons();
 
 		/**
 		 * Tracks information about the DOM focus in the form.
@@ -137,6 +137,24 @@ export default class TableCellPropertiesView extends View {
 			backgroundColor: '',
 
 			/**
+			 * The value of the table cell width style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #width
+			 */
+			width: '',
+
+			/**
+			 * The value of the table cell height style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #height
+			 */
+			height: '',
+
+			/**
 			 * The value of the horizontal text alignment style.
 			 *
 			 * @observable
@@ -196,6 +214,22 @@ export default class TableCellPropertiesView extends View {
 		this.paddingInput = this._createPaddingField();
 
 		/**
+		 * An input that allows specifying the table cell width.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.widthInput = widthInput;
+
+		/**
+		 * An input that allows specifying the table cell height.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.heightInput = heightInput;
+
+		/**
 		 * A toolbar with buttons that allow changing the horizontal text alignment in a table cell.
 		 *
 		 * @readonly
@@ -211,6 +245,11 @@ export default class TableCellPropertiesView extends View {
 		 */
 		this.verticalAlignmentToolbar = verticalAlignmentToolbar;
 
+		// Defer creating to make sure other fields are present and the Save button can
+		// bind its #isEnabled to their error messages so there's no way to save unless all
+		// fields are valid.
+		const { saveButtonView, cancelButtonView } = this._createActionButtons();
+
 		/**
 		 * The "Save" button view.
 		 *
@@ -271,11 +310,34 @@ export default class TableCellPropertiesView extends View {
 			class: 'ck-table-form__border-row'
 		} ) );
 
-		// Background and padding row.
+		// Background.
 		this.children.add( new FormRowView( locale, {
 			children: [
-				this.backgroundInput,
-				this.paddingInput,
+				this.backgroundInput
+			]
+		} ) );
+
+		// Dimensions row and padding.
+		this.children.add( new FormRowView( locale, {
+			children: [
+				// Dimensions row.
+				new FormRowView( locale, {
+					labelView: dimensionsLabel,
+					children: [
+						dimensionsLabel,
+						widthInput,
+						operatorLabel,
+						heightInput
+					],
+					class: 'ck-table-cell-properties-form__dimensions-row'
+				} ),
+				// Padding row.
+				new FormRowView( locale, {
+					children: [
+						this.paddingInput
+					],
+					class: 'ck-table-cell-properties-form__padding-row'
+				} )
 			]
 		} ) );
 
@@ -332,6 +394,8 @@ export default class TableCellPropertiesView extends View {
 			this.borderColorInput,
 			this.borderWidthInput,
 			this.backgroundInput,
+			this.widthInput,
+			this.heightInput,
 			this.paddingInput,
 			this.horizontalAlignmentToolbar,
 			this.verticalAlignmentToolbar,
@@ -468,6 +532,75 @@ export default class TableCellPropertiesView extends View {
 	/**
 	 * 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-cell-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-form__dimension-operator'
+				]
+			},
+			children: [
+				{ text: '×' }
+			]
+		} );
+
+		// -- Height ---------------------------------------------------
+
+		const heightInput = new LabeledView( locale, createLabeledInputText );
+
+		heightInput.set( {
+			label: t( 'Height' ),
+			class: 'ck-table-cell-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 #paddingInput}.
 	 *
 	 * @private
@@ -562,9 +695,14 @@ export default class TableCellPropertiesView extends View {
 	_createActionButtons() {
 		const locale = this.locale;
 		const t = this.t;
-
 		const saveButtonView = new ButtonView( locale );
 		const cancelButtonView = new ButtonView( locale );
+		const fieldsThatShouldValidateToSave = [
+			this.borderWidthInput,
+			this.borderColorInput,
+			this.backgroundInput,
+			this.paddingInput
+		];
 
 		saveButtonView.set( {
 			label: t( 'Save' ),
@@ -574,6 +712,10 @@ export default class TableCellPropertiesView extends View {
 			withText: true,
 		} );
 
+		saveButtonView.bind( 'isEnabled' ).toMany( fieldsThatShouldValidateToSave, 'errorText', ( ...errorTexts ) => {
+			return errorTexts.every( errorText => !errorText );
+		} );
+
 		cancelButtonView.set( {
 			label: t( 'Cancel' ),
 			icon: cancelIcon,

+ 88 - 8
packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js

@@ -16,11 +16,17 @@ import TablePropertiesView from './ui/tablepropertiesview';
 import tableProperties from './../../theme/icons/table-properties.svg';
 import {
 	repositionContextualBalloon,
-	getBalloonTablePositionData
+	getBalloonTablePositionData,
+	getLocalizedColorErrorText,
+	getLocalizedLengthErrorText,
+	colorFieldValidator,
+	lengthFieldValidator
 } from '../ui/utils';
+import { debounce } from 'lodash-es';
 
 const DEFAULT_BORDER_STYLE = 'none';
 const DEFAULT_ALIGNMENT = 'center';
+const ERROR_TEXT_TIMEOUT = 500;
 
 /**
  * The table properties UI plugin. It introduces the `'tableProperties'` button
@@ -114,6 +120,7 @@ export default class TablePropertiesUI extends Plugin {
 		const editor = this.editor;
 		const viewDocument = editor.editing.view.document;
 		const view = new TablePropertiesView( editor.locale );
+		const t = editor.t;
 
 		// Render the view so its #element is available for the clickOutsideHandler.
 		view.render();
@@ -123,7 +130,11 @@ export default class TablePropertiesUI extends Plugin {
 		} );
 
 		this.listenTo( view, 'cancel', () => {
-			editor.execute( 'undo', this._undoStepBatch );
+			// https://github.com/ckeditor/ckeditor5/issues/6180
+			if ( this._undoStepBatch.operations.length ) {
+				editor.execute( 'undo', this._undoStepBatch );
+			}
+
 			this._hideView();
 		} );
 
@@ -150,16 +161,51 @@ export default class TablePropertiesUI extends Plugin {
 			callback: () => this._hideView()
 		} );
 
+		const colorErrorText = getLocalizedColorErrorText( t );
+		const lengthErrorText = getLocalizedLengthErrorText( t );
+
 		// 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
+		// property of the view has changed. They also validate the value and display errors in the UI
+		// when necessary. 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:borderColor', this._getValidatedPropertyChangeCallback( {
+			viewField: view.borderColorInput,
+			commandName: 'tableBorderColor',
+			errorText: colorErrorText,
+			validator: colorFieldValidator
+		} ) );
+
+		view.on( 'change:borderWidth', this._getValidatedPropertyChangeCallback( {
+			viewField: view.borderWidthInput,
+			commandName: 'tableBorderWidth',
+			errorText: lengthErrorText,
+			validator: lengthFieldValidator
+		} ) );
+
+		view.on( 'change:backgroundColor', this._getValidatedPropertyChangeCallback( {
+			viewField: view.backgroundInput,
+			commandName: 'tableBackgroundColor',
+			errorText: colorErrorText,
+			validator: colorFieldValidator
+		} ) );
+
+		view.on( 'change:width', this._getValidatedPropertyChangeCallback( {
+			viewField: view.widthInput,
+			commandName: 'tableWidth',
+			errorText: lengthErrorText,
+			validator: lengthFieldValidator
+		} ) );
+
+		view.on( 'change:height', this._getValidatedPropertyChangeCallback( {
+			viewField: view.heightInput,
+			commandName: 'tableHeight',
+			errorText: lengthErrorText,
+			validator: lengthFieldValidator
+		} ) );
+
 		view.on( 'change:alignment', this._getPropertyChangeCallback( 'tableAlignment' ) );
 
 		return view;
@@ -277,4 +323,38 @@ export default class TablePropertiesUI extends Plugin {
 			} );
 		};
 	}
+
+	/**
+	 * Creates a callback that when executed upon {@link #view view's} property change:
+	 * * executes a related editor command with the new property value if the value is valid,
+	 * * or sets the error text next to the invalid field, if the value did not pass the validation.
+	 *
+	 * @private
+	 * @param {Object} options
+	 * @param {String} options.commandName
+	 * @param {module:ui/view~View} options.viewField
+	 * @param {Function} options.validator
+	 * @param {String} options.errorText
+	 * @returns {Function}
+	 */
+	_getValidatedPropertyChangeCallback( { commandName, viewField, validator, errorText } ) {
+		const setErrorTextDebounced = debounce( () => {
+			viewField.errorText = errorText;
+		}, ERROR_TEXT_TIMEOUT );
+
+		return ( evt, propertyName, newValue ) => {
+			setErrorTextDebounced.cancel();
+
+			if ( validator( newValue ) ) {
+				this.editor.execute( commandName, {
+					value: newValue,
+					batch: this._undoStepBatch
+				} );
+
+				viewField.errorText = null;
+			} else {
+				setErrorTextDebounced();
+			}
+		};
+	}
 }

+ 17 - 2
packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js

@@ -57,7 +57,6 @@ export default class TablePropertiesView extends View {
 		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.
@@ -204,6 +203,11 @@ export default class TablePropertiesView extends View {
 		 */
 		this.alignmentToolbar = alignmentToolbar;
 
+		// Defer creating to make sure other fields are present and the Save button can
+		// bind its #isEnabled to their error messages so there's no way to save unless all
+		// fields are valid.
+		const { saveButtonView, cancelButtonView } = this._createActionButtons();
+
 		/**
 		 * The "Save" button view.
 		 *
@@ -510,7 +514,7 @@ export default class TablePropertiesView extends View {
 			tag: 'span',
 			attributes: {
 				class: [
-					'ck-table-properties-form__dimension-operator'
+					'ck-table-form__dimension-operator'
 				]
 			},
 			children: [
@@ -594,6 +598,13 @@ export default class TablePropertiesView extends View {
 
 		const saveButtonView = new ButtonView( locale );
 		const cancelButtonView = new ButtonView( locale );
+		const fieldsThatShouldValidateToSave = [
+			this.borderWidthInput,
+			this.borderColorInput,
+			this.backgroundInput,
+			this.widthInput,
+			this.heightInput
+		];
 
 		saveButtonView.set( {
 			label: t( 'Save' ),
@@ -603,6 +614,10 @@ export default class TablePropertiesView extends View {
 			withText: true,
 		} );
 
+		saveButtonView.bind( 'isEnabled' ).toMany( fieldsThatShouldValidateToSave, 'errorText', ( ...errorTexts ) => {
+			return errorTexts.every( errorText => !errorText );
+		} );
+
 		cancelButtonView.set( {
 			label: t( 'Cancel' ),
 			icon: cancelIcon,

+ 57 - 0
packages/ckeditor5-table/src/ui/utils.js

@@ -11,6 +11,7 @@ import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpa
 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 { isColor, isLength } from '@ckeditor/ckeditor5-engine/src/view/styles/utils';
 import { getTableWidgetAncestor } from '../utils';
 import { findAncestor } from '../commands/utils';
 
@@ -24,6 +25,8 @@ const BALLOON_POSITIONS = [
 	DEFAULT_BALLOON_POSITIONS.southArrowNorthEast
 ];
 
+const isEmpty = val => val === '';
+
 /**
  * A helper utility that positions the
  * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} instance
@@ -111,6 +114,60 @@ export function getBorderStyleLabels( t ) {
 }
 
 /**
+ * Returns a localized error string that can be displayed next to color (background, border)
+ * fields that have an invalid value.
+ *
+ * @param {module:utils/locale~Locale#t} t The "t" function provided by the editor
+ * that is used to localize strings.
+ * @returns {String}
+ */
+export function getLocalizedColorErrorText( t ) {
+	return t( 'The color is invalid. Try "#FF0000" or "rgb(255,0,0)" or "red".' );
+}
+
+/**
+ * Returns a localized error string that can be displayed next to length (padding, border width)
+ * fields that have an invalid value.
+ *
+ * @param {module:utils/locale~Locale#t} t The "t" function provided by the editor
+ * that is used to localize strings.
+ * @returns {String}
+ */
+export function getLocalizedLengthErrorText( t ) {
+	return t( 'The value is invalid. Try "10px" or "2em" or simply "2".' );
+}
+
+/**
+ * Returns `true` when the passed value is an empty string or a valid CSS color expression.
+ * Otherwise, `false` is returned.
+ *
+ * See {@link module:engine/view/styles/utils~isColor}.
+ *
+ * @param {String} value
+ * @returns {Boolean}
+ */
+export function colorFieldValidator( value ) {
+	value = value.trim();
+
+	return isEmpty( value ) || isColor( value );
+}
+
+/**
+ * Returns `true` when the passed value is an empty string or a valid CSS length expression.
+ * Otherwise, `false` is returned.
+ *
+ * See {@link module:engine/view/styles/utils~isLength}.
+ *
+ * @param {String} value
+ * @returns {Boolean}
+ */
+export function lengthFieldValidator( value ) {
+	value = value.trim();
+
+	return isEmpty( value ) || isLength( value );
+}
+
+/**
  * 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|

+ 309 - 52
packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js

@@ -24,11 +24,13 @@ import TableCellPropertiesUIView from '../../src/tablecellproperties/ui/tablecel
 describe( 'table cell properties', () => {
 	describe( 'TableCellPropertiesUI', () => {
 		let editor, editorElement, contextualBalloon,
-			tableCellPropertiesUI, tableCellPropertiesView, tableCellPropertiesButton;
+			tableCellPropertiesUI, tableCellPropertiesView, tableCellPropertiesButton,
+			clock;
 
 		testUtils.createSinonSandbox();
 
 		beforeEach( () => {
+			clock = sinon.useFakeTimers();
 			editorElement = document.createElement( 'div' );
 			document.body.appendChild( editorElement );
 
@@ -52,6 +54,7 @@ describe( 'table cell properties', () => {
 		} );
 
 		afterEach( () => {
+			clock.restore();
 			editorElement.remove();
 
 			return editor.destroy();
@@ -127,45 +130,64 @@ describe( 'table cell properties', () => {
 				expect( contextualBalloon.visibleView ).to.be.null;
 			} );
 
-			it( 'should undo the entire batch of changes on #cancel', () => {
-				// Show the view. New batch will be created.
-				tableCellPropertiesButton.fire( 'execute' );
+			describe( '#cancel event', () => {
+				// https://github.com/ckeditor/ckeditor5/issues/6180
+				it( 'should not undo if it there were no changes made to the property fields', () => {
+					const spy = sinon.spy( editor, 'execute' );
 
-				// Do the changes like a user.
-				tableCellPropertiesView.borderStyle = 'dotted';
-				tableCellPropertiesView.backgroundColor = 'red';
-
-				expect( getModelData( editor.model ) ).to.equal(
-					'<table>' +
-						'<tableRow>' +
-							'<tableCell backgroundColor="red" borderStyle="dotted">' +
-								'<paragraph>[]foo</paragraph>' +
-							'</tableCell>' +
-						'</tableRow>' +
-					'</table>' +
-					'<paragraph>bar</paragraph>'
-				);
-
-				tableCellPropertiesView.fire( 'cancel' );
-
-				expect( getModelData( editor.model ) ).to.equal(
-					'<table>' +
-						'<tableRow>' +
-							'<tableCell>' +
-								'<paragraph>[]foo</paragraph>' +
-							'</tableCell>' +
-						'</tableRow>' +
-					'</table>' +
-					'<paragraph>bar</paragraph>'
-				);
-			} );
+					// Show the view. New batch will be created.
+					tableCellPropertiesButton.fire( 'execute' );
 
-			it( 'should hide on #cancel', () => {
-				tableCellPropertiesButton.fire( 'execute' );
-				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+					// Cancel the view immediately.
+					tableCellPropertiesView.fire( 'cancel' );
 
-				tableCellPropertiesView.fire( 'cancel' );
-				expect( contextualBalloon.visibleView ).to.be.null;
+					sinon.assert.notCalled( spy );
+				} );
+
+				it( 'should undo the entire batch of changes if there were some', () => {
+					const spy = sinon.spy( editor, 'execute' );
+
+					// Show the view. New batch will be created.
+					tableCellPropertiesButton.fire( 'execute' );
+
+					// Do the changes like a user.
+					tableCellPropertiesView.borderStyle = 'dotted';
+					tableCellPropertiesView.backgroundColor = 'red';
+
+					expect( getModelData( editor.model ) ).to.equal(
+						'<table>' +
+							'<tableRow>' +
+								'<tableCell backgroundColor="red" borderStyle="dotted">' +
+									'<paragraph>[]foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>' +
+						'<paragraph>bar</paragraph>'
+					);
+
+					tableCellPropertiesView.fire( 'cancel' );
+
+					expect( getModelData( editor.model ) ).to.equal(
+						'<table>' +
+							'<tableRow>' +
+								'<tableCell>' +
+									'<paragraph>[]foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>' +
+						'<paragraph>bar</paragraph>'
+					);
+
+					sinon.assert.calledWith( spy, 'undo', tableCellPropertiesUI._undoStepBatch );
+				} );
+
+				it( 'should hide the view', () => {
+					tableCellPropertiesButton.fire( 'execute' );
+					expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+					tableCellPropertiesView.fire( 'cancel' );
+					expect( contextualBalloon.visibleView ).to.be.null;
+				} );
 			} );
 
 			it( 'should hide on the Esc key press', () => {
@@ -215,14 +237,242 @@ describe( 'table cell properties', () => {
 			} );
 
 			describe( 'property changes', () => {
-				it( 'should affect the editor state', () => {
-					const spy = testUtils.sinon.stub( editor, 'execute' );
-
+				beforeEach( () => {
 					tableCellPropertiesUI._undoStepBatch = 'foo';
-					tableCellPropertiesView.borderStyle = 'dotted';
+				} );
 
-					sinon.assert.calledOnce( spy );
-					sinon.assert.calledWithExactly( spy, 'tableCellBorderStyle', { value: 'dotted', batch: 'foo' } );
+				describe( '#borderStyle', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.borderStyle = 'dotted';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellBorderStyle', { value: 'dotted', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#borderColor', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.borderColor = '#FFAAFF';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellBorderColor', { value: '#FFAAFF', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tableCellPropertiesView.borderColor = '42';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.borderColorInput.errorText ).to.match( /^The color is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tableCellPropertiesView.borderColor = '#AAA';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.borderColorInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableCellBorderColor', { value: '#AAA', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#borderWidth', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.borderWidth = '12px';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellBorderWidth', { value: '12px', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tableCellPropertiesView.borderWidth = 'wrong';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.borderWidthInput.errorText ).to.match( /^The value is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tableCellPropertiesView.borderWidth = '3em';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableCellBorderWidth', { value: '3em', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#width', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.width = '12px';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellWidth', { value: '12px', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tableCellPropertiesView.width = 'wrong';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.widthInput.errorText ).to.match( /^The value is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tableCellPropertiesView.width = '3em';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableCellWidth', { value: '3em', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#height', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.height = '12px';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellHeight', { value: '12px', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tableCellPropertiesView.height = 'wrong';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.heightInput.errorText ).to.match( /^The value is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tableCellPropertiesView.height = '3em';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableCellHeight', { value: '3em', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#padding', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.padding = '12px';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellPadding', { value: '12px', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tableCellPropertiesView.padding = 'wrong';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.paddingInput.errorText ).to.match( /^The value is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tableCellPropertiesView.padding = '3em';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableCellPadding', { value: '3em', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#backgroundColor', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.backgroundColor = '#FFAAFF';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellBackgroundColor', { value: '#FFAAFF', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tableCellPropertiesView.backgroundColor = '42';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.backgroundInput.errorText ).to.match( /^The color is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tableCellPropertiesView.backgroundColor = '#AAA';
+
+						clock.tick( 500 );
+
+						expect( tableCellPropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableCellBackgroundColor', { value: '#AAA', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#horizontalAlignment', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.horizontalAlignment = 'right';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellHorizontalAlignment', { value: 'right', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#verticalAlignment', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tableCellPropertiesView.verticalAlignment = 'right';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableCellVerticalAlignment', { value: 'right', batch: 'foo' } );
+					} );
+				} );
+
+				it( 'should not display an error text if user managed to fix the value before the UI timeout', () => {
+					// First, let's pass an invalid value.
+					tableCellPropertiesView.borderColor = '#';
+
+					clock.tick( 100 );
+
+					// Then the user managed to quickly type the correct value.
+					tableCellPropertiesView.borderColor = '#aaa';
+
+					clock.tick( 400 );
+
+					// Because they were quick, they should see no error
+					expect( tableCellPropertiesView.borderColorInput.errorText ).to.be.null;
 				} );
 
 				it( 'should not affect the editor state if internal property has changed', () => {
@@ -230,7 +480,6 @@ describe( 'table cell properties', () => {
 
 					tableCellPropertiesView.set( 'internalProp', 'foo' );
 
-					tableCellPropertiesUI._undoStepBatch = 'foo';
 					tableCellPropertiesView.internalProp = 'bar';
 
 					sinon.assert.notCalled( spy );
@@ -267,10 +516,12 @@ describe( 'table cell properties', () => {
 					editor.commands.get( 'tableCellBorderStyle' ).value = 'a';
 					editor.commands.get( 'tableCellBorderColor' ).value = 'b';
 					editor.commands.get( 'tableCellBorderWidth' ).value = 'c';
-					editor.commands.get( 'tableCellPadding' ).value = 'd';
-					editor.commands.get( 'tableCellBackgroundColor' ).value = 'e';
-					editor.commands.get( 'tableCellHorizontalAlignment' ).value = 'f';
-					editor.commands.get( 'tableCellVerticalAlignment' ).value = 'g';
+					editor.commands.get( 'tableCellWidth' ).value = 'd';
+					editor.commands.get( 'tableCellHeight' ).value = 'e';
+					editor.commands.get( 'tableCellPadding' ).value = 'f';
+					editor.commands.get( 'tableCellBackgroundColor' ).value = 'g';
+					editor.commands.get( 'tableCellHorizontalAlignment' ).value = 'h';
+					editor.commands.get( 'tableCellVerticalAlignment' ).value = 'i';
 
 					tableCellPropertiesButton.fire( 'execute' );
 
@@ -279,10 +530,12 @@ describe( 'table cell properties', () => {
 						borderStyle: 'a',
 						borderColor: 'b',
 						borderWidth: 'c',
-						padding: 'd',
-						backgroundColor: 'e',
-						horizontalAlignment: 'f',
-						verticalAlignment: 'g'
+						width: 'd',
+						height: 'e',
+						padding: 'f',
+						backgroundColor: 'g',
+						horizontalAlignment: 'h',
+						verticalAlignment: 'i'
 					} );
 				} );
 
@@ -290,6 +543,8 @@ describe( 'table cell properties', () => {
 					editor.commands.get( 'tableCellBorderStyle' ).value = null;
 					editor.commands.get( 'tableCellBorderColor' ).value = null;
 					editor.commands.get( 'tableCellBorderWidth' ).value = null;
+					editor.commands.get( 'tableCellWidth' ).value = null;
+					editor.commands.get( 'tableCellHeight' ).value = null;
 					editor.commands.get( 'tableCellPadding' ).value = null;
 					editor.commands.get( 'tableCellBackgroundColor' ).value = null;
 					editor.commands.get( 'tableCellHorizontalAlignment' ).value = null;
@@ -302,6 +557,8 @@ describe( 'table cell properties', () => {
 						borderStyle: 'none',
 						borderColor: '',
 						borderWidth: '',
+						width: '',
+						height: '',
 						padding: '',
 						backgroundColor: '',
 						horizontalAlignment: 'left',

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

@@ -219,13 +219,12 @@ describe( 'table cell properties', () => {
 					} );
 				} );
 
-				describe( 'background and padding row', () => {
+				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 );
-						expect( row.childNodes[ 1 ] ).to.equal( view.paddingInput.element );
 					} );
 
 					describe( 'background color input', () => {
@@ -259,6 +258,93 @@ describe( 'table cell properties', () => {
 							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-cell-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-cell-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-cell-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( 'padding 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-cell-properties-form__padding-row' ) ).to.be.true;
+						expect( row.childNodes[ 0 ] ).to.equal( view.paddingInput.element );
+					} );
 
 					describe( 'padding input', () => {
 						let labeledInput;
@@ -295,7 +381,7 @@ describe( 'table cell properties', () => {
 
 				describe( 'text alignment row', () => {
 					it( 'should be defined', () => {
-						const row = view.element.childNodes[ 3 ];
+						const row = view.element.childNodes[ 4 ];
 
 						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
 						expect( row.classList.contains( 'ck-table-cell-properties-form__alignment-row' ) ).to.be.true;
@@ -386,7 +472,7 @@ describe( 'table cell properties', () => {
 
 				describe( 'action row', () => {
 					it( 'should be defined', () => {
-						const row = view.element.childNodes[ 4 ];
+						const row = view.element.childNodes[ 5 ];
 
 						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
 						expect( row.classList.contains( 'ck-table-form__action-row' ) ).to.be.true;
@@ -414,6 +500,43 @@ describe( 'table cell properties', () => {
 
 						expect( spy.calledOnce ).to.be.true;
 					} );
+
+					it( 'should make sure the #saveButtonView is disabled until text fields are without errors', () => {
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = 'foo';
+						view.backgroundInput.errorText = 'foo';
+						view.paddingInput.errorText = 'foo';
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = 'foo';
+						view.backgroundInput.errorText = 'foo';
+						view.paddingInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = 'foo';
+						view.backgroundInput.errorText = null;
+						view.paddingInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = null;
+						view.backgroundInput.errorText = null;
+						view.paddingInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = null;
+						view.borderColorInput.errorText = null;
+						view.backgroundInput.errorText = null;
+						view.paddingInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.true;
+					} );
 				} );
 			} );
 
@@ -441,6 +564,8 @@ describe( 'table cell properties', () => {
 					view.borderColorInput,
 					view.borderWidthInput,
 					view.backgroundInput,
+					view.widthInput,
+					view.heightInput,
 					view.paddingInput,
 					view.horizontalAlignmentToolbar,
 					view.verticalAlignmentToolbar,

+ 251 - 44
packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js

@@ -24,11 +24,13 @@ import TablePropertiesUIView from '../../src/tableproperties/ui/tablepropertiesv
 describe( 'table properties', () => {
 	describe( 'TablePropertiesUI', () => {
 		let editor, editorElement, contextualBalloon,
-			tablePropertiesUI, tablePropertiesView, tablePropertiesButton;
+			tablePropertiesUI, tablePropertiesView, tablePropertiesButton,
+			clock;
 
 		testUtils.createSinonSandbox();
 
 		beforeEach( () => {
+			clock = sinon.useFakeTimers();
 			editorElement = document.createElement( 'div' );
 			document.body.appendChild( editorElement );
 
@@ -52,6 +54,7 @@ describe( 'table properties', () => {
 		} );
 
 		afterEach( () => {
+			clock.restore();
 			editorElement.remove();
 
 			return editor.destroy();
@@ -127,45 +130,64 @@ describe( 'table properties', () => {
 				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' );
+			describe( '#cancel event', () => {
+				// https://github.com/ckeditor/ckeditor5/issues/6180
+				it( 'should not undo if it there were no changes made to the property fields', () => {
+					const spy = sinon.spy( editor, '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>'
-				);
-			} );
+					// Show the view. New batch will be created.
+					tablePropertiesButton.fire( 'execute' );
 
-			it( 'should hide on #cancel', () => {
-				tablePropertiesButton.fire( 'execute' );
-				expect( contextualBalloon.visibleView ).to.equal( tablePropertiesView );
+					// Cancel the view immediately.
+					tablePropertiesView.fire( 'cancel' );
 
-				tablePropertiesView.fire( 'cancel' );
-				expect( contextualBalloon.visibleView ).to.be.null;
+					sinon.assert.notCalled( spy );
+				} );
+
+				it( 'should undo the entire batch of changes if there were some', () => {
+					const spy = sinon.spy( editor, 'execute' );
+
+					// 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>'
+					);
+
+					sinon.assert.calledWith( spy, 'undo', tablePropertiesUI._undoStepBatch );
+				} );
+
+				it( 'should hide the view', () => {
+					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', () => {
@@ -215,14 +237,200 @@ describe( 'table properties', () => {
 			} );
 
 			describe( 'property changes', () => {
-				it( 'should affect the editor state', () => {
-					const spy = testUtils.sinon.stub( editor, 'execute' );
-
+				beforeEach( () => {
 					tablePropertiesUI._undoStepBatch = 'foo';
-					tablePropertiesView.borderStyle = 'dotted';
+				} );
 
-					sinon.assert.calledOnce( spy );
-					sinon.assert.calledWithExactly( spy, 'tableBorderStyle', { value: 'dotted', batch: 'foo' } );
+				describe( '#borderStyle', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tablePropertiesView.borderStyle = 'dotted';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableBorderStyle', { value: 'dotted', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#borderColor', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tablePropertiesView.borderColor = '#FFAAFF';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableBorderColor', { value: '#FFAAFF', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tablePropertiesView.borderColor = '42';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.borderColorInput.errorText ).to.match( /^The color is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tablePropertiesView.borderColor = '#AAA';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.borderColorInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableBorderColor', { value: '#AAA', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#borderWidth', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tablePropertiesView.borderWidth = '12px';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableBorderWidth', { value: '12px', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tablePropertiesView.borderWidth = 'wrong';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.borderWidthInput.errorText ).to.match( /^The value is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tablePropertiesView.borderWidth = '3em';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableBorderWidth', { value: '3em', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#backgroundColor', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tablePropertiesView.backgroundColor = '#FFAAFF';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableBackgroundColor', { value: '#FFAAFF', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tablePropertiesView.backgroundColor = '42';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.backgroundInput.errorText ).to.match( /^The color is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tablePropertiesView.backgroundColor = '#AAA';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableBackgroundColor', { value: '#AAA', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#width', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tablePropertiesView.width = '12px';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableWidth', { value: '12px', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tablePropertiesView.width = 'wrong';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.widthInput.errorText ).to.match( /^The value is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tablePropertiesView.width = '3em';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableWidth', { value: '3em', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#height', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tablePropertiesView.height = '12px';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableHeight', { value: '12px', batch: 'foo' } );
+					} );
+
+					it( 'should display an error message if value is invalid', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						// First, let's pass an invalid value and check what happens.
+						tablePropertiesView.height = 'wrong';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.heightInput.errorText ).to.match( /^The value is invalid/ );
+						sinon.assert.notCalled( spy );
+
+						// And now let's pass a valid value and check if the error text will be gone.
+						tablePropertiesView.height = '3em';
+
+						clock.tick( 500 );
+
+						expect( tablePropertiesView.backgroundInput.errorText ).to.be.null;
+						sinon.assert.calledWithExactly( spy, 'tableHeight', { value: '3em', batch: 'foo' } );
+					} );
+				} );
+
+				describe( '#alignment', () => {
+					it( 'should affect the editor state', () => {
+						const spy = testUtils.sinon.stub( editor, 'execute' );
+
+						tablePropertiesView.alignment = 'right';
+
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'tableAlignment', { value: 'right', batch: 'foo' } );
+					} );
+				} );
+
+				it( 'should not display an error text if user managed to fix the value before the UI timeout', () => {
+					// First, let's pass an invalid value.
+					tablePropertiesView.borderColor = '#';
+
+					clock.tick( 100 );
+
+					// Then the user managed to quickly type the correct value.
+					tablePropertiesView.borderColor = '#aaa';
+
+					clock.tick( 400 );
+
+					// Because they were quick, they should see no error
+					expect( tablePropertiesView.borderColorInput.errorText ).to.be.null;
 				} );
 
 				it( 'should not affect the editor state if internal property has changed', () => {
@@ -230,7 +438,6 @@ describe( 'table properties', () => {
 
 					tablePropertiesView.set( 'internalProp', 'foo' );
 
-					tablePropertiesUI._undoStepBatch = 'foo';
 					tablePropertiesView.internalProp = 'bar';
 
 					sinon.assert.notCalled( spy );

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

@@ -417,6 +417,56 @@ describe( 'table properties', () => {
 
 						expect( spy.calledOnce ).to.be.true;
 					} );
+
+					it( 'should make sure the #saveButtonView is disabled until text fields are without errors', () => {
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = 'foo';
+						view.backgroundInput.errorText = 'foo';
+						view.widthInput.errorText = 'foo';
+						view.heightInput.errorText = 'foo';
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = 'foo';
+						view.backgroundInput.errorText = 'foo';
+						view.widthInput.errorText = 'foo';
+						view.heightInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = 'foo';
+						view.backgroundInput.errorText = 'foo';
+						view.widthInput.errorText = null;
+						view.heightInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = 'foo';
+						view.backgroundInput.errorText = null;
+						view.widthInput.errorText = null;
+						view.heightInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = 'foo';
+						view.borderColorInput.errorText = null;
+						view.backgroundInput.errorText = null;
+						view.widthInput.errorText = null;
+						view.heightInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.false;
+
+						view.borderWidthInput.errorText = null;
+						view.borderColorInput.errorText = null;
+						view.backgroundInput.errorText = null;
+						view.widthInput.errorText = null;
+						view.heightInput.errorText = null;
+
+						expect( view.saveButtonView.isEnabled ).to.be.true;
+					} );
 				} );
 			} );
 

+ 64 - 1
packages/ckeditor5-table/tests/ui/utils.js

@@ -19,6 +19,10 @@ import {
 	getBalloonCellPositionData,
 	getBorderStyleDefinitions,
 	getBorderStyleLabels,
+	getLocalizedColorErrorText,
+	getLocalizedLengthErrorText,
+	lengthFieldValidator,
+	colorFieldValidator,
 	fillToolbar
 } from '../../src/ui/utils';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
@@ -145,7 +149,7 @@ describe( 'UI Utils', () => {
 		} );
 	} );
 
-	describe( 'getBalloonCellPositionData', () => {
+	describe( 'getBalloonCellPositionData()', () => {
 		it( 'returns the position data', () => {
 			const defaultPositions = BalloonPanelView.defaultPositions;
 
@@ -190,6 +194,65 @@ describe( 'UI Utils', () => {
 		} );
 	} );
 
+	describe( 'getLocalizedColorErrorText()', () => {
+		it( 'should return the error text', () => {
+			const t = string => string;
+
+			expect( getLocalizedColorErrorText( t ) ).to.match( /^The color is invalid/ );
+		} );
+	} );
+
+	describe( 'getLocalizedLengthErrorText()', () => {
+		it( 'should return the error text', () => {
+			const t = string => string;
+
+			expect( getLocalizedLengthErrorText( t ) ).to.match( /^The value is invalid/ );
+		} );
+	} );
+
+	describe( 'colorFieldValidator()', () => {
+		it( 'should passe for an empty value', () => {
+			expect( colorFieldValidator( '' ) ).to.be.true;
+		} );
+
+		it( 'should passe for white spaces', () => {
+			expect( colorFieldValidator( '  ' ) ).to.be.true;
+		} );
+
+		it( 'should pass for colors', () => {
+			expect( colorFieldValidator( '#FFF' ) ).to.be.true;
+			expect( colorFieldValidator( '#FFAA11' ) ).to.be.true;
+			expect( colorFieldValidator( 'rgb(255,123,100)' ) ).to.be.true;
+			expect( colorFieldValidator( 'red' ) ).to.be.true;
+		} );
+
+		it( 'should pass for colors surrounded by white spaces', () => {
+			expect( colorFieldValidator( ' #AAA ' ) ).to.be.true;
+			expect( colorFieldValidator( ' rgb(255,123,100) ' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'lengthFieldValidator()', () => {
+		it( 'should passe for an empty value', () => {
+			expect( lengthFieldValidator( '' ) ).to.be.true;
+		} );
+
+		it( 'should passe for white spaces', () => {
+			expect( lengthFieldValidator( '  ' ) ).to.be.true;
+		} );
+
+		it( 'should pass for lengths', () => {
+			expect( lengthFieldValidator( '1px' ) ).to.be.true;
+			expect( lengthFieldValidator( '12em' ) ).to.be.true;
+			expect( lengthFieldValidator( ' 12em ' ) ).to.be.true;
+		} );
+
+		it( 'should pass for lengths surrounded by white spaces', () => {
+			expect( lengthFieldValidator( '3px ' ) ).to.be.true;
+			expect( lengthFieldValidator( ' 12em ' ) ).to.be.true;
+		} );
+	} );
+
 	describe( 'getBorderStyleDefinitions()', () => {
 		let view, locale, definitions;
 

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

@@ -5,9 +5,26 @@
 
 .ck.ck-table-cell-properties-form {
 	& .ck-form__row {
+		&.ck-table-cell-properties-form__dimensions-row,
 		&.ck-table-cell-properties-form__alignment-row {
 			flex-wrap: wrap;
+		}
+
+		&.ck-table-cell-properties-form__dimensions-row {
+			align-items: center;
 
+			& .ck-labeled-view {
+				display: flex;
+				flex-direction: column-reverse;
+				align-items: center;
+
+				& .ck.ck-dropdown {
+					flex-grow: 0;
+				}
+			}
+		}
+
+		&.ck-table-cell-properties-form__alignment-row {
 			& .ck.ck-toolbar {
 				flex-grow: 0;
 			}

+ 28 - 8
packages/ckeditor5-table/theme/tableform.css

@@ -12,25 +12,45 @@
 			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;
 		}
 	}
+
+	& .ck-table-form__dimension-operator {
+		flex-grow: 0;
+	}
+
+	& .ck.ck-labeled-view {
+		/* Allow absolute positioning of the status (error) balloons. */
+		position: relative;
+
+		& .ck.ck-labeled-view__status {
+			position: absolute;
+			left: 50%;
+			bottom: calc( -1 * var(--ck-table-properties-error-arrow-size) );
+			transform: translate(-50%,100%);
+
+			/* Make sure the balloon status stays on top of other form elements. */
+			z-index: 1;
+
+			/* The arrow pointing towards the field. */
+			&::after {
+				content: "";
+				position: absolute;
+				top: calc( -1 * var(--ck-table-properties-error-arrow-size) );
+				left: 50%;
+				transform: translateX( -50% );
+			}
+		}
+	}
 }

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

@@ -13,10 +13,6 @@
 		&.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;