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

Implemented ToolbarView#focus() with support for focus cycling in #items. Implemented arrow[up,down,left,right] key toolbar navigation.

Aleksander Nowodzinski 9 лет назад
Родитель
Сommit
c456e2bafe

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

@@ -9,6 +9,9 @@
 
 import View from '../view';
 import Template from '../template';
+import FocusTracker from 'ckeditor5-utils/src/focustracker';
+import FocusCycler from 'ckeditor5-utils/src/focuscycler';
+import KeystrokeHandler from 'ckeditor5-utils/src/keystrokehandler';
 
 /**
  * The toolbar view class.
@@ -22,8 +25,38 @@ 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
+		 * @member {module:utils/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( this.items, this.focusTracker );
+
 		this.template = new Template( {
 			tag: 'div',
 			attributes: {
@@ -34,5 +67,63 @@ 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() {
+		this.keystrokes.listenTo( this.element );
+
+		const focusNext = ( data, cancel ) => {
+			const nextFocusable = this._focusCycler.next;
+
+			if ( nextFocusable ) {
+				nextFocusable.focus();
+			}
+
+			cancel();
+		};
+
+		const focusPrevious = ( data, cancel ) => {
+			const previousFocusable = this._focusCycler.previous;
+
+			if ( previousFocusable ) {
+				previousFocusable.focus();
+			}
+
+			cancel();
+		};
+
+		// Navigate toolbar items back using the arrow left/up key.
+		this.keystrokes.set( 'arrowleft', focusPrevious );
+		this.keystrokes.set( 'arrowup', focusPrevious );
+
+		// Navigate toolbar items forwards using the arrow right/down key.
+		this.keystrokes.set( 'arrowright', focusNext );
+		this.keystrokes.set( 'arrowdown', focusNext );
+
+		return super.init();
+	}
+
+	/**
+	 * Focuses the first focusable in {@link #items}.
+	 */
+	focus() {
+		// Find the very first toolbar item that can be focused.
+		const firstFocusable = this._focusCycler.first;
+
+		if ( firstFocusable ) {
+			firstFocusable.focus();
+		}
 	}
 }
+

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

@@ -3,8 +3,15 @@
  * For licensing, see LICENSE.md.
  */
 
+/* global document */
+
 import ToolbarView from 'ckeditor5-ui/src/toolbar/toolbarview';
+import KeystrokeHandler from 'ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from 'ckeditor5-utils/src/focustracker';
+import FocusCycler from 'ckeditor5-utils/src/focuscycler';
+import { keyCodes } from 'ckeditor5-utils/src/keyboard';
 import ViewCollection from 'ckeditor5-ui/src/viewcollection';
+import View from 'ckeditor5-ui/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 #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( '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 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 "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;
+}