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

Merge pull request #493 from ckeditor/t/491

Feature: Introduced rotatable panels for `ContextualBalloon`. Closes https://github.com/ckeditor/ckeditor5-ui/issues/491.
Piotr Jasiun 6 лет назад
Родитель
Сommit
4b88cad0c3

+ 435 - 70
packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js

@@ -9,22 +9,46 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import BalloonPanelView from './balloonpanelview';
+import View from '../../view';
+import ButtonView from '../../button/buttonview';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+
+import prevIcon from '../../../theme/icons/previous-arrow.svg';
+import nextIcon from '../../../theme/icons/next-arrow.svg';
+
+import '../../../theme/components/panel/balloonrotator.css';
 
 /**
- * Provides the common contextual balloon panel for the editor.
- *
- * This plugin allows reusing a single {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance
- * to display multiple contextual balloon panels in the editor.
+ * Provides the common contextual balloon for the editor.
  *
- * Child views of such a panel are stored in the stack and the last one in the stack is visible. When the
- * visible view is removed from the stack, the previous view becomes visible, etc. If there are no more
- * views in the stack, the balloon panel will hide.
- *
- * It simplifies managing the views and helps
+ * The role of this plugin is to unified contextual balloons logic, simplifies managing the views and helps
  * avoid the unnecessary complexity of handling multiple {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView}
  * instances in the editor.
  *
+ * This plugin allows creating single or multiple panel stacks.
+ *
+ * Each stack may have multiple views, the one on the top is visible. When the visible view is removed from the stack,
+ * the previous view becomes visible, etc. It might be useful to implement nested navigation in a balloon. For instance
+ * toolbar view may have a link button. When you click it, link view (which let you set the URL) is created and put on
+ * top of the toolbar view, so not link panel is displayed. When you finish editing link and close (remove) link view,
+ * the toolbar view is visible back.
+ *
+ * However, there are cases when there are multiple independent balloons to be displayed. For instance, if the selection
+ * is inside two inline comments at the same time. For such cases, you can create two independent panel stacks.
+ * Contextual balloon plugin will create a navigation bar to let users switch between these panel stacks - "next balloon"
+ * and "previous balloon" buttons.
+ *
+ * If there are no views in the current stack, the balloon panel will try to switch to the next stack. If there are no
+ * panels in any stack then balloon panel will be hidden.
+ *
+ * From the implementation point of view, contextual ballon plugin is reusing a single
+ * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance to display multiple contextual balloon
+ * panels in the editor. It also creates a special {@link module:ui/panel/balloon/contextualballoon~RotatorView rotator view},
+ * used to manage multiple panel stacks. Rotator view is a child of the balloon panel view and the parent of the specific
+ * view you want to display. If there is are more than one panel stack to be displayed, the rotator view will add a
+ * navigation bar. If there is only one stack, rotator view is transparent (do not add any UI elements).
+ *
  * @extends module:core/plugin~Plugin
  */
 export default class ContextualBalloon extends Plugin {
@@ -38,14 +62,8 @@ export default class ContextualBalloon extends Plugin {
 	/**
 	 * @inheritDoc
 	 */
-	init() {
-		/**
-		 * The common balloon panel view.
-		 *
-		 * @readonly
-		 * @member {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} #view
-		 */
-		this.view = new BalloonPanelView();
+	constructor( editor ) {
+		super( editor );
 
 		/**
 		 * The {@link module:utils/dom/position~Options#limiter position limiter}
@@ -71,47 +89,76 @@ export default class ContextualBalloon extends Plugin {
 		};
 
 		/**
-		 * Stack of the views injected into the balloon. Last one in the stack is displayed
-		 * as a content of {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon#view}.
+		 * The currently visible view or `null` when there are no views in the any stack.
 		 *
-		 * @private
-		 * @member {Map} #_stack
+		 * @readonly
+		 * @observable
+		 * @member {module:ui/view~View|null} #visibleView
 		 */
-		this._stack = new Map();
+		this.set( 'visibleView', null );
 
-		// Add balloon panel view to editor `body` collection and wait until view will be ready.
-		this.editor.ui.view.body.add( this.view );
+		/**
+		 * The common balloon panel view.
+		 *
+		 * @readonly
+		 * @member {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} #view
+		 */
+		this.view = new BalloonPanelView( editor.locale );
+		editor.ui.view.body.add( this.view );
+		editor.ui.focusTracker.add( this.view.element );
 
-		// Editor should be focused when contextual balloon is focused.
-		this.editor.ui.focusTracker.add( this.view.element );
-	}
+		/**
+		 * Map of views and its stacks.
+		 *
+		 * @private
+		 * @type {Map.<module:ui/view~View,Set>}
+		 */
+		this._viewToStack = new Map();
 
-	/**
-	 * Returns the currently visible view or `null` when there are no
-	 * views in the stack.
-	 *
-	 * @returns {module:ui/view~View|null}
-	 */
-	get visibleView() {
-		const item = this._stack.get( this.view.content.get( 0 ) );
+		/**
+		 * Map of ids and stacks.
+		 *
+		 * @private
+		 * @type {Map.<String,Set>}
+		 */
+		this._idToStack = new Map();
+
+		/**
+		 * A total number of all stacks in the balloon.
+		 *
+		 * @private
+		 * @readonly
+		 * @observable
+		 * @member {Number} #_numberOfStacks
+		 */
+		this.set( '_numberOfStacks', 0 );
 
-		return item ? item.view : null;
+		/**
+		 * Rotator view embedded in the contextual balloon.
+		 * Displays currently visible view in the balloon and provides navigation for switching stacks.
+		 *
+		 * @private
+		 * @type {module:ui/panel/balloon/contextualballoon~RotatorView}
+		 */
+		this._rotatorView = this._createRotatorView();
 	}
 
 	/**
-	 * Returns `true` when the given view is in the stack. Otherwise returns `false`.
+	 * Returns `true` when the given view is in one of the stack. Otherwise returns `false`.
 	 *
 	 * @param {module:ui/view~View} view
 	 * @returns {Boolean}
 	 */
 	hasView( view ) {
-		return this._stack.has( view );
+		return Array.from( this._viewToStack.keys() ).includes( view );
 	}
 
 	/**
-	 * Adds a new view to the stack and makes it visible.
+	 * Adds a new view to the stack and makes it visible if current stack is visible
+	 * or it is a first view in the balloon.
 	 *
 	 * @param {Object} data Configuration of the view.
+	 * @param {String} [data.stackId='main'] Id of a stack that view is added to.
 	 * @param {module:ui/view~View} [data.view] Content of the balloon.
 	 * @param {module:utils/dom/position~Options} [data.position] Positioning options.
 	 * @param {String} [data.balloonClassName] Additional CSS class added to the {@link #view balloon} when visible.
@@ -127,23 +174,38 @@ export default class ContextualBalloon extends Plugin {
 			throw new CKEditorError( 'contextualballoon-add-view-exist: Cannot add configuration of the same view twice.' );
 		}
 
-		// When adding view to the not empty balloon.
-		if ( this.visibleView ) {
-			// Remove displayed content from the view.
-			this.view.content.remove( this.visibleView );
+		const stackId = data.stackId || 'main';
+
+		// If new stack is added, creates it and show view from this stack.
+		if ( !this._idToStack.has( stackId ) ) {
+			this._idToStack.set( stackId, new Map( [ [ data.view, data ] ] ) );
+			this._viewToStack.set( data.view, this._idToStack.get( stackId ) );
+			this._numberOfStacks = this._idToStack.size;
+
+			if ( !this._visibleStack ) {
+				this.showStack( stackId );
+			}
+
+			return;
 		}
 
+		const stack = this._idToStack.get( stackId );
+
 		// Add new view to the stack.
-		this._stack.set( data.view, data );
+		stack.set( data.view, data );
+		this._viewToStack.set( data.view, stack );
 
-		// And display it.
-		this._show( data );
+		// And display it if is added to the currently visible stack.
+		if ( stack === this._visibleStack ) {
+			this._showView( data );
+		}
 	}
 
 	/**
 	 * Removes the given view from the stack. If the removed view was visible,
 	 * then the view preceding it in the stack will become visible instead.
-	 * When there is no view in the stack then balloon will hide.
+	 * When there is no view in the stack then next stack will be displayed.
+	 * When there is not more stacks then balloon will hide.
 	 *
 	 * @param {module:ui/view~View} view A view to be removed from the balloon.
 	 */
@@ -157,43 +219,180 @@ export default class ContextualBalloon extends Plugin {
 			throw new CKEditorError( 'contextualballoon-remove-view-not-exist: Cannot remove configuration of not existing view.' );
 		}
 
-		// When visible view is being removed.
-		if ( this.visibleView === view ) {
-			// We need to remove it from the view content.
-			this.view.content.remove( view );
-
-			// And then remove from the stack.
-			this._stack.delete( view );
-
-			// Next we need to check if there is other view in stack to show.
-			const last = Array.from( this._stack.values() ).pop();
+		const stack = this._viewToStack.get( view );
 
-			// If it is some other view.
-			if ( last ) {
-				// Just show it.
-				this._show( last );
+		// When visible view will be removed we need to show a preceding view or next stack
+		// if a view is the only view in the stack.
+		if ( this.visibleView === view ) {
+			if ( stack.size === 1 ) {
+				if ( this._idToStack.size > 1 ) {
+					this._showNextStack();
+				} else {
+					this.view.hide();
+					this.visibleView = null;
+					this._rotatorView.hideView();
+				}
 			} else {
-				// Hide the balloon panel.
-				this.view.hide();
+				this._showView( Array.from( stack.values() )[ stack.size - 2 ] );
 			}
+		}
+
+		if ( stack.size === 1 ) {
+			this._idToStack.delete( this._getStackId( stack ) );
+			this._numberOfStacks = this._idToStack.size;
 		} else {
-			// Just remove given view from the stack.
-			this._stack.delete( view );
+			stack.delete( view );
 		}
+
+		this._viewToStack.delete( view );
 	}
 
 	/**
 	 * Updates the position of the balloon using position data of the first visible view in the stack.
-	 * When new position data is given then position data of currently visible panel will be updated.
+	 * When new position data is given then position data of currently visible view will be updated.
 	 *
 	 * @param {module:utils/dom/position~Options} [position] position options.
 	 */
 	updatePosition( position ) {
 		if ( position ) {
-			this._stack.get( this.visibleView ).position = position;
+			this._visibleStack.get( this.visibleView ).position = position;
 		}
 
 		this.view.pin( this._getBalloonPosition() );
+		this._rotatorView.updateIsNarrow();
+	}
+
+	/**
+	 * Shows last view from the stack of a given id.
+	 *
+	 * @param {String} id
+	 */
+	showStack( id ) {
+		const stack = this._idToStack.get( id );
+
+		if ( !stack ) {
+			/**
+			 * Trying to show not existing stack.
+			 *
+			 * @error contextualballoon-showstack-stack-not-exist
+			 */
+			throw new CKEditorError( 'contextualballoon-showstack-stack-not-exist: Cannot show not existing stack.' );
+		}
+
+		if ( this._visibleStack === stack ) {
+			return;
+		}
+
+		this._showView( Array.from( stack.values() ).pop() );
+	}
+
+	/**
+	 * Returns the stack of currently visible view.
+	 *
+	 * @private
+	 * @type {Set}
+	 */
+	get _visibleStack() {
+		return this._viewToStack.get( this.visibleView );
+	}
+
+	/**
+	 * Returns id of given stack.
+	 *
+	 * @private
+	 * @param {Set} stack
+	 * @returns {String}
+	 */
+	_getStackId( stack ) {
+		const entry = Array.from( this._idToStack.entries() ).find( entry => entry[ 1 ] === stack );
+
+		return entry[ 0 ];
+	}
+
+	/**
+	 * Shows last view from the next stack.
+	 *
+	 * @private
+	 */
+	_showNextStack() {
+		const stacks = Array.from( this._idToStack.values() );
+
+		let nextIndex = stacks.indexOf( this._visibleStack ) + 1;
+
+		if ( !stacks[ nextIndex ] ) {
+			nextIndex = 0;
+		}
+
+		this.showStack( this._getStackId( stacks[ nextIndex ] ) );
+	}
+
+	/**
+	 * Shows last view from the previous stack.
+	 *
+	 * @private
+	 */
+	_showPrevStack() {
+		const stacks = Array.from( this._idToStack.values() );
+
+		let nextIndex = stacks.indexOf( this._visibleStack ) - 1;
+
+		if ( !stacks[ nextIndex ] ) {
+			nextIndex = stacks.length - 1;
+		}
+
+		this.showStack( this._getStackId( stacks[ nextIndex ] ) );
+	}
+
+	/**
+	 * Creates a rotator view.
+	 *
+	 * @private
+	 * @returns {module:ui/panel/balloon/contextualballoon~RotatorView}
+	 */
+	_createRotatorView() {
+		const view = new RotatorView( this.editor.locale );
+		const t = this.editor.locale.t;
+
+		this.view.content.add( view );
+
+		// Hide navigation when there is only a one stack.
+		view.bind( 'isNavigationVisible' ).to( this, '_numberOfStacks', value => value > 1 );
+
+		// Update balloon position after toggling navigation.
+		view.on( 'change:isNavigationVisible', () => ( this.updatePosition() ), { priority: 'low' } );
+
+		// Show stacks counter.
+		view.bind( 'counter' ).to( this, 'visibleView', this, '_numberOfStacks', ( visibleView, numberOfStacks ) => {
+			if ( numberOfStacks < 2 ) {
+				return '';
+			}
+
+			const current = Array.from( this._idToStack.values() ).indexOf( this._visibleStack ) + 1;
+
+			return `${ current } ${ t( 'of' ) } ${ numberOfStacks }`;
+		} );
+
+		view.buttonNextView.on( 'execute', () => {
+			// When current view has a focus then move focus to the editable before removing it,
+			// otherwise editor will lost focus.
+			if ( view.focusTracker.isFocused ) {
+				this.editor.editing.view.focus();
+			}
+
+			this._showNextStack();
+		} );
+
+		view.buttonPrevView.on( 'execute', () => {
+			// When current view has a focus then move focus to the editable before removing it,
+			// otherwise editor will lost focus.
+			if ( view.focusTracker.isFocused ) {
+				this.editor.editing.view.focus();
+			}
+
+			this._showPrevStack();
+		} );
+
+		return view;
 	}
 
 	/**
@@ -206,11 +405,12 @@ export default class ContextualBalloon extends Plugin {
 	 * @param {String} [data.balloonClassName=''] Additional class name which will be added to the {@link #view balloon}.
 	 * @param {Boolean} [data.withArrow=true] Whether the {@link #view balloon} should be rendered with an arrow.
 	 */
-	_show( { view, balloonClassName = '', withArrow = true } ) {
+	_showView( { view, balloonClassName = '', withArrow = true } ) {
 		this.view.class = balloonClassName;
 		this.view.withArrow = withArrow;
 
-		this.view.content.add( view );
+		this._rotatorView.showView( view );
+		this.visibleView = view;
 		this.view.pin( this._getBalloonPosition() );
 	}
 
@@ -222,7 +422,7 @@ export default class ContextualBalloon extends Plugin {
 	 * @returns {module:utils/dom/position~Options}
 	 */
 	_getBalloonPosition() {
-		let position = Array.from( this._stack.values() ).pop().position;
+		let position = Array.from( this._visibleStack.values() ).pop().position;
 
 		// Use the default limiter if none has been specified.
 		if ( position && !position.limiter ) {
@@ -235,3 +435,168 @@ export default class ContextualBalloon extends Plugin {
 		return position;
 	}
 }
+
+/**
+ * Rotator view is a helper class for the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon ContextualBalloon}.
+ * It is used for displaying last view from the current stack and providing navigation buttons for switching stacks.
+ * See {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon ContextualBalloon} documentation to learn more.
+ *
+ * @extends module:ui/view~View
+ */
+class RotatorView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const t = locale.t;
+		const bind = this.bindTemplate;
+
+		/**
+		 * Defines whether navigation is visible or not.
+		 *
+		 * @member {Boolean} #isNavigationVisible
+		 */
+		this.set( 'isNavigationVisible', true );
+
+		/**
+		 * Defines whether balloon should be marked as narrow or not.
+		 *
+		 * @member {Boolean} #isNarrow
+		 */
+		this.set( 'isNarrow', false );
+
+		/**
+		 * Used for checking if view is focused or not.
+		 *
+		 * @type {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * Navigation button for switching stack to the previous one.
+		 *
+		 * @type {module:ui/button/buttonview~ButtonView}
+		 */
+		this.buttonPrevView = this._createButtonView( t( 'Previous baloon' ), prevIcon );
+
+		/**
+		 * Navigation button for switching stack to the next one.
+		 *
+		 * @type {module:ui/button/buttonview~ButtonView}
+		 */
+		this.buttonNextView = this._createButtonView( t( 'Next balloon' ), nextIcon );
+
+		/**
+		 * Collection of the child views which creates rotator content.
+		 *
+		 * @readonly
+		 * @type {module:ui/viewcollection~ViewCollection}
+		 */
+		this.content = this.createCollection();
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-balloon-rotator'
+				],
+				'z-index': '-1'
+			},
+			children: [
+				{
+					tag: 'div',
+					attributes: {
+						class: [
+							'ck-balloon-rotator__navigation',
+							bind.to( 'isNavigationVisible', value => value ? '' : 'ck-hidden' )
+						]
+					},
+					children: [
+						this.buttonPrevView,
+						{
+							tag: 'span',
+
+							attributes: {
+								class: [
+									'ck-balloon-rotator__counter',
+									bind.to( 'isNarrow', value => value ? 'ck-hidden' : '' )
+								]
+							},
+
+							children: [
+								{
+									text: bind.to( 'counter' )
+								}
+							]
+						},
+						this.buttonNextView,
+					]
+				},
+				{
+					tag: 'div',
+					attributes: {
+						class: 'ck-balloon-rotator__content'
+					},
+					children: this.content
+				}
+			]
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	render() {
+		super.render();
+
+		this.focusTracker.add( this.element );
+	}
+
+	/**
+	 * Checks if view width is narrow and updated {@link ~RotatorView#isNarrow} state.
+	 */
+	updateIsNarrow() {
+		this.isNarrow = this.element.clientWidth <= 200;
+	}
+
+	/**
+	 * Shows given view.
+	 *
+	 * @param {module:ui/view~View} view The view to show.
+	 */
+	showView( view ) {
+		this.hideView();
+		this.content.add( view );
+		this.updateIsNarrow();
+	}
+
+	/**
+	 * Hides currently displayed view.
+	 */
+	hideView() {
+		this.content.clear();
+	}
+
+	/**
+	 * Creates a navigation button view.
+	 *
+	 * @private
+	 * @param {String} label The button's label.
+	 * @param {String} icon The button's icon.
+	 * @returns {module:ui/button/buttonview~ButtonView}
+	 */
+	_createButtonView( label, icon ) {
+		const view = new ButtonView( this.locale );
+
+		view.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
+
+		return view;
+	}
+}

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

@@ -8,9 +8,7 @@
  */
 
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
-import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
 /**
  * Collects {@link module:ui/view~View} instances.
@@ -206,8 +204,6 @@ export default class ViewCollection extends Collection {
 	 */
 }
 
-mix( Collection, ObservableMixin );
-
 // Check if all entries of the array are of `String` type.
 //
 // @private

+ 374 - 30
packages/ckeditor5-ui/tests/panel/balloon/contextualballoon.js

@@ -15,7 +15,7 @@ import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-util
 /* global document, Event */
 
 describe( 'ContextualBalloon', () => {
-	let editor, editorElement, balloon, viewA, viewB;
+	let editor, editorElement, balloon, viewA, viewB, viewC;
 
 	beforeEach( () => {
 		editorElement = document.createElement( 'div' );
@@ -36,6 +36,7 @@ describe( 'ContextualBalloon', () => {
 
 				viewA = new View();
 				viewB = new View();
+				viewC = new View();
 
 				// Add viewA to the pane and init viewB.
 				balloon.add( {
@@ -65,7 +66,7 @@ describe( 'ContextualBalloon', () => {
 		} );
 	} );
 
-	describe( 'init()', () => {
+	describe( 'constructor()', () => {
 		it( 'should create a plugin instance with properties', () => {
 			expect( balloon.view ).to.instanceof( BalloonPanelView );
 		} );
@@ -161,9 +162,11 @@ describe( 'ContextualBalloon', () => {
 	} );
 
 	describe( 'add()', () => {
-		it( 'should add view to the stack and display in balloon attached using given position options', () => {
-			expect( balloon.view.content.length ).to.equal( 1 );
-			expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewA );
+		it( 'should add view to the `main` stack and display in balloon attached using given position options', () => {
+			const content = balloon.view.content.get( 0 ).content;
+
+			expect( content.length ).to.equal( 1 );
+			expect( content.get( 0 ) ).to.deep.equal( viewA );
 			expect( balloon.view.pin.calledOnce ).to.true;
 			sinon.assert.calledWithMatch( balloon.view.pin.firstCall, {
 				target: 'fake',
@@ -171,6 +174,58 @@ describe( 'ContextualBalloon', () => {
 			} );
 		} );
 
+		it( 'should add view to the custom stack but not display it when other stack is already visible', () => {
+			balloon.add( {
+				view: viewB,
+				stackId: 'second',
+				position: {
+					target: 'fake'
+				}
+			} );
+
+			balloon.add( {
+				view: viewC,
+				stackId: 'second',
+				position: {
+					target: 'fake'
+				}
+			} );
+
+			const content = balloon.view.content.get( 0 ).content;
+
+			expect( content.length ).to.equal( 1 );
+			expect( content.get( 0 ) ).to.deep.equal( viewA );
+			expect( balloon.hasView( viewB ) );
+			expect( balloon.hasView( viewC ) );
+		} );
+
+		it( 'should add multiple views to he stack and display last one', () => {
+			balloon.add( {
+				view: viewB,
+				position: {
+					target: 'fake',
+					limiter: balloon.positionLimiter
+				}
+			} );
+
+			const content = balloon.view.content.get( 0 ).content;
+
+			expect( content.length ).to.equal( 1 );
+			expect( content.get( 0 ) ).to.deep.equal( viewB );
+		} );
+
+		it( 'should throw an error when try to add the same view more than once', () => {
+			expect( () => {
+				balloon.add( {
+					view: viewA,
+					position: {
+						target: 'fake',
+						limiter: balloon.positionLimiter
+					}
+				} );
+			} ).to.throw( CKEditorError, /^contextualballoon-add-view-exist/ );
+		} );
+
 		it( 'should use a provided limiter instead of #positionLimiter', () => {
 			balloon.remove( viewA );
 			balloon.view.pin.resetHistory();
@@ -230,31 +285,6 @@ describe( 'ContextualBalloon', () => {
 			sinon.assert.calledOnce( balloon.view.pin );
 		} );
 
-		it( 'should throw an error when try to add the same view more than once', () => {
-			expect( () => {
-				balloon.add( {
-					view: viewA,
-					position: {
-						target: 'fake',
-						limiter: balloon.positionLimiter
-					}
-				} );
-			} ).to.throw( CKEditorError, /^contextualballoon-add-view-exist/ );
-		} );
-
-		it( 'should add multiple views to he stack and display last one', () => {
-			balloon.add( {
-				view: viewB,
-				position: {
-					target: 'fake',
-					limiter: balloon.positionLimiter
-				}
-			} );
-
-			expect( balloon.view.content.length ).to.equal( 1 );
-			expect( balloon.view.content.get( 0 ) ).to.deep.equal( viewB );
-		} );
-
 		it( 'should use the position of the last view in the stack', () => {
 			balloon.add( {
 				view: viewB,
@@ -363,6 +393,53 @@ describe( 'ContextualBalloon', () => {
 			balloon.remove( viewA );
 			expect( balloon.visibleView ).to.null;
 		} );
+
+		it( 'should be observable', () => {
+			const spy = sinon.spy();
+
+			balloon.on( 'change:visibleView', spy );
+
+			balloon.add( { view: viewB } );
+
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWith( spy, sinon.match.any, 'visibleView', viewB, viewA );
+		} );
+	} );
+
+	describe( 'showStack()', () => {
+		it( 'should hide current view and display last view from the given stack', () => {
+			balloon.add( {
+				stackId: 'second',
+				view: viewB
+			} );
+
+			balloon.add( {
+				stackId: 'second',
+				view: viewC
+			} );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+
+			balloon.showStack( 'second' );
+
+			expect( balloon.visibleView ).to.equal( viewC );
+
+			balloon.showStack( 'main' );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+		} );
+
+		it( 'should do nothing when given stack is already visible', () => {
+			expect( () => {
+				balloon.showStack( 'main' );
+			} ).to.not.throw();
+		} );
+
+		it( 'should throw an error when there is no stack of given id', () => {
+			expect( () => {
+				balloon.showStack( 'second' );
+			} ).to.throw( CKEditorError, 'contextualballoon-showstack-stack-not-exist: Cannot show not existing stack.' );
+		} );
 	} );
 
 	describe( 'remove()', () => {
@@ -375,6 +452,53 @@ describe( 'ContextualBalloon', () => {
 			expect( balloon.view.isVisible ).to.false;
 		} );
 
+		it( 'should remove given view from not displayed stack', () => {
+			balloon.add( {
+				stackId: 'second',
+				view: viewB
+			} );
+
+			balloon.add( {
+				stackId: 'second',
+				view: viewC
+			} );
+
+			balloon.remove( viewB );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+			expect( () => {
+				balloon.showStack( 'second' );
+			} ).to.not.throw();
+		} );
+
+		it( 'should remove not displayed stack if a removed view was the only view in this stack', () => {
+			balloon.add( {
+				stackId: 'second',
+				view: viewB
+			} );
+
+			balloon.remove( viewB );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+			expect( () => {
+				balloon.showStack( 'second' );
+			} ).to.throw();
+		} );
+
+		it( 'should switch stack to the next one when removed view was the last one in the visible stack', () => {
+			balloon.add( {
+				stackId: 'second',
+				view: viewB
+			} );
+
+			balloon.remove( viewA );
+
+			expect( balloon.visibleView ).to.equal( viewB );
+			expect( () => {
+				balloon.showStack( 'main' );
+			} ).to.throw();
+		} );
+
 		it( 'should remove given view and set preceding in the stack as visible when removed view was visible', () => {
 			balloon.add( {
 				view: viewB,
@@ -403,6 +527,53 @@ describe( 'ContextualBalloon', () => {
 			expect( balloon.visibleView ).to.equal( viewB );
 		} );
 
+		it( 'should remove given view from a not currently visible stack', () => {
+			balloon.add( {
+				view: viewB,
+				stackId: 'second',
+				position: {
+					target: 'fake'
+				}
+			} );
+
+			balloon.add( {
+				view: viewC,
+				stackId: 'second',
+				position: {
+					target: 'fake'
+				}
+			} );
+
+			balloon.remove( viewB );
+
+			expect( balloon.hasView( viewB ) ).to.false;
+			expect( balloon.hasView( viewC ) ).to.true;
+
+			// Does not throw, so the stack is there.
+			expect( () => {
+				balloon.showStack( 'second' );
+			} ).to.not.throw();
+		} );
+
+		it( 'should remove not displayed stack when removied view was the last one in the stack', () => {
+			balloon.add( {
+				view: viewB,
+				stackId: 'second',
+				position: {
+					target: 'fake'
+				}
+			} );
+
+			balloon.remove( viewB );
+
+			expect( balloon.hasView( viewB ) ).to.false;
+
+			// Does throw, so the stack is not there.
+			expect( () => {
+				balloon.showStack( 'second' );
+			} ).to.throw();
+		} );
+
 		it( 'should throw an error when there is no given view in the stack', () => {
 			expect( () => {
 				balloon.remove( viewB );
@@ -533,4 +704,177 @@ describe( 'ContextualBalloon', () => {
 			expect( editor.ui.view.body.getIndex( balloon.view ) ).to.not.equal( -1 );
 		} );
 	} );
+
+	describe( 'rotator view', () => {
+		let rotatorView;
+
+		beforeEach( () => {
+			rotatorView = balloon.view.content.get( 0 );
+		} );
+
+		it( 'should display navigation when there is more than one stack', () => {
+			const navigationElement = rotatorView.element.querySelector( '.ck-balloon-rotator__navigation' );
+
+			expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.equal( true );
+
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			expect( navigationElement.classList.contains( 'ck-hidden' ) ).to.equal( false );
+		} );
+
+		it( 'should display counter', () => {
+			const counterElement = rotatorView.element.querySelector( '.ck-balloon-rotator__counter' );
+
+			expect( counterElement.textContent ).to.equal( '' );
+
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			expect( counterElement.textContent ).to.equal( '1 of 2' );
+
+			balloon.showStack( 'second' );
+
+			expect( counterElement.textContent ).to.equal( '2 of 2' );
+		} );
+
+		it( 'should hide counter when element width is less then 200px', () => {
+			// To be sure navigation is displayed.
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			const counterElement = rotatorView.element.querySelector( '.ck-balloon-rotator__counter' );
+			let mockedWidth = 201;
+
+			sinon.stub( rotatorView.element, 'clientWidth' ).get( () => mockedWidth );
+
+			balloon.showStack( 'second' );
+
+			expect( counterElement.classList.contains( 'ck-hidden' ) ).to.equal( false );
+
+			mockedWidth = 200;
+			balloon.showStack( 'main' );
+
+			expect( counterElement.classList.contains( 'ck-hidden' ) ).to.equal( true );
+
+			mockedWidth = 201;
+			balloon.updatePosition();
+
+			expect( counterElement.classList.contains( 'ck-hidden' ) ).to.equal( false );
+		} );
+
+		it( 'should switch stack to the next one after clicking next button', () => {
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			balloon.add( {
+				view: viewC,
+				stackId: 'third'
+			} );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+
+			rotatorView.buttonNextView.fire( 'execute' );
+
+			expect( balloon.visibleView ).to.equal( viewB );
+
+			rotatorView.buttonNextView.fire( 'execute' );
+
+			expect( balloon.visibleView ).to.equal( viewC );
+
+			rotatorView.buttonNextView.fire( 'execute' );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+		} );
+
+		it( 'should not move focus to the editable when switching not focused view to the next one', () => {
+			const editableFocusSpy = sinon.spy( editor.editing.view, 'focus' );
+
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			rotatorView.buttonNextView.fire( 'execute' );
+
+			sinon.assert.notCalled( editableFocusSpy );
+		} );
+
+		it( 'should move focus to the editable when switching focused view to the next one', () => {
+			const editableFocusSpy = sinon.spy( editor.editing.view, 'focus' );
+
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			rotatorView.focusTracker.isFocused = true;
+
+			rotatorView.buttonNextView.fire( 'execute' );
+
+			sinon.assert.calledOnce( editableFocusSpy );
+		} );
+
+		it( 'should switch stack to the prev one after clicking prev button', () => {
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			balloon.add( {
+				view: viewC,
+				stackId: 'third'
+			} );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+
+			rotatorView.buttonPrevView.fire( 'execute' );
+
+			expect( balloon.visibleView ).to.equal( viewC );
+
+			rotatorView.buttonPrevView.fire( 'execute' );
+
+			expect( balloon.visibleView ).to.equal( viewB );
+
+			rotatorView.buttonPrevView.fire( 'execute' );
+
+			expect( balloon.visibleView ).to.equal( viewA );
+		} );
+
+		it( 'should not move focus to the editable when switching not focused view to the prev one', () => {
+			const editableFocusSpy = sinon.spy( editor.editing.view, 'focus' );
+
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			rotatorView.buttonPrevView.fire( 'execute' );
+
+			sinon.assert.notCalled( editableFocusSpy );
+		} );
+
+		it( 'should move focus to the editable when switching focused view to the prev one', () => {
+			const editableFocusSpy = sinon.spy( editor.editing.view, 'focus' );
+
+			balloon.add( {
+				view: viewB,
+				stackId: 'second'
+			} );
+
+			rotatorView.focusTracker.isFocused = true;
+
+			rotatorView.buttonPrevView.fire( 'execute' );
+
+			sinon.assert.calledOnce( editableFocusSpy );
+		} );
+	} );
 } );

+ 10 - 0
packages/ckeditor5-ui/theme/components/panel/balloonrotator.css

@@ -0,0 +1,10 @@
+/*
+ * Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck .ck-balloon-rotator__navigation {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}

+ 1 - 0
packages/ckeditor5-ui/theme/icons/next-arrow.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.537 14.813a.888.888 0 1 1-1.254-1.255L10.84 10 7.283 6.442a.888.888 0 1 1 1.254-1.255L12.74 9.39a.888.888 0 0 1-.16 1.382l-4.043 4.042z"/></svg>

+ 1 - 0
packages/ckeditor5-ui/theme/icons/previous-arrow.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.463 5.187a.888.888 0 1 1 1.254 1.255L9.16 10l3.557 3.557a.888.888 0 1 1-1.254 1.255L7.26 10.61a.888.888 0 0 1 .16-1.382l4.043-4.042z" /></svg>