Browse Source

Merge pull request #439 from ckeditor/t/ckeditor5-media-embed/1

Feature: Allowed displaying an error message next to the `LabeledInputVIew` (see ckeditor/ckeditor5-media-embed#1).
Piotrek Koszuliński 7 năm trước cách đây
mục cha
commit
93587b07dd

+ 33 - 3
packages/ckeditor5-ui/src/inputtext/inputtextview.js

@@ -8,7 +8,6 @@
  */
 
 import View from '../view';
-
 import '../../theme/components/inputtext/inputtext.css';
 
 /**
@@ -55,6 +54,24 @@ export default class InputTextView extends View {
 		 */
 		this.set( 'isReadOnly', false );
 
+		/**
+		 * Set to `true` when the field has some error. Usually controlled via
+		 * {@link module:ui/labeledinput/labeledinputview~LabeledInputView#errorText}.
+		 *
+		 * @observable
+		 * @member {Boolean} #hasError
+		 */
+		this.set( 'hasError', false );
+
+		/**
+		 * The `id` of the element describing this field, e.g. when it has
+		 * some error, it helps screen readers read the error text.
+		 *
+		 * @observable
+		 * @member {Boolean} #ariaDesribedById
+		 */
+		this.set( 'ariaDesribedById' );
+
 		const bind = this.bindTemplate;
 
 		this.setTemplate( {
@@ -64,13 +81,26 @@ export default class InputTextView extends View {
 				class: [
 					'ck',
 					'ck-input',
-					'ck-input-text'
+					'ck-input-text',
+					bind.if( 'hasError', 'ck-error' )
 				],
 				id: bind.to( 'id' ),
 				placeholder: bind.to( 'placeholder' ),
-				readonly: bind.to( 'isReadOnly' )
+				readonly: bind.to( 'isReadOnly' ),
+				'aria-invalid': bind.if( 'hasError', true ),
+				'aria-describedby': bind.to( 'ariaDesribedById' )
+			},
+			on: {
+				input: bind.to( 'input' )
 			}
 		} );
+
+		/**
+		 * Fired when the user types in the input. Corresponds to the native
+		 * DOM `input` event.
+		 *
+		 * @event input
+		 */
 	}
 
 	/**

+ 77 - 10
packages/ckeditor5-ui/src/labeledinput/labeledinputview.js

@@ -9,8 +9,8 @@
 
 import View from '../view';
 import uid from '@ckeditor/ckeditor5-utils/src/uid';
-
 import LabelView from '../label/labelview';
+import '../../theme/components/labeledinput/labeledinput.css';
 
 /**
  * The labeled input view class.
@@ -27,7 +27,8 @@ export default class LabeledInputView extends View {
 	constructor( locale, InputView ) {
 		super( locale );
 
-		const id = `ck-input-${ uid() }`;
+		const inputUid = `ck-input-${ uid() }`;
+		const errorUid = `ck-error-${ uid() }`;
 
 		/**
 		 * The text of the label.
@@ -53,19 +54,44 @@ export default class LabeledInputView extends View {
 		 */
 		this.set( 'isReadOnly', false );
 
+		/**
+		 * The validation error text. When set, it will be displayed
+		 * next to the {@link #inputView} as a typical validation error message.
+		 * Set it to `null` to hide the message.
+		 *
+		 * **Note:** Setting this property to anything but `null` will automatically
+		 * make the {@link module:ui/inputtext/inputtextview~InputTextView#hasError `hasError`}
+		 * of the {@link #inputView} `true`.
+		 *
+		 * **Note:** Typing in the {@link #inputView} which fires the
+		 * {@link module:ui/inputtext/inputtextview~InputTextView#event:input `input` event}
+		 * resets this property back to `null`, indicating that the input field can be re–validated.
+		 *
+		 * @observable
+		 * @member {String|null} #errorText
+		 */
+		this.set( 'errorText', null );
+
 		/**
 		 * The label view.
 		 *
 		 * @member {module:ui/label/labelview~LabelView} #labelView
 		 */
-		this.labelView = this._createLabelView( id );
+		this.labelView = this._createLabelView( inputUid );
 
 		/**
 		 * The input view.
 		 *
-		 * @member {module:ui/view~View} #inputView
+		 * @member {module:ui/inputtext/inputtextview~InputTextView} #inputView
 		 */
-		this.inputView = this._createInputView( InputView, id );
+		this.inputView = this._createInputView( InputView, inputUid, errorUid );
+
+		/**
+		 * The error view for the {@link #inputView}.
+		 *
+		 * @member {module:ui/view~View} #errorView
+		 */
+		this.errorView = this._createErrorView( errorUid );
 
 		const bind = this.bindTemplate;
 
@@ -80,7 +106,8 @@ export default class LabeledInputView extends View {
 			},
 			children: [
 				this.labelView,
-				this.inputView
+				this.inputView,
+				this.errorView
 			]
 		} );
 	}
@@ -106,19 +133,59 @@ export default class LabeledInputView extends View {
 	 *
 	 * @private
 	 * @param {Function} InputView Input view constructor.
-	 * @param {String} id Unique id to set as inputView#id attribute.
+	 * @param {String} inputUid Unique id to set as inputView#id attribute.
+	 * @param {String} errorUid Unique id of the error for the input's `aria-describedby` attribute.
 	 * @returns {module:ui/inputtext/inputtextview~InputTextView}
 	 */
-	_createInputView( InputView, id ) {
-		const inputView = new InputView( this.locale );
+	_createInputView( InputView, inputUid, errorUid ) {
+		const inputView = new InputView( this.locale, errorUid );
 
-		inputView.id = id;
+		inputView.id = inputUid;
+		inputView.ariaDesribedById = errorUid;
 		inputView.bind( 'value' ).to( this );
 		inputView.bind( 'isReadOnly' ).to( this );
+		inputView.bind( 'hasError' ).to( this, 'errorText', value => !!value );
+
+		inputView.on( 'input', () => {
+			// UX: Make the error text disappear and disable the error indicator as the user
+			// starts fixing the errors.
+			this.errorText = null;
+		} );
 
 		return inputView;
 	}
 
+	/**
+	 * Creates the error view instance.
+	 *
+	 * @private
+	 * @param {String} errorUid Unique id of the error, shared with the input's `aria-describedby` attribute.
+	 * @returns {module:ui/view~View}
+	 */
+	_createErrorView( errorUid ) {
+		const errorView = new View( this.locale );
+		const bind = this.bindTemplate;
+
+		errorView.setTemplate( 				{
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-labeled-input__error',
+					bind.if( 'errorText', 'ck-hidden', value => !value )
+				],
+				id: errorUid
+			},
+			children: [
+				{
+					text: bind.to( 'errorText' )
+				}
+			]
+		} );
+
+		return errorView;
+	}
+
 	/**
 	 * Moves the focus to the input and selects the value.
 	 */

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

@@ -3,12 +3,15 @@
  * For licensing, see LICENSE.md.
  */
 
+/* global Event */
+
 import InputTextView from '../../src/inputtext/inputtextview';
 
 describe( 'InputTextView', () => {
-	let view;
+	let view, ariaDesribedById;
 
 	beforeEach( () => {
+		ariaDesribedById = 'ck-error-1234567890';
 		view = new InputTextView();
 
 		view.render();
@@ -98,6 +101,47 @@ describe( 'InputTextView', () => {
 				expect( view.element.readOnly ).to.true;
 			} );
 		} );
+
+		describe( 'class', () => {
+			it( 'should react on view#hasErrors', () => {
+				expect( view.element.classList.contains( 'ck-error' ) ).to.be.false;
+
+				view.hasError = true;
+
+				expect( view.element.classList.contains( 'ck-error' ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'aria-invalid', () => {
+			it( 'should react on view#hasError', () => {
+				expect( view.element.getAttribute( 'aria-invalid' ) ).to.be.null;
+
+				view.hasError = true;
+
+				expect( view.element.getAttribute( 'aria-invalid' ) ).to.equal( 'true' );
+			} );
+		} );
+
+		describe( 'aria-describedby', () => {
+			it( 'should react on view#hasError', () => {
+				expect( view.element.getAttribute( 'aria-describedby' ) ).to.be.null;
+
+				view.ariaDesribedById = ariaDesribedById;
+
+				expect( view.element.getAttribute( 'aria-describedby' ) ).to.equal( ariaDesribedById );
+			} );
+		} );
+
+		describe( 'input event', () => {
+			it( 'triggers view#input', () => {
+				const spy = sinon.spy();
+
+				view.on( 'input', spy );
+
+				view.element.dispatchEvent( new Event( 'input' ) );
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
 	} );
 
 	describe( 'select()', () => {

+ 54 - 4
packages/ckeditor5-ui/tests/labeledinput/labeledinputview.js

@@ -3,6 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
+import View from '../../src/view';
 import LabeledInputView from '../../src/labeledinput/labeledinputview';
 import InputView from '../../src/inputtext/inputtextview';
 import LabelView from '../../src/label/labelview';
@@ -23,6 +24,10 @@ describe( 'LabeledInputView', () => {
 			expect( view.locale ).to.deep.equal( locale );
 		} );
 
+		it( 'should set view#errorText', () => {
+			expect( view.errorText ).to.be.null;
+		} );
+
 		it( 'should create view#inputView', () => {
 			expect( view.inputView ).to.instanceOf( InputView );
 		} );
@@ -31,8 +36,20 @@ describe( 'LabeledInputView', () => {
 			expect( view.labelView ).to.instanceOf( LabelView );
 		} );
 
-		it( 'should pair inputView and labelView by unique id', () => {
-			expect( view.labelView.for ).to.equal( view.inputView.id ).to.ok;
+		it( 'should create view#errorView', () => {
+			expect( view.errorView ).to.instanceOf( View );
+
+			expect( view.errorView.element.tagName ).to.equal( 'DIV' );
+			expect( view.errorView.element.classList.contains( 'ck' ) ).to.be.true;
+			expect( view.errorView.element.classList.contains( 'ck-labeled-input__error' ) ).to.be.true;
+		} );
+
+		it( 'should pair #inputView and #labelView by unique id', () => {
+			expect( view.labelView.for ).to.equal( view.inputView.id );
+		} );
+
+		it( 'should pair #inputView and #errorView by unique id', () => {
+			expect( view.inputView.ariaDesribedById ).to.equal( view.errorView.element.id );
 		} );
 	} );
 
@@ -50,6 +67,10 @@ describe( 'LabeledInputView', () => {
 			expect( view.template.children[ 1 ] ).to.equal( view.inputView );
 		} );
 
+		it( 'should have the error container', () => {
+			expect( view.template.children[ 2 ] ).to.equal( view.errorView );
+		} );
+
 		describe( 'DOM bindings', () => {
 			describe( 'class', () => {
 				it( 'should react on view#isReadOnly', () => {
@@ -60,6 +81,20 @@ describe( 'LabeledInputView', () => {
 					expect( view.element.classList.contains( 'ck-disabled' ) ).to.be.true;
 				} );
 			} );
+
+			describe( 'error container', () => {
+				it( 'should react on view#errorText', () => {
+					const errorContainer = view.element.lastChild;
+
+					view.errorText = '';
+					expect( errorContainer.classList.contains( 'ck-hidden' ) ).to.be.true;
+					expect( errorContainer.innerHTML ).to.equal( '' );
+
+					view.errorText = 'foo';
+					expect( errorContainer.classList.contains( 'ck-hidden' ) ).to.be.false;
+					expect( errorContainer.innerHTML ).to.equal( 'foo' );
+				} );
+			} );
 		} );
 	} );
 
@@ -79,11 +114,26 @@ describe( 'LabeledInputView', () => {
 		it( 'should bind view#isreadOnly to view.inputView#isReadOnly', () => {
 			view.isReadOnly = false;
 
-			expect( view.inputView.isReadOnly ).to.false;
+			expect( view.inputView.isReadOnly ).to.be.false;
 
 			view.isReadOnly = true;
 
-			expect( view.inputView.isReadOnly ).to.true;
+			expect( view.inputView.isReadOnly ).to.be.true;
+		} );
+
+		it( 'should bind view#errorText to view.inputView#hasError', () => {
+			view.errorText = '';
+			expect( view.inputView.hasError ).to.be.false;
+
+			view.errorText = 'foo';
+			expect( view.inputView.hasError ).to.be.true;
+		} );
+
+		it( 'should clear view#errorText upon view.inputView#input', () => {
+			view.errorText = 'foo';
+
+			view.inputView.fire( 'input' );
+			expect( view.errorText ).to.be.null;
 		} );
 	} );
 

+ 10 - 0
packages/ckeditor5-ui/theme/components/labeledinput/labeledinput.css

@@ -0,0 +1,10 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/*
+ * Note: This file should contain the wireframe styles only. But since there are no such styles,
+ * it acts as a message to the builder telling that it should look for the corresponding styles
+ * **in the theme** when compiling the editor.
+ */