ソースを参照

Merge pull request #80 from ckeditor/t/79

t/79: Controlled keyboard navigation in the link UI (LinkFormView).
Piotrek Koszuliński 9 年 前
コミット
ca4a1a64c4

+ 20 - 6
packages/ckeditor5-link/src/link.js

@@ -13,7 +13,6 @@ import LinkEngine from './linkengine';
 import LinkElement from './linkelement';
 import LinkElement from './linkelement';
 
 
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
-import escPressHandler from '@ckeditor/ckeditor5-ui/src/bindings/escpresshandler';
 
 
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/balloonpanel/balloonpanelview';
 import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/balloonpanel/balloonpanelview';
@@ -168,11 +167,20 @@ export default class Link extends Plugin {
 			}
 			}
 		} );
 		} );
 
 
-		// Close on `ESC` press.
-		escPressHandler( {
-			emitter: balloonPanelView,
-			activator: () => balloonPanelView.isVisible,
-			callback: () => this._hidePanel( true )
+		// Focus the form if balloon panel is open and tab key has been pressed.
+		editor.keystrokes.set( 'Tab', ( data, cancel ) => {
+			if ( balloonPanelView.isVisible && !this.formView.focusTracker.isFocused ) {
+				this.formView.focus();
+				cancel();
+			}
+		} );
+
+		// Close the panel on esc key press when editable has focus.
+		editor.keystrokes.set( 'Esc', ( data, cancel ) => {
+			if ( balloonPanelView.isVisible ) {
+				this._hidePanel( true );
+				cancel();
+			}
 		} );
 		} );
 
 
 		// Close on click outside of balloon panel element.
 		// Close on click outside of balloon panel element.
@@ -212,6 +220,12 @@ export default class Link extends Plugin {
 			this._hidePanel( true );
 			this._hidePanel( true );
 		} );
 		} );
 
 
+		// Close the panel on esc key press when the form has focus.
+		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hidePanel( true );
+			cancel();
+		} );
+
 		// Hide balloon panel after clicking on formView `Cancel` button.
 		// Hide balloon panel after clicking on formView `Cancel` button.
 		this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
 		this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
 
 

+ 81 - 0
packages/ckeditor5-link/src/ui/linkformview.js

@@ -9,12 +9,16 @@
 
 
 import View from '@ckeditor/ckeditor5-ui/src/view';
 import View from '@ckeditor/ckeditor5-ui/src/view';
 import Template from '@ckeditor/ckeditor5-ui/src/template';
 import Template from '@ckeditor/ckeditor5-ui/src/template';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
 
 
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
 import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
 import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
 import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
 
 
 import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
 import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
 
 /**
 /**
  * The link form view controller class.
  * The link form view controller class.
@@ -32,6 +36,22 @@ export default class LinkFormView extends View {
 
 
 		const t = locale.t;
 		const t = locale.t;
 
 
+		/**
+		 * Tracks information about DOM focus in the form.
+		 *
+		 * @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();
+
 		/**
 		/**
 		 * The url input view.
 		 * The url input view.
 		 *
 		 *
@@ -61,6 +81,35 @@ export default class LinkFormView extends View {
 		 */
 		 */
 		this.unlinkButtonView = this._createButton( t( 'Unlink' ), 'unlink' );
 		this.unlinkButtonView = this._createButton( t( 'Unlink' ), 'unlink' );
 
 
+		/**
+		 * A collection of views which can be focused in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/viewcollection~ViewCollection}
+		 */
+		this._focusables = new ViewCollection();
+
+		/**
+		 * Helps cycling over {@link #_focusables} in the form.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {module:ui/focuscycler~FocusCycler}
+		 */
+		this._focusCycler = new FocusCycler( {
+			focusables: this._focusables,
+			focusTracker: this.focusTracker,
+			keystrokeHandler: this.keystrokes,
+			actions: {
+				// Navigate form fields backwards using the shift + tab keystroke.
+				focusPrevious: 'shift + tab',
+
+				// Navigate form fields forwards using the tab key.
+				focusNext: 'tab'
+			}
+		} );
+
 		Template.extend( this.saveButtonView.template, {
 		Template.extend( this.saveButtonView.template, {
 			attributes: {
 			attributes: {
 				class: [
 				class: [
@@ -101,6 +150,38 @@ export default class LinkFormView extends View {
 		submitHandler( {
 		submitHandler( {
 			view: this
 			view: this
 		} );
 		} );
+
+		const childViews = [
+			this.urlInputView,
+			this.saveButtonView,
+			this.cancelButtonView,
+			this.unlinkButtonView
+		];
+
+		childViews.forEach( v => {
+			// Register the view as focusable.
+			this._focusables.add( v );
+
+			// Register the view in the focus tracker.
+			this.focusTracker.add( v.element );
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		// Start listening for the keystrokes coming from #element.
+		this.keystrokes.listenTo( this.element );
+
+		return super.init();
+	}
+
+	/**
+	 * Focuses the fist {@link #_focusables} in the form.
+	 */
+	focus() {
+		this._focusCycler.focusFirst();
 	}
 	}
 
 
 	/**
 	/**

+ 67 - 20
packages/ckeditor5-link/tests/link.js

@@ -183,15 +183,78 @@ describe( 'Link', () => {
 			expect( editor.ui.focusTracker.isFocused ).to.true;
 			expect( editor.ui.focusTracker.isFocused ).to.true;
 		} );
 		} );
 
 
+		it( 'should focus the link form on Tab key press', () => {
+			const keyEvtData = {
+				keyCode: keyCodes.tab,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			// Mock balloon invisible, form not focused.
+			balloonPanelView.isVisible = false;
+			formView.focusTracker.isFocused = false;
+
+			const spy = sinon.spy( formView, 'focus' );
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.notCalled( keyEvtData.preventDefault );
+			sinon.assert.notCalled( keyEvtData.stopPropagation );
+			sinon.assert.notCalled( spy );
+
+			// Mock balloon visible, form focused.
+			balloonPanelView.isVisible = true;
+			formView.focusTracker.isFocused = true;
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.notCalled( keyEvtData.preventDefault );
+			sinon.assert.notCalled( keyEvtData.stopPropagation );
+			sinon.assert.notCalled( spy );
+
+			// Mock balloon visible, form not focused.
+			balloonPanelView.isVisible = true;
+			formView.focusTracker.isFocused = false;
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.calledOnce( keyEvtData.preventDefault );
+			sinon.assert.calledOnce( keyEvtData.stopPropagation );
+			sinon.assert.calledOnce( spy );
+		} );
+
 		describe( 'close listeners', () => {
 		describe( 'close listeners', () => {
 			describe( 'keyboard', () => {
 			describe( 'keyboard', () => {
-				it( 'should close after `ESC` press', () => {
+				it( 'should close after Esc key press (from editor)', () => {
+					const keyEvtData = {
+						keyCode: keyCodes.esc,
+						preventDefault: sinon.spy(),
+						stopPropagation: sinon.spy()
+					};
+
+					balloonPanelView.isVisible = false;
+
+					editor.keystrokes.press( keyEvtData );
+
+					sinon.assert.notCalled( hidePanelSpy );
+					sinon.assert.notCalled( focusEditableSpy );
+
 					balloonPanelView.isVisible = true;
 					balloonPanelView.isVisible = true;
 
 
-					dispatchKeyboardEvent( document, 'keydown', keyCodes.esc );
+					editor.keystrokes.press( keyEvtData );
 
 
-					expect( hidePanelSpy.calledOnce ).to.true;
-					expect( focusEditableSpy.calledOnce ).to.true;
+					sinon.assert.calledOnce( hidePanelSpy );
+					sinon.assert.calledOnce( focusEditableSpy );
+				} );
+
+				it( 'should close after Esc key press (from the form)', () => {
+					const keyEvtData = {
+						keyCode: keyCodes.esc,
+						preventDefault: sinon.spy(),
+						stopPropagation: sinon.spy()
+					};
+
+					formView.keystrokes.press( keyEvtData );
+
+					sinon.assert.calledOnce( hidePanelSpy );
+					sinon.assert.calledOnce( focusEditableSpy );
 				} );
 				} );
 			} );
 			} );
 
 
@@ -415,19 +478,3 @@ describe( 'Link', () => {
 		} );
 		} );
 	} );
 	} );
 } );
 } );
-
-// Creates and dispatches keyboard event with specified keyCode.
-//
-// @private
-// @param {EventTarget} eventTarget
-// @param {String} eventName
-// @param {Number} keyCode
-function dispatchKeyboardEvent( element, eventName, keyCode ) {
-	const event = document.createEvent( 'Events' );
-
-	event.initEvent( eventName, true, true );
-	event.which = keyCode;
-	event.keyCode = keyCode;
-
-	element.dispatchEvent( event );
-}

+ 109 - 1
packages/ckeditor5-link/tests/ui/linkformview.js

@@ -4,7 +4,15 @@
  */
  */
 
 
 import View from '@ckeditor/ckeditor5-ui/src/view';
 import View from '@ckeditor/ckeditor5-ui/src/view';
-import LinkFormView from '../../src/ui/linkformview';
+import LinkFormView from '@ckeditor/ckeditor5-link/src/ui/linkformview';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
+import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
+import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler';
+import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+testUtils.createSinonSandbox();
 
 
 describe( 'LinkFormView', () => {
 describe( 'LinkFormView', () => {
 	let view;
 	let view;
@@ -32,6 +40,42 @@ describe( 'LinkFormView', () => {
 			expect( view._unboundChildren.get( 3 ) ).to.equal( view.unlinkButtonView );
 			expect( view._unboundChildren.get( 3 ) ).to.equal( view.unlinkButtonView );
 		} );
 		} );
 
 
+		it( 'should create #focusTracker instance', () => {
+			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
+		} );
+
+		it( 'should create #keystrokes instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
+		it( 'should create #_focusCycler instance', () => {
+			expect( view._focusCycler ).to.be.instanceOf( FocusCycler );
+		} );
+
+		it( 'should create #_focusables view collection', () => {
+			expect( view._focusables ).to.be.instanceOf( ViewCollection );
+		} );
+
+		it( 'should register child views in #_focusables', () => {
+			expect( view._focusables.map( f => f ) ).to.have.members( [
+				view.urlInputView,
+				view.saveButtonView,
+				view.cancelButtonView,
+				view.unlinkButtonView
+			] );
+		} );
+
+		it( 'should register child views\' #element in #focusTracker', () => {
+			const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' );
+
+			view = new LinkFormView( { t: () => {} } );
+
+			sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element );
+			sinon.assert.calledWithExactly( spy.getCall( 3 ), view.unlinkButtonView.element );
+		} );
+
 		it( 'should fire `cancel` event on cancelButtonView#execute', () => {
 		it( 'should fire `cancel` event on cancelButtonView#execute', () => {
 			const spy = sinon.spy();
 			const spy = sinon.spy();
 
 
@@ -71,6 +115,60 @@ describe( 'LinkFormView', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'init()', () => {
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new LinkFormView( { t: () => {} } );
+
+			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 "tab" the next focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the url input is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.urlInputView.element;
+
+				const spy = sinon.spy( view.saveButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'so "shift + tab" focuses the previous focusable item', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.tab,
+					shiftKey: true,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				// Mock the cancel button is focused.
+				view.focusTracker.isFocused = true;
+				view.focusTracker.focusedElement = view.cancelButtonView.element;
+
+				const spy = sinon.spy( view.saveButtonView, 'focus' );
+
+				view.keystrokes.press( keyEvtData );
+				sinon.assert.calledOnce( keyEvtData.preventDefault );
+				sinon.assert.calledOnce( keyEvtData.stopPropagation );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
 	describe( 'DOM bindings', () => {
 	describe( 'DOM bindings', () => {
 		describe( 'submit event', () => {
 		describe( 'submit event', () => {
 			it( 'should trigger submit event', () => {
 			it( 'should trigger submit event', () => {
@@ -83,4 +181,14 @@ describe( 'LinkFormView', () => {
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'focus()', () => {
+		it( 'focuses the #urlInputView', () => {
+			const spy = sinon.spy( view.urlInputView, 'focus' );
+
+			view.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
 } );
 } );