Browse Source

Enhanced FocusCycler utility with focus[Next,Previous,First]() methods and an automated keystroke detection to engage with configured methods.

Aleksander Nowodzinski 9 years ago
parent
commit
1ec150c23e
2 changed files with 394 additions and 77 deletions
  1. 135 13
      packages/ckeditor5-ui/src/focuscycler.js
  2. 259 64
      packages/ckeditor5-ui/tests/focuscycler.js

+ 135 - 13
packages/ckeditor5-ui/src/focuscycler.js

@@ -13,54 +13,134 @@
  */
 export default class FocusCycler {
 	/**
-	 * Creates an instance of the focus cycler.
+	 * Creates an instance of the focus cycler utility.
 	 *
-	 * @param {module:ui/viewcollection~ViewCollection} viewCollection
-	 * @param {module:utils/focustracker~FocusTracker} focusTracker
+	 *		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' ],
+	 *			}
+	 *		} );
+	 *
+	 * @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( viewCollection, focusTracker ) {
+	constructor( options ) {
+		Object.assign( this, options );
+
 		/**
 		 * A view collection the cycler operates on.
 		 *
 		 * @readonly
-		 * @member {module:ui/viewcollection~ViewCollection}
+		 * @member {module:utils/collection~Collection} focusables
 		 */
-		this.viewCollection = viewCollection;
 
 		/**
 		 * A focus tracker instance that cycler uses to determine focus
 		 * state in {@link #viewCollection}.
 		 *
 		 * @readonly
-		 * @member {module:utils/focustracker~FocusTracker}
+		 * @member {module:utils/focustracker~FocusTracker} focusTracker
+		 */
+
+		/**
+		 * Instance of the {@link module:core/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:core/keystrokehandler~KeystrokeHandler} keystrokeHandler
 		 */
-		this.focusTracker = focusTracker;
+
+		/**
+		 * 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.viewCollection.find( isFocusable ) || null;
+		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.viewCollection.filter( isFocusable ).slice( -1 )[ 0 ] || null;
+		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() {
@@ -71,6 +151,7 @@ export default class FocusCycler {
 	 * Returns the previous focusable view in {@link #viewCollection} based on {@link #current}.
 	 * `null` if there's none.
 	 *
+	 * @readonly
 	 * @member {module:ui/view~View|null} #next
 	 */
 	get previous() {
@@ -81,6 +162,7 @@ export default class FocusCycler {
 	 * 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() {
@@ -91,7 +173,7 @@ export default class FocusCycler {
 			return null;
 		}
 
-		this.viewCollection.find( ( view, viewIndex ) => {
+		this.focusables.find( ( view, viewIndex ) => {
 			const focused = view.element === this.focusTracker.focusedElement;
 
 			if ( focused ) {
@@ -105,6 +187,46 @@ export default class FocusCycler {
 	}
 
 	/**
+	 * Focuses the {@link #first}.
+	 */
+	focusFirst() {
+		this._focus( this.first );
+	}
+
+	/**
+	 * Focuses the {@link #last}.
+	 */
+	focusLast() {
+		this._focus( this.last );
+	}
+
+	/**
+	 * Focuses the {@link #next}.
+	 */
+	focusNext() {
+		this._focus( this.next );
+	}
+
+	/**
+	 * Focuses the {@link #previous}.
+	 */
+	focusPrevious() {
+		this._focus( this.previous );
+	}
+
+	/**
+	 * Focuses the passed 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}.
 	 *
@@ -116,7 +238,7 @@ export default class FocusCycler {
 	_getFocusableItem( step ) {
 		// Cache for speed.
 		const current = this.current;
-		const collectionLength = this.viewCollection.length;
+		const collectionLength = this.focusables.length;
 
 		if ( !collectionLength || current === null ) {
 			return null;
@@ -126,7 +248,7 @@ export default class FocusCycler {
 		let index = ( current + collectionLength + step ) % collectionLength;
 
 		do {
-			let view = this.viewCollection.get( index );
+			let view = this.focusables.get( index );
 
 			// TODO: Check if view is visible.
 			if ( isFocusable( view ) ) {

+ 259 - 64
packages/ckeditor5-ui/tests/focuscycler.js

@@ -6,27 +6,32 @@
 import ViewCollection from 'ckeditor5-ui/src/viewcollection';
 import View from 'ckeditor5-ui/src/view';
 import FocusCycler from 'ckeditor5-ui/src/focuscycler';
+import KeystrokeHandler from 'ckeditor5-utils/src/keystrokehandler';
+import { keyCodes } from 'ckeditor5-utils/src/keyboard';
 
 describe( 'FocusCycler', () => {
-	let viewCollection, focusTracker, cycler;
+	let focusables, focusTracker, cycler;
 
 	beforeEach( () => {
-		viewCollection = new ViewCollection();
+		focusables = new ViewCollection();
 		focusTracker = {
 			focusedElement: null
 		};
-		cycler = new FocusCycler( viewCollection, focusTracker );
+		cycler = new FocusCycler( {
+			focusables: focusables,
+			focusTracker: focusTracker
+		} );
 
-		viewCollection.add( nonFocusable() );
-		viewCollection.add( focusable() );
-		viewCollection.add( focusable() );
-		viewCollection.add( focusable() );
-		viewCollection.add( nonFocusable() );
+		focusables.add( nonFocusable() );
+		focusables.add( focusable() );
+		focusables.add( focusable() );
+		focusables.add( focusable() );
+		focusables.add( nonFocusable() );
 	} );
 
 	describe( 'constructor()', () => {
 		it( 'sets class properties', () => {
-			expect( cycler.viewCollection ).to.equal( viewCollection );
+			expect( cycler.focusables ).to.equal( focusables );
 			expect( cycler.focusTracker ).to.equal( focusTracker );
 		} );
 	} );
@@ -35,7 +40,7 @@ describe( 'FocusCycler', () => {
 		it( 'returns null when no view is focused', () => {
 			expect( cycler.current ).to.equal( null );
 
-			focusTracker.focusedElement = viewCollection.get( 2 ).element;
+			focusTracker.focusedElement = focusables.get( 2 ).element;
 			expect( cycler.current ).to.equal( 2 );
 
 			focusTracker.focusedElement = null;
@@ -45,22 +50,22 @@ describe( 'FocusCycler', () => {
 
 	describe( 'first()', () => {
 		it( 'returns first focusable view', () => {
-			expect( cycler.first ).to.equal( viewCollection.get( 1 ) );
+			expect( cycler.first ).to.equal( focusables.get( 1 ) );
 		} );
 
 		it( 'returns null when no focusable items', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
-			viewCollection.add( nonFocusable() );
-			viewCollection.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
 
 			expect( cycler.first ).to.be.null;
 		} );
 
 		it( 'returns null when no items', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
 			expect( cycler.first ).to.be.null;
 		} );
@@ -68,22 +73,22 @@ describe( 'FocusCycler', () => {
 
 	describe( 'last()', () => {
 		it( 'returns last focusable view', () => {
-			expect( cycler.last ).to.equal( viewCollection.get( 3 ) );
+			expect( cycler.last ).to.equal( focusables.get( 3 ) );
 		} );
 
 		it( 'returns null when no focusable items', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
-			viewCollection.add( nonFocusable() );
-			viewCollection.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
 
 			expect( cycler.last ).to.be.null;
 		} );
 
 		it( 'returns null when no items', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
 			expect( cycler.last ).to.be.null;
 		} );
@@ -91,14 +96,14 @@ describe( 'FocusCycler', () => {
 
 	describe( 'next()', () => {
 		it( 'cycles to return the next focusable view', () => {
-			focusTracker.focusedElement = viewCollection.get( 2 ).element;
-			expect( cycler.next ).to.equal( viewCollection.get( 3 ) );
+			focusTracker.focusedElement = focusables.get( 2 ).element;
+			expect( cycler.next ).to.equal( focusables.get( 3 ) );
 
-			focusTracker.focusedElement = viewCollection.get( 3 ).element;
-			expect( cycler.next ).to.equal( viewCollection.get( 1 ) );
+			focusTracker.focusedElement = focusables.get( 3 ).element;
+			expect( cycler.next ).to.equal( focusables.get( 1 ) );
 
-			focusTracker.focusedElement = viewCollection.get( 1 ).element;
-			expect( cycler.next ).to.equal( viewCollection.get( 2 ) );
+			focusTracker.focusedElement = focusables.get( 1 ).element;
+			expect( cycler.next ).to.equal( focusables.get( 2 ) );
 		} );
 
 		it( 'returns null when no view is focused', () => {
@@ -108,47 +113,47 @@ describe( 'FocusCycler', () => {
 		} );
 
 		it( 'returns null when no items', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
 			expect( cycler.next ).to.be.null;
 		} );
 
 		it( 'returns null when no focusable items', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
-			viewCollection.add( nonFocusable() );
-			viewCollection.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
 
 			expect( cycler.next ).to.be.null;
 		} );
 
-		it( 'returns null if the only focusable in viewCollection', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+		it( 'returns null if the only focusable in focusables', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
-			viewCollection.add( nonFocusable() );
-			viewCollection.add( focusable() );
-			viewCollection.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+			focusables.add( focusable() );
+			focusables.add( nonFocusable() );
 
-			focusTracker.focusedElement = viewCollection.get( 1 ).element;
+			focusTracker.focusedElement = focusables.get( 1 ).element;
 
-			expect( cycler.first ).to.equal( viewCollection.get( 1 ) );
+			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 = viewCollection.get( 1 ).element;
-			expect( cycler.previous ).to.equal( viewCollection.get( 3 ) );
+			focusTracker.focusedElement = focusables.get( 1 ).element;
+			expect( cycler.previous ).to.equal( focusables.get( 3 ) );
 
-			focusTracker.focusedElement = viewCollection.get( 2 ).element;
-			expect( cycler.previous ).to.equal( viewCollection.get( 1 ) );
+			focusTracker.focusedElement = focusables.get( 2 ).element;
+			expect( cycler.previous ).to.equal( focusables.get( 1 ) );
 
-			focusTracker.focusedElement = viewCollection.get( 3 ).element;
-			expect( cycler.previous ).to.equal( viewCollection.get( 2 ) );
+			focusTracker.focusedElement = focusables.get( 3 ).element;
+			expect( cycler.previous ).to.equal( focusables.get( 2 ) );
 		} );
 
 		it( 'returns null when no view is focused', () => {
@@ -158,36 +163,226 @@ describe( 'FocusCycler', () => {
 		} );
 
 		it( 'returns null when no items', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
 			expect( cycler.previous ).to.be.null;
 		} );
 
 		it( 'returns null when no focusable items', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
-			viewCollection.add( nonFocusable() );
-			viewCollection.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+			focusables.add( nonFocusable() );
 
 			expect( cycler.previous ).to.be.null;
 		} );
 
-		it( 'returns null if the only focusable in viewCollection', () => {
-			viewCollection = new ViewCollection();
-			cycler = new FocusCycler( viewCollection, focusTracker );
+		it( 'returns null if the only focusable in focusables', () => {
+			focusables = new ViewCollection();
+			cycler = new FocusCycler( { focusables, focusTracker } );
 
-			viewCollection.add( nonFocusable() );
-			viewCollection.add( focusable() );
-			viewCollection.add( nonFocusable() );
+			focusables.add( nonFocusable() );
+			focusables.add( focusable() );
+			focusables.add( nonFocusable() );
 
-			focusTracker.focusedElement = viewCollection.get( 1 ).element;
+			focusTracker.focusedElement = focusables.get( 1 ).element;
 
-			expect( cycler.first ).to.equal( viewCollection.get( 1 ) );
+			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() {