autocomplete-popup.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. const HTML_NS = "http://www.w3.org/1999/xhtml";
  6. const Services = require("Services");
  7. const {gDevTools} = require("devtools/client/framework/devtools");
  8. const {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
  9. const EventEmitter = require("devtools/shared/event-emitter");
  10. let itemIdCounter = 0;
  11. /**
  12. * Autocomplete popup UI implementation.
  13. *
  14. * @constructor
  15. * @param {Document} toolboxDoc
  16. * The toolbox document to attach the autocomplete popup panel.
  17. * @param {Object} options
  18. * An object consiting any of the following options:
  19. * - listId {String} The id for the list <LI> element.
  20. * - position {String} The position for the tooltip ("top" or "bottom").
  21. * - theme {String} String related to the theme of the popup
  22. * - autoSelect {Boolean} Boolean to allow the first entry of the popup
  23. * panel to be automatically selected when the popup shows.
  24. * - onSelect {String} Callback called when the selected index is updated.
  25. * - onClick {String} Callback called when the autocomplete popup receives a click
  26. * event. The selectedIndex will already be updated if need be.
  27. */
  28. function AutocompletePopup(toolboxDoc, options = {}) {
  29. EventEmitter.decorate(this);
  30. this._document = toolboxDoc;
  31. this.autoSelect = options.autoSelect || false;
  32. this.position = options.position || "bottom";
  33. let theme = options.theme || "dark";
  34. this.onSelectCallback = options.onSelect;
  35. this.onClickCallback = options.onClick;
  36. // If theme is auto, use the devtools.theme pref
  37. if (theme === "auto") {
  38. theme = Services.prefs.getCharPref("devtools.theme");
  39. this.autoThemeEnabled = true;
  40. // Setup theme change listener.
  41. this._handleThemeChange = this._handleThemeChange.bind(this);
  42. gDevTools.on("pref-changed", this._handleThemeChange);
  43. }
  44. // Create HTMLTooltip instance
  45. this._tooltip = new HTMLTooltip(this._document);
  46. this._tooltip.panel.classList.add(
  47. "devtools-autocomplete-popup",
  48. "devtools-monospace",
  49. theme + "-theme");
  50. // Stop this appearing as an alert to accessibility.
  51. this._tooltip.panel.setAttribute("role", "presentation");
  52. this._list = this._document.createElementNS(HTML_NS, "ul");
  53. this._list.setAttribute("flex", "1");
  54. // The list clone will be inserted in the same document as the anchor, and will receive
  55. // a copy of the main list innerHTML to allow screen readers to access the list.
  56. this._listClone = this._document.createElementNS(HTML_NS, "ul");
  57. this._listClone.className = "devtools-autocomplete-list-aria-clone";
  58. if (options.listId) {
  59. this._list.setAttribute("id", options.listId);
  60. }
  61. this._list.className = "devtools-autocomplete-listbox " + theme + "-theme";
  62. this._tooltip.setContent(this._list);
  63. this.onClick = this.onClick.bind(this);
  64. this._list.addEventListener("click", this.onClick, false);
  65. // Array of raw autocomplete items
  66. this.items = [];
  67. // Map of autocompleteItem to HTMLElement
  68. this.elements = new WeakMap();
  69. this.selectedIndex = -1;
  70. }
  71. exports.AutocompletePopup = AutocompletePopup;
  72. AutocompletePopup.prototype = {
  73. _document: null,
  74. _tooltip: null,
  75. _list: null,
  76. onSelect: function (e) {
  77. if (this.onSelectCallback) {
  78. this.onSelectCallback(e);
  79. }
  80. },
  81. onClick: function (e) {
  82. let item = e.target.closest(".autocomplete-item");
  83. if (item && typeof item.dataset.index !== "undefined") {
  84. this.selectedIndex = parseInt(item.dataset.index, 10);
  85. }
  86. this.emit("popup-click");
  87. if (this.onClickCallback) {
  88. this.onClickCallback(e);
  89. }
  90. },
  91. /**
  92. * Open the autocomplete popup panel.
  93. *
  94. * @param {nsIDOMNode} anchor
  95. * Optional node to anchor the panel to.
  96. * @param {Number} xOffset
  97. * Horizontal offset in pixels from the left of the node to the left
  98. * of the popup.
  99. * @param {Number} yOffset
  100. * Vertical offset in pixels from the top of the node to the starting
  101. * of the popup.
  102. * @param {Number} index
  103. * The position of item to select.
  104. */
  105. openPopup: function (anchor, xOffset = 0, yOffset = 0, index) {
  106. this.__maxLabelLength = -1;
  107. this._updateSize();
  108. // Retrieve the anchor's document active element to add accessibility metadata.
  109. this._activeElement = anchor.ownerDocument.activeElement;
  110. this._tooltip.show(anchor, {
  111. x: xOffset,
  112. y: yOffset,
  113. position: this.position,
  114. });
  115. this._tooltip.once("shown", () => {
  116. if (this.autoSelect) {
  117. this.selectItemAtIndex(index);
  118. }
  119. this.emit("popup-opened");
  120. });
  121. },
  122. /**
  123. * Select item at the provided index.
  124. *
  125. * @param {Number} index
  126. * The position of the item to select.
  127. */
  128. selectItemAtIndex: function (index) {
  129. if (typeof index !== "number") {
  130. // If no index was provided, select the item closest to the input.
  131. let isAboveInput = this.position === "top";
  132. index = isAboveInput ? this.itemCount - 1 : 0;
  133. }
  134. this.selectedIndex = index;
  135. },
  136. /**
  137. * Hide the autocomplete popup panel.
  138. */
  139. hidePopup: function () {
  140. this._tooltip.once("hidden", () => {
  141. this.emit("popup-closed");
  142. });
  143. this._clearActiveDescendant();
  144. this._activeElement = null;
  145. this._tooltip.hide();
  146. },
  147. /**
  148. * Check if the autocomplete popup is open.
  149. */
  150. get isOpen() {
  151. return this._tooltip && this._tooltip.isVisible();
  152. },
  153. /**
  154. * Destroy the object instance. Please note that the panel DOM elements remain
  155. * in the DOM, because they might still be in use by other instances of the
  156. * same code. It is the responsability of the client code to perform DOM
  157. * cleanup.
  158. */
  159. destroy: function () {
  160. if (this.isOpen) {
  161. this.hidePopup();
  162. }
  163. this._list.removeEventListener("click", this.onClick, false);
  164. if (this.autoThemeEnabled) {
  165. gDevTools.off("pref-changed", this._handleThemeChange);
  166. }
  167. this._list.remove();
  168. this._listClone.remove();
  169. this._tooltip.destroy();
  170. this._document = null;
  171. this._list = null;
  172. this._tooltip = null;
  173. },
  174. /**
  175. * Get the autocomplete items array.
  176. *
  177. * @param {Number} index
  178. * The index of the item what is wanted.
  179. *
  180. * @return {Object} The autocomplete item at index index.
  181. */
  182. getItemAtIndex: function (index) {
  183. return this.items[index];
  184. },
  185. /**
  186. * Get the autocomplete items array.
  187. *
  188. * @return {Array} The array of autocomplete items.
  189. */
  190. getItems: function () {
  191. // Return a copy of the array to avoid side effects from the caller code.
  192. return this.items.slice(0);
  193. },
  194. /**
  195. * Set the autocomplete items list, in one go.
  196. *
  197. * @param {Array} items
  198. * The list of items you want displayed in the popup list.
  199. * @param {Number} index
  200. * The position of the item to select.
  201. */
  202. setItems: function (items, index) {
  203. this.clearItems();
  204. items.forEach(this.appendItem, this);
  205. if (this.isOpen && this.autoSelect) {
  206. this.selectItemAtIndex(index);
  207. }
  208. },
  209. __maxLabelLength: -1,
  210. get _maxLabelLength() {
  211. if (this.__maxLabelLength !== -1) {
  212. return this.__maxLabelLength;
  213. }
  214. let max = 0;
  215. for (let {label, count} of this.items) {
  216. if (count) {
  217. label += count + "";
  218. }
  219. max = Math.max(label.length, max);
  220. }
  221. this.__maxLabelLength = max;
  222. return this.__maxLabelLength;
  223. },
  224. /**
  225. * Update the panel size to fit the content.
  226. */
  227. _updateSize: function () {
  228. if (!this._tooltip) {
  229. return;
  230. }
  231. this._list.style.width = (this._maxLabelLength + 3) + "ch";
  232. let selectedItem = this.selectedItem;
  233. if (selectedItem) {
  234. this._scrollElementIntoViewIfNeeded(this.elements.get(selectedItem));
  235. }
  236. },
  237. _scrollElementIntoViewIfNeeded: function (element) {
  238. let quads = element.getBoxQuads({relativeTo: this._tooltip.panel});
  239. if (!quads || !quads[0]) {
  240. return;
  241. }
  242. let {top, height} = quads[0].bounds;
  243. let containerHeight = this._tooltip.panel.getBoundingClientRect().height;
  244. if (top < 0) {
  245. // Element is above container.
  246. element.scrollIntoView(true);
  247. } else if ((top + height) > containerHeight) {
  248. // Element is beloew container.
  249. element.scrollIntoView(false);
  250. }
  251. },
  252. /**
  253. * Clear all the items from the autocomplete list.
  254. */
  255. clearItems: function () {
  256. // Reset the selectedIndex to -1 before clearing the list
  257. this.selectedIndex = -1;
  258. this._list.innerHTML = "";
  259. this.__maxLabelLength = -1;
  260. this.items = [];
  261. this.elements = new WeakMap();
  262. },
  263. /**
  264. * Getter for the index of the selected item.
  265. *
  266. * @type {Number}
  267. */
  268. get selectedIndex() {
  269. return this._selectedIndex;
  270. },
  271. /**
  272. * Setter for the selected index.
  273. *
  274. * @param {Number} index
  275. * The number (index) of the item you want to select in the list.
  276. */
  277. set selectedIndex(index) {
  278. let previousSelected = this._list.querySelector(".autocomplete-selected");
  279. if (previousSelected) {
  280. previousSelected.classList.remove("autocomplete-selected");
  281. }
  282. let item = this.items[index];
  283. if (this.isOpen && item) {
  284. let element = this.elements.get(item);
  285. element.classList.add("autocomplete-selected");
  286. this._scrollElementIntoViewIfNeeded(element);
  287. this._setActiveDescendant(element.id);
  288. } else {
  289. this._clearActiveDescendant();
  290. }
  291. this._selectedIndex = index;
  292. if (this.isOpen && item && this.onSelectCallback) {
  293. // Call the user-defined select callback if defined.
  294. this.onSelectCallback();
  295. }
  296. },
  297. /**
  298. * Getter for the selected item.
  299. * @type Object
  300. */
  301. get selectedItem() {
  302. return this.items[this._selectedIndex];
  303. },
  304. /**
  305. * Setter for the selected item.
  306. *
  307. * @param {Object} item
  308. * The object you want selected in the list.
  309. */
  310. set selectedItem(item) {
  311. let index = this.items.indexOf(item);
  312. if (index !== -1 && this.isOpen) {
  313. this.selectedIndex = index;
  314. }
  315. },
  316. /**
  317. * Update the aria-activedescendant attribute on the current active element for
  318. * accessibility.
  319. *
  320. * @param {String} id
  321. * The id (as in DOM id) of the currently selected autocomplete suggestion
  322. */
  323. _setActiveDescendant: function (id) {
  324. if (!this._activeElement) {
  325. return;
  326. }
  327. // Make sure the list clone is in the same document as the anchor.
  328. let anchorDoc = this._activeElement.ownerDocument;
  329. if (!this._listClone.parentNode || this._listClone.ownerDocument !== anchorDoc) {
  330. anchorDoc.documentElement.appendChild(this._listClone);
  331. }
  332. // Update the clone content to match the current list content.
  333. this._listClone.innerHTML = this._list.innerHTML;
  334. this._activeElement.setAttribute("aria-activedescendant", id);
  335. },
  336. /**
  337. * Clear the aria-activedescendant attribute on the current active element.
  338. */
  339. _clearActiveDescendant: function () {
  340. if (!this._activeElement) {
  341. return;
  342. }
  343. this._activeElement.removeAttribute("aria-activedescendant");
  344. },
  345. /**
  346. * Append an item into the autocomplete list.
  347. *
  348. * @param {Object} item
  349. * The item you want appended to the list.
  350. * The item object can have the following properties:
  351. * - label {String} Property which is used as the displayed value.
  352. * - preLabel {String} [Optional] The String that will be displayed
  353. * before the label indicating that this is the already
  354. * present text in the input box, and label is the text
  355. * that will be auto completed. When this property is
  356. * present, |preLabel.length| starting characters will be
  357. * removed from label.
  358. * - count {Number} [Optional] The number to represent the count of
  359. * autocompleted label.
  360. */
  361. appendItem: function (item) {
  362. let listItem = this._document.createElementNS(HTML_NS, "li");
  363. // Items must have an id for accessibility.
  364. listItem.setAttribute("id", "autocomplete-item-" + itemIdCounter++);
  365. listItem.className = "autocomplete-item";
  366. listItem.setAttribute("data-index", this.items.length);
  367. if (this.direction) {
  368. listItem.setAttribute("dir", this.direction);
  369. }
  370. let label = this._document.createElementNS(HTML_NS, "span");
  371. label.textContent = item.label;
  372. label.className = "autocomplete-value";
  373. if (item.preLabel) {
  374. let preDesc = this._document.createElementNS(HTML_NS, "span");
  375. preDesc.textContent = item.preLabel;
  376. preDesc.className = "initial-value";
  377. listItem.appendChild(preDesc);
  378. label.textContent = item.label.slice(item.preLabel.length);
  379. }
  380. listItem.appendChild(label);
  381. if (item.count && item.count > 1) {
  382. let countDesc = this._document.createElementNS(HTML_NS, "span");
  383. countDesc.textContent = item.count;
  384. countDesc.setAttribute("flex", "1");
  385. countDesc.className = "autocomplete-count";
  386. listItem.appendChild(countDesc);
  387. }
  388. this._list.appendChild(listItem);
  389. this.items.push(item);
  390. this.elements.set(item, listItem);
  391. },
  392. /**
  393. * Remove an item from the popup list.
  394. *
  395. * @param {Object} item
  396. * The item you want removed.
  397. */
  398. removeItem: function (item) {
  399. if (!this.items.includes(item)) {
  400. return;
  401. }
  402. let itemIndex = this.items.indexOf(item);
  403. let selectedIndex = this.selectedIndex;
  404. // Remove autocomplete item.
  405. this.items.splice(itemIndex, 1);
  406. // Remove corresponding DOM element from the elements WeakMap and from the DOM.
  407. let elementToRemove = this.elements.get(item);
  408. this.elements.delete(elementToRemove);
  409. elementToRemove.remove();
  410. if (itemIndex <= selectedIndex) {
  411. // If the removed item index was before or equal to the selected index, shift the
  412. // selected index by 1.
  413. this.selectedIndex = Math.max(0, selectedIndex - 1);
  414. }
  415. },
  416. /**
  417. * Getter for the number of items in the popup.
  418. * @type {Number}
  419. */
  420. get itemCount() {
  421. return this.items.length;
  422. },
  423. /**
  424. * Getter for the height of each item in the list.
  425. *
  426. * @type {Number}
  427. */
  428. get _itemsPerPane() {
  429. if (this.items.length) {
  430. let listHeight = this._tooltip.panel.clientHeight;
  431. let element = this.elements.get(this.items[0]);
  432. let elementHeight = element.getBoundingClientRect().height;
  433. return Math.floor(listHeight / elementHeight);
  434. }
  435. return 0;
  436. },
  437. /**
  438. * Select the next item in the list.
  439. *
  440. * @return {Object}
  441. * The newly selected item object.
  442. */
  443. selectNextItem: function () {
  444. if (this.selectedIndex < (this.items.length - 1)) {
  445. this.selectedIndex++;
  446. } else {
  447. this.selectedIndex = 0;
  448. }
  449. return this.selectedItem;
  450. },
  451. /**
  452. * Select the previous item in the list.
  453. *
  454. * @return {Object}
  455. * The newly-selected item object.
  456. */
  457. selectPreviousItem: function () {
  458. if (this.selectedIndex > 0) {
  459. this.selectedIndex--;
  460. } else {
  461. this.selectedIndex = this.items.length - 1;
  462. }
  463. return this.selectedItem;
  464. },
  465. /**
  466. * Select the top-most item in the next page of items or
  467. * the last item in the list.
  468. *
  469. * @return {Object}
  470. * The newly-selected item object.
  471. */
  472. selectNextPageItem: function () {
  473. let nextPageIndex = this.selectedIndex + this._itemsPerPane + 1;
  474. this.selectedIndex = Math.min(nextPageIndex, this.itemCount - 1);
  475. return this.selectedItem;
  476. },
  477. /**
  478. * Select the bottom-most item in the previous page of items,
  479. * or the first item in the list.
  480. *
  481. * @return {Object}
  482. * The newly-selected item object.
  483. */
  484. selectPreviousPageItem: function () {
  485. let prevPageIndex = this.selectedIndex - this._itemsPerPane - 1;
  486. this.selectedIndex = Math.max(prevPageIndex, 0);
  487. return this.selectedItem;
  488. },
  489. /**
  490. * Manages theme switching for the popup based on the devtools.theme pref.
  491. *
  492. * @private
  493. *
  494. * @param {String} event
  495. * The name of the event. In this case, "pref-changed".
  496. * @param {Object} data
  497. * An object passed by the emitter of the event. In this case, the
  498. * object consists of three properties:
  499. * - pref {String} The name of the preference that was modified.
  500. * - newValue {Object} The new value of the preference.
  501. * - oldValue {Object} The old value of the preference.
  502. */
  503. _handleThemeChange: function (event, data) {
  504. if (data.pref === "devtools.theme") {
  505. this._tooltip.panel.classList.toggle(data.oldValue + "-theme", false);
  506. this._tooltip.panel.classList.toggle(data.newValue + "-theme", true);
  507. this._list.classList.toggle(data.oldValue + "-theme", false);
  508. this._list.classList.toggle(data.newValue + "-theme", true);
  509. }
  510. },
  511. /**
  512. * Used by tests.
  513. */
  514. get _panel() {
  515. return this._tooltip.panel;
  516. },
  517. /**
  518. * Used by tests.
  519. */
  520. get _window() {
  521. return this._document.defaultView;
  522. },
  523. };