浏览代码

Merge pull request #143 from ckeditor/t/138

t/138: Basic a11y support w/ keyboard.
Piotrek Koszuliński 9 年之前
父节点
当前提交
f28308b47d

+ 18 - 1
packages/ckeditor5-ui/src/button/buttonview.js

@@ -101,6 +101,15 @@ export default class ButtonView extends View {
 		this.set( 'icon' );
 
 		/**
+		 * Controls the `tabindex` attribute of the button.
+		 *
+		 * @observable
+		 * @default -1
+		 * @member {String} #tabindex
+		 */
+		this.set( 'tabindex', -1 );
+
+		/**
 		 * Tooltip of the button bound to the template.
 		 *
 		 * @see #tooltip
@@ -139,7 +148,8 @@ export default class ButtonView extends View {
 				type: bind.to( 'type', value => value ? value : 'button' ),
 				'data-ck-tooltip': [
 					bind.to( '_tooltipString' )
-				]
+				],
+				tabindex: bind.to( 'tabindex' )
 			},
 
 			children: [
@@ -205,6 +215,13 @@ export default class ButtonView extends View {
 	}
 
 	/**
+	 * Focuses the button.
+	 */
+	focus() {
+		this.element.focus();
+	}
+
+	/**
 	 * Gets value for the `data-ck-tooltip` attribute from the combination of
 	 * {@link #tooltip}, {@link #label} and {@link #keystroke} attributes.
 	 *

+ 65 - 0
packages/ckeditor5-ui/src/dropdown/dropdownview.js

@@ -9,6 +9,8 @@
 
 import View from '../view';
 import Template from '../template';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
 /**
  * The dropdown view class.
@@ -58,6 +60,22 @@ export default class DropdownView extends View {
 		 */
 		this.set( 'isOpen', false );
 
+		/**
+		 * Tracks information about DOM focus in the list.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:core/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
 		this.template = new Template( {
 			tag: 'div',
 
@@ -107,4 +125,51 @@ export default class DropdownView extends View {
 		 * @member {Boolean} #withText
 		 */
 	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		// Listen for keystrokes coming from within #element.
+		this.keystrokes.listenTo( this.element );
+
+		// Register #element in the focus tracker.
+		this.focusTracker.add( this.element );
+
+		const closeDropdown = ( data, cancel ) => {
+			if ( this.isOpen ) {
+				this.buttonView.focus();
+				this.isOpen = false;
+				cancel();
+			}
+		};
+
+		// Open the dropdown panel using the arrow down key, just like with return or space.
+		this.keystrokes.set( 'arrowdown', ( data, cancel ) => {
+			if ( !this.isOpen ) {
+				this.isOpen = true;
+				cancel();
+			}
+		} );
+
+		// Block the right arrow key (until nested dropdowns are implemented).
+		this.keystrokes.set( 'arrowright', ( data, cancel ) => {
+			if ( this.isOpen ) {
+				cancel();
+			}
+		} );
+
+		// Close the dropdown using the arrow left/escape key.
+		this.keystrokes.set( 'arrowleft', closeDropdown );
+		this.keystrokes.set( 'esc', closeDropdown );
+
+		return super.init();
+	}
+
+	/**
+	 * Focuses the {@link #buttonView}.
+	 */
+	focus() {
+		this.buttonView.focus();
+	}
 }

+ 18 - 0
packages/ckeditor5-ui/src/dropdown/list/createlistdropdown.js

@@ -53,6 +53,24 @@ export default function createListDropdown( model, locale ) {
 		dropdownView.isOpen = false;
 	} );
 
+	// If the dropdown panel is already open, the arrow down key should
+	// focus the first element in list.
+	dropdownView.keystrokes.set( 'arrowdown', ( data, cancel ) => {
+		if ( dropdownView.isOpen ) {
+			listView.focus();
+			cancel();
+		}
+	} );
+
+	// If the dropdown panel is already open, the arrow up key should
+	// focus the last element in the list.
+	dropdownView.keystrokes.set( 'arrowup', ( data, cancel ) => {
+		if ( dropdownView.isOpen ) {
+			listView.focusLast();
+			cancel();
+		}
+	} );
+
 	return dropdownView;
 }
 

+ 275 - 0
packages/ckeditor5-ui/src/focuscycler.js

@@ -0,0 +1,275 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module ui/focuscycler
+ */
+
+/**
+ * Helps cycling over focusable views in a {@link module:ui/viewcollection~ViewCollection}
+ * when the focus is tracked by {@link module:utils/focustracker~FocusTracker} instance.
+ *
+ * It requires a collection of focusable views and associated focus tracker:
+ *
+ *		const focusables = new ViewCollection();
+ *		const focusTracker = new FocusTracker();
+ *
+ *		// Add focusables to the focus tracker.
+ *		focusTracker.add( ... );
+ *
+ * The cycler can be used manually:
+ *
+ *		const cycler = new FocusCycler( { focusables, focusTracker } );
+ *
+ *		// Will focus the first forusable view in #focusables.
+ *		cycler.focusFirst();
+ *
+ *		// Will log next focusable item in #focusables.
+ *		console.log( cycler.next );
+ *
+ * or it can be used as an automated, keystroke–detecting utility:
+ *
+ *		const keystrokeHandler = new KeystrokeHandler();
+ *
+ *		// Activate the keystroke handler.
+ *		keystrokeHandler.listenTo( sourceOfEvents );
+ *
+ *		const cycler = new FocusCycler( {
+ *			focusables, focusTracker, keystrokeHandler,
+ *			actions: {
+ *				// When arrowup of arrowleft is detected by the #keystrokeHandler
+ *				// focusPrevious() will be called by the cycler.
+ *				focusPrevious: [ 'arrowup', 'arrowleft' ],
+ *			}
+ *		} );
+ */
+export default class FocusCycler {
+	/**
+	 * Creates an instance of the focus cycler utility.
+	 *
+	 * @param {Object} options Configuration options.
+	 * @param {module:utils/collection~Collection|Object} options.focusables
+	 * @param {module:utils/focustracker~FocusTracker} options.focusTracker
+	 * @param {module:core/keystrokehandler~KeystrokeHandler} [options.keystrokeHandler]
+	 * @param {Object} [options.actions]
+	 */
+	constructor( options ) {
+		Object.assign( this, options );
+
+		/**
+		 * A view collection the cycler operates on.
+		 *
+		 * @readonly
+		 * @member {module:utils/collection~Collection} #focusables
+		 */
+
+		/**
+		 * A focus tracker instance that cycler uses to determine focus
+		 * state in {@link #viewCollection}.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker} #focusTracker
+		 */
+
+		/**
+		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:core/keystrokehandler~KeystrokeHandler} #keystrokeHandler
+		 */
+
+		/**
+		 * Actions that the cycler can take when a keystroke is pressed. Requires
+		 * `options.keystrokeHandler` to be passed and working. When an action is
+		 * performed, the event the keystroke fired is will be `preventDefault` and
+		 * `stopPropagation` in DOM.
+		 *
+		 *		actions: {
+		 *			// Will call #focusPrevious() when arrowleft or arrowup is pressed.
+		 *			focusPrevious: [ 'arrowleft', 'arrowup' ],
+		 *
+		 *			// Will call #focusNext() when arrowdown is pressed.
+		 *			focusNext: 'arrowdown'
+		 *		}
+		 *
+		 * @readonly
+		 * @member {Object} #actions
+		 */
+
+		if ( options.actions && options.keystrokeHandler ) {
+			for ( let methodName in options.actions ) {
+				let actions = options.actions[ methodName ];
+
+				if ( typeof actions == 'string' ) {
+					actions = [ actions ];
+				}
+
+				for ( let keystroke of actions ) {
+					options.keystrokeHandler.set( keystroke, ( data, cancel ) => {
+						this[ methodName ]();
+						cancel();
+					} );
+				}
+			}
+		}
+	}
+
+	/**
+	 * Returns the first focusable view in {@link #viewCollection}.
+	 * `null` if there's none.
+	 *
+	 * @readonly
+	 * @member {module:ui/view~View|null} #first
+	 */
+	get first() {
+		return this.focusables.find( isFocusable ) || null;
+	}
+
+	/**
+	 * Returns the last focusable view in {@link #viewCollection}.
+	 * `null` if there's none.
+	 *
+	 * @readonly
+	 * @member {module:ui/view~View|null} #last
+	 */
+	get last() {
+		return this.focusables.filter( isFocusable ).slice( -1 )[ 0 ] || null;
+	}
+
+	/**
+	 * Returns the next focusable view in {@link #viewCollection} based on {@link #current}.
+	 * `null` if there's none.
+	 *
+	 * @readonly
+	 * @member {module:ui/view~View|null} #next
+	 */
+	get next() {
+		return this._getFocusableItem( 1 );
+	}
+
+	/**
+	 * Returns the previous focusable view in {@link #viewCollection} based on {@link #current}.
+	 * `null` if there's none.
+	 *
+	 * @readonly
+	 * @member {module:ui/view~View|null} #previous
+	 */
+	get previous() {
+		return this._getFocusableItem( -1 );
+	}
+
+	/**
+	 * An index of the view in the {@link #viewCollection} which is focused according
+	 * to {@link #focusTracker}. `null` when there's no such view.
+	 *
+	 * @readonly
+	 * @member {Number|null} #current
+	 */
+	get current() {
+		let index = null;
+
+		// There's no focused view in the viewCollection.
+		if ( this.focusTracker.focusedElement === null ) {
+			return null;
+		}
+
+		this.focusables.find( ( view, viewIndex ) => {
+			const focused = view.element === this.focusTracker.focusedElement;
+
+			if ( focused ) {
+				index = viewIndex;
+			}
+
+			return focused;
+		} );
+
+		return index;
+	}
+
+	/**
+	 * Focuses the {@link #first} item.
+	 */
+	focusFirst() {
+		this._focus( this.first );
+	}
+
+	/**
+	 * Focuses the {@link #last} item.
+	 */
+	focusLast() {
+		this._focus( this.last );
+	}
+
+	/**
+	 * Focuses the {@link #next} item.
+	 */
+	focusNext() {
+		this._focus( this.next );
+	}
+
+	/**
+	 * Focuses the {@link #previous} item.
+	 */
+	focusPrevious() {
+		this._focus( this.previous );
+	}
+
+	/**
+	 * Focuses the given view, if exists.
+	 *
+	 * @protected
+	 * @param {module:ui/view~View} view
+	 */
+	_focus( view ) {
+		if ( view ) {
+			view.focus();
+		}
+	}
+
+	/**
+	 * Returns the next/previous focusable view in {@link #viewCollection} with respect
+	 * to {@link #current}.
+	 *
+	 * @protected
+	 * @param {Number} step Either `1` for checking forward of {@link #current} or
+	 * `-1` for checking backwards.
+	 * @returns {module:ui/view~View|null}
+	 */
+	_getFocusableItem( step ) {
+		// Cache for speed.
+		const current = this.current;
+		const collectionLength = this.focusables.length;
+
+		if ( !collectionLength || current === null ) {
+			return null;
+		}
+
+		// Cycle in both directions.
+		let index = ( current + collectionLength + step ) % collectionLength;
+
+		do {
+			let view = this.focusables.get( index );
+
+			// TODO: Check if view is visible.
+			if ( isFocusable( view ) ) {
+				return view;
+			}
+
+			// Cycle in both directions.
+			index = ( index + collectionLength + step ) % collectionLength;
+		} while ( index !== current );
+
+		return null;
+	}
+}
+
+// Checks whether an view is focusable.
+//
+// @private
+// @param {module:ui/view~View} view A view to be checked.
+// @returns {Boolean}
+function isFocusable( view ) {
+	return view.focus;
+}

+ 7 - 0
packages/ckeditor5-ui/src/inputtext/inputtextview.js

@@ -62,4 +62,11 @@ export default class InputTextView extends View {
 	select() {
 		this.element.select();
 	}
+
+	/**
+	 * Focuses the input.
+	 */
+	focus() {
+		this.element.focus();
+	}
 }

+ 7 - 0
packages/ckeditor5-ui/src/labeledinput/labeledinputview.js

@@ -109,4 +109,11 @@ export default class LabeledInputView extends View {
 	select() {
 		this.inputView.select();
 	}
+
+	/**
+	 * Focuses the input.
+	 */
+	focus() {
+		this.inputView.focus();
+	}
 }

+ 18 - 1
packages/ckeditor5-ui/src/list/listitemview.js

@@ -22,6 +22,15 @@ export default class ListItemView extends View {
 	constructor() {
 		super();
 
+		/**
+		 * Controls the `tabindex` attribute of the item.
+		 *
+		 * @observable
+		 * @default -1
+		 * @member {String} #tabindex
+		 */
+		this.set( 'tabindex', -1 );
+
 		const bind = this.bindTemplate;
 
 		this.template = new Template( {
@@ -31,7 +40,8 @@ export default class ListItemView extends View {
 				class: [
 					'ck-list__item'
 				],
-				style: bind.to( 'style' )
+				style: bind.to( 'style' ),
+				tabindex: bind.to( 'tabindex' )
 			},
 
 			children: [
@@ -65,4 +75,11 @@ export default class ListItemView extends View {
 		 * @event #execute
 		 */
 	}
+
+	/**
+	 * Focuses the list item.
+	 */
+	focus() {
+		this.element.focus();
+	}
 }

+ 71 - 0
packages/ckeditor5-ui/src/list/listview.js

@@ -9,6 +9,9 @@
 
 import View from '../view';
 import Template from '../template';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '../focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
 /**
  * The list view class.
@@ -30,6 +33,42 @@ export default class ListView extends View {
 		 */
 		this.items = this.createCollection();
 
+		/**
+		 * Tracks information about DOM focus in the list.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:core/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * Helps cycling over focusable {@link #items} in the list.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this.items,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate list items backwards using the arrowup key.
+				focusPrevious: 'arrowup',
+
+				// Navigate toolbar items forwards using the arrowdown key.
+				focusNext: 'arrowdown',
+			}
+		} );
+
 		this.template = new Template( {
 			tag: 'ul',
 
@@ -42,5 +81,37 @@ export default class ListView extends View {
 
 			children: this.items
 		} );
+
+		this.items.on( 'add', ( evt, item ) => {
+			this.focusTracker.add( item.element );
+		} );
+
+		this.items.on( 'remove', ( evt, item ) => {
+			this.focusTracker.remove( item.element );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+
+		return super.init();
+	}
+
+	/**
+	 * Focuses the first focusable in {@link #items}.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
+	}
+
+	/**
+	 * Focuses the last focusable in {@link #items}.
+	 */
+	focusLast() {
+		this._focusCycler.focusLast();
 	}
 }

+ 71 - 0
packages/ckeditor5-ui/src/toolbar/toolbarview.js

@@ -9,6 +9,9 @@
 
 import View from '../view';
 import Template from '../template';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '../focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
 /**
  * The toolbar view class.
@@ -22,8 +25,50 @@ export default class ToolbarView extends View {
 	constructor( locale ) {
 		super( locale );
 
+		/**
+		 * Collection of the toolbar items (like buttons).
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
 		this.items = this.createCollection();
 
+		/**
+		 * Tracks information about DOM focus in the list.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = new FocusTracker();
+
+		/**
+		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:core/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
+		 * Helps cycling over focusable {@link #items} in the toolbar.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this.items,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate toolbar items backwards using the arrow[left,up] keys.
+				focusPrevious: [ 'arrowleft', 'arrowup' ],
+
+				// Navigate toolbar items forwards using the arrow[right,down] keys.
+				focusNext: [ 'arrowright', 'arrowdown' ]
+			}
+		} );
+
 		this.template = new Template( {
 			tag: 'div',
 			attributes: {
@@ -34,5 +79,31 @@ export default class ToolbarView extends View {
 
 			children: this.items
 		} );
+
+		this.items.on( 'add', ( evt, item ) => {
+			this.focusTracker.add( item.element );
+		} );
+
+		this.items.on( 'remove', ( evt, item ) => {
+			this.focusTracker.remove( item.element );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+
+		return super.init();
+	}
+
+	/**
+	 * Focuses the first focusable in {@link #items}.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
 	}
 }
+

+ 22 - 0
packages/ckeditor5-ui/tests/button/buttonview.js

@@ -168,6 +168,18 @@ describe( 'ButtonView', () => {
 			} );
 		} );
 
+		describe( 'tabindex', () => {
+			it( 'is initially set ', () => {
+				expect( view.element.attributes.tabindex.value ).to.equal( '-1' );
+			} );
+
+			it( 'reacts on view#tabindex', () => {
+				view.tabindex = 3;
+
+				expect( view.element.attributes.tabindex.value ).to.equal( '3' );
+			} );
+		} );
+
 		describe( 'mousedown event', () => {
 			it( 'should be prevented', () => {
 				const ret = view.element.dispatchEvent( new Event( 'mousedown', { cancelable: true } ) );
@@ -229,4 +241,14 @@ describe( 'ButtonView', () => {
 			} );
 		} );
 	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the button in DOM', () => {
+			const spy = sinon.spy( view.element, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
 } );

+ 137 - 0
packages/ckeditor5-ui/tests/dropdown/dropdownview.js

@@ -4,6 +4,9 @@
  */
 
 import DropdownView from '../../src/dropdown/dropdownview';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import ButtonView from '../../src/button/buttonview';
 import DropdownPanelView from '../../src/dropdown/dropdownpanelview';
 
@@ -36,6 +39,14 @@ describe( 'DropdownView', () => {
 			expect( view.isOpen ).to.be.false;
 		} );
 
+		it( 'creates #focusTracker instance', () => {
+			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+		} );
+
+		it( 'creates #keystrokeHandler instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
 		it( 'creates #element from template', () => {
 			expect( view.element.classList.contains( 'ck-dropdown' ) ).to.be.true;
 			expect( view.element.firstChild ).to.equal( buttonView.element );
@@ -80,4 +91,130 @@ describe( 'DropdownView', () => {
 			} );
 		} );
 	} );
+
+	describe( 'init()', () => {
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new DropdownView( locale,
+				new ButtonView( locale ),
+				new DropdownPanelView( locale ) );
+
+			const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+			return view.init().then( () => {
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, view.element );
+			} );
+		} );
+
+		it( 'adds #element to #focusTracker', () => {
+			view = new DropdownView( locale,
+				new ButtonView( locale ),
+				new DropdownPanelView( locale ) );
+
+			const spy = sinon.spy( view.focusTracker, 'add' );
+
+			return view.init().then( () => {
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, view.element );
+			} );
+		} );
+
+		describe( 'activates keyboard navigation for the dropdown', () => {
+			it( 'so "arrowdown" opens the #panelView', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowdown,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				view.isOpen = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.notCalled( keyEvtData.preventDefault );
+				sinon.assert.notCalled( keyEvtData.stopPropagation );
+				expect( view.isOpen ).to.be.true;
+
+				view.isOpen = false;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				expect( view.isOpen ).to.be.true;
+			} );
+
+			it( 'so "arrowright" is blocked', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowright,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				view.false = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.notCalled( keyEvtData.preventDefault );
+				sinon.assert.notCalled( keyEvtData.stopPropagation );
+				expect( view.isOpen ).to.be.false;
+
+				view.isOpen = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				expect( view.isOpen ).to.be.true;
+			} );
+
+			it( 'so "arrowleft" closes the #panelView', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowleft,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+				const spy = sinon.spy( view.buttonView, 'focus' );
+
+				view.isOpen = false;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.notCalled( keyEvtData.preventDefault );
+				sinon.assert.notCalled( keyEvtData.stopPropagation );
+				sinon.assert.notCalled( spy );
+				expect( view.isOpen ).to.be.false;
+
+				view.isOpen = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+				expect( view.isOpen ).to.be.false;
+			} );
+
+			it( 'so "esc" closes the #panelView', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+				const spy = sinon.spy( view.buttonView, 'focus' );
+
+				view.isOpen = false;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.notCalled( keyEvtData.preventDefault );
+				sinon.assert.notCalled( keyEvtData.stopPropagation );
+				sinon.assert.notCalled( spy );
+				expect( view.isOpen ).to.be.false;
+
+				view.isOpen = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+				expect( view.isOpen ).to.be.false;
+			} );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the #buttonView in DOM', () => {
+			const spy = sinon.spy( view.buttonView, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
 } );

+ 37 - 0
packages/ckeditor5-ui/tests/dropdown/list/createlistdropdown.js

@@ -10,6 +10,7 @@ import createListDropdown from '../../../src/dropdown/list/createlistdropdown';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import ListView from '../../../src/list/listview';
 import ListItemView from '../../../src/list/listitemview';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 describe( 'createListDropdown', () => {
 	let view, model, locale, items;
@@ -140,5 +141,41 @@ describe( 'createListDropdown', () => {
 			// Dropdown is still open.
 			expect( view.isOpen ).to.be.true;
 		} );
+
+		describe( 'activates keyboard navigation for the dropdown', () => {
+			it( 'so "arrowdown" focuses the #listView if dropdown is open', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowdown,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+				const spy = sinon.spy( view.listView, 'focus' );
+
+				view.isOpen = false;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.notCalled( spy );
+
+				view.isOpen = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "arrowup" focuses the last #item in #listView if dropdown is open', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowup,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+				const spy = sinon.spy( view.listView, 'focusLast' );
+
+				view.isOpen = false;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.notCalled( spy );
+
+				view.isOpen = true;
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
 	} );
 } );

+ 399 - 0
packages/ckeditor5-ui/tests/focuscycler.js

@@ -0,0 +1,399 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewCollection from '../src/viewcollection';
+import View from '../src/view';
+import FocusCycler from '../src/focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+describe( 'FocusCycler', () => {
+	let focusables, focusTracker, cycler;
+
+	beforeEach( () => {
+		focusables = new ViewCollection();
+		focusTracker = {
+			focusedElement: null
+		};
+		cycler = new FocusCycler( {
+			focusables: focusables,
+			focusTracker: focusTracker
+		} );
+
+		focusables.add( nonFocusable() );
+		focusables.add( focusable() );
+		focusables.add( focusable() );
+		focusables.add( focusable() );
+		focusables.add( nonFocusable() );
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'sets class properties', () => {
+			expect( cycler.focusables ).to.equal( focusables );
+			expect( cycler.focusTracker ).to.equal( focusTracker );
+		} );
+	} );
+
+	describe( 'current()', () => {
+		it( 'returns null when no view is focused', () => {
+			expect( cycler.current ).to.equal( null );
+
+			focusTracker.focusedElement = focusables.get( 2 ).element;
+			expect( cycler.current ).to.equal( 2 );
+
+			focusTracker.focusedElement = null;
+			expect( cycler.current ).to.equal( null );
+		} );
+	} );
+
+	describe( 'first()', () => {
+		it( 'returns first focusable view', () => {
+			expect( cycler.first ).to.equal( focusables.get( 1 ) );
+		} );
+
+		it( 'returns null when no focusable items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+
+			expect( cycler.first ).to.be.null;
+		} );
+
+		it( 'returns null when no items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			expect( cycler.first ).to.be.null;
+		} );
+	} );
+
+	describe( 'last()', () => {
+		it( 'returns last focusable view', () => {
+			expect( cycler.last ).to.equal( focusables.get( 3 ) );
+		} );
+
+		it( 'returns null when no focusable items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+
+			expect( cycler.last ).to.be.null;
+		} );
+
+		it( 'returns null when no items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			expect( cycler.last ).to.be.null;
+		} );
+	} );
+
+	describe( 'next()', () => {
+		it( 'cycles to return the next focusable view', () => {
+			focusTracker.focusedElement = focusables.get( 2 ).element;
+			expect( cycler.next ).to.equal( focusables.get( 3 ) );
+
+			focusTracker.focusedElement = focusables.get( 3 ).element;
+			expect( cycler.next ).to.equal( focusables.get( 1 ) );
+
+			focusTracker.focusedElement = focusables.get( 1 ).element;
+			expect( cycler.next ).to.equal( focusables.get( 2 ) );
+		} );
+
+		it( 'returns null when no view is focused', () => {
+			focusTracker.focusedElement = null;
+
+			expect( cycler.next ).to.be.null;
+		} );
+
+		it( 'returns null when no items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			expect( cycler.next ).to.be.null;
+		} );
+
+		it( 'returns null when no focusable items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+
+			expect( cycler.next ).to.be.null;
+		} );
+
+		it( 'returns null if the only focusable in focusables', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( focusable() );
+			focusables.add( nonFocusable() );
+
+			focusTracker.focusedElement = focusables.get( 1 ).element;
+
+			expect( cycler.first ).to.equal( focusables.get( 1 ) );
+			expect( cycler.next ).to.be.null;
+		} );
+	} );
+
+	describe( 'previous()', () => {
+		it( 'cycles to return the previous focusable view', () => {
+			focusTracker.focusedElement = focusables.get( 1 ).element;
+			expect( cycler.previous ).to.equal( focusables.get( 3 ) );
+
+			focusTracker.focusedElement = focusables.get( 2 ).element;
+			expect( cycler.previous ).to.equal( focusables.get( 1 ) );
+
+			focusTracker.focusedElement = focusables.get( 3 ).element;
+			expect( cycler.previous ).to.equal( focusables.get( 2 ) );
+		} );
+
+		it( 'returns null when no view is focused', () => {
+			focusTracker.focusedElement = null;
+
+			expect( cycler.previous ).to.be.null;
+		} );
+
+		it( 'returns null when no items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			expect( cycler.previous ).to.be.null;
+		} );
+
+		it( 'returns null when no focusable items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+
+			expect( cycler.previous ).to.be.null;
+		} );
+
+		it( 'returns null if the only focusable in focusables', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( focusable() );
+			focusables.add( nonFocusable() );
+
+			focusTracker.focusedElement = focusables.get( 1 ).element;
+
+			expect( cycler.first ).to.equal( focusables.get( 1 ) );
+			expect( cycler.previous ).to.be.null;
+		} );
+	} );
+
+	describe( 'focusFirst()', () => {
+		it( 'focuses first focusable view', () => {
+			const spy = sinon.spy( focusables.get( 1 ), 'focus' );
+
+			cycler.focusFirst();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'does not throw when no focusable items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+
+			expect( () => {
+				cycler.focusFirst();
+			} ).to.not.throw();
+		} );
+
+		it( 'does not throw when no items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			expect( () => {
+				cycler.focusFirst();
+			} ).to.not.throw();
+		} );
+	} );
+
+	describe( 'focusLast()', () => {
+		it( 'focuses last focusable view', () => {
+			const spy = sinon.spy( focusables.get( 3 ), 'focus' );
+
+			cycler.focusLast();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'does not throw when no focusable items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+
+			expect( () => {
+				cycler.focusLast();
+			} ).to.not.throw();
+		} );
+
+		it( 'does not throw when no items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			expect( () => {
+				cycler.focusLast();
+			} ).to.not.throw();
+		} );
+	} );
+
+	describe( 'focusNext()', () => {
+		it( 'focuses next focusable view', () => {
+			const spy = sinon.spy( focusables.get( 3 ), 'focus' );
+
+			focusTracker.focusedElement = focusables.get( 2 ).element;
+			cycler.focusNext();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'does not throw when no focusable items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+
+			expect( () => {
+				cycler.focusNext();
+			} ).to.not.throw();
+		} );
+
+		it( 'does not throw when no items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			expect( () => {
+				cycler.focusNext();
+			} ).to.not.throw();
+		} );
+	} );
+
+	describe( 'focusPrevious()', () => {
+		it( 'focuses previous focusable view', () => {
+			const spy = sinon.spy( focusables.get( 3 ), 'focus' );
+
+			focusTracker.focusedElement = focusables.get( 1 ).element;
+			cycler.focusPrevious();
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'does not throw when no focusable items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+
+			expect( () => {
+				cycler.focusPrevious();
+			} ).to.not.throw();
+		} );
+
+		it( 'does not throw when no items', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
+
+			expect( () => {
+				cycler.focusPrevious();
+			} ).to.not.throw();
+		} );
+	} );
+
+	describe( 'keystrokes', () => {
+		it( 'creates event listeners', () => {
+			const keystrokeHandler = new KeystrokeHandler();
+
+			cycler = new FocusCycler( {
+				focusables, focusTracker, keystrokeHandler,
+				actions: {
+					focusPrevious: 'arrowup',
+					focusNext: 'arrowdown'
+				}
+			} );
+
+			const keyEvtData = {
+				keyCode: keyCodes.arrowup,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			const spy1 = sinon.spy( cycler, 'focusPrevious' );
+			const spy2 = sinon.spy( cycler, 'focusNext' );
+
+			keystrokeHandler.press( keyEvtData );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( keyEvtData.preventDefault );
+			sinon.assert.calledOnce( keyEvtData.stopPropagation );
+			sinon.assert.notCalled( spy2 );
+
+			keyEvtData.keyCode = keyCodes.arrowdown;
+
+			keystrokeHandler.press( keyEvtData );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledTwice( keyEvtData.preventDefault );
+			sinon.assert.calledTwice( keyEvtData.stopPropagation );
+			sinon.assert.calledOnce( spy2 );
+		} );
+
+		it( 'supports array keystroke syntax', () => {
+			const keystrokeHandler = new KeystrokeHandler();
+
+			cycler = new FocusCycler( {
+				focusables, focusTracker, keystrokeHandler,
+				actions: {
+					focusPrevious: [ 'arrowup', 'arrowleft' ],
+				}
+			} );
+
+			const keyEvtData = {
+				keyCode: keyCodes.arrowleft,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			const spy = sinon.spy( cycler, 'focusPrevious' );
+
+			keystrokeHandler.press( keyEvtData );
+
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledOnce( keyEvtData.preventDefault );
+			sinon.assert.calledOnce( keyEvtData.stopPropagation );
+		} );
+	} );
+} );
+
+function focusable() {
+	const view = new View();
+
+	view.focus = () => {};
+	view.element = {};
+
+	return view;
+}
+
+function nonFocusable() {
+	return new View();
+}

+ 11 - 1
packages/ckeditor5-ui/tests/inputtext/inputtextview.js

@@ -58,7 +58,7 @@ describe( 'InputTextView', () => {
 		} );
 	} );
 
-	describe( 'select', () => {
+	describe( 'select()', () => {
 		it( 'should select input value', () => {
 			const selectSpy = sinon.spy( view.element, 'select' );
 
@@ -69,4 +69,14 @@ describe( 'InputTextView', () => {
 			selectSpy.restore();
 		} );
 	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the input in DOM', () => {
+			const spy = sinon.spy( view.element, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
 } );

+ 11 - 1
packages/ckeditor5-ui/tests/labeledinput/labeledinputview.js

@@ -60,7 +60,7 @@ describe( 'LabeledInputView', () => {
 		} );
 	} );
 
-	describe( 'select', () => {
+	describe( 'select()', () => {
 		it( 'should select input value', () => {
 			const spy = sinon.spy( view.inputView, 'select' );
 
@@ -69,4 +69,14 @@ describe( 'LabeledInputView', () => {
 			sinon.assert.calledOnce( spy );
 		} );
 	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the input in DOM', () => {
+			const spy = sinon.spy( view.inputView, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
 } );

+ 22 - 0
packages/ckeditor5-ui/tests/list/listitemview.js

@@ -47,6 +47,18 @@ describe( 'ListItemView', () => {
 			} );
 		} );
 
+		describe( 'tabindex', () => {
+			it( 'is initially set ', () => {
+				expect( view.element.attributes.tabindex.value ).to.equal( '-1' );
+			} );
+
+			it( 'reacts on view#tabindex', () => {
+				view.tabindex = 3;
+
+				expect( view.element.attributes.tabindex.value ).to.equal( '3' );
+			} );
+		} );
+
 		describe( 'view#execute event', () => {
 			it( 'triggers view#execute event when "click" is fired in DOM', () => {
 				const spy = sinon.spy();
@@ -58,4 +70,14 @@ describe( 'ListItemView', () => {
 			} );
 		} );
 	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the item in DOM', () => {
+			const spy = sinon.spy( view.element, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
 } );

+ 168 - 0
packages/ckeditor5-ui/tests/list/listview.js

@@ -3,8 +3,15 @@
  * For licensing, see LICENSE.md.
  */
 
+/* global document */
+
 import ViewCollection from '../../src/viewcollection';
 import ListView from '../../src/list/listview';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '../../src/focuscycler';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import View from '../../src/view';
 
 describe( 'ListView', () => {
 	let view;
@@ -24,5 +31,166 @@ describe( 'ListView', () => {
 			expect( view.template.children ).to.have.length( 1 );
 			expect( view.template.children.get( 0 ) ).to.equal( view.items );
 		} );
+
+		it( 'creates #focusTracker instance', () => {
+			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+		} );
+
+		it( 'creates #keystrokeHandler instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
+		it( 'creates #_focusCycler instance', () => {
+			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
+		} );
+
+		it( 'registers #items in #focusTracker', () => {
+			const spyAdd = sinon.spy( view.focusTracker, 'add' );
+			const spyRemove = sinon.spy( view.focusTracker, 'remove' );
+
+			view.items.add( focusable() );
+			view.items.add( focusable() );
+
+			sinon.assert.calledTwice( spyAdd );
+
+			view.items.remove( 1 );
+			sinon.assert.calledOnce( spyRemove );
+		} );
+	} );
+
+	describe( 'init()', () => {
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new ListView();
+
+			const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+			return view.init().then( () => {
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, view.element );
+			} );
+		} );
+
+		describe( 'activates keyboard navigation for the list', () => {
+			it( 'so "arrowup" focuses previous focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowup,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// No children to focus.
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+
+				view.items.add( nonFocusable() );
+				view.items.add( nonFocusable() );
+
+				// No focusable children.
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledTwice( keyEvtData.preventDefault );
+				sinon.assert.calledTwice( keyEvtData.stopPropagation );
+
+				view.items.add( focusable() );
+				view.items.add( nonFocusable() );
+				view.items.add( focusable() );
+
+				// Mock the last item is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 4 ).element;
+
+				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
+				view.keystrokes.press( keyEvtData );
+
+				sinon.assert.calledThrice( keyEvtData.preventDefault );
+				sinon.assert.calledThrice( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "arrowdown" focuses next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowdown,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// No children to focus.
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+
+				view.items.add( nonFocusable() );
+				view.items.add( nonFocusable() );
+
+				// No focusable children.
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledTwice( keyEvtData.preventDefault );
+				sinon.assert.calledTwice( keyEvtData.stopPropagation );
+
+				view.items.add( focusable() );
+				view.items.add( nonFocusable() );
+				view.items.add( focusable() );
+
+				// Mock the last item is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 4 ).element;
+
+				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
+				view.keystrokes.press( keyEvtData );
+
+				sinon.assert.calledThrice( keyEvtData.preventDefault );
+				sinon.assert.calledThrice( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the first focusable item in DOM', () => {
+			// No children to focus.
+			view.focus();
+
+			// The second child is focusable.
+			view.items.add( nonFocusable() );
+			view.items.add( focusable() );
+			view.items.add( nonFocusable() );
+
+			const spy = sinon.spy( view.items.get( 1 ), 'focus' );
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
+	describe( 'focusLast()', () => {
+		it( 'focuses the last focusable item in DOM', () => {
+			// No children to focus.
+			view.focusLast();
+
+			// The second child is focusable.
+			view.items.add( nonFocusable() );
+			view.items.add( focusable() );
+			view.items.add( nonFocusable() );
+
+			const spy = sinon.spy( view.items.get( 1 ), 'focus' );
+			view.focusLast();
+
+			sinon.assert.calledOnce( spy );
+		} );
 	} );
 } );
+
+function focusable() {
+	const view = nonFocusable();
+
+	view.focus = () => {};
+
+	return view;
+}
+
+function nonFocusable() {
+	const view = new View();
+	view.element = document.createElement( 'li' );
+
+	return view;
+}

+ 193 - 0
packages/ckeditor5-ui/tests/toolbar/toolbarview.js

@@ -3,8 +3,15 @@
  * For licensing, see LICENSE.md.
  */
 
+/* global document */
+
 import ToolbarView from '../../src/toolbar/toolbarview';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '../../src/focuscycler';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import ViewCollection from '../../src/viewcollection';
+import View from '../../src/view';
 
 describe( 'ToolbarView', () => {
 	let locale, view;
@@ -24,6 +31,31 @@ describe( 'ToolbarView', () => {
 		it( 'should create view#children collection', () => {
 			expect( view.items ).to.be.instanceOf( ViewCollection );
 		} );
+
+		it( 'creates #focusTracker instance', () => {
+			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+		} );
+
+		it( 'creates #keystrokes instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
+		it( 'creates #_focusCycler instance', () => {
+			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
+		} );
+
+		it( 'registers #items in #focusTracker', () => {
+			const spyAdd = sinon.spy( view.focusTracker, 'add' );
+			const spyRemove = sinon.spy( view.focusTracker, 'remove' );
+
+			view.items.add( focusable() );
+			view.items.add( focusable() );
+
+			sinon.assert.calledTwice( spyAdd );
+
+			view.items.remove( 1 );
+			sinon.assert.calledOnce( spyRemove );
+		} );
 	} );
 
 	describe( 'template', () => {
@@ -31,4 +63,165 @@ describe( 'ToolbarView', () => {
 			expect( view.element.classList.contains( 'ck-toolbar' ) ).to.true;
 		} );
 	} );
+
+	describe( 'init()', () => {
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new ToolbarView();
+
+			const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+			return view.init().then( () => {
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, view.element );
+			} );
+		} );
+
+		describe( 'activates keyboard navigation for the toolbar', () => {
+			it( 'so "arrowup" focuses previous focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowup,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// No children to focus.
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+
+				view.items.add( nonFocusable() );
+				view.items.add( nonFocusable() );
+
+				// No focusable children.
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledTwice( keyEvtData.preventDefault );
+				sinon.assert.calledTwice( keyEvtData.stopPropagation );
+
+				view.items.add( focusable() );
+				view.items.add( nonFocusable() );
+				view.items.add( focusable() );
+
+				// Mock the last item is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 4 ).element;
+
+				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
+				view.keystrokes.press( keyEvtData );
+
+				sinon.assert.calledThrice( keyEvtData.preventDefault );
+				sinon.assert.calledThrice( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "arrowleft" focuses previous focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowleft,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				view.items.add( focusable() );
+				view.items.add( nonFocusable() );
+				view.items.add( focusable() );
+
+				// Mock the last item is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 2 ).element;
+
+				const spy = sinon.spy( view.items.get( 0 ), 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "arrowdown" focuses next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowdown,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// No children to focus.
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+
+				view.items.add( nonFocusable() );
+				view.items.add( nonFocusable() );
+
+				// No focusable children.
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledTwice( keyEvtData.preventDefault );
+				sinon.assert.calledTwice( keyEvtData.stopPropagation );
+
+				view.items.add( focusable() );
+				view.items.add( nonFocusable() );
+				view.items.add( focusable() );
+
+				// Mock the last item is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 4 ).element;
+
+				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
+				view.keystrokes.press( keyEvtData );
+
+				sinon.assert.calledThrice( keyEvtData.preventDefault );
+				sinon.assert.calledThrice( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "arrowright" focuses next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.arrowright,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				view.items.add( focusable() );
+				view.items.add( nonFocusable() );
+				view.items.add( focusable() );
+
+				// Mock the last item is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.items.get( 0 ).element;
+
+				const spy = sinon.spy( view.items.get( 2 ), 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the first focusable item in DOM', () => {
+			// No children to focus.
+			view.focus();
+
+			// The second child is focusable.
+			view.items.add( nonFocusable() );
+			view.items.add( focusable() );
+			view.items.add( nonFocusable() );
+
+			const spy = sinon.spy( view.items.get( 1 ), 'focus' );
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
 } );
+
+function focusable() {
+	const view = nonFocusable();
+
+	view.focus = () => {};
+
+	return view;
+}
+
+function nonFocusable() {
+	const view = new View();
+	view.element = document.createElement( 'li' );
+
+	return view;
+}