Thu, 15 Jan 2015 15:55:04 +0100
Back out 97036ab72558 which inappropriately compared turds to third parties.
1 /* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
3 /* This Source Code Form is subject to the terms of the Mozilla Public
4 * License, v. 2.0. If a copy of the MPL was not distributed with this
5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 "use strict";
8 const Ci = Components.interfaces;
9 const Cu = Components.utils;
11 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
12 Cu.import("resource://gre/modules/devtools/event-emitter.js");
14 this.EXPORTED_SYMBOLS = ["SideMenuWidget"];
16 /**
17 * A simple side menu, with the ability of grouping menu items.
18 *
19 * Note: this widget should be used in tandem with the WidgetMethods in
20 * ViewHelpers.jsm.
21 *
22 * @param nsIDOMNode aNode
23 * The element associated with the widget.
24 * @param Object aOptions
25 * - showArrows: specifies if items should display horizontal arrows.
26 * - showItemCheckboxes: specifies if items should display checkboxes.
27 * - showGroupCheckboxes: specifies if groups should display checkboxes.
28 */
29 this.SideMenuWidget = function SideMenuWidget(aNode, aOptions={}) {
30 this.document = aNode.ownerDocument;
31 this.window = this.document.defaultView;
32 this._parent = aNode;
34 let { showArrows, showItemCheckboxes, showGroupCheckboxes } = aOptions;
35 this._showArrows = showArrows || false;
36 this._showItemCheckboxes = showItemCheckboxes || false;
37 this._showGroupCheckboxes = showGroupCheckboxes || false;
39 // Create an internal scrollbox container.
40 this._list = this.document.createElement("scrollbox");
41 this._list.className = "side-menu-widget-container theme-sidebar";
42 this._list.setAttribute("flex", "1");
43 this._list.setAttribute("orient", "vertical");
44 this._list.setAttribute("with-arrows", this._showArrows);
45 this._list.setAttribute("with-item-checkboxes", this._showItemCheckboxes);
46 this._list.setAttribute("with-group-checkboxes", this._showGroupCheckboxes);
47 this._list.setAttribute("tabindex", "0");
48 this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
49 this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false);
50 this._parent.appendChild(this._list);
52 // Menu items can optionally be grouped.
53 this._groupsByName = new Map(); // Can't use a WeakMap because keys are strings.
54 this._orderedGroupElementsArray = [];
55 this._orderedMenuElementsArray = [];
56 this._itemsByElement = new Map();
58 // This widget emits events that can be handled in a MenuContainer.
59 EventEmitter.decorate(this);
61 // Delegate some of the associated node's methods to satisfy the interface
62 // required by MenuContainer instances.
63 ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
64 ViewHelpers.delegateWidgetEventMethods(this, aNode);
65 };
67 SideMenuWidget.prototype = {
68 /**
69 * Specifies if groups in this container should be sorted.
70 */
71 sortedGroups: true,
73 /**
74 * The comparator used to sort groups.
75 */
76 groupSortPredicate: function(a, b) a.localeCompare(b),
78 /**
79 * Specifies that the container viewport should be "stuck" to the
80 * bottom. That is, the container is automatically scrolled down to
81 * keep appended items visible, but only when the scroll position is
82 * already at the bottom.
83 */
84 autoscrollWithAppendedItems: false,
86 /**
87 * Inserts an item in this container at the specified index, optionally
88 * grouping by name.
89 *
90 * @param number aIndex
91 * The position in the container intended for this item.
92 * @param nsIDOMNode aContents
93 * The node displayed in the container.
94 * @param object aAttachment [optional]
95 * Some attached primitive/object. Custom options supported:
96 * - group: a string specifying the group to place this item into
97 * - checkboxState: the checked state of the checkbox, if shown
98 * - checkboxTooltip: the tooltip text for the checkbox, if shown
99 * @return nsIDOMNode
100 * The element associated with the displayed item.
101 */
102 insertItemAt: function(aIndex, aContents, aAttachment={}) {
103 // Maintaining scroll position at the bottom when a new item is inserted
104 // depends on several factors (the order of testing is important to avoid
105 // needlessly expensive operations that may cause reflows):
106 let maintainScrollAtBottom =
107 // 1. The behavior should be enabled,
108 this.autoscrollWithAppendedItems &&
109 // 2. There shouldn't currently be any selected item in the list.
110 !this._selectedItem &&
111 // 3. The new item should be appended at the end of the list.
112 (aIndex < 0 || aIndex >= this._orderedMenuElementsArray.length) &&
113 // 4. The list should already be scrolled at the bottom.
114 (this._list.scrollTop + this._list.clientHeight >= this._list.scrollHeight);
116 let group = this._getMenuGroupForName(aAttachment.group);
117 let item = this._getMenuItemForGroup(group, aContents, aAttachment);
118 let element = item.insertSelfAt(aIndex);
120 if (maintainScrollAtBottom) {
121 this._list.scrollTop = this._list.scrollHeight;
122 }
124 return element;
125 },
127 /**
128 * Returns the child node in this container situated at the specified index.
129 *
130 * @param number aIndex
131 * The position in the container intended for this item.
132 * @return nsIDOMNode
133 * The element associated with the displayed item.
134 */
135 getItemAtIndex: function(aIndex) {
136 return this._orderedMenuElementsArray[aIndex];
137 },
139 /**
140 * Removes the specified child node from this container.
141 *
142 * @param nsIDOMNode aChild
143 * The element associated with the displayed item.
144 */
145 removeChild: function(aChild) {
146 this._getNodeForContents(aChild).remove();
148 this._orderedMenuElementsArray.splice(
149 this._orderedMenuElementsArray.indexOf(aChild), 1);
151 this._itemsByElement.delete(aChild);
153 if (this._selectedItem == aChild) {
154 this._selectedItem = null;
155 }
156 },
158 /**
159 * Removes all of the child nodes from this container.
160 */
161 removeAllItems: function() {
162 let parent = this._parent;
163 let list = this._list;
165 while (list.hasChildNodes()) {
166 list.firstChild.remove();
167 }
169 this._selectedItem = null;
171 this._groupsByName.clear();
172 this._orderedGroupElementsArray.length = 0;
173 this._orderedMenuElementsArray.length = 0;
174 this._itemsByElement.clear();
175 },
177 /**
178 * Gets the currently selected child node in this container.
179 * @return nsIDOMNode
180 */
181 get selectedItem() {
182 return this._selectedItem;
183 },
185 /**
186 * Sets the currently selected child node in this container.
187 * @param nsIDOMNode aChild
188 */
189 set selectedItem(aChild) {
190 let menuArray = this._orderedMenuElementsArray;
192 if (!aChild) {
193 this._selectedItem = null;
194 }
195 for (let node of menuArray) {
196 if (node == aChild) {
197 this._getNodeForContents(node).classList.add("selected");
198 this._selectedItem = node;
199 } else {
200 this._getNodeForContents(node).classList.remove("selected");
201 }
202 }
203 },
205 /**
206 * Ensures the specified element is visible.
207 *
208 * @param nsIDOMNode aElement
209 * The element to make visible.
210 */
211 ensureElementIsVisible: function(aElement) {
212 if (!aElement) {
213 return;
214 }
216 // Ensure the element is visible but not scrolled horizontally.
217 let boxObject = this._list.boxObject.QueryInterface(Ci.nsIScrollBoxObject);
218 boxObject.ensureElementIsVisible(aElement);
219 boxObject.scrollBy(-this._list.clientWidth, 0);
220 },
222 /**
223 * Shows all the groups, even the ones with no visible children.
224 */
225 showEmptyGroups: function() {
226 for (let group of this._orderedGroupElementsArray) {
227 group.hidden = false;
228 }
229 },
231 /**
232 * Hides all the groups which have no visible children.
233 */
234 hideEmptyGroups: function() {
235 let visibleChildNodes = ".side-menu-widget-item-contents:not([hidden=true])";
237 for (let group of this._orderedGroupElementsArray) {
238 group.hidden = group.querySelectorAll(visibleChildNodes).length == 0;
239 }
240 for (let menuItem of this._orderedMenuElementsArray) {
241 menuItem.parentNode.hidden = menuItem.hidden;
242 }
243 },
245 /**
246 * Adds a new attribute or changes an existing attribute on this container.
247 *
248 * @param string aName
249 * The name of the attribute.
250 * @param string aValue
251 * The desired attribute value.
252 */
253 setAttribute: function(aName, aValue) {
254 this._parent.setAttribute(aName, aValue);
256 if (aName == "emptyText") {
257 this._textWhenEmpty = aValue;
258 }
259 },
261 /**
262 * Removes an attribute on this container.
263 *
264 * @param string aName
265 * The name of the attribute.
266 */
267 removeAttribute: function(aName) {
268 this._parent.removeAttribute(aName);
270 if (aName == "emptyText") {
271 this._removeEmptyText();
272 }
273 },
275 /**
276 * Set the checkbox state for the item associated with the given node.
277 *
278 * @param nsIDOMNode aNode
279 * The dom node for an item we want to check.
280 * @param boolean aCheckState
281 * True to check, false to uncheck.
282 */
283 checkItem: function(aNode, aCheckState) {
284 const widgetItem = this._itemsByElement.get(aNode);
285 if (!widgetItem) {
286 throw new Error("No item for " + aNode);
287 }
288 widgetItem.check(aCheckState);
289 },
291 /**
292 * Sets the text displayed in this container when empty.
293 * @param string aValue
294 */
295 set _textWhenEmpty(aValue) {
296 if (this._emptyTextNode) {
297 this._emptyTextNode.setAttribute("value", aValue);
298 }
299 this._emptyTextValue = aValue;
300 this._showEmptyText();
301 },
303 /**
304 * Creates and appends a label signaling that this container is empty.
305 */
306 _showEmptyText: function() {
307 if (this._emptyTextNode || !this._emptyTextValue) {
308 return;
309 }
310 let label = this.document.createElement("label");
311 label.className = "plain side-menu-widget-empty-text";
312 label.setAttribute("value", this._emptyTextValue);
314 this._parent.insertBefore(label, this._list);
315 this._emptyTextNode = label;
316 },
318 /**
319 * Removes the label representing a notice in this container.
320 */
321 _removeEmptyText: function() {
322 if (!this._emptyTextNode) {
323 return;
324 }
326 this._parent.removeChild(this._emptyTextNode);
327 this._emptyTextNode = null;
328 },
330 /**
331 * Gets a container representing a group for menu items. If the container
332 * is not available yet, it is immediately created.
333 *
334 * @param string aName
335 * The required group name.
336 * @return SideMenuGroup
337 * The newly created group.
338 */
339 _getMenuGroupForName: function(aName) {
340 let cachedGroup = this._groupsByName.get(aName);
341 if (cachedGroup) {
342 return cachedGroup;
343 }
345 let group = new SideMenuGroup(this, aName, {
346 showCheckbox: this._showGroupCheckboxes
347 });
349 this._groupsByName.set(aName, group);
350 group.insertSelfAt(this.sortedGroups ? group.findExpectedIndexForSelf(this.groupSortPredicate) : -1);
352 return group;
353 },
355 /**
356 * Gets a menu item to be displayed inside a group.
357 * @see SideMenuWidget.prototype._getMenuGroupForName
358 *
359 * @param SideMenuGroup aGroup
360 * The group to contain the menu item.
361 * @param nsIDOMNode aContents
362 * The node displayed in the container.
363 * @param object aAttachment [optional]
364 * Some attached primitive/object.
365 */
366 _getMenuItemForGroup: function(aGroup, aContents, aAttachment) {
367 return new SideMenuItem(aGroup, aContents, aAttachment, {
368 showArrow: this._showArrows,
369 showCheckbox: this._showItemCheckboxes
370 });
371 },
373 /**
374 * Returns the .side-menu-widget-item node corresponding to a SideMenuItem.
375 * To optimize the markup, some redundant elemenst are skipped when creating
376 * these child items, in which case we need to be careful on which nodes
377 * .selected class names are added, or which nodes are removed.
378 *
379 * @param nsIDOMNode aChild
380 * An element which is the target node of a SideMenuItem.
381 * @return nsIDOMNode
382 * The wrapper node if there is one, or the same child otherwise.
383 */
384 _getNodeForContents: function(aChild) {
385 if (aChild.hasAttribute("merged-item-contents")) {
386 return aChild;
387 } else {
388 return aChild.parentNode;
389 }
390 },
392 window: null,
393 document: null,
394 _showArrows: false,
395 _showItemCheckboxes: false,
396 _showGroupCheckboxes: false,
397 _parent: null,
398 _list: null,
399 _selectedItem: null,
400 _groupsByName: null,
401 _orderedGroupElementsArray: null,
402 _orderedMenuElementsArray: null,
403 _itemsByElement: null,
404 _emptyTextNode: null,
405 _emptyTextValue: ""
406 };
408 /**
409 * A SideMenuGroup constructor for the BreadcrumbsWidget.
410 * Represents a group which should contain SideMenuItems.
411 *
412 * @param SideMenuWidget aWidget
413 * The widget to contain this menu item.
414 * @param string aName
415 * The string displayed in the container.
416 * @param object aOptions [optional]
417 * An object containing the following properties:
418 * - showCheckbox: specifies if a checkbox should be displayed.
419 */
420 function SideMenuGroup(aWidget, aName, aOptions={}) {
421 this.document = aWidget.document;
422 this.window = aWidget.window;
423 this.ownerView = aWidget;
424 this.identifier = aName;
426 // Create an internal title and list container.
427 if (aName) {
428 let target = this._target = this.document.createElement("vbox");
429 target.className = "side-menu-widget-group";
430 target.setAttribute("name", aName);
432 let list = this._list = this.document.createElement("vbox");
433 list.className = "side-menu-widget-group-list";
435 let title = this._title = this.document.createElement("hbox");
436 title.className = "side-menu-widget-group-title";
438 let name = this._name = this.document.createElement("label");
439 name.className = "plain name";
440 name.setAttribute("value", aName);
441 name.setAttribute("crop", "end");
442 name.setAttribute("flex", "1");
444 // Show a checkbox before the content.
445 if (aOptions.showCheckbox) {
446 let checkbox = this._checkbox = makeCheckbox(title, { description: aName });
447 checkbox.className = "side-menu-widget-group-checkbox";
448 }
450 title.appendChild(name);
451 target.appendChild(title);
452 target.appendChild(list);
453 }
454 // Skip a few redundant nodes when no title is shown.
455 else {
456 let target = this._target = this._list = this.document.createElement("vbox");
457 target.className = "side-menu-widget-group side-menu-widget-group-list";
458 target.setAttribute("merged-group-contents", "");
459 }
460 }
462 SideMenuGroup.prototype = {
463 get _orderedGroupElementsArray() this.ownerView._orderedGroupElementsArray,
464 get _orderedMenuElementsArray() this.ownerView._orderedMenuElementsArray,
465 get _itemsByElement() { return this.ownerView._itemsByElement; },
467 /**
468 * Inserts this group in the parent container at the specified index.
469 *
470 * @param number aIndex
471 * The position in the container intended for this group.
472 */
473 insertSelfAt: function(aIndex) {
474 let ownerList = this.ownerView._list;
475 let groupsArray = this._orderedGroupElementsArray;
477 if (aIndex >= 0) {
478 ownerList.insertBefore(this._target, groupsArray[aIndex]);
479 groupsArray.splice(aIndex, 0, this._target);
480 } else {
481 ownerList.appendChild(this._target);
482 groupsArray.push(this._target);
483 }
484 },
486 /**
487 * Finds the expected index of this group based on its name.
488 *
489 * @return number
490 * The expected index.
491 */
492 findExpectedIndexForSelf: function(sortPredicate) {
493 let identifier = this.identifier;
494 let groupsArray = this._orderedGroupElementsArray;
496 for (let group of groupsArray) {
497 let name = group.getAttribute("name");
498 if (sortPredicate(name, identifier) > 0 && // Insertion sort at its best :)
499 !name.contains(identifier)) { // Least significant group should be last.
500 return groupsArray.indexOf(group);
501 }
502 }
503 return -1;
504 },
506 window: null,
507 document: null,
508 ownerView: null,
509 identifier: "",
510 _target: null,
511 _checkbox: null,
512 _title: null,
513 _name: null,
514 _list: null
515 };
517 /**
518 * A SideMenuItem constructor for the BreadcrumbsWidget.
519 *
520 * @param SideMenuGroup aGroup
521 * The group to contain this menu item.
522 * @param nsIDOMNode aContents
523 * The node displayed in the container.
524 * @param object aAttachment [optional]
525 * The attachment object.
526 * @param object aOptions [optional]
527 * An object containing the following properties:
528 * - showArrow: specifies if a horizontal arrow should be displayed.
529 * - showCheckbox: specifies if a checkbox should be displayed.
530 */
531 function SideMenuItem(aGroup, aContents, aAttachment={}, aOptions={}) {
532 this.document = aGroup.document;
533 this.window = aGroup.window;
534 this.ownerView = aGroup;
536 if (aOptions.showArrow || aOptions.showCheckbox) {
537 let container = this._container = this.document.createElement("hbox");
538 container.className = "side-menu-widget-item";
540 let target = this._target = this.document.createElement("vbox");
541 target.className = "side-menu-widget-item-contents";
543 // Show a checkbox before the content.
544 if (aOptions.showCheckbox) {
545 let checkbox = this._checkbox = makeCheckbox(container, aAttachment);
546 checkbox.className = "side-menu-widget-item-checkbox";
547 }
549 container.appendChild(target);
551 // Show a horizontal arrow towards the content.
552 if (aOptions.showArrow) {
553 let arrow = this._arrow = this.document.createElement("hbox");
554 arrow.className = "side-menu-widget-item-arrow";
555 container.appendChild(arrow);
556 }
557 }
558 // Skip a few redundant nodes when no horizontal arrow or checkbox is shown.
559 else {
560 let target = this._target = this._container = this.document.createElement("hbox");
561 target.className = "side-menu-widget-item side-menu-widget-item-contents";
562 target.setAttribute("merged-item-contents", "");
563 }
565 this._target.setAttribute("flex", "1");
566 this.contents = aContents;
567 }
569 SideMenuItem.prototype = {
570 get _orderedGroupElementsArray() this.ownerView._orderedGroupElementsArray,
571 get _orderedMenuElementsArray() this.ownerView._orderedMenuElementsArray,
572 get _itemsByElement() { return this.ownerView._itemsByElement; },
574 /**
575 * Inserts this item in the parent group at the specified index.
576 *
577 * @param number aIndex
578 * The position in the container intended for this item.
579 * @return nsIDOMNode
580 * The element associated with the displayed item.
581 */
582 insertSelfAt: function(aIndex) {
583 let ownerList = this.ownerView._list;
584 let menuArray = this._orderedMenuElementsArray;
586 if (aIndex >= 0) {
587 ownerList.insertBefore(this._container, ownerList.childNodes[aIndex]);
588 menuArray.splice(aIndex, 0, this._target);
589 } else {
590 ownerList.appendChild(this._container);
591 menuArray.push(this._target);
592 }
593 this._itemsByElement.set(this._target, this);
595 return this._target;
596 },
598 /**
599 * Check or uncheck the checkbox associated with this item.
600 *
601 * @param boolean aCheckState
602 * True to check, false to uncheck.
603 */
604 check: function(aCheckState) {
605 if (!this._checkbox) {
606 throw new Error("Cannot check items that do not have checkboxes.");
607 }
608 // Don't set or remove the "checked" attribute, assign the property instead.
609 // Otherwise, the "CheckboxStateChange" event will not be fired. XUL!!
610 this._checkbox.checked = !!aCheckState;
611 },
613 /**
614 * Sets the contents displayed in this item's view.
615 *
616 * @param string | nsIDOMNode aContents
617 * The string or node displayed in the container.
618 */
619 set contents(aContents) {
620 // If there are already some contents displayed, replace them.
621 if (this._target.hasChildNodes()) {
622 this._target.replaceChild(aContents, this._target.firstChild);
623 return;
624 }
625 // These are the first contents ever displayed.
626 this._target.appendChild(aContents);
627 },
629 window: null,
630 document: null,
631 ownerView: null,
632 _target: null,
633 _container: null,
634 _checkbox: null,
635 _arrow: null
636 };
638 /**
639 * Creates a checkbox to a specified parent node. Emits a "check" event
640 * whenever the checkbox is checked or unchecked by the user.
641 *
642 * @param nsIDOMNode aParentNode
643 * The parent node to contain this checkbox.
644 * @param object aOptions
645 * An object containing some or all of the following properties:
646 * - description: defaults to "item" if unspecified
647 * - checkboxState: true for checked, false for unchecked
648 * - checkboxTooltip: the tooltip text of the checkbox
649 */
650 function makeCheckbox(aParentNode, aOptions) {
651 let checkbox = aParentNode.ownerDocument.createElement("checkbox");
652 checkbox.setAttribute("tooltiptext", aOptions.checkboxTooltip);
654 if (aOptions.checkboxState) {
655 checkbox.setAttribute("checked", true);
656 } else {
657 checkbox.removeAttribute("checked");
658 }
660 // Stop the toggling of the checkbox from selecting the list item.
661 checkbox.addEventListener("mousedown", e => {
662 e.stopPropagation();
663 }, false);
665 // Emit an event from the checkbox when it is toggled. Don't listen for the
666 // "command" event! It won't fire for programmatic changes. XUL!!
667 checkbox.addEventListener("CheckboxStateChange", e => {
668 ViewHelpers.dispatchEvent(checkbox, "check", {
669 description: aOptions.description || "item",
670 checked: checkbox.checked
671 });
672 }, false);
674 aParentNode.appendChild(checkbox);
675 return checkbox;
676 }