It's been nearly two months since I've written about JavaScript and
now is the time to follow up on the
previous part
of this
tutorial. Since
then, there have been some changes to the Closure library, nothing
major, mostly improvements to the documentation and bug fixes.
Sliders
Adding a Slider is unsurprisingly as easy to do as any other
components. Contrary to those we already used though, it doesn't come
with a nice interface with pre-made CSS and images. Closure let you
decide how your sliders will look like, which is comprehensible as this
control is really versatile and it would be hard to come up with a good
generic design for it. A slider element is composed of two DIVs, one
representing the slider itself, the other being the thumb. Both have a
class name to let you control their appearance. For the slider element
this name is composed of a prefix found in the Slider's
CSS_CLASS_PREFIX static property followed by either of
"-horizontal" or "-vertical" depending on the orientation. The thumb has
only a single class name determined by the THUMB_CSS_CLASS
property, note that as the thumb is contained in the slider element,
it can be styled depending on orientation too.
There's some rules to obey while styling a slider:
- a slider cannot be inlined, but you can use inline-block;
- a slider cannot be floated;
- the thumb position style must be set to absolute or relative.
Note that it's preferable to make both elements hide their overflowing
contents to prevent scrolling bars from appearing.
Using Sliders
As sliders cannot be floated, we have a problem with the existing
code. If you remember the way tasks were constructed in the previous
tutorial, the action buttons were added to the summary DIV directly with
their float styles set to right. This isn't the correct manner of doing
things anyway, so we'll fix that first. We'll group the task controls
into a single floating element.
this.summaryControlsDiv = goog.dom.createDom('div', { 'class': 'controls' });
this.summaryDiv = goog.dom.createDom('div', { 'class': 'summary' }, this.summary, this.summaryControlsDiv);
This break some ugly part of our code, the makeActionButton
function is being passed the task DIV and renders the button being
created by selecting the appropriate child node. We'll just do a quick
fix and correct this issue in the next section.
button.render(element.childNodes[0].childNodes[1]);
Another more subtle change we need to make is in makeButtons, we
must change the order in which the action buttons are created. We must
also change some styles and add an entry for the new CSS class.
.task .summary .controls { float: right; }
.task .description { background: #eee; border-top: solid 1px #333; }
.taskButton { margin-top: -6px; margin-left: 7px; }
.editorButton { margin: 0.2em 0.3em 0.5em; }
The following code create a slider, we'll stop event propagation the
same way it's done for tasks' action buttons.
mu.tutorial.tasks.Task.prototype.makeSlider = function(task, element) {
if (task.parent.id == 'taskList') {
var slider = new goog.ui.Slider;
slider.createDom();
slider.render(element.childNodes[0].childNodes[1]);
slider.addEventListener(
goog.ui.Component.EventType.CHANGE,
function() { });
goog.events.listen(
slider.getContentElement(),
goog.events.EventType.CLICK,
function(e) { e.stopPropagation(); });
}
}
We need to call this function from makeDom just before creating
the task buttons. To make the page actually show the sliders, we'll add
the styles below.
.goog-slider-horizontal, .goog-slider-thumb {
overflow: hidden;
height: 20px;
}
.goog-slider-horizontal {
background-color: #ccc;
display: inline-block;
position: relative;
width: 200px;
}
.goog-slider-thumb {
background-color: #777;
position: absolute;
width: 20px;
}
Cleaning Up
Earlier, we talked about how bad it was for makeActionButton to
have to know where to render the button. We'll take the time to clean
that up. If we look back at the code, one thing is obvious, we pass lots
of arguments around. That make the code overly functional and this isn't
a good approach here. Another thing is that we carefully initialized
prototype members for every elements contained in a task and we end up
not using them.
For this section I'll skip the code, so use your imagination. First
thing to do is to remove the element argument from functions having
it. For the controls, we'll use the object's properties,
summaryControlsDiv for action buttons and sliders, and
editorContainer for editor buttons. Finally, we need to replace
the taskDiv local variable in makeDom by a property that
we'll name element. We can't use it directly in our callback
function as it is a closure and this doesn't point to the task
object. We can work around that simply by using a temporary variable,
we'll rewrite that code later anyway.
Making the Sliders Do Something
To put the sliders to good use, we'll make them control the priority of
a task. We'll first make the tasks display their priorities, to be able to
test things manually.
this.priorityDiv = goog.dom.createDom('div', { 'class': 'priority' }, this.priority.toString());
this.summaryDiv = goog.dom.createDom('div', { 'class': 'summary' },
this.summary,
this.summaryControlsDiv,
this.priorityDiv);
We'll also have to add styling for the new priority DIV, it must float
to the right and have some padding on the right side. Now, lets add two
things to the makeSlider function. Firstly, setting the slider
value to the priority of the task currently being created. Secondly,
replacing our TODO comment by changing the priority property as well as
the content of the priority DIV.
slider.setValue(this.priority);
var task = this;
slider.addEventListener(
goog.ui.Component.EventType.CHANGE,
function() {
task.priority = slider.getValue();
task.priorityDiv.innerHTML = task.priority;
});
There's a small bug with this code, can you find it?
If you delete or mark a task as done, then undo that action, you'll see
that the resurrected tasks' slider thumb is set at its lowest value. If
you insert an alert to display the sliders' value when the task is
recreated, you'll see that it's value is correct. Also the slider behave
as if the thumb was at the correct position
strangely,
it can even go into infinite loop. This all points out toward the fact
that when we recreate our tasks, they're being added to an invisible
element. We'll reorganize our code in the next section to circumvent
this issue.
Sorting Tasks
The sliders are now working (with some issues) that's good but it
doesn't add much to our little application feature-wise. The obvious use
of tasks priority would be to sort them. We'll need to do some major
modification to our code to do this though. Currently the tasks add
themselves to their containers and appear in whatever order they're
being appended. We'll need to create a new object to represent task
lists. I won't be showing all the changes made, just the most important
ones, you can always refer to the
final code
if you need more details. First, we'll need to provide a new class name
for our TaskList prototype.
goog.provide('mu.tutorial.tasks.TaskList');
mu.tutorial.tasks.TaskList = function(name, data, container) {
this.name = name;
this.container = container;
this.tasks = [];
for (var i = 0; i < data.length; i++) {
this.tasks.push(new mu.tutorial.tasks.Task(data[i], this));
}
this.sort()
this.render();
}
It's followed by its constructor which creates, sorts and renders the
given tasks, this greatly simplify the makeTasks function. With
this modification, we introduced some unwanted duplication of
information, our tasks objects already have a reference to their
containers. We'll just change that reference for another pointed at the
list it's associated to. We'll also change the way we're using the
taskLists namespace variable, it will now contains TaskList
objects. Now lets see how the render function works.
mu.tutorial.tasks.TaskList.prototype.noTasks = function() {
this.container.appendChild(
goog.dom.createDom('h2', { 'class': 'empty' }, 'This list is empty!'));
}
mu.tutorial.tasks.TaskList.prototype.render = function() {
goog.dom.removeChildren(this.container);
if (this.tasks.length == 0)
this.noTasks();
else
for (var i = 0; i < this.tasks.length; i++) {
this.tasks[i].makeDom();
}
}
It's basically the same code that was previously in makeTasks with
the only difference that it clean up its container before adding new
elements. We also moved noTasks into the TaskList object for
convenience.
Next, some new code. We've got an array of tasks and we'll sort it using
the default JavaScript sort function. Google has thought about
adding helper functions for arrays including one for sorting, its
advantage over the default JavaScript one is that it sorts numbers
correctly. Here this sort function wouldn't be helping us as we'll
write our own compare function.
mu.tutorial.tasks.defaultCompare = function(t1, t2) {
return goog.array.defaultCompare(
t2.priority,
t1.priority);
};
mu.tutorial.tasks.TaskList.prototype.sort = function() {
this.tasks.sort(mu.tutorial.tasks.defaultCompare);
}
We'll need to insert and remove tasks from our task lists. As they're
always sorted, the Closure Library has two functions to help us:
binaryInsert and binaryRemove. These allow for fast manipulation of sorted arrays by using a binary search algorithm.
mu.tutorial.tasks.TaskList.prototype.add = function(task) {
task.list = this;
goog.array.binaryInsert(
this.tasks,
task,
mu.tutorial.tasks.defaultCompare);
}
mu.tutorial.tasks.TaskList.prototype.remove = function(task) {
this.container.removeChild(task.element);
goog.array.binaryRemove(
this.tasks,
task,
mu.tutorial.tasks.defaultCompare);
if (this.tasks.length == 0)
this.render();
}
The add method is very simple, it just set the current task list
as the given task parent and insert it into the array. For the
remove method though, it's actually in charge of cleaning up the
element of the task being removed. It also ensures to call noTask
if there's no task left in that task list.
Next, we'll replace the clickActionButton listener code by a call
to a new Task method called moveTo. This method will be in
charge of removing the task from its parent list and inserting it into
the specified one using the above methods.
mu.tutorial.tasks.Task.prototype.moveTo = function(target) {
this.list.remove(this);
mu.tutorial.tasks.taskLists[target].add(this);
}
Yet another change that merit a mention is the switchPanel
function which takes a new argument to know what TaskList to
render. This could be made cleaner, but we'll keep it that way for this
part of the tutorial.
When a user change the priority of a task, we better have them
sorted. We'll create a new Task method that will be called when
someone stop using the slider. It make use of a new property of the
Task object to remember the previous value so as not to reload a
task list if the priority didn't changed.
mu.tutorial.tasks.Task.prototype.reload = function() {
if (this.priority != this.previous_priority) {
this.list.sort();
this.list.render();
this.previous_priority = this.priority;
}
}
But now we have a problem. When shall we call this function? I've
thought about it for some time and came up with a
complex and convoluted solution.
The correct solution, that is to use a timer and accumulate events,
being too much bothersome for the scope of this tutorial, I've decided
to settle on a small hack I've found that should do the job.
By the principles of YAGNI, I
figured out that the sliders are to heavy to handle. Why would we need
four different ways of modifying a task priority? So, from now on, we'll
only listen for MOUSEOUT and KEYUP events.
goog.events.listen(
slider.getContentElement(),
[goog.events.EventType.MOUSEOUT,
goog.events.EventType.KEYUP],
function() { task.reload(); });
There's still a problem with dragging the sliders, if only we could make
that issue disappear.
Invisible Sliders
Sometimes the best style is no styles at all. We must face it, the
sliders are ugly. Making them look awesome can take some time for a
non-designer like me. While thinking about this problem, I've came up
with a solution that would also simplify sliders event handling code. We
could make the sliders invisible and put them on top of the priority
numbers shown. That doesn't actually fix our issue, we only prevent
users from thinking about dragging the slider thumb. This is just a hack
and should only be done if you're really short on time. Talking of time,
this part is much more longer than I expected. So I'll let you
look at
the result
rather than the code as it's a very simple modification, mostly new DOM
elements and CSS.
Less Rendering and More Stability
One last thing, our code is working like a charm and is more than fast
enough for the small data set we're testing it with. But in real world
situations, there's some bottlenecks that could potentially slow our
application down. I'll let the testing for another part, but we'll
remove one of those bottleneck.
The most processor hungry function in our code is certainly the
render one. Presently, we're calling it every time the priority of
a task change. But if the order of tasks don't change, we don't need to
render them again. Moreover, the JavaScript sort function isn't
stable, two tasks having the same priority could be reordered. So, to
kill two birds with one stone a second time, we'll make our own stable
sort function. Well, in fact, we'll just take the code from
goog.array.stableSort and alter it to return true if the array
changed.
mu.tutorial.tasks.TaskList.prototype.sort = function() {
var arr = this.tasks
for (var i = 0; i < arr.length; i++) {
arr[i] = {index: i, value: arr[i]};
}
function stableCompareFn(obj1, obj2) {
return mu.tutorial.tasks.defaultCompare(obj1.value, obj2.value) ||
obj1.index - obj2.index;
};
goog.array.sort(arr, stableCompareFn);
var changed = false;
for (var i = 0; i < arr.length; i++) {
if (i != arr[i].index)
changed = true;
arr[i] = arr[i].value;
}
return changed;
}
Then, it's only a matter of calling render when the sort
call returns true in the reload method.
mu.tutorial.tasks.Task.prototype.reload = function() {
if (this.priority != this.previous_priority) {
if (this.list.sort())
this.list.render();
this.previous_priority = this.priority;
}
}
Phew, that was a big post, I hope you enjoyed it! I'm still not quite
sure about what will be the subject of the next part, if you have any
suggestion, drop by a comment.