瀏覽代碼

Merge pull request #537 from ckeditor/i/6049

Feature: Created the `LabeledView` class (see https://github.com/ckeditor/ckeditor5-table/pull/227).

Also added `id` properties to the `DropdownView` and `LabelView` for compatibility with the `LabeledView`.
Maciej 5 年之前
父節點
當前提交
f5fb660a83

+ 11 - 1
packages/ckeditor5-ui/src/dropdown/dropdownview.js

@@ -131,6 +131,14 @@ export default class DropdownView extends View {
 		this.set( 'class' );
 
 		/**
+		 * (Optional) The `id` attribute of the dropdown (i.e. to pair with a `<label>` element).
+		 *
+		 * @observable
+		 * @member {String} #id
+		 */
+		this.set( 'id' );
+
+		/**
 		 * The position of the panel, relative to the dropdown.
 		 *
 		 * **Note**: When `'auto'`, the panel will use one of the remaining positions to stay
@@ -176,7 +184,9 @@ export default class DropdownView extends View {
 					'ck-dropdown',
 					bind.to( 'class' ),
 					bind.if( 'isEnabled', 'ck-disabled', value => !value )
-				]
+				],
+				id: bind.to( 'id' ),
+				'aria-describedby': bind.to( 'ariaDescribedById' )
 			},
 
 			children: [

+ 3 - 7
packages/ckeditor5-ui/src/editorui/boxed/boxededitoruiview.js

@@ -9,7 +9,6 @@
 
 import EditorUIView from '../../editorui/editoruiview';
 import LabelView from '../../label/labelview';
-import uid from '@ckeditor/ckeditor5-utils/src/uid';
 
 /**
  * The boxed editor UI view class. This class represents an editor interface
@@ -26,8 +25,6 @@ export default class BoxedEditorUIView extends EditorUIView {
 	constructor( locale ) {
 		super( locale );
 
-		const ariaLabelUid = uid();
-
 		/**
 		 * Collection of the child views located in the top (`.ck-editor__top`)
 		 * area of the UI.
@@ -53,7 +50,7 @@ export default class BoxedEditorUIView extends EditorUIView {
 		 * @readonly
 		 * @member {module:ui/view~View} #_voiceLabelView
 		 */
-		this._voiceLabelView = this._createVoiceLabel( ariaLabelUid );
+		this._voiceLabelView = this._createVoiceLabel();
 
 		this.setTemplate( {
 			tag: 'div',
@@ -68,7 +65,7 @@ export default class BoxedEditorUIView extends EditorUIView {
 				role: 'application',
 				dir: locale.uiLanguageDirection,
 				lang: locale.uiLanguage,
-				'aria-labelledby': `ck-editor__aria-label_${ ariaLabelUid }`
+				'aria-labelledby': this._voiceLabelView.id
 			},
 
 			children: [
@@ -106,7 +103,7 @@ export default class BoxedEditorUIView extends EditorUIView {
 	 * @private
 	 * @returns {module:ui/label/labelview~LabelView}
 	 */
-	_createVoiceLabel( ariaLabelUid ) {
+	_createVoiceLabel() {
 		const t = this.t;
 		const voiceLabel = new LabelView();
 
@@ -114,7 +111,6 @@ export default class BoxedEditorUIView extends EditorUIView {
 
 		voiceLabel.extendTemplate( {
 			attributes: {
-				id: `ck-editor__aria-label_${ ariaLabelUid }`,
 				class: 'ck-voice-label'
 			}
 		} );

+ 10 - 0
packages/ckeditor5-ui/src/label/labelview.js

@@ -8,6 +8,7 @@
  */
 
 import View from '../view';
+import uid from '@ckeditor/ckeditor5-utils/src/uid';
 
 import '../../theme/components/label/label.css';
 
@@ -39,6 +40,14 @@ export default class LabelView extends View {
 		 */
 		this.set( 'for' );
 
+		/**
+		 * An unique id of the label. It can be used by other UI components to reference
+		 * the label, for instance, using the `aria-describedby` DOM attribute.
+		 *
+		 * @member {String} #id
+		 */
+		this.id = `ck-editor__label_${ uid() }`;
+
 		const bind = this.bindTemplate;
 
 		this.setTemplate( {
@@ -48,6 +57,7 @@ export default class LabelView extends View {
 					'ck',
 					'ck-label'
 				],
+				id: this.id,
 				for: bind.to( 'for' )
 			},
 			children: [

+ 241 - 0
packages/ckeditor5-ui/src/labeledview/labeledview.js

@@ -0,0 +1,241 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module ui/labeledview/labeledview
+ */
+
+import View from '../view';
+import uid from '@ckeditor/ckeditor5-utils/src/uid';
+import LabelView from '../label/labelview';
+import '../../theme/components/labeledview/labeledview.css';
+
+/**
+ * The labeled view class. It can be used to enhance any view with the following features:
+ *
+ * * a label,
+ * * (optional) an error message,
+ * * (optional) an info (status) text,
+ *
+ * all bound logically by proper DOM attributes for UX and accessibility.  It also provides an interface
+ * (e.g. observable properties) that allows controlling those additional features.
+ *
+ * The constructor of this class requires a callback that returns a view to be labeled. The callback
+ * is called with unique ids that allow binding of DOM properties:
+ *
+ *		const labeledInputView = new LabeledView( locale, ( labeledView, viewUid, statusUid ) => {
+ *			const inputView = new InputTextView( labeledView.locale );
+ *
+ *			inputView.set( {
+ *				id: viewUid,
+ *				ariaDescribedById: statusUid
+ *			} );
+ *
+ *			inputView.bind( 'isReadOnly' ).to( labeledView, 'isEnabled', value => !value );
+ *			inputView.bind( 'hasError' ).to( labeledView, 'errorText', value => !!value );
+ *
+ *			return inputView;
+ *		} );
+ *
+ *		labeledInputView.label = 'User name';
+ *		labeledInputView.infoText = 'Full name like for instance, John Doe.';
+ *		labeledInputView.render();
+ *
+ *		document.body.append( labeledInputView.element );
+ *
+ * See {@link module:ui/labeledview/utils} to discover ready–to–use labeled input helpers for common
+ * UI components.
+ *
+ * @extends module:ui/view~View
+ */
+export default class LabeledView extends View {
+	/**
+	 * Creates an instance of the labeled view class using a provided creator function
+	 * that provides the view to be labeled.
+	 *
+	 * @param {module:utils/locale~Locale} locale The locale instance.
+	 * @param {Function} viewCreator A function that returns a {@link module:ui/view~View}
+	 * that will be labeled. The following arguments are passed to the creator function:
+	 *
+	 * * an instance of the `LabeledView` to allow binding observable properties,
+	 * * an UID string that connects the {@link #labelView label} and the labeled view in DOM,
+	 * * an UID string that connects the {@link #statusView status} and the labeled view in DOM.
+	 */
+	constructor( locale, viewCreator ) {
+		super( locale );
+
+		const viewUid = `ck-labeled-view-${ uid() }`;
+		const statusUid = `ck-labeled-view-status-${ uid() }`;
+
+		/**
+		 * The view that gets labeled.
+		 *
+		 * @member {module:ui/view~View} #view
+		 */
+		this.view = viewCreator( this, viewUid, statusUid );
+
+		/**
+		 * The text of the label.
+		 *
+		 * @observable
+		 * @member {String} #label
+		 */
+		this.set( 'label' );
+
+		/**
+		 * Controls whether the component is in read-only mode.
+		 *
+		 * @observable
+		 * @member {Boolean} #isEnabled
+		 */
+		this.set( 'isEnabled', true );
+
+		/**
+		 * The validation error text. When set, it will be displayed
+		 * next to the {@link #view} 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 `hasError` of the {@link #view} `true`.
+		 *
+		 * @observable
+		 * @member {String|null} #errorText
+		 */
+		this.set( 'errorText', null );
+
+		/**
+		 * The additional information text displayed next to the {@link #view} which can
+		 * be used to inform the user about its purpose, provide help or hints.
+		 *
+		 * Set it to `null` to hide the message.
+		 *
+		 * **Note:** This text will be displayed in the same place as {@link #errorText} but the
+		 * latter always takes precedence: if the {@link #errorText} is set, it replaces
+		 * {@link #infoText}.
+		 *
+		 * @observable
+		 * @member {String|null} #infoText
+		 */
+		this.set( 'infoText', null );
+
+		/**
+		 * (Optional) The additional CSS class set on the dropdown {@link #element}.
+		 *
+		 * @observable
+		 * @member {String} #class
+		 */
+		this.set( 'class' );
+
+		/**
+		 * The label view instance that describes the entire view.
+		 *
+		 * @member {module:ui/label/labelview~LabelView} #labelView
+		 */
+		this.labelView = this._createLabelView( viewUid );
+
+		/**
+		 * The status view for the {@link #view}. It displays {@link #errorText} and
+		 * {@link #infoText}.
+		 *
+		 * @member {module:ui/view~View} #statusView
+		 */
+		this.statusView = this._createStatusView( statusUid );
+
+		/**
+		 * The combined status text made of {@link #errorText} and {@link #infoText}.
+		 * Note that when present, {@link #errorText} always takes precedence in the
+		 * status.
+		 *
+		 * @see #errorText
+		 * @see #infoText
+		 * @see #statusView
+		 * @private
+		 * @observable
+		 * @member {String|null} #_statusText
+		 */
+		this.bind( '_statusText' ).to(
+			this, 'errorText',
+			this, 'infoText',
+			( errorText, infoText ) => errorText || infoText
+		);
+
+		const bind = this.bindTemplate;
+
+		this.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-labeled-view',
+					bind.to( 'class' ),
+					bind.if( 'isEnabled', 'ck-disabled', value => !value )
+				]
+			},
+			children: [
+				this.labelView,
+				this.view,
+				this.statusView
+			]
+		} );
+	}
+
+	/**
+	 * Creates label view class instance and bind with view.
+	 *
+	 * @private
+	 * @param {String} id Unique id to set as labelView#for attribute.
+	 * @returns {module:ui/label/labelview~LabelView}
+	 */
+	_createLabelView( id ) {
+		const labelView = new LabelView( this.locale );
+
+		labelView.for = id;
+		labelView.bind( 'text' ).to( this, 'label' );
+
+		return labelView;
+	}
+
+	/**
+	 * Creates the status view instance. It displays {@link #errorText} and {@link #infoText}
+	 * next to the {@link #view}. See {@link #_statusText}.
+	 *
+	 * @private
+	 * @param {String} statusUid Unique id of the status, shared with the {@link #view view's}
+	 * `aria-describedby` attribute.
+	 * @returns {module:ui/view~View}
+	 */
+	_createStatusView( statusUid ) {
+		const statusView = new View( this.locale );
+		const bind = this.bindTemplate;
+
+		statusView.setTemplate( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-labeled-view__status',
+					bind.if( 'errorText', 'ck-labeled-view__status_error' ),
+					bind.if( '_statusText', 'ck-hidden', value => !value )
+				],
+				id: statusUid,
+				role: bind.if( 'errorText', 'alert' )
+			},
+			children: [
+				{
+					text: bind.to( '_statusText' )
+				}
+			]
+		} );
+
+		return statusView;
+	}
+
+	/**
+	 * Focuses the {@link #view}.
+	 */
+	focus() {
+		this.view.focus();
+	}
+}

+ 92 - 0
packages/ckeditor5-ui/src/labeledview/utils.js

@@ -0,0 +1,92 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module ui/labeledview/utils
+ */
+
+import InputTextView from '../inputtext/inputtextview';
+import { createDropdown } from '../dropdown/utils';
+
+/**
+ * A helper for creating labeled inputs.
+ *
+ * It creates an instance of a {@link module:ui/inputtext/inputtextview~InputTextView input text} that is
+ * logically related to a {@link module:ui/labeledview/labeledview~LabeledView labeled view} in DOM.
+ *
+ * The helper does the following:
+ *
+ * * It sets input's `id` and `ariaDescribedById` attributes.
+ * * It binds input's `isReadOnly` to the labeled view.
+ * * It binds input's `hasError` to the labeled view.
+ * * It enables a logic that cleans up the error when user starts typing in the input..
+ *
+ * Usage:
+ *
+ *		const labeledInputView = new LabeledView( locale, createLabeledDropdown );
+ *		console.log( labeledInputView.view ); // An input instance.
+ *
+ * @param {module:ui/labeledview/labeledview~LabeledView} labeledView The instance of the labeled view.
+ * @param {String} viewUid An UID string that allows DOM logical connection between the
+ * {@link module:ui/labeledview/labeledview~LabeledView#labelView labeled view's label} and the input.
+ * @param {String} statusUid An UID string that allows DOM logical connection between the
+ * {@link module:ui/labeledview/labeledview~LabeledView#statusView labeled view's status} and the input.
+ * @returns {module:ui/inputtext/inputtextview~InputTextView} The input text view instance.
+ */
+export function createLabeledInputText( labeledView, viewUid, statusUid ) {
+	const inputView = new InputTextView( labeledView.locale );
+
+	inputView.set( {
+		id: viewUid,
+		ariaDescribedById: statusUid
+	} );
+
+	inputView.bind( 'isReadOnly' ).to( labeledView, 'isEnabled', value => !value );
+	inputView.bind( 'hasError' ).to( labeledView, 'errorText', value => !!value );
+
+	inputView.on( 'input', () => {
+		// UX: Make the error text disappear and disable the error indicator as the user
+		// starts fixing the errors.
+		labeledView.errorText = null;
+	} );
+
+	return inputView;
+}
+
+/**
+ * A helper for creating labeled dropdowns.
+ *
+ * It creates an instance of a {@link module:ui/dropdown/dropdownview~DropdownView dropdown} that is
+ * logically related to a {@link module:ui/labeledview/labeledview~LabeledView labeled view}.
+ *
+ * The helper does the following:
+ *
+ * * It sets dropdown's `id` and `ariaDescribedById` attributes.
+ * * It binds input's `isEnabled` to the labeled view.
+ *
+ * Usage:
+ *
+ *		const labeledInputView = new LabeledView( locale, createLabeledDropdown );
+ *		console.log( labeledInputView.view ); // A dropdown instance.
+ *
+ * @param {module:ui/labeledview/labeledview~LabeledView} labeledView The instance of the labeled view.
+ * @param {String} viewUid An UID string that allows DOM logical connection between the
+ * {@link module:ui/labeledview/labeledview~LabeledView#labelView labeled view label} and the dropdown.
+ * @param {String} statusUid An UID string that allows DOM logical connection between the
+ * {@link module:ui/labeledview/labeledview~LabeledView#statusView labeled view status} and the dropdown.
+ * @returns {module:ui/dropdown/dropdownview~DropdownView} The dropdown view instance.
+ */
+export function createLabeledDropdown( labeledView, viewUid, statusUid ) {
+	const dropdownView = createDropdown( labeledView.locale );
+
+	dropdownView.set( {
+		id: viewUid,
+		ariaDescribedById: statusUid
+	} );
+
+	dropdownView.bind( 'isEnabled' ).to( labeledView );
+
+	return dropdownView;
+}

+ 15 - 0
packages/ckeditor5-ui/tests/dropdown/dropdownview.js

@@ -60,6 +60,14 @@ describe( 'DropdownView', () => {
 			expect( view.isEnabled ).to.be.true;
 		} );
 
+		it( 'sets view#class', () => {
+			expect( view.class ).to.be.undefined;
+		} );
+
+		it( 'sets view#id', () => {
+			expect( view.id ).to.be.undefined;
+		} );
+
 		it( 'sets view#panelPosition "auto"', () => {
 			expect( view.panelPosition ).to.equal( 'auto' );
 		} );
@@ -180,6 +188,13 @@ describe( 'DropdownView', () => {
 						expect( view.element.classList.contains( 'custom-css-class' ) ).to.be.true;
 					} );
 				} );
+
+				describe( 'id', () => {
+					it( 'reacts on view#id', () => {
+						view.id = 'foo';
+						expect( view.element.id ).to.equal( 'foo' );
+					} );
+				} );
 			} );
 		} );
 	} );

+ 1 - 1
packages/ckeditor5-ui/tests/editorui/boxed/boxededitoruiview.js

@@ -34,7 +34,7 @@ describe( 'BoxedEditorUIView', () => {
 			expect( view.element.getAttribute( 'dir' ) ).to.equal( 'ltr' );
 			expect( element.attributes[ 'aria-labelledby' ].value )
 				.to.equal( view.element.firstChild.id )
-				.to.match( /^ck-editor__aria-label_\w+$/ );
+				.to.match( /^ck-editor__label_\w+$/ );
 		} );
 
 		it( 'bootstraps the voice label from template', () => {

+ 9 - 0
packages/ckeditor5-ui/tests/label/labelview.js

@@ -20,6 +20,15 @@ describe( 'LabelView', () => {
 			expect( view.element.classList.contains( 'ck' ) ).to.be.true;
 			expect( view.element.classList.contains( 'ck-label' ) ).to.be.true;
 		} );
+
+		it( 'should define the #id', () => {
+			expect( view.id ).to.match( /^ck-editor__label_.+/ );
+		} );
+
+		it( 'should assign an #id to the #element attribute', () => {
+			expect( view.element.id ).to.equal( view.id );
+			expect( view.element.id ).to.match( /^ck-editor__label_.+/ );
+		} );
 	} );
 
 	describe( 'DOM bindings', () => {

+ 166 - 0
packages/ckeditor5-ui/tests/labeledview/labeledview.js

@@ -0,0 +1,166 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import View from '../../src/view';
+import LabeledView from '../../src/labeledview/labeledview';
+import LabelView from '../../src/label/labelview';
+
+describe( 'LabeledView', () => {
+	const locale = {};
+
+	let labeledView, view;
+
+	beforeEach( () => {
+		labeledView = new LabeledView( locale, ( labeledView, viewUid, statusUid ) => {
+			view = new View( locale );
+			view.setTemplate( { tag: 'div' } );
+			view.focus = () => {};
+			view.viewUid = viewUid;
+			view.statusUid = statusUid;
+
+			return view;
+		} );
+
+		labeledView.render();
+	} );
+
+	afterEach( () => {
+		labeledView.destroy();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should set labeledView#locale', () => {
+			expect( labeledView.locale ).to.deep.equal( locale );
+		} );
+
+		it( 'should set labeledView#view', () => {
+			expect( labeledView.view ).to.equal( view );
+		} );
+
+		it( 'should set labeledView#label', () => {
+			expect( labeledView.label ).to.be.undefined;
+		} );
+
+		it( 'should set labeledView#isEnabled', () => {
+			expect( labeledView.isEnabled ).to.be.true;
+		} );
+
+		it( 'should set labeledView#errorText', () => {
+			expect( labeledView.errorText ).to.be.null;
+		} );
+
+		it( 'should set labeledView#infoText', () => {
+			expect( labeledView.infoText ).to.be.null;
+		} );
+
+		it( 'should set labeledView#class', () => {
+			expect( labeledView.class ).to.be.undefined;
+		} );
+
+		it( 'should create labeledView#labelView', () => {
+			expect( labeledView.labelView ).to.instanceOf( LabelView );
+		} );
+
+		it( 'should create labeledView#statusView', () => {
+			expect( labeledView.statusView ).to.instanceOf( View );
+
+			expect( labeledView.statusView.element.tagName ).to.equal( 'DIV' );
+			expect( labeledView.statusView.element.classList.contains( 'ck' ) ).to.be.true;
+			expect( labeledView.statusView.element.classList.contains( 'ck-labeled-view__status' ) ).to.be.true;
+		} );
+
+		it( 'should allow pairing #view and #labelView by unique id', () => {
+			expect( labeledView.labelView.for ).to.equal( view.viewUid );
+		} );
+
+		it( 'should allow pairing #view and #statusView by unique id', () => {
+			expect( view.statusUid ).to.equal( labeledView.statusView.element.id );
+		} );
+	} );
+
+	describe( 'template', () => {
+		it( 'should have the CSS class', () => {
+			expect( labeledView.element.classList.contains( 'ck' ) ).to.be.true;
+			expect( labeledView.element.classList.contains( 'ck-labeled-view' ) ).to.be.true;
+		} );
+
+		it( 'should have #labeledView', () => {
+			expect( labeledView.template.children[ 0 ] ).to.equal( labeledView.labelView );
+		} );
+
+		it( 'should have #view', () => {
+			expect( labeledView.template.children[ 1 ] ).to.equal( view );
+		} );
+
+		it( 'should have the #statusView container', () => {
+			expect( labeledView.template.children[ 2 ] ).to.equal( labeledView.statusView );
+		} );
+
+		describe( 'DOM bindings', () => {
+			describe( 'class', () => {
+				it( 'should react on labeledView#class', () => {
+					labeledView.class = 'foo';
+					expect( labeledView.element.classList.contains( 'foo' ) ).to.be.true;
+
+					labeledView.class = 'bar';
+					expect( labeledView.element.classList.contains( 'foo' ) ).to.be.false;
+					expect( labeledView.element.classList.contains( 'bar' ) ).to.be.true;
+				} );
+			} );
+
+			describe( 'status container', () => {
+				it( 'should react on labeledView#errorText', () => {
+					const statusElement = labeledView.statusView.element;
+
+					labeledView.errorText = '';
+					expect( statusElement.classList.contains( 'ck-hidden' ) ).to.be.true;
+					expect( statusElement.classList.contains( 'ck-labeled-view__status_error' ) ).to.be.false;
+					expect( statusElement.hasAttribute( 'role' ) ).to.be.false;
+					expect( statusElement.innerHTML ).to.equal( '' );
+
+					labeledView.errorText = 'foo';
+					expect( statusElement.classList.contains( 'ck-hidden' ) ).to.be.false;
+					expect( statusElement.classList.contains( 'ck-labeled-view__status_error' ) ).to.be.true;
+					expect( statusElement.getAttribute( 'role' ) ).to.equal( 'alert' );
+					expect( statusElement.innerHTML ).to.equal( 'foo' );
+				} );
+
+				it( 'should react on labeledView#infoText', () => {
+					const statusElement = labeledView.statusView.element;
+
+					labeledView.infoText = '';
+					expect( statusElement.classList.contains( 'ck-hidden' ) ).to.be.true;
+					expect( statusElement.classList.contains( 'ck-labeled-view__status_error' ) ).to.be.false;
+					expect( statusElement.hasAttribute( 'role' ) ).to.be.false;
+					expect( statusElement.innerHTML ).to.equal( '' );
+
+					labeledView.infoText = 'foo';
+					expect( statusElement.classList.contains( 'ck-hidden' ) ).to.be.false;
+					expect( statusElement.classList.contains( 'ck-labeled-view__status_error' ) ).to.be.false;
+					expect( statusElement.hasAttribute( 'role' ) ).to.be.false;
+					expect( statusElement.innerHTML ).to.equal( 'foo' );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'binding', () => {
+		it( 'should bind labeledView#label to labeledView.labelView#label', () => {
+			labeledView.label = 'Foo bar';
+
+			expect( labeledView.labelView.text ).to.equal( 'Foo bar' );
+		} );
+	} );
+
+	describe( 'focus()', () => {
+		it( 'should focus the #view in DOM', () => {
+			const spy = sinon.spy( view, 'focus' );
+
+			labeledView.focus();
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+} );

+ 108 - 0
packages/ckeditor5-ui/tests/labeledview/utils.js

@@ -0,0 +1,108 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import {
+	createLabeledInputText,
+	createLabeledDropdown
+} from '../../src/labeledview/utils';
+
+import LabeledView from '../../src/labeledview/labeledview';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import InputTextView from '../../src/inputtext/inputtextview';
+import DropdownView from '../../src/dropdown/dropdownview';
+
+describe( 'LabeledView utils', () => {
+	let locale;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		locale = { t: val => val };
+	} );
+
+	describe( 'createLabeledInputText()', () => {
+		let labeledView;
+
+		beforeEach( () => {
+			labeledView = new LabeledView( locale, createLabeledInputText );
+		} );
+
+		afterEach( () => {
+			labeledView.destroy();
+		} );
+
+		it( 'should create an InputTextView instance', () => {
+			expect( labeledView.view ).to.be.instanceOf( InputTextView );
+		} );
+
+		it( 'should pass the Locale to the input', () => {
+			expect( labeledView.view.locale ).to.equal( locale );
+		} );
+
+		it( 'should set input #id and #ariaDescribedById', () => {
+			labeledView.render();
+
+			expect( labeledView.view.id ).to.equal( labeledView.labelView.for );
+			expect( labeledView.view.ariaDescribedById ).to.equal( labeledView.statusView.element.id );
+		} );
+
+		it( 'should bind input\'s #isReadOnly to LabeledView#isEnabled', () => {
+			labeledView.isEnabled = true;
+			expect( labeledView.view.isReadOnly ).to.be.false;
+
+			labeledView.isEnabled = false;
+			expect( labeledView.view.isReadOnly ).to.be.true;
+		} );
+
+		it( 'should bind input\'s #hasError to LabeledView#errorText', () => {
+			labeledView.errorText = 'some error';
+			expect( labeledView.view.hasError ).to.be.true;
+
+			labeledView.errorText = null;
+			expect( labeledView.view.hasError ).to.be.false;
+		} );
+
+		it( 'should clean LabeledView#errorText upon input\'s DOM "update" event', () => {
+			labeledView.errorText = 'some error';
+			labeledView.view.fire( 'input' );
+			expect( labeledView.errorText ).to.be.null;
+		} );
+	} );
+
+	describe( 'createLabeledDropdown', () => {
+		let labeledView;
+
+		beforeEach( () => {
+			labeledView = new LabeledView( locale, createLabeledDropdown );
+		} );
+
+		afterEach( () => {
+			labeledView.destroy();
+		} );
+
+		it( 'should create a DropdownView', () => {
+			expect( labeledView.view ).to.be.instanceOf( DropdownView );
+		} );
+
+		it( 'should pass the Locale to the dropdown', () => {
+			expect( labeledView.view.locale ).to.equal( locale );
+		} );
+
+		it( 'should set dropdown\'s #id and #ariaDescribedById', () => {
+			labeledView.render();
+
+			expect( labeledView.view.id ).to.equal( labeledView.labelView.for );
+			expect( labeledView.view.ariaDescribedById ).to.equal( labeledView.statusView.element.id );
+		} );
+
+		it( 'should bind dropdown\'s #isEnabled to the labeled view', () => {
+			labeledView.isEnabled = true;
+			expect( labeledView.view.isEnabled ).to.be.true;
+
+			labeledView.isEnabled = false;
+			expect( labeledView.view.isEnabled ).to.be.false;
+		} );
+	} );
+} );

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

@@ -0,0 +1,10 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/*
+ * 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.
+ */