8
0
Просмотр исходного кода

Merge pull request #165 from ckeditor/t/ckeditor5/645

Feature: Implemented a 2–step link UI with a refreshed look&feel (see ckeditor/ckeditor5#645). Closes #31.

BREAKING CHANGE: The structure of the link UI has changed dramatically. Some pieces of the `LinkFormView` belong now to the `LinkActionsView` class. The CSS classes have also changed along with component templates.
Piotrek Koszuliński 8 лет назад
Родитель
Сommit
d74a389488

+ 248 - 80
packages/ckeditor5-link/src/link.js

@@ -18,6 +18,7 @@ import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsid
 
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import LinkFormView from './ui/linkformview';
+import LinkActionsView from './ui/linkactionsview';
 
 import linkIcon from '../theme/icons/link.svg';
 
@@ -54,12 +55,19 @@ export default class Link extends Plugin {
 
 		editor.editing.view.addObserver( ClickObserver );
 
+		/**
+		 * The actions view displayed inside of the balloon.
+		 *
+		 * @member {module:link/ui/linkactionsview~LinkActionsView}
+		 */
+		this.actionsView = this._createActionsView();
+
 		/**
 		 * The form view displayed inside the balloon.
 		 *
 		 * @member {module:link/ui/linkformview~LinkFormView}
 		 */
-		this.formView = this._createForm();
+		this.formView = this._createFormView();
 
 		/**
 		 * The contextual balloon plugin instance.
@@ -73,7 +81,43 @@ export default class Link extends Plugin {
 		this._createToolbarLinkButton();
 
 		// Attach lifecycle actions to the the balloon.
-		this._attachActions();
+		this._enableUserBalloonInteractions();
+	}
+
+	/**
+	 * Creates the {@link module:link/ui/linkactionsview~LinkActionsView} instance.
+	 *
+	 * @private
+	 * @returns {module:link/ui/linkactionsview~LinkActionsView} The link actions view instance.
+	 */
+	_createActionsView() {
+		const editor = this.editor;
+		const actionsView = new LinkActionsView( editor.locale );
+		const linkCommand = editor.commands.get( 'link' );
+		const unlinkCommand = editor.commands.get( 'unlink' );
+
+		actionsView.bind( 'href' ).to( linkCommand, 'value' );
+		actionsView.editButtonView.bind( 'isEnabled' ).to( linkCommand );
+		actionsView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand );
+
+		// Execute unlink command after clicking on the "Edit" button.
+		this.listenTo( actionsView, 'edit', () => {
+			this._addFormView();
+		} );
+
+		// Execute unlink command after clicking on the "Unlink" button.
+		this.listenTo( actionsView, 'unlink', () => {
+			editor.execute( 'unlink' );
+			this._hideUI();
+		} );
+
+		// Close the panel on esc key press when the **actions have focus**.
+		actionsView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hideUI();
+			cancel();
+		} );
+
+		return actionsView;
 	}
 
 	/**
@@ -82,37 +126,31 @@ export default class Link extends Plugin {
 	 * @private
 	 * @returns {module:link/ui/linkformview~LinkFormView} The link form instance.
 	 */
-	_createForm() {
+	_createFormView() {
 		const editor = this.editor;
 		const formView = new LinkFormView( editor.locale );
 		const linkCommand = editor.commands.get( 'link' );
-		const unlinkCommand = editor.commands.get( 'unlink' );
 
 		formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' );
 
 		// Form elements should be read-only when corresponding commands are disabled.
 		formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value );
 		formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand );
-		formView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand );
 
-		// Execute link command after clicking on formView `Save` button.
+		// Execute link command after clicking the "Save" button.
 		this.listenTo( formView, 'submit', () => {
 			editor.execute( 'link', formView.urlInputView.inputView.element.value );
-			this._hidePanel( true );
+			this._removeFormView();
 		} );
 
-		// Execute unlink command after clicking on formView `Unlink` button.
-		this.listenTo( formView, 'unlink', () => {
-			editor.execute( 'unlink' );
-			this._hidePanel( true );
+		// Hide the panel after clicking the "Cancel" button.
+		this.listenTo( formView, 'cancel', () => {
+			this._removeFormView();
 		} );
 
-		// Hide the panel after clicking on formView `Cancel` button.
-		this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
-
-		// Close the panel on esc key press when the form has focus.
+		// Close the panel on esc key press when the **form has focus**.
 		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
-			this._hidePanel( true );
+			this._removeFormView();
 			cancel();
 		} );
 
@@ -136,7 +174,7 @@ export default class Link extends Plugin {
 			cancel();
 
 			if ( linkCommand.isEnabled ) {
-				this._showPanel( true );
+				this._showUI();
 			}
 		} );
 
@@ -153,7 +191,7 @@ export default class Link extends Plugin {
 			button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
 
 			// Show the panel on button click.
-			this.listenTo( button, 'execute', () => this._showPanel( true ) );
+			this.listenTo( button, 'execute', () => this._showUI() );
 
 			return button;
 		} );
@@ -165,7 +203,7 @@ export default class Link extends Plugin {
 	 *
 	 * @private
 	 */
-	_attachActions() {
+	_enableUserBalloonInteractions() {
 		const viewDocument = this.editor.editing.view;
 
 		// Handle click on view document and show panel when selection is placed inside the link element.
@@ -175,14 +213,14 @@ export default class Link extends Plugin {
 
 			if ( parentLink ) {
 				// Then show panel but keep focus inside editor editable.
-				this._showPanel();
+				this._showUI();
 			}
 		} );
 
 		// Focus the form if the balloon is visible and the Tab key has been pressed.
 		this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
-			if ( this._balloon.visibleView === this.formView && !this.formView.focusTracker.isFocused ) {
-				this.formView.focus();
+			if ( this._areActionsVisible && !this.actionsView.focusTracker.isFocused ) {
+				this.actionsView.focus();
 				cancel();
 			}
 		}, {
@@ -194,8 +232,8 @@ export default class Link extends Plugin {
 
 		// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
 		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
-			if ( this._balloon.visibleView === this.formView ) {
-				this._hidePanel();
+			if ( this._isUIVisible ) {
+				this._hideUI();
 				cancel();
 			}
 		} );
@@ -203,9 +241,21 @@ export default class Link extends Plugin {
 		// Close on click outside of balloon panel element.
 		clickOutsideHandler( {
 			emitter: this.formView,
-			activator: () => this._balloon.hasView( this.formView ),
+			activator: () => this._isUIVisible,
 			contextElements: [ this._balloon.view.element ],
-			callback: () => this._hidePanel()
+			callback: () => this._hideUI()
+		} );
+	}
+
+	/**
+	 * Adds the {@link #actionsView} to the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_addActionsView() {
+		this._balloon.add( {
+			view: this.actionsView,
+			position: this._getBalloonPositionData()
 		} );
 	}
 
@@ -213,90 +263,208 @@ export default class Link extends Plugin {
 	 * Adds the {@link #formView} to the {@link #_balloon}.
 	 *
 	 * @protected
-	 * @param {Boolean} [focusInput=false] When `true`, the link form will be focused on panel show.
 	 */
-	_showPanel( focusInput ) {
+	_addFormView() {
 		const editor = this.editor;
 		const linkCommand = editor.commands.get( 'link' );
-		const unlinkCommand = editor.commands.get( 'unlink' );
+
+		this._balloon.add( {
+			view: this.formView,
+			position: this._getBalloonPositionData()
+		} );
+
+		this.formView.urlInputView.select();
+
+		// Make sure that each time the panel shows up, the URL field remains in sync with the value of
+		// the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays
+		// unaltered) and re-opened it without changing the value of the link command (e.g. because they
+		// clicked the same link), they would see the old value instead of the actual value of the command.
+		// https://github.com/ckeditor/ckeditor5-link/issues/78
+		// https://github.com/ckeditor/ckeditor5-link/issues/123
+		this.formView.urlInputView.inputView.element.value = linkCommand.value || '';
+	}
+
+	/**
+	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_removeFormView() {
+		if ( this._isFormInPanel ) {
+			this._balloon.remove( this.formView );
+
+			// Because the form has an input which has focus, the focus must be brought back
+			// to the editor. Otherwise, it would be lost.
+			this.editor.editing.view.focus();
+		}
+	}
+
+	/**
+	 * Shows the right kind of the UI for current state of the command. It's either
+	 * {@link #formView} or {@link #actionsView}.
+	 *
+	 * @private
+	 */
+	_showUI() {
+		const editor = this.editor;
+		const linkCommand = editor.commands.get( 'link' );
+
+		if ( !linkCommand.isEnabled || this._isUIInPanel ) {
+			return;
+		}
+
+		// When there's no link under the selection, go straight to the editing UI.
+		if ( !this._getSelectedLinkElement() ) {
+			this._addActionsView();
+			this._addFormView();
+		}
+		// Otherwise display just the actions UI.
+		else {
+			this._addActionsView();
+		}
+
+		// Begin responding to view#render once the UI is added.
+		this._startUpdatingUIOnViewRender();
+	}
+
+	/**
+	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 *
+	 * See {@link #_addFormView}, {@link #_addActionsView}.
+	 *
+	 * @protected
+	 */
+	_hideUI() {
+		if ( !this._isUIInPanel ) {
+			return;
+		}
+
+		const editingView = this.editor.editing.view;
+
+		this.stopListening( editingView, 'render' );
+
+		// Remove form first because it's on top of the stack.
+		this._removeFormView();
+
+		// Then remove the actions view because it's beneath the form.
+		this._balloon.remove( this.actionsView );
+
+		// Make sure the focus always gets back to the editable.
+		editingView.focus();
+	}
+
+	/**
+	 * Makes the UI react to the {@link module:engine/view/document~Document#event:render} in the view
+	 * document to reposition itself as the document changes.
+	 *
+	 * See: {@link #_hideUI} to learn when the UI stops reacting to the `render` event.
+	 *
+	 * @protected
+	 */
+	_startUpdatingUIOnViewRender() {
+		const editor = this.editor;
 		const editing = editor.editing;
-		const showViewDocument = editing.view;
-		const showIsCollapsed = showViewDocument.selection.isCollapsed;
-		const showSelectedLink = this._getSelectedLinkElement();
+		const editingView = editing.view;
 
-		this.listenTo( showViewDocument, 'render', () => {
-			const renderSelectedLink = this._getSelectedLinkElement();
-			const renderIsCollapsed = showViewDocument.selection.isCollapsed;
-			const hasSellectionExpanded = showIsCollapsed && !renderIsCollapsed;
+		let prevSelectedLink = this._getSelectedLinkElement();
+		let prevSelectionParent = getSelectionParent();
+
+		this.listenTo( editingView, 'render', () => {
+			const selectedLink = this._getSelectedLinkElement();
+			const selectionParent = getSelectionParent();
 
 			// Hide the panel if:
-			//   * the selection went out of the original link element
-			//     (e.g. paragraph containing the link was removed),
-			//   * the selection has expanded
-			// upon the #render event.
-			if ( hasSellectionExpanded || showSelectedLink !== renderSelectedLink ) {
-				this._hidePanel( true );
+			//
+			// * the selection went out of the EXISTING link element. E.g. user moved the caret out
+			//   of the link,
+			// * the selection went to a different parent when creating a NEW link. E.g. someone
+			//   else modified the document.
+			// * the selection has expanded (e.g. displaying link actions then pressing SHIFT+Right arrow).
+			//
+			// Note: #_getSelectedLinkElement will return a link for a non-collapsed selection only
+			// when fully selected.
+			if ( ( prevSelectedLink && !selectedLink ) ||
+				( !prevSelectedLink && selectionParent !== prevSelectionParent ) ) {
+				this._hideUI();
 			}
 			// Update the position of the panel when:
 			//  * the selection remains in the original link element,
 			//  * there was no link element in the first place, i.e. creating a new link
 			else {
 				// If still in a link element, simply update the position of the balloon.
-				// If there was no link, upon #render, the balloon must be moved
+				// If there was no link (e.g. inserting one), the balloon must be moved
 				// to the new position in the editing view (a new native DOM range).
 				this._balloon.updatePosition( this._getBalloonPositionData() );
 			}
-		} );
 
-		if ( this._balloon.hasView( this.formView ) ) {
-			// Check if formView should be focused and focus it if is visible.
-			if ( focusInput && this._balloon.visibleView === this.formView ) {
-				this.formView.urlInputView.select();
-			}
-		} else {
-			this._balloon.add( {
-				view: this.formView,
-				position: this._getBalloonPositionData()
-			} );
+			prevSelectedLink = selectedLink;
+			prevSelectionParent = selectionParent;
+		} );
 
-			if ( focusInput ) {
-				this.formView.urlInputView.select();
-			}
+		function getSelectionParent() {
+			return editingView.selection.focus.getAncestors()
+				.reverse()
+				.find( node => node.is( 'element' ) );
 		}
+	}
 
-		// https://github.com/ckeditor/ckeditor5-link/issues/53
-		this.formView.unlinkButtonView.isVisible = unlinkCommand.isEnabled;
-
-		// Make sure that each time the panel shows up, the URL field remains in sync with the value of
-		// the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays
-		// unaltered) and re-opened it without changing the value of the link command (e.g. because they
-		// clicked the same link), they would see the old value instead of the actual value of the command.
-		// https://github.com/ckeditor/ckeditor5-link/issues/78
-		// https://github.com/ckeditor/ckeditor5-link/issues/123
-		this.formView.urlInputView.inputView.element.value = linkCommand.value || '';
+	/**
+	 * Returns true when {@link #formView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isFormInPanel() {
+		return this._balloon.hasView( this.formView );
 	}
 
 	/**
-	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 * Returns true when {@link #actionsView} is in the {@link #_balloon}.
 	 *
-	 * See {@link #_showPanel}.
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _areActionsInPanel() {
+		return this._balloon.hasView( this.actionsView );
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} is in the {@link #_balloon} and it is
+	 * currently visible.
 	 *
+	 * @readonly
 	 * @protected
-	 * @param {Boolean} [focusEditable=false] When `true`, editable focus will be restored on panel hide.
+	 * @type {Boolean}
 	 */
-	_hidePanel( focusEditable ) {
-		this.stopListening( this.editor.editing.view, 'render' );
+	get _areActionsVisible() {
+		return this._balloon.visibleView === this.actionsView;
+	}
 
-		if ( !this._balloon.hasView( this.formView ) ) {
-			return;
-		}
+	/**
+	 * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isUIInPanel() {
+		return this._isFormInPanel || this._areActionsInPanel;
+	}
 
-		if ( focusEditable ) {
-			this.editor.editing.view.focus();
-		}
+	/**
+	 * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is
+	 * currently visible.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isUIVisible() {
+		const visibleView = this._balloon.visibleView;
 
-		this.stopListening( this.editor.editing.view, 'render' );
-		this._balloon.remove( this.formView );
+		return visibleView == this.formView || this._areActionsVisible;
 	}
 
 	/**

+ 235 - 0
packages/ckeditor5-link/src/ui/linkactionsview.js

@@ -0,0 +1,235 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module link/ui/linkactionsview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+
+import unlinkIcon from '../../theme/icons/unlink.svg';
+import pencilIcon from '@ckeditor/ckeditor5-core/theme/icons/pencil.svg';
+import '../../theme/linkactions.css';
+
+/**
+ * The link actions view class. This view displays link preview, allows
+ * unlinking or editing the link.
+ *
+ * @extends module:ui/view~View
+ */
+export default class LinkActionsView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const t = locale.t;
+
+		/**
+		 * Tracks information about DOM focus in the actions.
+		 *
+		 * @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();
+
+		/**
+		 * The href preview view.
+		 *
+		 * @member {module:ui/view~View}
+		 */
+		this.previewButtonView = this._createPreviewButton();
+
+		/**
+		 * The unlink button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.unlinkButtonView = this._createButton( t( 'Unlink' ), unlinkIcon, 'unlink' );
+
+		/**
+		 * The edit link button view.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView}
+		 */
+		this.editButtonView = this._createButton( t( 'Edit link' ), pencilIcon, 'edit' );
+
+		/**
+		 * Value of the "href" attribute of the link to use in the {@link #previewButtonView}.
+		 *
+		 * @observable
+		 * @member {String}
+		 */
+		this.set( 'href' );
+
+		/**
+		 * A collection of views which can be focused in the view.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the view.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate fields backwards using the Shift + Tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate fields forwards using the Tab key.
+				focusNext: 'tab'
+			}
+		} );
+
+		this.setTemplate( {
+			tag: 'div',
+
+			attributes: {
+				class: [
+					'ck-link-actions',
+				],
+
+				// https://github.com/ckeditor/ckeditor5-link/issues/90
+				tabindex: '-1'
+			},
+
+			children: [
+				this.previewButtonView,
+				this.editButtonView,
+				this.unlinkButtonView
+			]
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		const childViews = [
+			this.previewButtonView,
+			this.editButtonView,
+			this.unlinkButtonView
+		];
+
+		childViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+	}
+
+	/**
+	 * Focuses the fist {@link #_focusables} in the actions.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+
+	/**
+	 * Creates a button view.
+	 *
+	 * @private
+	 * @param {String} label The button label.
+	 * @param {String} icon The button's icon.
+	 * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to.
+	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
+	 */
+	_createButton( label, icon, eventName ) {
+		const button = new ButtonView( this.locale );
+
+		button.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
+
+		button.delegate( 'execute' ).to( this, eventName );
+
+		return button;
+	}
+
+	/**
+	 * Creates a link href preview button.
+	 *
+	 * @private
+	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
+	 */
+	_createPreviewButton() {
+		const button = new ButtonView( this.locale );
+		const bind = this.bindTemplate;
+		const t = this.t;
+
+		button.set( {
+			withText: true,
+			tooltip: t( 'Open link in new tab' )
+		} );
+
+		button.extendTemplate( {
+			attributes: {
+				class: [
+					'ck-link-actions__preview'
+				],
+				href: bind.to( 'href' ),
+				target: '_blank'
+			}
+		} );
+
+		button.bind( 'label' ).to( this, 'href', href => {
+			return href || t( 'This link has no URL' );
+		} );
+
+		button.bind( 'isEnabled' ).to( this, 'href', href => !!href );
+
+		button.template.tag = 'a';
+		button.template.eventListeners = {};
+
+		return button;
+	}
+}
+
+/**
+ * Fired when the {@link #editButtonView} is clicked.
+ *
+ * @event edit
+ */
+
+/**
+ * Fired when the {@link #unlinkButtonView} is clicked.
+ *
+ * @event unlink
+ */

+ 15 - 36
packages/ckeditor5-link/src/ui/linkformview.js

@@ -19,6 +19,8 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
 import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
+import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg';
+import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg';
 import '../../theme/linkform.css';
 
 /**
@@ -65,7 +67,7 @@ export default class LinkFormView extends View {
 		 *
 		 * @member {module:ui/button/buttonview~ButtonView}
 		 */
-		this.saveButtonView = this._createButton( t( 'Save' ) );
+		this.saveButtonView = this._createButton( t( 'Save' ), checkIcon );
 		this.saveButtonView.type = 'submit';
 
 		/**
@@ -73,14 +75,7 @@ export default class LinkFormView extends View {
 		 *
 		 * @member {module:ui/button/buttonview~ButtonView}
 		 */
-		this.cancelButtonView = this._createButton( t( 'Cancel' ), 'cancel' );
-
-		/**
-		 * The Unlink button view.
-		 *
-		 * @member {module:ui/button/buttonview~ButtonView}
-		 */
-		this.unlinkButtonView = this._createButton( t( 'Unlink' ), 'unlink' );
+		this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'cancel' );
 
 		/**
 		 * A collection of views which can be focused in the form.
@@ -133,21 +128,8 @@ export default class LinkFormView extends View {
 
 			children: [
 				this.urlInputView,
-				{
-					tag: 'div',
-
-					attributes: {
-						class: [
-							'ck-link-form__actions'
-						]
-					},
-
-					children: [
-						this.saveButtonView,
-						this.cancelButtonView,
-						this.unlinkButtonView
-					]
-				}
+				this.saveButtonView,
+				this.cancelButtonView
 			]
 		} );
 	}
@@ -165,8 +147,7 @@ export default class LinkFormView extends View {
 		const childViews = [
 			this.urlInputView,
 			this.saveButtonView,
-			this.cancelButtonView,
-			this.unlinkButtonView
+			this.cancelButtonView
 		];
 
 		childViews.forEach( v => {
@@ -209,15 +190,19 @@ export default class LinkFormView extends View {
 	 * Creates a button view.
 	 *
 	 * @private
-	 * @param {String} label The button label
+	 * @param {String} label The button label.
+	 * @param {String} icon The button's icon.
 	 * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to.
 	 * @returns {module:ui/button/buttonview~ButtonView} The button view instance.
 	 */
-	_createButton( label, eventName ) {
+	_createButton( label, icon, eventName ) {
 		const button = new ButtonView( this.locale );
 
-		button.label = label;
-		button.withText = true;
+		button.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
 
 		if ( eventName ) {
 			button.delegate( 'execute' ).to( this, eventName );
@@ -239,9 +224,3 @@ export default class LinkFormView extends View {
  *
  * @event cancel
  */
-
-/**
- * Fired when the {@link #unlinkButtonView} is clicked.
- *
- * @event unlink
- */

+ 268 - 235
packages/ckeditor5-link/tests/link.js

@@ -13,6 +13,8 @@ import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-util
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Link from '../src/link';
 import LinkEngine from '../src/linkengine';
+import LinkFormView from '../src/ui/linkformview';
+import LinkActionsView from '../src/ui/linkactionsview';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 
@@ -22,7 +24,7 @@ import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobs
 testUtils.createSinonSandbox();
 
 describe( 'Link', () => {
-	let editor, linkFeature, linkButton, balloon, formView, editorElement;
+	let editor, linkFeature, linkButton, balloon, formView, actionsView, editorElement;
 
 	beforeEach( () => {
 		editorElement = document.createElement( 'div' );
@@ -39,6 +41,7 @@ describe( 'Link', () => {
 				linkButton = editor.ui.componentFactory.create( 'link' );
 				balloon = editor.plugins.get( ContextualBalloon );
 				formView = linkFeature.formView;
+				actionsView = linkFeature.actionsView;
 
 				// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
 				testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} );
@@ -55,22 +58,55 @@ describe( 'Link', () => {
 	} );
 
 	it( 'should be loaded', () => {
-		expect( linkFeature ).to.instanceOf( Link );
+		expect( linkFeature ).to.be.instanceOf( Link );
 	} );
 
 	it( 'should load LinkEngine', () => {
-		expect( editor.plugins.get( LinkEngine ) ).to.instanceOf( LinkEngine );
+		expect( editor.plugins.get( LinkEngine ) ).to.be.instanceOf( LinkEngine );
 	} );
 
 	it( 'should load ContextualBalloon', () => {
-		expect( editor.plugins.get( ContextualBalloon ) ).to.instanceOf( ContextualBalloon );
+		expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon );
 	} );
 
-	it( 'should register click observer', () => {
-		expect( editor.editing.view.getObserver( ClickObserver ) ).to.instanceOf( ClickObserver );
+	describe( 'init', () => {
+		it( 'should register click observer', () => {
+			expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver );
+		} );
+
+		it( 'should create #actionsView', () => {
+			expect( actionsView ).to.be.instanceOf( LinkActionsView );
+		} );
+
+		it( 'should create #formView', () => {
+			expect( formView ).to.be.instanceOf( LinkFormView );
+		} );
+
+		describe( 'link toolbar button', () => {
+			it( 'should be registered', () => {
+				expect( linkButton ).to.be.instanceOf( ButtonView );
+			} );
+
+			it( 'should be bound to the link command', () => {
+				const command = editor.commands.get( 'link' );
+
+				command.isEnabled = true;
+				expect( linkButton.isEnabled ).to.be.true;
+
+				command.isEnabled = false;
+				expect( linkButton.isEnabled ).to.be.false;
+			} );
+
+			it( 'should call #_showUI upon #execute', () => {
+				const spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} );
+
+				linkButton.fire( 'execute' );
+				sinon.assert.calledWithExactly( spy );
+			} );
+		} );
 	} );
 
-	describe( '_showPanel()', () => {
+	describe( '_showUI()', () => {
 		let balloonAddSpy;
 
 		beforeEach( () => {
@@ -78,11 +114,30 @@ describe( 'Link', () => {
 			editor.editing.view.isFocused = true;
 		} );
 
-		it( 'should add #formView to the #_balloon and attach the #_balloon to the selection when text fragment is selected', () => {
+		it( 'should not work if the link command is disabled', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+			editor.commands.get( 'link' ).isEnabled = false;
+
+			linkFeature._showUI();
+
+			expect( balloon.visibleView ).to.be.null;
+		} );
+
+		it( 'should not throw if the UI is already visible', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+
+			linkFeature._showUI();
+
+			expect( () => {
+				linkFeature._showUI();
+			} ).to.not.throw();
+		} );
+
+		it( 'should add #formView to the balloon and attach the balloon to the selection when text fragment is selected', () => {
 			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
 			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
 
-			linkFeature._showPanel();
+			linkFeature._showUI();
 
 			expect( balloon.visibleView ).to.equal( formView );
 			sinon.assert.calledWithExactly( balloonAddSpy, {
@@ -93,11 +148,11 @@ describe( 'Link', () => {
 			} );
 		} );
 
-		it( 'should add #formView to the #_balloon and attach the #_balloon to the selection when selection is collapsed', () => {
+		it( 'should add #formView to the balloon and attach the balloon to the selection when selection is collapsed', () => {
 			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
 			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
 
-			linkFeature._showPanel();
+			linkFeature._showUI();
 
 			expect( balloon.visibleView ).to.equal( formView );
 			sinon.assert.calledWithExactly( balloonAddSpy, {
@@ -108,106 +163,47 @@ describe( 'Link', () => {
 			} );
 		} );
 
-		it( 'should add #formView to the #_balloon and attach the #_balloon to the link element when collapsed selection is inside ' +
+		it( 'should add #actionsView to the balloon and attach the balloon to the link element when collapsed selection is inside ' +
 			'that link',
 		() => {
 			setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
 			const linkElement = editor.editing.view.getDomRoot().querySelector( 'a' );
 
-			linkFeature._showPanel();
+			linkFeature._showUI();
 
-			expect( balloon.visibleView ).to.equal( formView );
+			expect( balloon.visibleView ).to.equal( actionsView );
 			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: formView,
+				view: actionsView,
 				position: {
 					target: linkElement
 				}
 			} );
 		} );
 
-		it( 'should not focus the #formView at default', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-
-			linkFeature._showPanel();
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should not focus the #formView when called with a `false` parameter', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-
-			linkFeature._showPanel( false );
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should not focus the #formView when called with a `true` parameter while the balloon is opened but link ' +
-			'form is not visible', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-			const viewMock = {
-				ready: true,
-				render: () => {},
-				destroy: () => {}
-			};
-
-			linkFeature._showPanel( false );
-			balloon.add( { view: viewMock } );
-			linkFeature._showPanel( true );
-
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should focus the #formView when called with a `true` parameter', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-
-			linkFeature._showPanel( true );
-			sinon.assert.calledOnce( spy );
-		} );
-
-		it( 'should focus the #formView when called with a `true` parameter while the balloon is open and the #formView is visible', () => {
-			const spy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-
-			linkFeature._showPanel( false );
-			linkFeature._showPanel( true );
-			sinon.assert.calledOnce( spy );
-		} );
-
-		it( 'should disable #formView elements when link and unlink commands are disabled', () => {
+		it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => {
 			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
 
-			linkFeature._showPanel();
+			linkFeature._showUI();
 
 			editor.commands.get( 'link' ).isEnabled = true;
 			editor.commands.get( 'unlink' ).isEnabled = true;
 
-			expect( formView.urlInputView.isReadOnly ).to.false;
-			expect( formView.saveButtonView.isEnabled ).to.true;
-			expect( formView.unlinkButtonView.isEnabled ).to.true;
-			expect( formView.cancelButtonView.isEnabled ).to.true;
+			expect( formView.urlInputView.isReadOnly ).to.be.false;
+			expect( formView.saveButtonView.isEnabled ).to.be.true;
+			expect( formView.cancelButtonView.isEnabled ).to.be.true;
+
+			expect( actionsView.unlinkButtonView.isEnabled ).to.be.true;
+			expect( actionsView.editButtonView.isEnabled ).to.be.true;
 
 			editor.commands.get( 'link' ).isEnabled = false;
 			editor.commands.get( 'unlink' ).isEnabled = false;
 
-			expect( formView.urlInputView.isReadOnly ).to.true;
-			expect( formView.saveButtonView.isEnabled ).to.false;
-			expect( formView.unlinkButtonView.isEnabled ).to.false;
-			expect( formView.cancelButtonView.isEnabled ).to.true;
-		} );
-
-		// https://github.com/ckeditor/ckeditor5-link/issues/53
-		it( 'should set formView.unlinkButtonView#isVisible depending on the selection in a link or not', () => {
-			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
-
-			linkFeature._showPanel();
-			expect( formView.unlinkButtonView.isVisible ).to.be.false;
+			expect( formView.urlInputView.isReadOnly ).to.be.true;
+			expect( formView.saveButtonView.isEnabled ).to.be.false;
+			expect( formView.cancelButtonView.isEnabled ).to.be.true;
 
-			setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-
-			linkFeature._showPanel();
-			expect( formView.unlinkButtonView.isVisible ).to.be.true;
-
-			setModelData( editor.model, '<paragraph><$text linkHref="url">[fo]o</$text></paragraph>' );
-
-			linkFeature._showPanel();
-			expect( formView.unlinkButtonView.isVisible ).to.be.true;
+			expect( actionsView.unlinkButtonView.isEnabled ).to.be.false;
+			expect( actionsView.editButtonView.isEnabled ).to.be.false;
 		} );
 
 		// https://github.com/ckeditor/ckeditor5-link/issues/78
@@ -217,7 +213,9 @@ describe( 'Link', () => {
 			// Mock some leftover value **in DOM**, e.g. after previous editing.
 			formView.urlInputView.inputView.element.value = 'leftover';
 
-			linkFeature._showPanel();
+			linkFeature._showUI();
+			actionsView.fire( 'edit' );
+
 			expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' );
 		} );
 
@@ -225,11 +223,11 @@ describe( 'Link', () => {
 		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (no link selected)', () => {
 			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
 
-			linkFeature._showPanel();
+			linkFeature._showUI();
 			expect( formView.urlInputView.inputView.element.value ).to.equal( '' );
 		} );
 
-		describe( 'when the document is rendering', () => {
+		describe( 'response to view#render', () => {
 			it( 'should not duplicate #render listeners', () => {
 				const viewDocument = editor.editing.view;
 
@@ -237,22 +235,22 @@ describe( 'Link', () => {
 
 				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
 
-				linkFeature._showPanel();
+				linkFeature._showUI();
 				viewDocument.render();
-				linkFeature._hidePanel();
+				linkFeature._hideUI();
 
-				linkFeature._showPanel();
+				linkFeature._showUI();
 				viewDocument.render();
 				sinon.assert.calledTwice( spy );
 			} );
 
 			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'updates the position of the panel – editing a link, then the selection remains in the link upon #render', () => {
+			it( 'updates the position of the panel – editing a link, then the selection remains in the link', () => {
 				const viewDocument = editor.editing.view;
 
 				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
 
-				linkFeature._showPanel();
+				linkFeature._showUI();
 				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
 
 				const root = viewDocument.getRoot();
@@ -269,12 +267,12 @@ describe( 'Link', () => {
 			} );
 
 			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'updates the position of the panel – creating a new link, then the selection moved upon #render', () => {
+			it( 'updates the position of the panel – creating a new link, then the selection moved', () => {
 				const viewDocument = editor.editing.view;
 
 				setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
 
-				linkFeature._showPanel();
+				linkFeature._showUI();
 				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
 
 				// Fires #render.
@@ -291,15 +289,15 @@ describe( 'Link', () => {
 			} );
 
 			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides of the panel – editing a link, then the selection moved out of the link upon #render', () => {
+			it( 'hides of the panel – editing a link, then the selection moved out of the link', () => {
 				const viewDocument = editor.editing.view;
 
 				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text>bar</paragraph>' );
 
-				linkFeature._showPanel();
+				linkFeature._showUI();
 
 				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' );
+				const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' );
 
 				const root = viewDocument.getRoot();
 				const text = root.getChild( 0 ).getChild( 1 );
@@ -313,24 +311,21 @@ describe( 'Link', () => {
 			} );
 
 			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides of the panel – editing a link, then the selection moved to another link upon #render', () => {
+			it( 'hides the panel – editing a link, then the selection expands', () => {
 				const viewDocument = editor.editing.view;
 
-				setModelData(
-					editor.model,
-					'<paragraph><$text linkHref="url">f[]oo</$text>bar<$text linkHref="url">b[]az</$text></paragraph>'
-				);
+				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
 
-				linkFeature._showPanel();
+				linkFeature._showUI();
 
 				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' );
+				const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' );
 
 				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 2 ).getChild( 0 );
+				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
 
-				// Move selection to b[]az.
-				viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 1 ), true );
+				// Move selection to f[o]o.
+				viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true );
 				viewDocument.render();
 
 				sinon.assert.calledOnce( spyHide );
@@ -338,18 +333,19 @@ describe( 'Link', () => {
 			} );
 
 			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides the panel – editing a link, then the selection expands upon #render', () => {
+			it( 'hides the panel – creating a new link, then the selection moved to another parent', () => {
 				const viewDocument = editor.editing.view;
 
-				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+				setModelData( editor.model, '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
 
-				linkFeature._showPanel();
+				linkFeature._showUI();
 
 				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' );
+				const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' );
 
+				// Fires #render.
 				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
+				const text = root.getChild( 1 ).getChild( 0 );
 
 				// Move selection to f[o]o.
 				viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true );
@@ -361,85 +357,56 @@ describe( 'Link', () => {
 		} );
 	} );
 
-	describe( '_hidePanel()', () => {
+	describe( '_hideUI()', () => {
 		beforeEach( () => {
-			return balloon.add( { view: formView } );
+			linkFeature._showUI();
 		} );
 
-		it( 'should remove #formView from the #_balloon', () => {
-			linkFeature._hidePanel();
-			expect( balloon.hasView( formView ) ).to.false;
-		} );
+		it( 'should remove the UI from the balloon', () => {
+			expect( balloon.hasView( formView ) ).to.be.true;
+			expect( balloon.hasView( actionsView ) ).to.be.true;
 
-		it( 'should not focus the `editable` by default', () => {
-			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+			linkFeature._hideUI();
 
-			linkFeature._hidePanel();
-			sinon.assert.notCalled( spy );
+			expect( balloon.hasView( formView ) ).to.be.false;
+			expect( balloon.hasView( actionsView ) ).to.be.false;
 		} );
 
-		it( 'should not focus the `editable` when called with a `false` parameter', () => {
+		it( 'should focus the `editable` by default', () => {
 			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
 
-			linkFeature._hidePanel( false );
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'should focus the `editable` when called with a `true` parameter', () => {
-			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+			linkFeature._hideUI();
 
-			linkFeature._hidePanel( true );
-			sinon.assert.calledOnce( spy );
+			// First call is from _removeFormView.
+			sinon.assert.calledTwice( spy );
 		} );
 
-		it( 'should not throw an error when #formView is not added to the `balloon`', () => {
-			linkFeature._hidePanel( true );
+		it( 'should not throw an error when views are not in the `balloon`', () => {
+			linkFeature._hideUI();
 
 			expect( () => {
-				linkFeature._hidePanel( true );
+				linkFeature._hideUI();
 			} ).to.not.throw();
 		} );
 
-		it( 'should clear `render` listener from ViewDocument', () => {
+		it( 'should clear #render listener from the ViewDocument', () => {
 			const spy = sinon.spy();
 
 			linkFeature.listenTo( editor.editing.view, 'render', spy );
-			linkFeature._hidePanel();
+			linkFeature._hideUI();
 			editor.editing.view.render();
 
 			sinon.assert.notCalled( spy );
 		} );
 	} );
 
-	describe( 'link toolbar button', () => {
-		it( 'should register link button', () => {
-			expect( linkButton ).to.instanceOf( ButtonView );
-		} );
-
-		it( 'should bind linkButtonView to link command', () => {
-			const command = editor.commands.get( 'link' );
-
-			command.isEnabled = true;
-			expect( linkButton.isEnabled ).to.be.true;
-
-			command.isEnabled = false;
-			expect( linkButton.isEnabled ).to.be.false;
-		} );
-
-		it( 'should show the #_balloon on execute event with the selected #formView', () => {
-			const spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} );
-
-			linkButton.fire( 'execute' );
-			sinon.assert.calledWithExactly( spy, true );
-		} );
-	} );
-
 	describe( 'keyboard support', () => {
-		it( 'should show the #_balloon with selected #formView on Ctrl+K keystroke', () => {
-			const spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} );
+		it( 'should show the UI on Ctrl+K keystroke', () => {
+			const spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} );
 			const command = editor.commands.get( 'link' );
 
 			command.isEnabled = false;
+
 			editor.keystrokes.press( {
 				keyCode: keyCodes.k,
 				ctrlKey: true,
@@ -449,13 +416,14 @@ describe( 'Link', () => {
 			sinon.assert.notCalled( spy );
 
 			command.isEnabled = true;
+
 			editor.keystrokes.press( {
 				keyCode: keyCodes.k,
 				ctrlKey: true,
 				preventDefault: sinon.spy(),
 				stopPropagation: sinon.spy()
 			} );
-			sinon.assert.calledWithExactly( spy, true );
+			sinon.assert.calledWithExactly( spy );
 		} );
 
 		it( 'should prevent default action on Ctrl+K keystroke', () => {
@@ -473,7 +441,7 @@ describe( 'Link', () => {
 			sinon.assert.calledOnce( stopPropagationSpy );
 		} );
 
-		it( 'should focus the the #formView on `Tab` key press when the #_balloon is open', () => {
+		it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => {
 			const keyEvtData = {
 				keyCode: keyCodes.tab,
 				preventDefault: sinon.spy(),
@@ -486,9 +454,9 @@ describe( 'Link', () => {
 			editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } );
 
 			// Balloon is invisible, form not focused.
-			formView.focusTracker.isFocused = false;
+			actionsView.focusTracker.isFocused = false;
 
-			const spy = sinon.spy( formView, 'focus' );
+			const spy = sinon.spy( actionsView, 'focus' );
 
 			editor.keystrokes.press( keyEvtData );
 			sinon.assert.notCalled( keyEvtData.preventDefault );
@@ -498,8 +466,10 @@ describe( 'Link', () => {
 			sinon.assert.calledOnce( highestPriorityTabCallbackSpy );
 
 			// Balloon is visible, form focused.
-			linkFeature._showPanel( true );
-			formView.focusTracker.isFocused = true;
+			linkFeature._showUI();
+			testUtils.sinon.stub( linkFeature, '_areActionsVisible' ).value( true );
+
+			actionsView.focusTracker.isFocused = true;
 
 			editor.keystrokes.press( keyEvtData );
 			sinon.assert.notCalled( keyEvtData.preventDefault );
@@ -509,7 +479,7 @@ describe( 'Link', () => {
 			sinon.assert.calledTwice( highestPriorityTabCallbackSpy );
 
 			// Balloon is still visible, form not focused.
-			formView.focusTracker.isFocused = false;
+			actionsView.focusTracker.isFocused = false;
 
 			editor.keystrokes.press( keyEvtData );
 			sinon.assert.calledOnce( keyEvtData.preventDefault );
@@ -519,8 +489,8 @@ describe( 'Link', () => {
 			sinon.assert.calledThrice( highestPriorityTabCallbackSpy );
 		} );
 
-		it( 'should hide the #_balloon after Esc key press (from editor) and not focus the editable', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
+		it( 'should hide the UI after Esc key press (from editor) and not focus the editable', () => {
+			const spy = testUtils.sinon.spy( linkFeature, '_hideUI' );
 			const keyEvtData = {
 				keyCode: keyCodes.esc,
 				preventDefault: sinon.spy(),
@@ -528,14 +498,14 @@ describe( 'Link', () => {
 			};
 
 			// Balloon is visible.
-			linkFeature._showPanel( false );
+			linkFeature._showUI();
 			editor.keystrokes.press( keyEvtData );
 
 			sinon.assert.calledWithExactly( spy );
 		} );
 
-		it( 'should not hide #_balloon after Esc key press (from editor) when #_balloon is open but is not visible', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
+		it( 'should not hide the UI after Esc key press (from editor) when UI is open but is not visible', () => {
+			const spy = testUtils.sinon.spy( linkFeature, '_hideUI' );
 			const keyEvtData = {
 				keyCode: keyCodes.esc,
 				preventDefault: () => {},
@@ -548,42 +518,30 @@ describe( 'Link', () => {
 				destroy: () => {}
 			};
 
-			linkFeature._showPanel( false );
+			linkFeature._showUI();
+
+			// Some view precedes the link UI in the balloon.
 			balloon.add( { view: viewMock } );
 			editor.keystrokes.press( keyEvtData );
 
 			sinon.assert.notCalled( spy );
 		} );
-
-		it( 'should hide the #_balloon after Esc key press (from the form) and focus the editable', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
-			const keyEvtData = {
-				keyCode: keyCodes.esc,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			};
-
-			linkFeature._showPanel( true );
-
-			formView.keystrokes.press( keyEvtData );
-			sinon.assert.calledWithExactly( spy, true );
-		} );
 	} );
 
 	describe( 'mouse support', () => {
-		it( 'should hide #_balloon and not focus editable on click outside the #_balloon', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
+		it( 'should hide the UI and not focus editable upon clicking outside the UI', () => {
+			const spy = testUtils.sinon.spy( linkFeature, '_hideUI' );
 
-			linkFeature._showPanel( true );
+			linkFeature._showUI( true );
 			document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
 
 			sinon.assert.calledWithExactly( spy );
 		} );
 
-		it( 'should not hide #_balloon on click inside the #_balloon', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' );
+		it( 'should not hide the UI upon clicking inside the the UI', () => {
+			const spy = testUtils.sinon.spy( linkFeature, '_hideUI' );
 
-			linkFeature._showPanel( true );
+			linkFeature._showUI( true );
 			balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
 
 			sinon.assert.notCalled( spy );
@@ -596,66 +554,66 @@ describe( 'Link', () => {
 				observer = editor.editing.view.getObserver( ClickObserver );
 				editor.model.schema.extend( '$text', { allowIn: '$root' } );
 
-				spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} );
+				spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} );
 			} );
 
-			it( 'should open with not selected formView when collapsed selection is inside link element', () => {
+			it( 'should show the UI when collapsed selection is inside link element', () => {
 				setModelData( editor.model, '<$text linkHref="url">fo[]o</$text>' );
 
 				observer.fire( 'click', { target: document.body } );
 				sinon.assert.calledWithExactly( spy );
 			} );
 
-			it( 'should open when selection exclusively encloses a link element (#1)', () => {
+			it( 'should show the UI when selection exclusively encloses a link element (#1)', () => {
 				setModelData( editor.model, '[<$text linkHref="url">foo</$text>]' );
 
 				observer.fire( 'click', { target: {} } );
 				sinon.assert.calledWithExactly( spy );
 			} );
 
-			it( 'should open when selection exclusively encloses a link element (#2)', () => {
+			it( 'should show the UI when selection exclusively encloses a link element (#2)', () => {
 				setModelData( editor.model, '<$text linkHref="url">[foo]</$text>' );
 
 				observer.fire( 'click', { target: {} } );
 				sinon.assert.calledWithExactly( spy );
 			} );
 
-			it( 'should not open when selection is not inside link element', () => {
+			it( 'should do nothing when selection is not inside link element', () => {
 				setModelData( editor.model, '[]' );
 
 				observer.fire( 'click', { target: {} } );
 				sinon.assert.notCalled( spy );
 			} );
 
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a link element (#1)', () => {
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#1)', () => {
 				setModelData( editor.model, '<$text linkHref="url">f[o]o</$text>' );
 
 				observer.fire( 'click', { target: {} } );
 				sinon.assert.notCalled( spy );
 			} );
 
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a link element (#2)', () => {
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#2)', () => {
 				setModelData( editor.model, '<$text linkHref="url">[fo]o</$text>' );
 
 				observer.fire( 'click', { target: {} } );
 				sinon.assert.notCalled( spy );
 			} );
 
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a link element (#3)', () => {
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#3)', () => {
 				setModelData( editor.model, '<$text linkHref="url">f[oo]</$text>' );
 
 				observer.fire( 'click', { target: {} } );
 				sinon.assert.notCalled( spy );
 			} );
 
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a link element (#4)', () => {
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#4)', () => {
 				setModelData( editor.model, 'ba[r<$text linkHref="url">foo]</$text>' );
 
 				observer.fire( 'click', { target: {} } );
 				sinon.assert.notCalled( spy );
 			} );
 
-			it( 'should not open when selection is non-collapsed and doesn\'t enclose a link element (#5)', () => {
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#5)', () => {
 				setModelData( editor.model, 'ba[r<$text linkHref="url">foo</$text>]' );
 
 				observer.fire( 'click', { target: {} } );
@@ -664,22 +622,100 @@ describe( 'Link', () => {
 		} );
 	} );
 
-	describe( 'link form', () => {
+	describe( 'actions view', () => {
+		let focusEditableSpy;
+
+		beforeEach( () => {
+			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+		} );
+
+		it( 'should mark the editor UI as focused when the #actionsView is focused', () => {
+			linkFeature._showUI();
+			linkFeature._removeFormView();
+
+			expect( balloon.visibleView ).to.equal( actionsView );
+
+			editor.ui.focusTracker.isFocused = false;
+			actionsView.element.dispatchEvent( new Event( 'focus' ) );
+
+			expect( editor.ui.focusTracker.isFocused ).to.be.true;
+		} );
+
+		describe( 'binding', () => {
+			it( 'should show the #formView on #edit event and select the URL input field', () => {
+				linkFeature._showUI();
+				linkFeature._removeFormView();
+
+				const selectSpy = testUtils.sinon.spy( formView.urlInputView, 'select' );
+				actionsView.fire( 'edit' );
+
+				expect( balloon.visibleView ).to.equal( formView );
+				sinon.assert.calledOnce( selectSpy );
+			} );
+
+			it( 'should execute unlink command on actionsView#unlink event', () => {
+				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+				actionsView.fire( 'unlink' );
+
+				expect( executeSpy.calledOnce ).to.be.true;
+				expect( executeSpy.calledWithExactly( 'unlink' ) ).to.be.true;
+			} );
+
+			it( 'should hide and focus editable on actionsView#unlink event', () => {
+				linkFeature._showUI();
+				linkFeature._removeFormView();
+
+				// Removing the form would call the focus spy.
+				focusEditableSpy.resetHistory();
+				actionsView.fire( 'unlink' );
+
+				expect( balloon.visibleView ).to.be.null;
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+
+			it( 'should hide after Esc key press', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				linkFeature._showUI();
+				linkFeature._removeFormView();
+
+				// Removing the form would call the focus spy.
+				focusEditableSpy.resetHistory();
+
+				actionsView.keystrokes.press( keyEvtData );
+				expect( balloon.visibleView ).to.equal( null );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( 'link form view', () => {
 		let focusEditableSpy;
 
 		beforeEach( () => {
 			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
 		} );
 
-		it( 'should mark the editor ui as focused when the #formView is focused', () => {
-			linkFeature._showPanel();
+		it( 'should mark the editor UI as focused when the #formView is focused', () => {
+			linkFeature._showUI();
+			expect( balloon.visibleView ).to.equal( formView );
+
 			editor.ui.focusTracker.isFocused = false;
 			formView.element.dispatchEvent( new Event( 'focus' ) );
 
-			expect( editor.ui.focusTracker.isFocused ).to.true;
+			expect( editor.ui.focusTracker.isFocused ).to.be.true;
 		} );
 
 		describe( 'binding', () => {
+			beforeEach( () => {
+				setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+			} );
+
 			it( 'should bind formView.urlInputView#value to link command value', () => {
 				const command = editor.commands.get( 'link' );
 
@@ -698,41 +734,38 @@ describe( 'Link', () => {
 				formView.urlInputView.inputView.element.value = 'http://cksource.com';
 				formView.fire( 'submit' );
 
-				expect( executeSpy.calledOnce ).to.true;
-				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.true;
+				expect( executeSpy.calledOnce ).to.be.true;
+				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.be.true;
 			} );
 
-			it( 'should hide and focus editable on formView#submit event', () => {
-				linkFeature._showPanel();
+			it( 'should hide and reveal the #actionsView on formView#submit event', () => {
+				linkFeature._showUI();
 				formView.fire( 'submit' );
 
-				expect( balloon.visibleView ).to.null;
-				expect( focusEditableSpy.calledOnce ).to.true;
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
 			} );
 
-			it( 'should execute unlink command on formView#unlink event', () => {
-				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
-
-				formView.fire( 'unlink' );
+			it( 'should hide and reveal the #actionsView on formView#cancel event', () => {
+				linkFeature._showUI();
+				formView.fire( 'cancel' );
 
-				expect( executeSpy.calledOnce ).to.true;
-				expect( executeSpy.calledWithExactly( 'unlink' ) ).to.true;
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
 			} );
 
-			it( 'should hide and focus editable on formView#unlink event', () => {
-				linkFeature._showPanel();
-				formView.fire( 'unlink' );
-
-				expect( balloon.visibleView ).to.null;
-				expect( focusEditableSpy.calledOnce ).to.true;
-			} );
+			it( 'should hide after Esc key press', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
 
-			it( 'should hide and focus editable on formView#cancel event', () => {
-				linkFeature._showPanel();
-				formView.fire( 'cancel' );
+				linkFeature._showUI();
 
-				expect( balloon.visibleView ).to.null;
-				expect( focusEditableSpy.calledOnce ).to.true;
+				formView.keystrokes.press( keyEvtData );
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
 			} );
 		} );
 	} );

+ 199 - 0
packages/ckeditor5-link/tests/ui/linkactionsview.js

@@ -0,0 +1,199 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import LinkActionsView from '../../src/ui/linkactionsview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+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';
+
+testUtils.createSinonSandbox();
+
+describe( 'LinkActionsView', () => {
+	let view;
+
+	beforeEach( () => {
+		view = new LinkActionsView( { t: val => val } );
+		view.render();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should create element from template', () => {
+			expect( view.element.classList.contains( 'ck-link-actions' ) ).to.true;
+			expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' );
+		} );
+
+		it( 'should create child views', () => {
+			expect( view.previewButtonView ).to.be.instanceOf( View );
+			expect( view.unlinkButtonView ).to.be.instanceOf( View );
+			expect( view.editButtonView ).to.be.instanceOf( View );
+
+			expect( view._unboundChildren.get( 0 ) ).to.equal( view.previewButtonView );
+			expect( view._unboundChildren.get( 1 ) ).to.equal( view.editButtonView );
+			expect( view._unboundChildren.get( 2 ) ).to.equal( view.unlinkButtonView );
+		} );
+
+		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 );
+		} );
+
+		it( 'should fire `edit` event on editButtonView#execute', () => {
+			const spy = sinon.spy();
+
+			view.on( 'edit', spy );
+
+			view.editButtonView.fire( 'execute' );
+
+			expect( spy.calledOnce ).to.true;
+		} );
+
+		it( 'should fire `unlink` event on unlinkButtonView#execute', () => {
+			const spy = sinon.spy();
+
+			view.on( 'unlink', spy );
+
+			view.unlinkButtonView.fire( 'execute' );
+
+			expect( spy.calledOnce ).to.true;
+		} );
+
+		describe( 'preview button view', () => {
+			it( 'is an anchor', () => {
+				expect( view.previewButtonView.element.tagName.toLowerCase() ).to.equal( 'a' );
+			} );
+
+			it( 'has a CSS class', () => {
+				expect( view.previewButtonView.element.classList.contains( 'ck-link-actions__preview' ) ).to.be.true;
+			} );
+
+			it( 'has a target attribute', () => {
+				expect( view.previewButtonView.element.getAttribute( 'target' ) ).to.equal( '_blank' );
+			} );
+
+			describe( '<button> bindings', () => {
+				it( 'binds href DOM attribute to view#href', () => {
+					expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.be.null;
+
+					view.href = 'foo';
+
+					expect( view.previewButtonView.element.getAttribute( 'href' ) ).to.equal( 'foo' );
+				} );
+
+				it( 'binds #isEnabled to view#href', () => {
+					expect( view.previewButtonView.isEnabled ).to.be.false;
+
+					view.href = 'foo';
+
+					expect( view.previewButtonView.isEnabled ).to.be.true;
+				} );
+			} );
+		} );
+
+		describe( 'template', () => {
+			it( 'has child views', () => {
+				expect( view.template.children[ 0 ] ).to.equal( view.previewButtonView );
+				expect( view.template.children[ 1 ] ).to.equal( view.editButtonView );
+				expect( view.template.children[ 2 ] ).to.equal( view.unlinkButtonView );
+			} );
+		} );
+	} );
+
+	describe( 'render()', () => {
+		it( 'should register child views in #_focusables', () => {
+			expect( view._focusables.map( f => f ) ).to.have.members( [
+				view.previewButtonView,
+				view.editButtonView,
+				view.unlinkButtonView
+			] );
+		} );
+
+		it( 'should register child views\' #element in #focusTracker', () => {
+			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+
+			view = new LinkActionsView( { t: () => {} } );
+			view.render();
+
+			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.previewButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.editButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.unlinkButtonView.element );
+		} );
+
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new LinkActionsView( { t: () => {} } );
+
+			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 toolbar', () => {
+			it( 'so "tab" focuses the next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the preview button is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.previewButtonView.element;
+
+				const spy = sinon.spy( view.editButtonView, '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 edit button is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.editButtonView.element;
+
+				const spy = sinon.spy( view.previewButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the #previewButtonView', () => {
+			const spy = sinon.spy( view.previewButtonView, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+} );

+ 3 - 24
packages/ckeditor5-link/tests/ui/linkformview.js

@@ -34,12 +34,10 @@ describe( 'LinkFormView', () => {
 			expect( view.urlInputView ).to.be.instanceOf( View );
 			expect( view.saveButtonView ).to.be.instanceOf( View );
 			expect( view.cancelButtonView ).to.be.instanceOf( View );
-			expect( view.unlinkButtonView ).to.be.instanceOf( View );
 
 			expect( view._unboundChildren.get( 0 ) ).to.equal( view.urlInputView );
 			expect( view._unboundChildren.get( 1 ) ).to.equal( view.saveButtonView );
 			expect( view._unboundChildren.get( 2 ) ).to.equal( view.cancelButtonView );
-			expect( view._unboundChildren.get( 3 ) ).to.equal( view.unlinkButtonView );
 		} );
 
 		it( 'should create #focusTracker instance', () => {
@@ -68,16 +66,6 @@ describe( 'LinkFormView', () => {
 			expect( spy.calledOnce ).to.true;
 		} );
 
-		it( 'should fire `unlink` event on unlinkButtonView#execute', () => {
-			const spy = sinon.spy();
-
-			view.on( 'unlink', spy );
-
-			view.unlinkButtonView.fire( 'execute' );
-
-			expect( spy.calledOnce ).to.true;
-		} );
-
 		describe( 'url input view', () => {
 			it( 'has placeholder', () => {
 				expect( view.urlInputView.inputView.placeholder ).to.equal( 'https://example.com' );
@@ -89,16 +77,9 @@ describe( 'LinkFormView', () => {
 				expect( view.template.children[ 0 ] ).to.equal( view.urlInputView );
 			} );
 
-			it( 'has form actions container', () => {
-				expect( view.template.children[ 1 ].attributes.class ).to.have.members( [ 'ck-link-form__actions' ] );
-			} );
-
-			it( 'has form action views', () => {
-				const actions = view.template.children[ 1 ].children;
-
-				expect( actions[ 0 ] ).to.equal( view.saveButtonView );
-				expect( actions[ 1 ] ).to.equal( view.cancelButtonView );
-				expect( actions[ 2 ] ).to.equal( view.unlinkButtonView );
+			it( 'has button views', () => {
+				expect( view.template.children[ 1 ] ).to.equal( view.saveButtonView );
+				expect( view.template.children[ 2 ] ).to.equal( view.cancelButtonView );
 			} );
 		} );
 	} );
@@ -109,7 +90,6 @@ describe( 'LinkFormView', () => {
 				view.urlInputView,
 				view.saveButtonView,
 				view.cancelButtonView,
-				view.unlinkButtonView
 			] );
 		} );
 
@@ -122,7 +102,6 @@ describe( 'LinkFormView', () => {
 			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element );
 			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element );
 			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element );
-			sinon.assert.calledWithExactly( spy.getCall( 3 ), view.unlinkButtonView.element );
 		} );
 
 		it( 'starts listening for #keystrokes coming from #element', () => {

+ 1 - 1
packages/ckeditor5-link/theme/icons/link.svg

@@ -1 +1 @@
-<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#222" fill-rule="evenodd"><path d="M14.2 10.956l1.227-1.227a3.995 3.995 0 0 0-.002-5.654 4 4 0 0 0-5.654-.002L7.698 6.145a3.995 3.995 0 0 0 .003 5.654c.39.39.84.682 1.32.878l-.305-.307.638-.638a2.99 2.99 0 0 1-.946-.64 2.995 2.995 0 0 1-.003-4.24l2.073-2.072a3 3 0 0 1 4.242 4.242l-1.226 1.227.707.707z"/><path d="M10.166 7.405c.41.192.795.457 1.133.796a3.995 3.995 0 0 1 .003 5.654l-2.073 2.072a4 4 0 0 1-5.654-.002 3.995 3.995 0 0 1-.002-5.654l1.362-1.363.707.707-1.362 1.363a3 3 0 0 0 4.243 4.243l2.072-2.073a2.995 2.995 0 0 0-.003-4.24 2.987 2.987 0 0 0-1.196-.733l.77-.77z"/></g></svg>
+<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.077 15l.991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955c.02-.095.06-.189.12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184z" fill="#000" fill-rule="evenodd"/></svg>

+ 1 - 1
packages/ckeditor5-link/theme/icons/unlink.svg

@@ -1 +1 @@
-<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#222" fill-rule="evenodd"><path d="M14.2 10.956l1.227-1.227a3.995 3.995 0 0 0-.002-5.654 4 4 0 0 0-5.654-.002L7.698 6.145a3.995 3.995 0 0 0 .003 5.654c.39.39.84.682 1.32.878l-.305-.307.638-.638a2.99 2.99 0 0 1-.946-.64 2.995 2.995 0 0 1-.003-4.24l2.073-2.072a3 3 0 0 1 4.242 4.242l-1.226 1.227.707.707z"/><path d="M14.906 16.092l2.557-2.557-.722-.721-2.556 2.556-2.345-2.345-.722.721 2.345 2.346-2.556 2.556.721.722 2.557-2.557 2.768 2.768.721-.722-2.768-2.767zm-2.513-3.999A4.067 4.067 0 0 0 10.148 7.5l-.794.794c.448.142.869.39 1.223.745a3.054 3.054 0 0 1 .758 3.055h1.058zm-2.204 3.104l-1.049 1.05c-1.609 1.608-4.208 1.62-5.8.028-1.595-1.595-1.584-4.188.028-5.8l1.406-1.406.721.722-1.405 1.405c-1.21 1.21-1.216 3.154-.02 4.35 1.192 1.192 3.142 1.186 4.349-.021l1.77-1.771v1.443z"/></g></svg>
+<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><g fill="#000" fill-rule="evenodd"><path d="M11.077 15l.991-1.416a.75.75 0 1 1 1.229.86l-1.148 1.64a.748.748 0 0 1-.217.206 5.251 5.251 0 0 1-8.503-5.955c.02-.095.06-.189.12-.274l1.147-1.639a.75.75 0 1 1 1.228.86L4.933 10.7l.006.003a3.75 3.75 0 0 0 6.132 4.294l.006.004zm5.494-5.335a.748.748 0 0 1-.12.274l-1.147 1.639a.75.75 0 1 1-1.228-.86l.86-1.23a3.75 3.75 0 0 0-6.144-4.301l-.86 1.229a.75.75 0 0 1-1.229-.86l1.148-1.64a.748.748 0 0 1 .217-.206 5.251 5.251 0 0 1 8.503 5.955zm-4.563-2.532a.75.75 0 0 1 .184 1.045l-3.155 4.505a.75.75 0 1 1-1.229-.86l3.155-4.506a.75.75 0 0 1 1.045-.184zM16.927 17.695l-1.414 1.414a.75.75 0 1 1-1.06-1.06l1.414-1.415-1.415-1.414a.75.75 0 0 1 1.061-1.06l1.414 1.414 1.414-1.415a.75.75 0 0 1 1.061 1.061l-1.414 1.414 1.414 1.415a.75.75 0 0 1-1.06 1.06l-1.415-1.414z"/></g></svg>

+ 14 - 0
packages/ckeditor5-link/theme/linkactions.css

@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck-link-actions {
+	& .ck-link-actions__preview {
+		display: inline-block;
+
+		& .ck-button__label {
+			overflow: hidden;
+		}
+	}
+}

+ 5 - 12
packages/ckeditor5-link/theme/linkform.css

@@ -4,18 +4,11 @@
  */
 
 .ck-link-form {
-	overflow: hidden;
-}
-
-.ck-link-form__actions {
-	clear: both;
-
-	& .ck-button {
-		float: right;
+	& .ck-labeled-input {
+		display: inline-block;
+	}
 
-		/* The "Unlink" button.*/
-		&:last-child {
-			float: left;
-		}
+	& .ck-label {
+		display: none;
 	}
 }