Przeglądaj źródła

Created FocusCycler utility class to help cycling over focusable views in a ViewCollection.

Aleksander Nowodzinski 9 lat temu
rodzic
commit
743c995722

+ 140 - 0
packages/ckeditor5-utils/src/focuscycler.js

@@ -0,0 +1,140 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module utils/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.
+ */
+export default class FocusCycler {
+	/**
+	 * Creates an instance of the focus cycler.
+	 *
+	 * @param {module:ui/viewcollection~ViewCollection} viewCollection
+	 * @param {module:utils/focustracker~FocusTracker} focusTracker
+	 */
+	constructor( viewCollection, focusTracker ) {
+		/**
+		 * A view collection the cycler operates on.
+		 *
+		 * @readonly
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this.viewCollection = viewCollection;
+
+		/**
+		 * A focus tracker instance that cycler uses to determine focus
+		 * state in {@link #viewCollection}.
+		 *
+		 * @readonly
+		 * @member {module:utils/focustracker~FocusTracker}
+		 */
+		this.focusTracker = focusTracker;
+	}
+
+	/**
+	 * Returns the first focusable view in the collection. `null` if there's none.
+	 *
+	 * @member {module:ui/view~View|null} #first
+	 */
+	get first() {
+		return this.viewCollection.find( isFocusable ) || null;
+	}
+
+	/**
+	 * Returns the next focusable view in the collection based on {@link #current}.
+	 * `null` if there's none.
+	 *
+	 * @member {module:ui/view~View|null} #next
+	 */
+	get next() {
+		return this._getFocusableItem( 1 );
+	}
+
+	/**
+	 * Returns the previous focusable view in the collection based on {@link #current}.
+	 * `null` if there's none.
+	 *
+	 * @member {module:ui/view~View|null} #next
+	 */
+	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.
+	 *
+	 * @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.viewCollection.find( ( view, viewIndex ) => {
+			const focused = view.element === this.focusTracker.focusedElement;
+
+			if ( focused ) {
+				index = viewIndex;
+			}
+
+			return focused;
+		} );
+
+		return index;
+	}
+
+	/**
+	 * 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.viewCollection.length;
+
+		if ( !collectionLength || current === null ) {
+			return null;
+		}
+
+		// Cycle in both directions.
+		let index = ( current + collectionLength + step ) % collectionLength;
+
+		do {
+			let view = this.viewCollection.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;
+}

+ 181 - 0
packages/ckeditor5-utils/tests/focuscycler.js

@@ -0,0 +1,181 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewCollection from 'ckeditor5-ui/src/viewcollection';
+import View from 'ckeditor5-ui/src/view';
+import FocusCycler from 'ckeditor5-utils/src/focuscycler';
+
+describe( 'FocusCycler', () => {
+	let viewCollection, focusTracker, cycler;
+
+	beforeEach( () => {
+		viewCollection = new ViewCollection();
+		focusTracker = {
+			focusedElement: null
+		};
+		cycler = new FocusCycler( viewCollection, focusTracker );
+
+		viewCollection.add( nonFocusable() );
+		viewCollection.add( focusable() );
+		viewCollection.add( focusable() );
+		viewCollection.add( focusable() );
+		viewCollection.add( nonFocusable() );
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'sets class properties', () => {
+			expect( cycler.viewCollection ).to.equal( viewCollection );
+			expect( cycler.focusTracker ).to.equal( focusTracker );
+		} );
+	} );
+
+	describe( 'current()', () => {
+		it( 'returns null when no view is focused', () => {
+			expect( cycler.current ).to.equal( null );
+
+			focusTracker.focusedElement = viewCollection.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( viewCollection.get( 1 ) );
+		} );
+
+		it( 'returns null when no focusable items', () => {
+			viewCollection = new ViewCollection();
+			cycler = new FocusCycler( viewCollection, focusTracker );
+
+			viewCollection.add( nonFocusable() );
+			viewCollection.add( nonFocusable() );
+
+			expect( cycler.first ).to.be.null;
+		} );
+
+		it( 'returns null when no items', () => {
+			viewCollection = new ViewCollection();
+			cycler = new FocusCycler( viewCollection, focusTracker );
+
+			expect( cycler.first ).to.be.null;
+		} );
+	} );
+
+	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 = viewCollection.get( 3 ).element;
+			expect( cycler.next ).to.equal( viewCollection.get( 1 ) );
+
+			focusTracker.focusedElement = viewCollection.get( 1 ).element;
+			expect( cycler.next ).to.equal( viewCollection.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', () => {
+			viewCollection = new ViewCollection();
+			cycler = new FocusCycler( viewCollection, focusTracker );
+
+			expect( cycler.next ).to.be.null;
+		} );
+
+		it( 'returns null when no focusable items', () => {
+			viewCollection = new ViewCollection();
+			cycler = new FocusCycler( viewCollection, focusTracker );
+
+			viewCollection.add( nonFocusable() );
+			viewCollection.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 );
+
+			viewCollection.add( nonFocusable() );
+			viewCollection.add( focusable() );
+			viewCollection.add( nonFocusable() );
+
+			focusTracker.focusedElement = viewCollection.get( 1 ).element;
+
+			expect( cycler.first ).to.equal( viewCollection.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 = viewCollection.get( 2 ).element;
+			expect( cycler.previous ).to.equal( viewCollection.get( 1 ) );
+
+			focusTracker.focusedElement = viewCollection.get( 3 ).element;
+			expect( cycler.previous ).to.equal( viewCollection.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', () => {
+			viewCollection = new ViewCollection();
+			cycler = new FocusCycler( viewCollection, focusTracker );
+
+			expect( cycler.previous ).to.be.null;
+		} );
+
+		it( 'returns null when no focusable items', () => {
+			viewCollection = new ViewCollection();
+			cycler = new FocusCycler( viewCollection, focusTracker );
+
+			viewCollection.add( nonFocusable() );
+			viewCollection.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 );
+
+			viewCollection.add( nonFocusable() );
+			viewCollection.add( focusable() );
+			viewCollection.add( nonFocusable() );
+
+			focusTracker.focusedElement = viewCollection.get( 1 ).element;
+
+			expect( cycler.first ).to.equal( viewCollection.get( 1 ) );
+			expect( cycler.previous ).to.be.null;
+		} );
+	} );
+} );
+
+function focusable() {
+	const view = new View();
+
+	view.focus = () => {};
+	view.element = {};
+
+	return view;
+}
+
+function nonFocusable() {
+	return new View();
+}