ソースを参照

Merge pull request #227 from ckeditor/i/6049

Feature: Introduced the table cell properties UI. Closes ckeditor/ckeditor5#6049.
Maciej 5 年 前
コミット
d3002c6204

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

@@ -17,5 +17,33 @@
 	"Split cell vertically": "Label for the split table cell vertically button.",
 	"Split cell horizontally": "Label for the split table cell horizontally button.",
 	"Merge cells": "Label for the merge table cells button.",
-	"Table toolbar": "The label used by assistive technologies describing a table toolbar attached to a table widget."
+	"Table toolbar": "The label used by assistive technologies describing a table toolbar attached to a table widget.",
+	"Cell properties": "The label describing the form allowing to specify the properties of a selected table cell.",
+	"Border": "The label describing a group of border–related form fields (border style, color, etc.).",
+	"Style": "The label for the dropdown that allows configuring the border style of a table or a table cell.",
+	"Width": "The label for the input that allows configuring the border width of a table or a table cell.",
+	"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.",
+	"Text alignment": "The label for the group of toolbars that allows configuring the text alignment in a table cell.",
+	"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.",
+	"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.",
+	"Solid": "The label for the border style dropdown when the solid border is applied to a table or a table cell.",
+	"Dotted": "The label for the border style dropdown when the dotted border is applied to a table or a table cell.",
+	"Dashed": "The label for the border style dropdown when the dashed border is applied to a table or a table cell.",
+	"Double": "The label for the border style dropdown when the double border is applied to a table or a table cell.",
+	"Groove": "The label for the border style dropdown when the groove border is applied to a table or a table cell.",
+	"Ridge": "The label for the border style dropdown when the ridge border is applied to a table or a table cell.",
+	"Inset": "The label for the border style dropdown when the inset border is applied to a table or a table cell.",
+	"Outset": "The label for the border style dropdown when the outset border is applied to a table or a table cell.",
+	"Align cell text to the left": "The label used by assistive technologies describing a button that aligns the table cell text to the left.",
+	"Align cell text to the center": "The label used by assistive technologies describing a button that aligns the table cell text to the center.",
+	"Align cell text to the right": "The label used by assistive technologies describing a button that aligns the table cell text to the right.",
+	"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."
 }

+ 4 - 4
packages/ckeditor5-table/src/tablecellproperties.js

@@ -8,15 +8,15 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-
+import TableCellPropertiesUI from './tablecellproperties/tablecellpropertiesui';
 import TableCellPropertiesEditing from './tablecellproperties/tablecellpropertiesediting';
 
 /**
  * The table cell properties feature.
  *
  * This is a "glue" plugin which loads the
- * {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing table properties editing feature} and
- * table cell properties UI feature.
+ * {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing table cell properties editing feature} and
+ * the {@link module:table/tablecellproperties/tablecellpropertiesui~TableCellPropertiesUI table cell properties UI feature}.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -32,6 +32,6 @@ export default class TableCellProperties extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ TableCellPropertiesEditing ];
+		return [ TableCellPropertiesEditing, TableCellPropertiesUI ];
 	}
 }

+ 278 - 0
packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js

@@ -0,0 +1,278 @@
+/**
+ * @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/tablecellproperties/tablecellpropertiesui
+ */
+
+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 TableCellPropertiesView from './ui/tablecellpropertiesview';
+import tableCellProperties from './../../theme/icons/table-cell-properties.svg';
+import { repositionContextualBalloon, getBalloonCellPositionData } from '../ui/utils';
+
+const DEFAULT_BORDER_STYLE = 'none';
+const DEFAULT_HORIZONTAL_ALIGNMENT = 'left';
+const DEFAULT_VERTICAL_ALIGNMENT = 'middle';
+
+/**
+ * The table cell properties UI plugin. It introduces the `'tableCellProperties'` button
+ * that opens a form allowing to specify visual styling of a table cell.
+ *
+ * It uses the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableCellPropertiesUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableCellPropertiesUI';
+	}
+
+	/**
+	 * @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 cell properties form view displayed inside the balloon.
+		 *
+		 * @member {module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView}
+		 */
+		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( 'tableCellProperties', locale => {
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: t( 'Cell properties' ),
+				icon: tableCellProperties,
+				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/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView} instance.
+	 *
+	 * @private
+	 * @returns {module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView} The cell
+	 * properties form view instance.
+	 */
+	_createPropertiesView() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view.document;
+		const view = new TableCellPropertiesView( 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 cell is no longer selected.
+		this.listenTo( editor.ui, 'update', () => {
+			if ( !getTableWidgetAncestor( viewDocument.selection ) ) {
+				this._hideView();
+			} else if ( this._isViewVisible ) {
+				repositionContextualBalloon( editor );
+			}
+		} );
+
+		// 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( '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:horizontalAlignment', this._getPropertyChangeCallback( 'tableCellHorizontalAlignment' ) );
+		view.on( 'change:verticalAlignment', this._getPropertyChangeCallback( 'tableCellVerticalAlignment' ) );
+
+		return view;
+	}
+
+	/**
+	 * In this method the "editor data -> UI" binding is happening.
+	 *
+	 * When executed, this method obtains selected cell 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( 'tableCellBorderStyle' ).value || DEFAULT_BORDER_STYLE,
+			borderColor: commands.get( 'tableCellBorderColor' ).value || '',
+			borderWidth: commands.get( 'tableCellBorderWidth' ).value || '',
+			padding: commands.get( 'tableCellPadding' ).value || '',
+			backgroundColor: commands.get( 'tableCellBackgroundColor' ).value || '',
+			horizontalAlignment: commands.get( 'tableCellHorizontalAlignment' ).value || DEFAULT_HORIZONTAL_ALIGNMENT,
+			verticalAlignment: commands.get( 'tableCellVerticalAlignment' ).value || DEFAULT_VERTICAL_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: getBalloonCellPositionData( 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
+			} );
+		};
+	}
+}

+ 707 - 0
packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js

@@ -0,0 +1,707 @@
+/**
+ * @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/tablecellproperties/ui/tablecellpropertiesview
+ */
+
+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';
+
+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 { createLabeledInputText, createLabeledDropdown } 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 checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
+import alignLeftIcon from '@ckeditor/ckeditor5-core/theme/icons/align-left.svg';
+import alignRightIcon from '@ckeditor/ckeditor5-core/theme/icons/align-right.svg';
+import alignCenterIcon from '@ckeditor/ckeditor5-core/theme/icons/align-center.svg';
+import alignJustifyIcon from '@ckeditor/ckeditor5-core/theme/icons/align-justify.svg';
+import alignTopIcon from '@ckeditor/ckeditor5-core/theme/icons/align-top.svg';
+import alignMiddleIcon from '@ckeditor/ckeditor5-core/theme/icons/align-middle.svg';
+import alignBottomIcon from '@ckeditor/ckeditor5-core/theme/icons/align-bottom.svg';
+
+import '../../../theme/form.css';
+import '../../../theme/tablecellproperties.css';
+import FormRowView from '../../ui/formrowview';
+
+const ALIGNMENT_ICONS = {
+	left: alignLeftIcon,
+	center: alignCenterIcon,
+	right: alignRightIcon,
+	justify: alignJustifyIcon,
+	top: alignTopIcon,
+	middle: alignMiddleIcon,
+	bottom: alignBottomIcon
+};
+
+/**
+ * The class representing a table cell properties form, allowing users to customize
+ * certain style aspects of a table cell, for instance, border, padding, text alignment, etc..
+ *
+ * @extends module:ui/view~View
+ */
+export default class TableCellPropertiesView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const { borderStyleDropdown, borderWidthInput, borderColorInput, borderRowLabel } = this._createBorderFields();
+		const { horizontalAlignmentToolbar, verticalAlignmentToolbar, 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 cell border style.
+			 *
+			 * @observable
+			 * @default 'none'
+			 * @member #borderStyle
+			 */
+			borderStyle: 'none',
+
+			/**
+			 * The value of the cell border width style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #borderWidth
+			 */
+			borderWidth: '',
+
+			/**
+			 * The value of the cell border color style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #borderColor
+			 */
+			borderColor: '',
+
+			/**
+			 * The value of the cell padding style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #padding
+			 */
+			padding: '',
+
+			/**
+			 * The value of the cell background color style.
+			 *
+			 * @observable
+			 * @default ''
+			 * @member #backgroundColor
+			 */
+			backgroundColor: '',
+
+			/**
+			 * The value of the horizontal text alignment style.
+			 *
+			 * @observable
+			 * @default 'left'
+			 * @member #horizontalAlignment
+			 */
+			horizontalAlignment: 'left',
+
+			/**
+			 * The value of the vertical text alignment style.
+			 *
+			 * @observable
+			 * @default 'middle'
+			 * @member #verticalAlignment
+			 */
+			verticalAlignment: 'middle'
+		} );
+
+		/**
+		 * A dropdown that allows selecting the style of the table cell border.
+		 *
+		 * @readonly
+		 * @member {module:ui/dropdown/dropdownview~DropdownView}
+		 */
+		this.borderStyleDropdown = borderStyleDropdown;
+
+		/**
+		 * An input that allows specifying the width of the table cell border.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.borderWidthInput = borderWidthInput;
+
+		/**
+		 * An input that allows specifying the color of the table cell border.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.borderColorInput = borderColorInput;
+
+		/**
+		 * An input that allows specifying the table cell background color.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.backgroundInput = this._createBackgroundField();
+
+		/**
+		 * An input that allows specifying the table cell padding.
+		 *
+		 * @readonly
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
+		 */
+		this.paddingInput = this._createPaddingField();
+
+		/**
+		 * A toolbar with buttons that allow changing the horizontal text alignment in a table cell.
+		 *
+		 * @readonly
+		 * @member {module:ui/toolbar/toolbar~ToolbarView}
+		 */
+		this.horizontalAlignmentToolbar = horizontalAlignmentToolbar;
+
+		/**
+		 * A toolbar with buttons that allow changing the vertical text alignment in a table cell.
+		 *
+		 * @readonly
+		 * @member {module:ui/toolbar/toolbar~ToolbarView}
+		 */
+		this.verticalAlignmentToolbar = verticalAlignmentToolbar;
+
+		/**
+		 * 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( this._createHeaderView() );
+
+		// Border row.
+		this.children.add( new FormRowView( locale, {
+			labelView: borderRowLabel,
+			children: [
+				borderRowLabel,
+				borderStyleDropdown,
+				borderColorInput,
+				borderWidthInput
+			],
+			class: 'ck-table-cell-properties-form__border-row'
+		} ) );
+
+		// Background and padding row.
+		this.children.add( new FormRowView( locale, {
+			children: [
+				this.backgroundInput,
+				this.paddingInput,
+			]
+		} ) );
+
+		// Text alignment row.
+		this.children.add( new FormRowView( locale, {
+			labelView: alignmentLabel,
+			children: [
+				alignmentLabel,
+				horizontalAlignmentToolbar,
+				verticalAlignmentToolbar,
+			],
+			class: 'ck-table-cell-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-cell-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.paddingInput,
+			this.horizontalAlignmentToolbar,
+			this.verticalAlignmentToolbar,
+			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 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},
+	 * * {@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 borderStyleDropdown = new LabeledView( locale, createLabeledDropdown );
+		borderStyleDropdown.set( {
+			label: t( 'Style' ),
+			class: 'ck-table-cell-properties-form__border-style'
+		} );
+
+		borderStyleDropdown.view.buttonView.set( {
+			isOn: false,
+			withText: true,
+			tooltip: t( 'Style' )
+		} );
+
+		borderStyleDropdown.view.buttonView.bind( 'label' ).to( this, 'borderStyle', value => {
+			return this._borderStyleLabels[ value ];
+		} );
+
+		borderStyleDropdown.view.on( 'execute', evt => {
+			this.borderStyle = evt.source._borderStyleValue;
+		} );
+
+		addListToDropdown( borderStyleDropdown.view, this._getBorderStyleDefinitions() );
+
+		// -- Width ---------------------------------------------------
+
+		const borderWidthInput = new LabeledView( locale, createLabeledInputText );
+
+		borderWidthInput.set( {
+			label: t( 'Width' ),
+			class: 'ck-table-cell-properties-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-cell-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 #paddingInput}.
+	 *
+	 * @private
+	 * @returns {module:ui/labeledview/labeledview~LabeledView}
+	 */
+	_createPaddingField() {
+		const locale = this.locale;
+		const t = this.t;
+
+		const paddingInput = new LabeledView( locale, createLabeledInputText );
+
+		paddingInput.set( {
+			label: t( 'Padding' ),
+			class: 'ck-table-cell-properties-form__padding',
+		} );
+
+		paddingInput.view.bind( 'value' ).to( this, 'padding' );
+		paddingInput.view.on( 'input', () => {
+			this.padding = paddingInput.view.element.value;
+		} );
+
+		return paddingInput;
+	}
+
+	/**
+	 * Creates the following form fields:
+	 *
+	 * * {@link #horizontalAlignmentToolbar},
+	 * * {@link #verticalAlignmentToolbar}.
+	 *
+	 * @private
+	 * @returns {Object.<String,module:ui/view~View>}
+	 */
+	_createAlignmentFields() {
+		const locale = this.locale;
+		const t = this.t;
+
+		const alignmentLabel = new LabelView( locale );
+
+		alignmentLabel.text = t( 'Text alignment' );
+
+		// -- Horizontal ---------------------------------------------------
+
+		const horizontalAlignmentToolbar = new ToolbarView( locale );
+		horizontalAlignmentToolbar.ariaLabel = t( 'Horizontal text alignment toolbar' );
+		this._fillAlignmentToolbar( horizontalAlignmentToolbar, this._horizontalAlignmentLabels, 'horizontalAlignment' );
+
+		// -- Vertical -----------------------------------------------------
+
+		const verticalAlignmentToolbar = new ToolbarView( locale );
+		verticalAlignmentToolbar.ariaLabel = t( 'Vertical text alignment toolbar' );
+		this._fillAlignmentToolbar( verticalAlignmentToolbar, this._verticalAlignmentLabels, 'verticalAlignment' );
+
+		return {
+			horizontalAlignmentToolbar,
+			verticalAlignmentToolbar,
+			alignmentLabel
+		};
+	}
+
+	/**
+	 * 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 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
+	 * @type {Object.<String,String>}
+	 */
+	get _horizontalAlignmentLabels() {
+		const t = this.t;
+
+		return {
+			left: t( 'Align cell text to the left' ),
+			center: t( 'Align cell text to the center' ),
+			right: t( 'Align cell text to the right' ),
+			justify: t( 'Justify cell text' ),
+		};
+	}
+
+	/**
+	 * Provides localized labels for {@link #verticalAlignmentToolbar} buttons.
+	 *
+	 * @private
+	 * @type {Object.<String,String>}
+	 */
+	get _verticalAlignmentLabels() {
+		const t = this.t;
+
+		return {
+			top: t( 'Align cell text to the top' ),
+			middle: t( 'Align cell text to the middle' ),
+			bottom: t( 'Align cell text to the bottom' )
+		};
+	}
+}

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

@@ -0,0 +1,101 @@
+/**
+ * @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/formrowview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+/**
+ * The class representing a single row in the complex form,
+ * used by {@link module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView}.
+ *
+ * **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 FormRowView extends View {
+	/**
+	 * Creates an instance of the form row class.
+	 *
+	 * @param {module:utils/locale~Locale} locale The locale instance.
+	 * @param {Object} options
+	 * @param {Array.<module:ui/view~View>} options.children
+	 * @param {String} [options.class]
+	 * @param {module:ui/view~View} [options.labelView] When passed, the row gets the `group` and `aria-labelledby`
+	 * DOM attributes and get described by the label.
+	 */
+	constructor( locale, options = {} ) {
+		super( locale );
+
+		const bind = this.bindTemplate;
+
+		/**
+		 * An additional CSS class added to the {@link #element}.
+		 *
+		 * @observable
+		 * @member {String} #class
+		 */
+		this.set( 'class', options.class || null );
+
+		/**
+		 * A collection of row items (buttons, dropdowns, etc.).
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.children = this.createCollection();
+
+		if ( options.children ) {
+			options.children.forEach( child => this.children.add( child ) );
+		}
+
+		/**
+		 * The role property reflected by the `role` DOM attribute of the {@link #element}.
+		 *
+		 * **Note**: Used only when a `labelView` is passed to constructor `options`.
+		 *
+		 * @private
+		 * @observable
+		 * @member {String} #role
+		 */
+		this.set( '_role', null );
+
+		/**
+		 * The ARIA property reflected by the `aria-labelledby` DOM attribute of the {@link #element}.
+		 *
+		 * **Note**: Used only when a `labelView` is passed to constructor `options`.
+		 *
+		 * @private
+		 * @observable
+		 * @member {String} #ariaLabelledBy
+		 */
+		this.set( '_ariaLabelledBy', null );
+
+		if ( options.labelView ) {
+			this.set( {
+				_role: 'group',
+				_ariaLabelledBy: options.labelView.id
+			} );
+		}
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-form__row',
+					bind.to( 'class' )
+				],
+				role: bind.to( '_role' ),
+				'aria-labelledby': bind.to( '_ariaLabelledBy' )
+			},
+			children: this.children
+		} );
+	}
+}

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

@@ -0,0 +1,56 @@
+/**
+ * @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/utils
+ */
+
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import { getTableWidgetAncestor } from '../utils';
+import { findAncestor } from '../commands/utils';
+
+/**
+ * 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.
+ */
+export function repositionContextualBalloon( editor ) {
+	const balloon = editor.plugins.get( 'ContextualBalloon' );
+
+	if ( getTableWidgetAncestor( editor.editing.view.document.selection ) ) {
+		const position = getBalloonCellPositionData( editor );
+
+		balloon.updatePosition( position );
+	}
+}
+
+/**
+ * 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.
+ * @returns {module:utils/dom/position~Options}
+ */
+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
+		]
+	};
+}

+ 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' ],
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableCellProperties' ],
 			tableToolbar: [ 'bold', 'italic' ]
 		}
 	} )

+ 5 - 0
packages/ckeditor5-table/tests/tablecellproperties.js

@@ -10,6 +10,7 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import TableEditing from '../src/tableediting';
 import TableCellProperties from '../src/tablecellproperties';
 import TableCellPropertiesEditing from '../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellPropertiesUI from '../src/tablecellproperties/tablecellpropertiesui';
 
 describe( 'table cell properties', () => {
 	let editor, editorElement;
@@ -33,6 +34,10 @@ describe( 'table cell properties', () => {
 			expect( editor.plugins.get( TableCellProperties ) ).to.instanceOf( TableCellProperties );
 		} );
 
+		it( 'should load TableCellPropertiesUI plugin', () => {
+			expect( editor.plugins.get( TableCellPropertiesUI ) ).to.instanceOf( TableCellPropertiesUI );
+		} );
+
 		it( 'should load TableCellPropertiesEditing plugin', () => {
 			expect( editor.plugins.get( TableCellPropertiesEditing ) ).to.instanceOf( TableCellPropertiesEditing );
 		} );

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

@@ -0,0 +1,348 @@
+/**
+ * @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 TableCellPropertiesEditing from '../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellPropertiesUI from '../../src/tablecellproperties/tablecellpropertiesui';
+import TableCellPropertiesUIView from '../../src/tablecellproperties/ui/tablecellpropertiesview';
+
+describe( 'table cell properties', () => {
+	describe( 'TableCellPropertiesUI', () => {
+		let editor, editorElement, contextualBalloon,
+			tableCellPropertiesUI, tableCellPropertiesView, tableCellPropertiesButton;
+
+		testUtils.createSinonSandbox();
+
+		beforeEach( () => {
+			editorElement = document.createElement( 'div' );
+			document.body.appendChild( editorElement );
+
+			return ClassicTestEditor
+				.create( editorElement, {
+					plugins: [ Table, TableCellPropertiesEditing, TableCellPropertiesUI, Paragraph, Undo ],
+					initialData: '<table><tr><td>foo</td></tr></table><p>bar</p>'
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+
+					tableCellPropertiesUI = editor.plugins.get( TableCellPropertiesUI );
+					tableCellPropertiesButton = editor.ui.componentFactory.create( 'tableCellProperties' );
+					contextualBalloon = editor.plugins.get( ContextualBalloon );
+					tableCellPropertiesView = tableCellPropertiesUI.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( TableCellPropertiesUI.pluginName ).to.equal( 'TableCellPropertiesUI' );
+		} );
+
+		it( 'should load ContextualBalloon', () => {
+			expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon );
+		} );
+
+		describe( 'init()', () => {
+			it( 'should set a batch', () => {
+				expect( tableCellPropertiesUI._undoStepBatch ).to.be.null;
+			} );
+
+			describe( '#view', () => {
+				it( 'should be created', () => {
+					expect( tableCellPropertiesUI.view ).to.be.instanceOf( TableCellPropertiesUIView );
+				} );
+
+				it( 'should be rendered', () => {
+					expect( tableCellPropertiesUI.view.isRendered ).to.be.true;
+				} );
+			} );
+
+			describe( 'toolbar button', () => {
+				it( 'should be registered', () => {
+					expect( tableCellPropertiesButton ).to.be.instanceOf( ButtonView );
+				} );
+
+				it( 'should have a label', () => {
+					expect( tableCellPropertiesButton.label ).to.equal( 'Cell properties' );
+				} );
+
+				it( 'should have a tooltip', () => {
+					expect( tableCellPropertiesButton.tooltip ).to.be.true;
+				} );
+
+				it( 'should call #_showView upon #execute', () => {
+					const spy = testUtils.sinon.stub( tableCellPropertiesUI, '_showView' ).returns( {} );
+
+					tableCellPropertiesButton.fire( 'execute' );
+					sinon.assert.calledOnce( spy );
+				} );
+			} );
+		} );
+
+		describe( 'destroy()', () => {
+
+		} );
+
+		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', () => {
+				tableCellPropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+				tableCellPropertiesView.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.
+				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>'
+				);
+			} );
+
+			it( 'should hide on #cancel', () => {
+				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', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				tableCellPropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+				tableCellPropertiesView.keystrokes.press( keyEvtData );
+				expect( contextualBalloon.visibleView ).to.be.null;
+			} );
+
+			it( 'should hide if the table cell is no longer selected on EditorUI#update', () => {
+				tableCellPropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+				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 cell is still selected on on EditorUI#update', () => {
+				tableCellPropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+				editor.model.change( writer => {
+					writer.insertText( 'qux', editor.model.document.selection.getFirstPosition() );
+				} );
+
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+			} );
+
+			it( 'should hide if clicked outside the balloon', () => {
+				tableCellPropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+				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' );
+
+					tableCellPropertiesUI._undoStepBatch = 'foo';
+					tableCellPropertiesView.borderStyle = 'dotted';
+
+					sinon.assert.calledOnce( spy );
+					sinon.assert.calledWithExactly( spy, 'tableCellBorderStyle', { value: 'dotted', batch: 'foo' } );
+				} );
+
+				it( 'should not affect the editor state if internal property has changed', () => {
+					const spy = testUtils.sinon.stub( editor, 'execute' );
+
+					tableCellPropertiesView.set( 'internalProp', 'foo' );
+
+					tableCellPropertiesUI._undoStepBatch = 'foo';
+					tableCellPropertiesView.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', () => {
+				tableCellPropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+				const firstBatch = tableCellPropertiesUI._undoStepBatch;
+				expect( firstBatch ).to.be.instanceOf( Batch );
+
+				tableCellPropertiesView.fire( 'submit' );
+				expect( contextualBalloon.visibleView ).to.be.null;
+
+				tableCellPropertiesButton.fire( 'execute' );
+
+				const secondBatch = tableCellPropertiesUI._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( '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';
+
+					tableCellPropertiesButton.fire( 'execute' );
+
+					expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+					expect( tableCellPropertiesView ).to.include( {
+						borderStyle: 'a',
+						borderColor: 'b',
+						borderWidth: 'c',
+						padding: 'd',
+						backgroundColor: 'e',
+						horizontalAlignment: 'f',
+						verticalAlignment: 'g'
+					} );
+				} );
+
+				it( 'should use default values when command has no value', () => {
+					editor.commands.get( 'tableCellBorderStyle' ).value = null;
+					editor.commands.get( 'tableCellBorderColor' ).value = null;
+					editor.commands.get( 'tableCellBorderWidth' ).value = null;
+					editor.commands.get( 'tableCellPadding' ).value = null;
+					editor.commands.get( 'tableCellBackgroundColor' ).value = null;
+					editor.commands.get( 'tableCellHorizontalAlignment' ).value = null;
+					editor.commands.get( 'tableCellVerticalAlignment' ).value = null;
+
+					tableCellPropertiesButton.fire( 'execute' );
+
+					expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+					expect( tableCellPropertiesView ).to.include( {
+						borderStyle: 'none',
+						borderColor: '',
+						borderWidth: '',
+						padding: '',
+						backgroundColor: '',
+						horizontalAlignment: 'left',
+						verticalAlignment: 'middle'
+					} );
+				} );
+			} );
+
+			it( 'should focus the form view', () => {
+				const spy = testUtils.sinon.spy( tableCellPropertiesView, 'focus' );
+
+				tableCellPropertiesButton.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( tableCellPropertiesUI, 'stopListening' );
+
+				tableCellPropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+				tableCellPropertiesView.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' );
+
+				tableCellPropertiesButton.fire( 'execute' );
+				expect( contextualBalloon.visibleView ).to.equal( tableCellPropertiesView );
+
+				tableCellPropertiesView.fire( 'submit' );
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+} );

+ 543 - 0
packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js

@@ -0,0 +1,543 @@
+/**
+ * @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 TableCellPropertiesView from '../../../src/tablecellproperties/ui/tablecellpropertiesview';
+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 cell properties', () => {
+	describe( 'TableCellPropertiesView', () => {
+		let view, locale;
+
+		testUtils.createSinonSandbox();
+
+		beforeEach( () => {
+			locale = { t: val => val };
+			view = new TableCellPropertiesView( 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: '',
+					padding: '',
+					backgroundColor: '',
+					horizontalAlignment: 'left',
+					verticalAlignment: 'middle'
+				} );
+			} );
+
+			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-cell-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.paddingInput ).to.be.instanceOf( LabeledView );
+				expect( view.horizontalAlignmentToolbar ).to.be.instanceOf( ToolbarView );
+				expect( view.verticalAlignmentToolbar ).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( 'Cell 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-cell-properties-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-cell-properties-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-cell-properties-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 and padding 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', () => {
+						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-cell-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( 'padding input', () => {
+						let labeledInput;
+
+						beforeEach( () => {
+							labeledInput = view.paddingInput;
+						} );
+
+						it( 'should be created', () => {
+							expect( labeledInput.view ).to.be.instanceOf( InputTextView );
+							expect( labeledInput.label ).to.equal( 'Padding' );
+							expect( labeledInput.class ).to.equal( 'ck-table-cell-properties-form__padding' );
+						} );
+
+						it( 'should reflect #padding property', () => {
+							view.padding = 'foo';
+							expect( labeledInput.view.value ).to.equal( 'foo' );
+
+							view.padding = 'bar';
+							expect( labeledInput.view.value ).to.equal( 'bar' );
+						} );
+
+						it( 'should update #padding on DOM "input" event', () => {
+							labeledInput.view.element.value = 'foo';
+							labeledInput.view.fire( 'input' );
+							expect( view.padding ).to.equal( 'foo' );
+
+							labeledInput.view.element.value = 'bar';
+							labeledInput.view.fire( 'input' );
+							expect( view.padding ).to.equal( 'bar' );
+						} );
+					} );
+				} );
+
+				describe( 'text alignment row', () => {
+					it( 'should be defined', () => {
+						const row = view.element.childNodes[ 3 ];
+
+						expect( row.classList.contains( 'ck-form__row' ) ).to.be.true;
+						expect( row.classList.contains( 'ck-table-cell-properties-form__alignment-row' ) ).to.be.true;
+						expect( row.childNodes[ 0 ].textContent ).to.equal( 'Text alignment' );
+						expect( row.childNodes[ 1 ] ).to.equal( view.horizontalAlignmentToolbar.element );
+						expect( row.childNodes[ 2 ] ).to.equal( view.verticalAlignmentToolbar.element );
+					} );
+
+					describe( 'horizontal text alignment toolbar', () => {
+						let toolbar;
+
+						beforeEach( () => {
+							toolbar = view.horizontalAlignmentToolbar;
+						} );
+
+						it( 'should be defined', () => {
+							expect( toolbar ).to.be.instanceOf( ToolbarView );
+						} );
+
+						it( 'should have an ARIA label', () => {
+							expect( toolbar.ariaLabel ).to.equal( 'Horizontal text alignment toolbar' );
+						} );
+
+						it( 'should bring alignment buttons', () => {
+							expect( toolbar.items.map( ( { label } ) => label ) ).to.have.ordered.members( [
+								'Align cell text to the left',
+								'Align cell text to the center',
+								'Align cell text to the right',
+								'Justify cell text',
+							] );
+
+							expect( toolbar.items.map( ( { isOn } ) => isOn ) ).to.have.ordered.members( [
+								true, false, false, false
+							] );
+						} );
+
+						it( 'should change the #horizontalAlignment value', () => {
+							toolbar.items.last.fire( 'execute' );
+							expect( view.horizontalAlignment ).to.equal( 'justify' );
+							expect( toolbar.items.last.isOn ).to.be.true;
+
+							toolbar.items.first.fire( 'execute' );
+							expect( view.horizontalAlignment ).to.equal( 'left' );
+							expect( toolbar.items.last.isOn ).to.be.false;
+							expect( toolbar.items.first.isOn ).to.be.true;
+						} );
+					} );
+
+					describe( 'vertical text alignment toolbar', () => {
+						let toolbar;
+
+						beforeEach( () => {
+							toolbar = view.verticalAlignmentToolbar;
+						} );
+
+						it( 'should be defined', () => {
+							expect( toolbar ).to.be.instanceOf( ToolbarView );
+						} );
+
+						it( 'should have an ARIA label', () => {
+							expect( toolbar.ariaLabel ).to.equal( 'Vertical text alignment toolbar' );
+						} );
+
+						it( 'should bring alignment buttons', () => {
+							expect( toolbar.items.map( ( { label } ) => label ) ).to.have.ordered.members( [
+								'Align cell text to the top',
+								'Align cell text to the middle',
+								'Align cell text to the bottom',
+							] );
+
+							expect( toolbar.items.map( ( { isOn } ) => isOn ) ).to.have.ordered.members( [
+								false, true, false
+							] );
+						} );
+
+						it( 'should change the #verticalAlignment value', () => {
+							toolbar.items.last.fire( 'execute' );
+							expect( view.verticalAlignment ).to.equal( 'bottom' );
+							expect( toolbar.items.last.isOn ).to.be.true;
+
+							toolbar.items.first.fire( 'execute' );
+							expect( view.verticalAlignment ).to.equal( 'top' );
+							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.paddingInput,
+					view.horizontalAlignmentToolbar,
+					view.verticalAlignmentToolbar,
+					view.saveButtonView,
+					view.cancelButtonView
+				] );
+			} );
+
+			it( 'should register child views\' #element in #focusTracker', () => {
+				const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+				const view = new TableCellPropertiesView( { 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.paddingInput.element );
+				sinon.assert.calledWith( spy, view.horizontalAlignmentToolbar.element );
+				sinon.assert.calledWith( spy, view.verticalAlignmentToolbar.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 TableCellPropertiesView( { 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 );
+			} );
+		} );
+	} );
+} );

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

@@ -0,0 +1,96 @@
+/**
+ * @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 FormRowView from '../../src/ui/formrowview';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+
+describe( 'FormRowView', () => {
+	let view, locale;
+
+	beforeEach( () => {
+		locale = { t: val => val };
+		view = new FormRowView( 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( 0 );
+		} );
+
+		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__row' ) ).to.be.true;
+		} );
+
+		describe( 'options', () => {
+			it( 'should set view#class when class was passed', () => {
+				const view = new FormRowView( locale, {
+					class: 'foo'
+				} );
+
+				expect( view.class ).to.equal( 'foo' );
+
+				view.destroy();
+			} );
+
+			it( 'should fill view#children when children were passed', () => {
+				const view = new FormRowView( locale, {
+					children: [
+						new View()
+					]
+				} );
+
+				expect( view.children ).to.have.length( 1 );
+
+				view.destroy();
+			} );
+
+			it( 'should use a label view when passed', () => {
+				const labelView = new View();
+				labelView.id = '123';
+
+				const view = new FormRowView( locale, {
+					labelView
+				} );
+
+				view.render();
+
+				expect( view.element.getAttribute( 'role' ) ).to.equal( 'group' );
+				expect( view.element.getAttribute( 'aria-labelledby' ) ).to.equal( '123' );
+			} );
+		} );
+
+		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 #children to the template', () => {
+				const child = new View();
+				child.setTemplate( { tag: 'div' } );
+
+				view.children.add( child );
+
+				expect( view.element.firstChild ).to.equal( child.element );
+			} );
+		} );
+	} );
+} );

+ 115 - 0
packages/ckeditor5-table/tests/ui/utils.js

@@ -0,0 +1,115 @@
+/**
+ * @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 ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Table from '../../src/table';
+import TableCellProperties from '../../src/tablecellproperties';
+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 BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { repositionContextualBalloon, getBalloonCellPositionData } from '../../src/ui/utils';
+import { findAncestor } from '../../src/commands/utils';
+
+describe( 'Utils', () => {
+	let editor, editingView, balloon, editorElement;
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicEditor
+			.create( editorElement, {
+				plugins: [ Table, TableCellProperties, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				editingView = editor.editing.view;
+				balloon = editor.plugins.get( 'ContextualBalloon' );
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		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();
+
+			view.element = global.document.createElement( 'div' );
+
+			balloon.add( {
+				view,
+				position: {
+					target: global.document.body
+				}
+			} );
+
+			setData( editor.model,
+				'<table><tableRow>' +
+					'<tableCell><paragraph>foo</paragraph></tableCell>' +
+					'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
+				'</tableRow></table>' );
+			repositionContextualBalloon( editor );
+
+			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
+				]
+			} );
+		} );
+
+		it( 'should not engage with no table is selected', () => {
+			const spy = sinon.spy( balloon, 'updatePosition' );
+
+			setData( editor.model, '<paragraph>foo</paragraph>' );
+
+			repositionContextualBalloon( editor );
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'getBalloonCellPositionData', () => {
+		it( 'returns the position data', () => {
+			const defaultPositions = BalloonPanelView.defaultPositions;
+
+			setData( editor.model, '<table><tableRow>' +
+				'<tableCell><paragraph>foo</paragraph></tableCell>' +
+				'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
+			'</tableRow></table>' );
+
+			const data = getBalloonCellPositionData( editor );
+			const modelCell = findAncestor( 'tableCell', editor.model.document.selection.getFirstPosition() );
+			const viewCell = editor.editing.mapper.toViewElement( modelCell );
+
+			expect( data ).to.deep.equal( {
+				target: editingView.domConverter.viewToDom( viewCell ),
+				positions: [
+					defaultPositions.northArrowSouth,
+					defaultPositions.northArrowSouthWest,
+					defaultPositions.northArrowSouthEast,
+					defaultPositions.southArrowNorth,
+					defaultPositions.southArrowNorthWest,
+					defaultPositions.southArrowNorthEast
+				]
+			} );
+		} );
+	} );
+} );

+ 71 - 0
packages/ckeditor5-table/theme/form.css

@@ -0,0 +1,71 @@
+/*
+ * 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 {
+	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;
+			}
+
+			& .ck-button .ck-button__label {
+				color: var(--ck-color-text);
+			}
+		}
+	}
+}

ファイルの差分が大きいため隠しています
+ 1 - 0
packages/ckeditor5-table/theme/icons/table-cell-properties.svg


+ 1 - 0
packages/ckeditor5-table/theme/icons/table-properties.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g><path d="M8 2v5h4V2h1v5h5v1h-5v4h.021l-.172.351-1.916.28-.151.027c-.287.063-.54.182-.755.341L8 13v5H7v-5H2v-1h5V8H2V7h5V2h1zm4 6H8v4h4V8z" opacity=".6"/><path d="M15.5 11.5l1.323 2.68 2.957.43-2.14 2.085.505 2.946L15.5 18.25l-2.645 1.39.505-2.945-2.14-2.086 2.957-.43L15.5 11.5zM17 1a2 2 0 012 2v9.475l-.85-.124-.857-1.736a2.048 2.048 0 00-.292-.44L17 3H3v14h7.808l.402.392L10.935 19H3a2 2 0 01-2-2V3a2 2 0 012-2h14z"/></g></svg>

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

@@ -0,0 +1,83 @@
+/*
+ * 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-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;
+					}
+				}
+			}
+		}
+	}
+}