browser/devtools/shared/autocomplete-popup.js

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:4dce4ff6a5fe
1 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5
6 "use strict";
7
8 const {Cc, Ci, Cu} = require("chrome");
9 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
10
11 loader.lazyImporter(this, "Services", "resource://gre/modules/Services.jsm");
12 loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm");
13
14 /**
15 * Autocomplete popup UI implementation.
16 *
17 * @constructor
18 * @param nsIDOMDocument aDocument
19 * The document you want the popup attached to.
20 * @param Object aOptions
21 * An object consiting any of the following options:
22 * - panelId {String} The id for the popup panel.
23 * - listBoxId {String} The id for the richlistbox inside the panel.
24 * - position {String} The position for the popup panel.
25 * - theme {String} String related to the theme of the popup.
26 * - autoSelect {Boolean} Boolean to allow the first entry of the popup
27 * panel to be automatically selected when the popup shows.
28 * - direction {String} The direction of the text in the panel. rtl or ltr
29 * - onSelect {String} The select event handler for the richlistbox
30 * - onClick {String} The click event handler for the richlistbox.
31 * - onKeypress {String} The keypress event handler for the richlistitems.
32 */
33 function AutocompletePopup(aDocument, aOptions = {})
34 {
35 this._document = aDocument;
36
37 this.autoSelect = aOptions.autoSelect || false;
38 this.position = aOptions.position || "after_start";
39 this.direction = aOptions.direction || "ltr";
40
41 this.onSelect = aOptions.onSelect;
42 this.onClick = aOptions.onClick;
43 this.onKeypress = aOptions.onKeypress;
44
45 let id = aOptions.panelId || "devtools_autoCompletePopup";
46 let theme = aOptions.theme || "dark";
47 // If theme is auto, use the devtools.theme pref
48 if (theme == "auto") {
49 theme = Services.prefs.getCharPref("devtools.theme");
50 this.autoThemeEnabled = true;
51 // Setup theme change listener.
52 this._handleThemeChange = this._handleThemeChange.bind(this);
53 gDevTools.on("pref-changed", this._handleThemeChange);
54 }
55 // Reuse the existing popup elements.
56 this._panel = this._document.getElementById(id);
57 if (!this._panel) {
58 this._panel = this._document.createElementNS(XUL_NS, "panel");
59 this._panel.setAttribute("id", id);
60 this._panel.className = "devtools-autocomplete-popup devtools-monospace "
61 + theme + "-theme";
62
63 this._panel.setAttribute("noautofocus", "true");
64 this._panel.setAttribute("level", "top");
65 if (!aOptions.onKeypress) {
66 this._panel.setAttribute("ignorekeys", "true");
67 }
68
69 let mainPopupSet = this._document.getElementById("mainPopupSet");
70 if (mainPopupSet) {
71 mainPopupSet.appendChild(this._panel);
72 }
73 else {
74 this._document.documentElement.appendChild(this._panel);
75 }
76 }
77 else {
78 this._list = this._panel.firstChild;
79 }
80
81 if (!this._list) {
82 this._list = this._document.createElementNS(XUL_NS, "richlistbox");
83 this._panel.appendChild(this._list);
84
85 // Open and hide the panel, so we initialize the API of the richlistbox.
86 this._panel.openPopup(null, this.position, 0, 0);
87 this._panel.hidePopup();
88 }
89
90 this._list.setAttribute("flex", "1");
91 this._list.setAttribute("seltype", "single");
92
93 if (aOptions.listBoxId) {
94 this._list.setAttribute("id", aOptions.listBoxId);
95 }
96 this._list.className = "devtools-autocomplete-listbox " + theme + "-theme";
97
98 if (this.onSelect) {
99 this._list.addEventListener("select", this.onSelect, false);
100 }
101
102 if (this.onClick) {
103 this._list.addEventListener("click", this.onClick, false);
104 }
105
106 if (this.onKeypress) {
107 this._list.addEventListener("keypress", this.onKeypress, false);
108 }
109 }
110 exports.AutocompletePopup = AutocompletePopup;
111
112 AutocompletePopup.prototype = {
113 _document: null,
114 _panel: null,
115 _list: null,
116 __scrollbarWidth: null,
117
118 // Event handlers.
119 onSelect: null,
120 onClick: null,
121 onKeypress: null,
122
123 /**
124 * Open the autocomplete popup panel.
125 *
126 * @param nsIDOMNode aAnchor
127 * Optional node to anchor the panel to.
128 * @param Number aXOffset
129 * Horizontal offset in pixels from the left of the node to the left
130 * of the popup.
131 * @param Number aYOffset
132 * Vertical offset in pixels from the top of the node to the starting
133 * of the popup.
134 */
135 openPopup: function AP_openPopup(aAnchor, aXOffset = 0, aYOffset = 0)
136 {
137 this.__maxLabelLength = -1;
138 this._updateSize();
139 this._panel.openPopup(aAnchor, this.position, aXOffset, aYOffset);
140
141 if (this.autoSelect) {
142 this.selectFirstItem();
143 }
144 },
145
146 /**
147 * Hide the autocomplete popup panel.
148 */
149 hidePopup: function AP_hidePopup()
150 {
151 this._panel.hidePopup();
152 },
153
154 /**
155 * Check if the autocomplete popup is open.
156 */
157 get isOpen() {
158 return this._panel.state == "open" || this._panel.state == "showing";
159 },
160
161 /**
162 * Destroy the object instance. Please note that the panel DOM elements remain
163 * in the DOM, because they might still be in use by other instances of the
164 * same code. It is the responsability of the client code to perform DOM
165 * cleanup.
166 */
167 destroy: function AP_destroy()
168 {
169 if (this.isOpen) {
170 this.hidePopup();
171 }
172 this.clearItems();
173
174 if (this.onSelect) {
175 this._list.removeEventListener("select", this.onSelect, false);
176 }
177
178 if (this.onClick) {
179 this._list.removeEventListener("click", this.onClick, false);
180 }
181
182 if (this.onKeypress) {
183 this._list.removeEventListener("keypress", this.onKeypress, false);
184 }
185
186 if (this.autoThemeEnabled) {
187 gDevTools.off("pref-changed", this._handleThemeChange);
188 }
189
190 this._document = null;
191 this._list = null;
192 this._panel = null;
193 },
194
195 /**
196 * Get the autocomplete items array.
197 *
198 * @param Number aIndex The index of the item what is wanted.
199 *
200 * @return The autocomplete item at index aIndex.
201 */
202 getItemAtIndex: function AP_getItemAtIndex(aIndex)
203 {
204 return this._list.getItemAtIndex(aIndex)._autocompleteItem;
205 },
206
207 /**
208 * Get the autocomplete items array.
209 *
210 * @return array
211 * The array of autocomplete items.
212 */
213 getItems: function AP_getItems()
214 {
215 let items = [];
216
217 Array.forEach(this._list.childNodes, function(aItem) {
218 items.push(aItem._autocompleteItem);
219 });
220
221 return items;
222 },
223
224 /**
225 * Set the autocomplete items list, in one go.
226 *
227 * @param array aItems
228 * The list of items you want displayed in the popup list.
229 */
230 setItems: function AP_setItems(aItems)
231 {
232 this.clearItems();
233 aItems.forEach(this.appendItem, this);
234
235 // Make sure that the new content is properly fitted by the XUL richlistbox.
236 if (this.isOpen) {
237 if (this.autoSelect) {
238 this.selectFirstItem();
239 }
240 this._updateSize();
241 }
242 },
243
244 /**
245 * Selects the first item of the richlistbox. Note that first item here is the
246 * item closes to the input element, which means that 0th index if position is
247 * below, and last index if position is above.
248 */
249 selectFirstItem: function AP_selectFirstItem()
250 {
251 if (this.position.contains("before")) {
252 this.selectedIndex = this.itemCount - 1;
253 }
254 else {
255 this.selectedIndex = 0;
256 }
257 this._list.ensureIndexIsVisible(this._list.selectedIndex);
258 },
259
260 __maxLabelLength: -1,
261
262 get _maxLabelLength() {
263 if (this.__maxLabelLength != -1) {
264 return this.__maxLabelLength;
265 }
266
267 let max = 0;
268 for (let i = 0; i < this._list.childNodes.length; i++) {
269 let item = this._list.childNodes[i]._autocompleteItem;
270 let str = item.label;
271 if (item.count) {
272 str += (item.count + "");
273 }
274 max = Math.max(str.length, max);
275 }
276
277 this.__maxLabelLength = max;
278 return this.__maxLabelLength;
279 },
280
281 /**
282 * Update the panel size to fit the content.
283 *
284 * @private
285 */
286 _updateSize: function AP__updateSize()
287 {
288 if (!this._panel) {
289 return;
290 }
291
292 this._list.style.width = (this._maxLabelLength + 3) +"ch";
293 this._list.ensureIndexIsVisible(this._list.selectedIndex);
294 },
295
296 /**
297 * Clear all the items from the autocomplete list.
298 */
299 clearItems: function AP_clearItems()
300 {
301 // Reset the selectedIndex to -1 before clearing the list
302 this.selectedIndex = -1;
303
304 while (this._list.hasChildNodes()) {
305 this._list.removeChild(this._list.firstChild);
306 }
307
308 this.__maxLabelLength = -1;
309
310 // Reset the panel and list dimensions. New dimensions are calculated when
311 // a new set of items is added to the autocomplete popup.
312 this._list.width = "";
313 this._list.style.width = "";
314 this._list.height = "";
315 this._panel.width = "";
316 this._panel.height = "";
317 this._panel.top = "";
318 this._panel.left = "";
319 },
320
321 /**
322 * Getter for the index of the selected item.
323 *
324 * @type number
325 */
326 get selectedIndex() {
327 return this._list.selectedIndex;
328 },
329
330 /**
331 * Setter for the selected index.
332 *
333 * @param number aIndex
334 * The number (index) of the item you want to select in the list.
335 */
336 set selectedIndex(aIndex) {
337 this._list.selectedIndex = aIndex;
338 if (this.isOpen && this._list.ensureIndexIsVisible) {
339 this._list.ensureIndexIsVisible(this._list.selectedIndex);
340 }
341 },
342
343 /**
344 * Getter for the selected item.
345 * @type object
346 */
347 get selectedItem() {
348 return this._list.selectedItem ?
349 this._list.selectedItem._autocompleteItem : null;
350 },
351
352 /**
353 * Setter for the selected item.
354 *
355 * @param object aItem
356 * The object you want selected in the list.
357 */
358 set selectedItem(aItem) {
359 this._list.selectedItem = this._findListItem(aItem);
360 if (this.isOpen) {
361 this._list.ensureIndexIsVisible(this._list.selectedIndex);
362 }
363 },
364
365 /**
366 * Append an item into the autocomplete list.
367 *
368 * @param object aItem
369 * The item you want appended to the list.
370 * The item object can have the following properties:
371 * - label {String} Property which is used as the displayed value.
372 * - preLabel {String} [Optional] The String that will be displayed
373 * before the label indicating that this is the already
374 * present text in the input box, and label is the text
375 * that will be auto completed. When this property is
376 * present, |preLabel.length| starting characters will be
377 * removed from label.
378 * - count {Number} [Optional] The number to represent the count of
379 * autocompleted label.
380 */
381 appendItem: function AP_appendItem(aItem)
382 {
383 let listItem = this._document.createElementNS(XUL_NS, "richlistitem");
384 if (this.direction) {
385 listItem.setAttribute("dir", this.direction);
386 }
387 let label = this._document.createElementNS(XUL_NS, "label");
388 label.setAttribute("value", aItem.label);
389 label.setAttribute("class", "autocomplete-value");
390 if (aItem.preLabel) {
391 let preDesc = this._document.createElementNS(XUL_NS, "label");
392 preDesc.setAttribute("value", aItem.preLabel);
393 preDesc.setAttribute("class", "initial-value");
394 listItem.appendChild(preDesc);
395 label.setAttribute("value", aItem.label.slice(aItem.preLabel.length));
396 }
397 listItem.appendChild(label);
398 if (aItem.count && aItem.count > 1) {
399 let countDesc = this._document.createElementNS(XUL_NS, "label");
400 countDesc.setAttribute("value", aItem.count);
401 countDesc.setAttribute("flex", "1");
402 countDesc.setAttribute("class", "autocomplete-count");
403 listItem.appendChild(countDesc);
404 }
405 listItem._autocompleteItem = aItem;
406
407 this._list.appendChild(listItem);
408 },
409
410 /**
411 * Find the richlistitem element that belongs to an item.
412 *
413 * @private
414 *
415 * @param object aItem
416 * The object you want found in the list.
417 *
418 * @return nsIDOMNode|null
419 * The nsIDOMNode that belongs to the given item object. This node is
420 * the richlistitem element.
421 */
422 _findListItem: function AP__findListItem(aItem)
423 {
424 for (let i = 0; i < this._list.childNodes.length; i++) {
425 let child = this._list.childNodes[i];
426 if (child._autocompleteItem == aItem) {
427 return child;
428 }
429 }
430 return null;
431 },
432
433 /**
434 * Remove an item from the popup list.
435 *
436 * @param object aItem
437 * The item you want removed.
438 */
439 removeItem: function AP_removeItem(aItem)
440 {
441 let item = this._findListItem(aItem);
442 if (!item) {
443 throw new Error("Item not found!");
444 }
445 this._list.removeChild(item);
446 },
447
448 /**
449 * Getter for the number of items in the popup.
450 * @type number
451 */
452 get itemCount() {
453 return this._list.childNodes.length;
454 },
455
456 /**
457 * Getter for the height of each item in the list.
458 *
459 * @private
460 *
461 * @type number
462 */
463 get _itemHeight() {
464 return this._list.selectedItem.clientHeight;
465 },
466
467 /**
468 * Select the next item in the list.
469 *
470 * @return object
471 * The newly selected item object.
472 */
473 selectNextItem: function AP_selectNextItem()
474 {
475 if (this.selectedIndex < (this.itemCount - 1)) {
476 this.selectedIndex++;
477 }
478 else {
479 this.selectedIndex = 0;
480 }
481
482 return this.selectedItem;
483 },
484
485 /**
486 * Select the previous item in the list.
487 *
488 * @return object
489 * The newly-selected item object.
490 */
491 selectPreviousItem: function AP_selectPreviousItem()
492 {
493 if (this.selectedIndex > 0) {
494 this.selectedIndex--;
495 }
496 else {
497 this.selectedIndex = this.itemCount - 1;
498 }
499
500 return this.selectedItem;
501 },
502
503 /**
504 * Select the top-most item in the next page of items or
505 * the last item in the list.
506 *
507 * @return object
508 * The newly-selected item object.
509 */
510 selectNextPageItem: function AP_selectNextPageItem()
511 {
512 let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight);
513 let nextPageIndex = this.selectedIndex + itemsPerPane + 1;
514 this.selectedIndex = nextPageIndex > this.itemCount - 1 ?
515 this.itemCount - 1 : nextPageIndex;
516
517 return this.selectedItem;
518 },
519
520 /**
521 * Select the bottom-most item in the previous page of items,
522 * or the first item in the list.
523 *
524 * @return object
525 * The newly-selected item object.
526 */
527 selectPreviousPageItem: function AP_selectPreviousPageItem()
528 {
529 let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight);
530 let prevPageIndex = this.selectedIndex - itemsPerPane - 1;
531 this.selectedIndex = prevPageIndex < 0 ? 0 : prevPageIndex;
532
533 return this.selectedItem;
534 },
535
536 /**
537 * Focuses the richlistbox.
538 */
539 focus: function AP_focus()
540 {
541 this._list.focus();
542 },
543
544 /**
545 * Manages theme switching for the popup based on the devtools.theme pref.
546 *
547 * @private
548 *
549 * @param String aEvent
550 * The name of the event. In this case, "pref-changed".
551 * @param Object aData
552 * An object passed by the emitter of the event. In this case, the
553 * object consists of three properties:
554 * - pref {String} The name of the preference that was modified.
555 * - newValue {Object} The new value of the preference.
556 * - oldValue {Object} The old value of the preference.
557 */
558 _handleThemeChange: function AP__handleThemeChange(aEvent, aData)
559 {
560 if (aData.pref == "devtools.theme") {
561 this._panel.classList.toggle(aData.oldValue + "-theme", false);
562 this._panel.classList.toggle(aData.newValue + "-theme", true);
563 this._list.classList.toggle(aData.oldValue + "-theme", false);
564 this._list.classList.toggle(aData.newValue + "-theme", true);
565 }
566 },
567 };

mercurial