Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit 81aaea4

Browse files
authored
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).
2 parents 16157b4 + fa3c217 commit 81aaea4

File tree

5 files changed

+219
-18
lines changed

5 files changed

+219
-18
lines changed

src/inputtext/inputtextview.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
*/
99

1010
import View from '../view';
11-
1211
import '../../theme/components/inputtext/inputtext.css';
1312

1413
/**
@@ -55,6 +54,24 @@ export default class InputTextView extends View {
5554
*/
5655
this.set( 'isReadOnly', false );
5756

57+
/**
58+
* Set to `true` when the field has some error. Usually controlled via
59+
* {@link module:ui/labeledinput/labeledinputview~LabeledInputView#errorText}.
60+
*
61+
* @observable
62+
* @member {Boolean} #hasError
63+
*/
64+
this.set( 'hasError', false );
65+
66+
/**
67+
* The `id` of the element describing this field, e.g. when it has
68+
* some error, it helps screen readers read the error text.
69+
*
70+
* @observable
71+
* @member {Boolean} #ariaDesribedById
72+
*/
73+
this.set( 'ariaDesribedById' );
74+
5875
const bind = this.bindTemplate;
5976

6077
this.setTemplate( {
@@ -64,13 +81,26 @@ export default class InputTextView extends View {
6481
class: [
6582
'ck',
6683
'ck-input',
67-
'ck-input-text'
84+
'ck-input-text',
85+
bind.if( 'hasError', 'ck-error' )
6886
],
6987
id: bind.to( 'id' ),
7088
placeholder: bind.to( 'placeholder' ),
71-
readonly: bind.to( 'isReadOnly' )
89+
readonly: bind.to( 'isReadOnly' ),
90+
'aria-invalid': bind.if( 'hasError', true ),
91+
'aria-describedby': bind.to( 'ariaDesribedById' )
92+
},
93+
on: {
94+
input: bind.to( 'input' )
7295
}
7396
} );
97+
98+
/**
99+
* Fired when the user types in the input. Corresponds to the native
100+
* DOM `input` event.
101+
*
102+
* @event input
103+
*/
74104
}
75105

76106
/**

src/labeledinput/labeledinputview.js

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
import View from '../view';
1111
import uid from '@ckeditor/ckeditor5-utils/src/uid';
12-
1312
import LabelView from '../label/labelview';
13+
import '../../theme/components/labeledinput/labeledinput.css';
1414

1515
/**
1616
* The labeled input view class.
@@ -27,7 +27,8 @@ export default class LabeledInputView extends View {
2727
constructor( locale, InputView ) {
2828
super( locale );
2929

30-
const id = `ck-input-${ uid() }`;
30+
const inputUid = `ck-input-${ uid() }`;
31+
const errorUid = `ck-error-${ uid() }`;
3132

3233
/**
3334
* The text of the label.
@@ -53,19 +54,44 @@ export default class LabeledInputView extends View {
5354
*/
5455
this.set( 'isReadOnly', false );
5556

57+
/**
58+
* The validation error text. When set, it will be displayed
59+
* next to the {@link #inputView} as a typical validation error message.
60+
* Set it to `null` to hide the message.
61+
*
62+
* **Note:** Setting this property to anything but `null` will automatically
63+
* make the {@link module:ui/inputtext/inputtextview~InputTextView#hasError `hasError`}
64+
* of the {@link #inputView} `true`.
65+
*
66+
* **Note:** Typing in the {@link #inputView} which fires the
67+
* {@link module:ui/inputtext/inputtextview~InputTextView#event:input `input` event}
68+
* resets this property back to `null`, indicating that the input field can be re–validated.
69+
*
70+
* @observable
71+
* @member {String|null} #errorText
72+
*/
73+
this.set( 'errorText', null );
74+
5675
/**
5776
* The label view.
5877
*
5978
* @member {module:ui/label/labelview~LabelView} #labelView
6079
*/
61-
this.labelView = this._createLabelView( id );
80+
this.labelView = this._createLabelView( inputUid );
6281

6382
/**
6483
* The input view.
6584
*
66-
* @member {module:ui/view~View} #inputView
85+
* @member {module:ui/inputtext/inputtextview~InputTextView} #inputView
6786
*/
68-
this.inputView = this._createInputView( InputView, id );
87+
this.inputView = this._createInputView( InputView, inputUid, errorUid );
88+
89+
/**
90+
* The error view for the {@link #inputView}.
91+
*
92+
* @member {module:ui/view~View} #errorView
93+
*/
94+
this.errorView = this._createErrorView( errorUid );
6995

7096
const bind = this.bindTemplate;
7197

@@ -80,7 +106,8 @@ export default class LabeledInputView extends View {
80106
},
81107
children: [
82108
this.labelView,
83-
this.inputView
109+
this.inputView,
110+
this.errorView
84111
]
85112
} );
86113
}
@@ -106,19 +133,59 @@ export default class LabeledInputView extends View {
106133
*
107134
* @private
108135
* @param {Function} InputView Input view constructor.
109-
* @param {String} id Unique id to set as inputView#id attribute.
136+
* @param {String} inputUid Unique id to set as inputView#id attribute.
137+
* @param {String} errorUid Unique id of the error for the input's `aria-describedby` attribute.
110138
* @returns {module:ui/inputtext/inputtextview~InputTextView}
111139
*/
112-
_createInputView( InputView, id ) {
113-
const inputView = new InputView( this.locale );
140+
_createInputView( InputView, inputUid, errorUid ) {
141+
const inputView = new InputView( this.locale, errorUid );
114142

115-
inputView.id = id;
143+
inputView.id = inputUid;
144+
inputView.ariaDesribedById = errorUid;
116145
inputView.bind( 'value' ).to( this );
117146
inputView.bind( 'isReadOnly' ).to( this );
147+
inputView.bind( 'hasError' ).to( this, 'errorText', value => !!value );
148+
149+
inputView.on( 'input', () => {
150+
// UX: Make the error text disappear and disable the error indicator as the user
151+
// starts fixing the errors.
152+
this.errorText = null;
153+
} );
118154

119155
return inputView;
120156
}
121157

158+
/**
159+
* Creates the error view instance.
160+
*
161+
* @private
162+
* @param {String} errorUid Unique id of the error, shared with the input's `aria-describedby` attribute.
163+
* @returns {module:ui/view~View}
164+
*/
165+
_createErrorView( errorUid ) {
166+
const errorView = new View( this.locale );
167+
const bind = this.bindTemplate;
168+
169+
errorView.setTemplate( {
170+
tag: 'div',
171+
attributes: {
172+
class: [
173+
'ck',
174+
'ck-labeled-input__error',
175+
bind.if( 'errorText', 'ck-hidden', value => !value )
176+
],
177+
id: errorUid
178+
},
179+
children: [
180+
{
181+
text: bind.to( 'errorText' )
182+
}
183+
]
184+
} );
185+
186+
return errorView;
187+
}
188+
122189
/**
123190
* Moves the focus to the input and selects the value.
124191
*/

tests/inputtext/inputtextview.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
* For licensing, see LICENSE.md.
44
*/
55

6+
/* global Event */
7+
68
import InputTextView from '../../src/inputtext/inputtextview';
79

810
describe( 'InputTextView', () => {
9-
let view;
11+
let view, ariaDesribedById;
1012

1113
beforeEach( () => {
14+
ariaDesribedById = 'ck-error-1234567890';
1215
view = new InputTextView();
1316

1417
view.render();
@@ -98,6 +101,47 @@ describe( 'InputTextView', () => {
98101
expect( view.element.readOnly ).to.true;
99102
} );
100103
} );
104+
105+
describe( 'class', () => {
106+
it( 'should react on view#hasErrors', () => {
107+
expect( view.element.classList.contains( 'ck-error' ) ).to.be.false;
108+
109+
view.hasError = true;
110+
111+
expect( view.element.classList.contains( 'ck-error' ) ).to.be.true;
112+
} );
113+
} );
114+
115+
describe( 'aria-invalid', () => {
116+
it( 'should react on view#hasError', () => {
117+
expect( view.element.getAttribute( 'aria-invalid' ) ).to.be.null;
118+
119+
view.hasError = true;
120+
121+
expect( view.element.getAttribute( 'aria-invalid' ) ).to.equal( 'true' );
122+
} );
123+
} );
124+
125+
describe( 'aria-describedby', () => {
126+
it( 'should react on view#hasError', () => {
127+
expect( view.element.getAttribute( 'aria-describedby' ) ).to.be.null;
128+
129+
view.ariaDesribedById = ariaDesribedById;
130+
131+
expect( view.element.getAttribute( 'aria-describedby' ) ).to.equal( ariaDesribedById );
132+
} );
133+
} );
134+
135+
describe( 'input event', () => {
136+
it( 'triggers view#input', () => {
137+
const spy = sinon.spy();
138+
139+
view.on( 'input', spy );
140+
141+
view.element.dispatchEvent( new Event( 'input' ) );
142+
sinon.assert.calledOnce( spy );
143+
} );
144+
} );
101145
} );
102146

103147
describe( 'select()', () => {

tests/labeledinput/labeledinputview.js

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* For licensing, see LICENSE.md.
44
*/
55

6+
import View from '../../src/view';
67
import LabeledInputView from '../../src/labeledinput/labeledinputview';
78
import InputView from '../../src/inputtext/inputtextview';
89
import LabelView from '../../src/label/labelview';
@@ -23,6 +24,10 @@ describe( 'LabeledInputView', () => {
2324
expect( view.locale ).to.deep.equal( locale );
2425
} );
2526

27+
it( 'should set view#errorText', () => {
28+
expect( view.errorText ).to.be.null;
29+
} );
30+
2631
it( 'should create view#inputView', () => {
2732
expect( view.inputView ).to.instanceOf( InputView );
2833
} );
@@ -31,8 +36,20 @@ describe( 'LabeledInputView', () => {
3136
expect( view.labelView ).to.instanceOf( LabelView );
3237
} );
3338

34-
it( 'should pair inputView and labelView by unique id', () => {
35-
expect( view.labelView.for ).to.equal( view.inputView.id ).to.ok;
39+
it( 'should create view#errorView', () => {
40+
expect( view.errorView ).to.instanceOf( View );
41+
42+
expect( view.errorView.element.tagName ).to.equal( 'DIV' );
43+
expect( view.errorView.element.classList.contains( 'ck' ) ).to.be.true;
44+
expect( view.errorView.element.classList.contains( 'ck-labeled-input__error' ) ).to.be.true;
45+
} );
46+
47+
it( 'should pair #inputView and #labelView by unique id', () => {
48+
expect( view.labelView.for ).to.equal( view.inputView.id );
49+
} );
50+
51+
it( 'should pair #inputView and #errorView by unique id', () => {
52+
expect( view.inputView.ariaDesribedById ).to.equal( view.errorView.element.id );
3653
} );
3754
} );
3855

@@ -50,6 +67,10 @@ describe( 'LabeledInputView', () => {
5067
expect( view.template.children[ 1 ] ).to.equal( view.inputView );
5168
} );
5269

70+
it( 'should have the error container', () => {
71+
expect( view.template.children[ 2 ] ).to.equal( view.errorView );
72+
} );
73+
5374
describe( 'DOM bindings', () => {
5475
describe( 'class', () => {
5576
it( 'should react on view#isReadOnly', () => {
@@ -60,6 +81,20 @@ describe( 'LabeledInputView', () => {
6081
expect( view.element.classList.contains( 'ck-disabled' ) ).to.be.true;
6182
} );
6283
} );
84+
85+
describe( 'error container', () => {
86+
it( 'should react on view#errorText', () => {
87+
const errorContainer = view.element.lastChild;
88+
89+
view.errorText = '';
90+
expect( errorContainer.classList.contains( 'ck-hidden' ) ).to.be.true;
91+
expect( errorContainer.innerHTML ).to.equal( '' );
92+
93+
view.errorText = 'foo';
94+
expect( errorContainer.classList.contains( 'ck-hidden' ) ).to.be.false;
95+
expect( errorContainer.innerHTML ).to.equal( 'foo' );
96+
} );
97+
} );
6398
} );
6499
} );
65100

@@ -79,11 +114,26 @@ describe( 'LabeledInputView', () => {
79114
it( 'should bind view#isreadOnly to view.inputView#isReadOnly', () => {
80115
view.isReadOnly = false;
81116

82-
expect( view.inputView.isReadOnly ).to.false;
117+
expect( view.inputView.isReadOnly ).to.be.false;
83118

84119
view.isReadOnly = true;
85120

86-
expect( view.inputView.isReadOnly ).to.true;
121+
expect( view.inputView.isReadOnly ).to.be.true;
122+
} );
123+
124+
it( 'should bind view#errorText to view.inputView#hasError', () => {
125+
view.errorText = '';
126+
expect( view.inputView.hasError ).to.be.false;
127+
128+
view.errorText = 'foo';
129+
expect( view.inputView.hasError ).to.be.true;
130+
} );
131+
132+
it( 'should clear view#errorText upon view.inputView#input', () => {
133+
view.errorText = 'foo';
134+
135+
view.inputView.fire( 'input' );
136+
expect( view.errorText ).to.be.null;
87137
} );
88138
} );
89139

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
* Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
3+
* For licensing, see LICENSE.md.
4+
*/
5+
6+
/*
7+
* Note: This file should contain the wireframe styles only. But since there are no such styles,
8+
* it acts as a message to the builder telling that it should look for the corresponding styles
9+
* **in the theme** when compiling the editor.
10+
*/

0 commit comments

Comments
 (0)