OOUI Basics: Part 2

Custom Widgets for a ToDo List App

In the previous part of this tutorial, we walked through how to make a very basic version of a ToDo app using Wikimedia’s OOUI library. Now it’s time to add a way to store and display information from our items.

Displaying Info


Let’s first create a way to view the information we have about our items. We’ll start by adding a simple label to our page:

$( function () {
	const input = new OO.ui.TextInputWidget( {
			placeholder: 'Add a ToDo item'
		} ),
		list = new OO.ui.SelectWidget( {
			classes: [ 'todo-list' ]
		} ),
		info = new OO.ui.LabelWidget( {
			label: 'Information',
			classes: [ 'todo-info' ]
		} );

	// ... code ...

	// Append the app widgets
	$( '.wrapper' ).append(
		input.$element,
		list.$element,
		info.$element
	);
} );

Once again, we’re adding a widget, and appending its $element to the DOM. Now we can use it to display the information stored in our widget. The ToDo items all live inside an OO.ui.SelectWidget which emits a ‘choose’ event when an element is clicked or chosen, with the reference to the chosen item as the parameter. We’ll attach a listener to this event.

$( function () {
	// ... code ...
	list.on( 'choose', function ( item ) {
		info.setLabel( item.getData() );
	} );
	// ... code ...
} );

Now we have a very simple way of presenting the data stored in our item.

This is a good start, but it doesn’t yet seem to be all that helpful, because the data stored in each of our items is the same as its label, and doesn’t quite give us any useful information. Let’s change that now.

Creating a Custom Item Widget


In order to expand the functionality of the OO.ui.OptionWidget so we can store more information, we need to extend it and create our own class.

Create a new file in your assets/ directory, called ToDoItemWidget.js. Reopen your index.html and add it in.

<script src="assets/ToDoItemWidget.js">

Back to ToDoItemWidget.js. I this new file, we'll create our new class:

const ToDoItemWidget = function ( config ) {
	// Configuration object is optional
	config = config || {};

	// Call parent constructor
	ToDoItemWidget.super.call( this, config );
};
// Inheritance
OO.inheritClass( ToDoItemWidget, OO.ui.OptionWidget );

Our new function extends OO.ui.OptionWidget, by declaring OO.inheritClass( ToDoItemWidget, OO.ui.OptionWidget ); and by calling the parent constructor in the new class’ constructor.

Now, change the code to use the new ToDoItemWidget class:

$( function () {
	// ... code ...

	// Respond to 'enter' keypress
	input.on( 'enter', function () {
		// ... code ...

		// Add the item
		list.addItems( [
			new ToDoItemWidget( {
				data: input.getValue(),
				label: input.getValue()
			} )
		] );
	} );

	// ... code ...
} );

You can try out your app again, but nothing should be different just yet. We can now start developing our new class to add the functionality we want it to have.

Adding Functionality


Let’s add a property that stores the creation time of our todo item.

const ToDoItemWidget = function ( config ) {
	config = config || {};

	// Call parent constructor
	ToDoItemWidget.super.call( this, config );

	this.creationTime = config.creationTime;
};

OO.inheritClass( ToDoItemWidget, OO.ui.OptionWidget );

ToDoItemWidget.prototype.getCreationTime = function () {
	return this.creationTime;
};

There are a many different ways to format time in JavaScript. For the purpose of learning how to extend a class with our own methods, we will write our own time formatting method.

Still in the same file, namely ToDoItemWidget.js, just after the getCreationTime() method, define a new method, getPrettyCreationTime(). We'll use this method to make the ToDo item's creation time look just the way we want.

ToDoItemWidget.prototype.getPrettyCreationTime = function () {
	const time = new Date( this.creationTime ),
		hour = time.getHours(),
		minute = time.getMinutes(),
		second = time.getSeconds(),
		monthNames = [
			'Jan',
			'Feb',
			'Mar',
			'Apr',
			'May',
			'Jun',
			'Jul',
			'Aug',
			'Sep',
			'Oct',
			'Nov',
			'Dec'
		];

	let temp = String( ( hour > 12 ) ? hour - 12 : hour ),

	if ( hour === 0 ) {
		temp = '12';
	}
	temp += ( ( minute < 10 ) ? ':0' : ':' ) + minute;
	temp += ( ( second < 10 ) ? ':0' : ':' ) + second;
	temp += ( hour >= 12 ) ? ' P.M.' : ' A.M.';
	return [
		time.getDate(),
		monthNames[ time.getMonth() ],
		time.getFullYear() + ', ',
		temp
	].join( ' ' );
};

Now we can set this when we instantiate the object in our initialization script, and change the info label to display this new property.

$( function () {
	// ... code ...

	input.on( 'enter', function () {
		// ... code ...

		// Add the item
		list.addItems( [
			new ToDoItemWidget( {
				data: input.getValue(),
				label: input.getValue(),
				creationTime: Date.now()
			} )
		] );
	} );

	// ... code ...

	list.on( 'choose', function ( item ) {
		info.setLabel( item.getData() + ' (' +
			item.getPrettyCreationTime() + ')' );
	} );

	// ... code ...
} );

Try it out:

It's working!

But we’re not done.

We’ve already enhanced the items, so let’s add some real functionality in there.

Adding a ‘Delete’ Button to Items


Another of OOUI’s concepts is componentization—the ability to create bigger widgets from smaller ones. This can be pretty powerful and allow for advanced functionality.

We’re going to start small. Let’s add a ‘delete’ button to our list of items. You can read about what OO.ui.ButtonWidget expects in its configuration options in the documentation.

In our ToDoItemWidget.js we’ll add a button:

const ToDoItemWidget = function ( config ) {
	// ... code ...

	this.deleteButton = new OO.ui.ButtonWidget( {
		label: 'Delete'
	} );

	this.$element.append( this.deleteButton.$element );
};

Just like any other widget in OOUI, OO.ui.ButtonWidget has the $element property that contains its jQuery object. We’re attaching that to our own widget.

If you look at your app now, though, you’ll see that the button appears under the label. That’s because we need to add styles. Since we’re building our own widget, let’s do things properly and add a standalone style for it that we can then add and tweak in our CSS rules.

const ToDoItemWidget = function ( config ) {
	// ... code ...

	this.deleteButton = new OO.ui.ButtonWidget( {
		label: 'Delete'
	} );

	this.$element
		.addClass( 'todo-itemWidget' )
		.append( this.deleteButton.$element );
};

And the CSS rules:

.todo-itemWidget {
	display: inline-table;
}

.todo-itemWidget.oo-ui-optionWidget.oo-ui-labelElement >
.oo-ui-labelElement-label {
	display: table-cell;
	width: 100%;
	padding: 0.5em;
}

.todo-itemWidget .oo-ui-buttonWidget {
	display: table-cell;
}

There, that looks better.

Now, let’s add functionality to this button.

Aggregating Events


One of the best things about using an OO.ui.SelectWidget for our list, is that it uses the OO.ui.mixin.GroupElement that allows for really cool operations on a group of items.

One of those operations is an aggregation of events.

In effect, we can have each of our items emit a certain event, and have our list aggregate all of those events and respond whenever any of its items have emitted it. This means our logic can live “up” in the parent widget, consolidating our work with our items.

This means, however, that we will need to enhance our list object. We are going to do exactly what we did for our items (by creating the ToDoItemWidget class) but with a new ToDoListWidget class that extends OO.ui.SelectWidget.

Creating a ToDoListWidget


The new file ToDoListWidget.js:

const ToDoListWidget = function ToDoListWidget( config ) {
	config = config || {};

	// Call parent constructor
	ToDoListWidget.super.call( this, config );
};

OO.inheritClass( ToDoListWidget, OO.ui.SelectWidget );

Attach it to index.html and change the initialization code:

<!-- ... -->
<!-- ToDo app -->
<link rel="stylesheet" href="todo.css">
<script src="assets/ToDoItemWidget.js"></script>
<script src="assets/ToDoListWidget.js"></script>
$( function () {
	// ... code ...
		list = new ToDoListWidget( {
			classes: [ 'todo-list' ]
		} ),
	// ... code ...
} );

Responding to aggregation of events


Now that we have our ToDoListWidgetwe can aggregate its item events.

So how is it actually done? First, let’s have our button emit a “delete” event for our item:

const ToDoItemWidget = function ( config ) {
	// ... code ...

	this.deleteButton.connect( this, {
		click: 'onDeleteButtonClick'
	} );

	// ... code ...
};

// ... code ...

ToDoItemWidget.prototype.onDeleteButtonClick = function () {
	this.emit( 'delete' );
};

Notice that this time, we didn’t use .on( ... ) but rather .connect( this, { ... } ). This is because we are now connecting the object we are currently “in” the context of, to the event. I’ve used “on” before when we were in the general initialization script, and had no context to give the event emitter.

The string ‘onDeleteButtonClick’ refers to the method of the same name. When ‘click’ is emitted from that button, that method is invoked. It, in turn, will emit “delete” event.

Now, we need to make sure that the list is listening to this event from all of its sub-items. We will first aggregate the event and then listen to the aggregated event and respond to it:

const ToDoListWidget = function ToDoListWidget( config ) {
	// ... code ...

	this.aggregate( {
		delete: 'itemDelete'
	} );

	this.connect( this, {
		itemDelete: 'onItemDelete'
	} );
};

OO.inheritClass( ToDoListWidget, OO.ui.SelectWidget );

ToDoListWidget.prototype.onItemDelete = function ( itemWidget ) {
	this.removeItems( [ itemWidget ] );
};

We’ve used this.aggregate() to tell the group which events to listen to in its items, and we’ve used this.connect( this, { ... } ); to connect our own object to the event we aggregated.

Then, the responding method (onItemDelete) removes the item from the list.

You can now add and remove items from your ToDo app, yay!

Now that the app has basic operations, we can call this tutorial over. We hope that you got a good taste as to what OOjs UI is like, and the potential it holds in quickly – but efficiently – developing JavaScript apps.

The complete code


1. Full code for index.html

<!doctype html>
<html>
	<head>
		<meta charset="UTF-8">
		<title>ToDo OOUI</title>
		<meta name="description" content="A demo ToDo app made with OOUI">
		<meta name="viewport" content="width=device-width, initial-scale=1">

		<!-- jQuery -->
		<script src="node_modules/jquery/dist/jquery.min.js"></script>
		<!-- OOjs -->
		<script src="node_modules/oojs/dist/oojs.min.js"></script>
		<!-- OOUI -->
		<script src="node_modules/oojs-ui/dist/oojs-ui.min.js"></script>
		<!-- OOUI theme -->
		<script src="node_modules/oojs-ui/dist/oojs-ui-wikimediaui.min.js"></script>
		<link rel="stylesheet" href="node_modules/oojs-ui/dist/oojs-ui-wikimediaui.min.css">

		<!-- ToDo app custom -->
		<link rel="stylesheet" href="todo.css">
		<script src="assets/ToDoItemWidget.js"></script>
		<script src="assets/ToDoListWidget.js"></script>
		<script src="assets/init.js"></script>
	</head>
	<body>
		<div class="wrapper">
			<h1>Demo ToDo app with OOUI</h1>
		</div>
	</body>
</html>

2. Full code for init.js

$( function () {
	const input = new OO.ui.TextInputWidget( {
			placeholder: 'Add a ToDo item'
		} ),
		list = new ToDoListWidget( {
			classes: [ 'todo-list' ]
		} ),
		info = new OO.ui.LabelWidget( {
			label: 'Information',
			classes: [ 'todo-info' ]
		} );

	// Respond to 'enter' keypress
	input.on( 'enter', function () {
		// Check for duplicates and prevent empty input
		if ( list.findItemFromData( input.getValue() ) ||
				input.getValue() === '' ) {
			input.$element.addClass( 'todo-error' );
			return;
		}
		input.$element.removeClass( 'todo-error' );

		list.on( 'choose', function ( item ) {
			info.setLabel( item.getData() + ' (' +
				item.getPrettyCreationTime() + ')' );
		} );

		// Add the item
		list.addItems( [
			new ToDoItemWidget( {
				data: input.getValue(),
				label: input.getValue(),
				creationTime: Date.now()
			} )
		] );
		input.setValue( '' );
	} );

	// Append the app widgets
	$( '.wrapper' ).append(
		input.$element,
		list.$element,
		info.$element
	);
} );

3. Full code for ToDoItemWidget.js

const ToDoItemWidget = function ( config ) {
	config = config || {};
	ToDoItemWidget.super.call( this, config );

	this.creationTime = config.creationTime;

	this.deleteButton = new OO.ui.ButtonWidget( {
		label: 'Delete'
	} );

	this.$element
		.addClass( 'todo-itemWidget' )
		.append( this.deleteButton.$element );

	this.deleteButton.connect( this, {
		click: 'onDeleteButtonClick'
	} );
};

OO.inheritClass( ToDoItemWidget, OO.ui.OptionWidget );

ToDoItemWidget.prototype.getCreationTime = function () {
	return this.creationTime;
};

ToDoItemWidget.prototype.getPrettyCreationTime = function () {
	const time = new Date( this.creationTime ),
		hour = time.getHours(),
		minute = time.getMinutes(),
		second = time.getSeconds(),
		monthNames = [
			'Jan',
			'Feb',
			'Mar',
			'Apr',
			'May',
			'Jun',
			'Jul',
			'Aug',
			'Sep',
			'Oct',
			'Nov',
			'Dec'
		];

	let temp = String( ( hour > 12 ) ? hour - 12 : hour );

	if ( hour === 0 ) {
		temp = '12';
	}
	temp += ( ( minute < 10 ) ? ':0' : ':' ) + minute;
	temp += ( ( second < 10 ) ? ':0' : ':' ) + second;
	temp += ( hour >= 12 ) ? ' P.M.' : ' A.M.';
	return [
		time.getDate(),
		monthNames[ time.getMonth() ],
		time.getFullYear() + ', ',
		temp
	].join( ' ' );
};

ToDoItemWidget.prototype.onDeleteButtonClick = function () {
	this.emit( 'delete' );
};

4. Full code for ToDoListWidget.js

const ToDoListWidget = function ToDoListWidget( config ) {
	config = config || {};

	// Call parent constructor
	ToDoListWidget.super.call( this, config );

	this.aggregate( {
		delete: 'itemDelete'
	} );

	this.connect( this, {
		itemDelete: 'onItemDelete'
	} );
};

OO.inheritClass( ToDoListWidget, OO.ui.SelectWidget );

ToDoListWidget.prototype.onItemDelete = function ( itemWidget ) {
	this.removeItems( [ itemWidget ] );
};

5. Full code for todo.css

.todo-error input {
	background-color: #ff9696;
}

.wrapper {
	width: 60%;
	margin-left: auto;
	margin-right: auto;
}

.todo-list .oo-ui-optionWidget {
	border-bottom: 1px solid #666;
}

.oo-ui-selectWidget-unpressed .oo-ui-optionWidget-selected {
	background-color: #80ccff;
}

.oo-ui-optionWidget-highlighted {
	background-color: #b9e3ff;
}

.oo-ui-inputWidget {
	margin-bottom: 0.5em;
}

.todo-list .oo-ui-labelElement-label {
	margin-left: 0.25em;
}

.oo-ui-labelElement .oo-ui-optionWidget {
	padding: 0.25em;
}

.todo-itemWidget {
	display: inline-table;
}

.todo-itemWidget.oo-ui-optionWidget.oo-ui-labelElement >
.oo-ui-labelElement-label {
	display: table-cell;
	width: 100%;
	padding: 0.5em;
}

.todo-itemWidget .oo-ui-buttonWidget {
	display: table-cell;
}