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
5"use strict";
6
7const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm");
8const { KeyCodes } = require("devtools/client/shared/keycodes");
9
10const EXPORTED_SYMBOLS = ["SplitView"];
11
12/* this must be kept in sync with CSS (ie. splitview.css) */
13const LANDSCAPE_MEDIA_QUERY = "(min-width: 701px)";
14
15var bindings = new WeakMap();
16
17/**
18 * SplitView constructor
19 *
20 * Initialize the split view UI on an existing DOM element.
21 *
22 * A split view contains items, each of those having one summary and one details
23 * elements.
24 * It is adaptive as it behaves similarly to a richlistbox when there the aspect
25 * ratio is narrow or as a pair listbox-box otherwise.
26 *
27 * @param DOMElement aRoot
28 * @see appendItem
29 */
30function SplitView(aRoot) {
31  this._root = aRoot;
32  this._controller = aRoot.querySelector(".splitview-controller");
33  this._nav = aRoot.querySelector(".splitview-nav");
34  this._side = aRoot.querySelector(".splitview-side-details");
35  this._activeSummary = null;
36
37  this._mql = aRoot.ownerDocument.defaultView.matchMedia(LANDSCAPE_MEDIA_QUERY);
38
39  // items list focus and search-on-type handling
40  this._nav.addEventListener("keydown", aEvent => {
41    function getFocusedItemWithin(nav) {
42      let node = nav.ownerDocument.activeElement;
43      while (node && node.parentNode != nav) {
44        node = node.parentNode;
45      }
46      return node;
47    }
48
49    // do not steal focus from inside iframes or textboxes
50    if (
51      aEvent.target.ownerDocument != this._nav.ownerDocument ||
52      aEvent.target.tagName == "input" ||
53      aEvent.target.tagName == "textarea" ||
54      aEvent.target.classList.contains("textbox")
55    ) {
56      return false;
57    }
58
59    // handle keyboard navigation within the items list
60    let newFocusOrdinal;
61    if (
62      aEvent.keyCode == KeyCodes.DOM_VK_PAGE_UP ||
63      aEvent.keyCode == KeyCodes.DOM_VK_HOME
64    ) {
65      newFocusOrdinal = 0;
66    } else if (
67      aEvent.keyCode == KeyCodes.DOM_VK_PAGE_DOWN ||
68      aEvent.keyCode == KeyCodes.DOM_VK_END
69    ) {
70      newFocusOrdinal = this._nav.childNodes.length - 1;
71    } else if (aEvent.keyCode == KeyCodes.DOM_VK_UP) {
72      newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute(
73        "data-ordinal"
74      );
75      newFocusOrdinal--;
76    } else if (aEvent.keyCode == KeyCodes.DOM_VK_DOWN) {
77      newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute(
78        "data-ordinal"
79      );
80      newFocusOrdinal++;
81    }
82    if (newFocusOrdinal !== undefined) {
83      aEvent.stopPropagation();
84      const el = this.getSummaryElementByOrdinal(newFocusOrdinal);
85      if (el) {
86        el.focus();
87      }
88      return false;
89    }
90  });
91}
92
93SplitView.prototype = {
94  /**
95   * Retrieve whether the UI currently has a landscape orientation.
96   *
97   * @return boolean
98   */
99  get isLandscape() {
100    return this._mql.matches;
101  },
102
103  /**
104   * Retrieve the root element.
105   *
106   * @return DOMElement
107   */
108  get rootElement() {
109    return this._root;
110  },
111
112  /**
113   * Retrieve the active item's summary element or null if there is none.
114   *
115   * @return DOMElement
116   */
117  get activeSummary() {
118    return this._activeSummary;
119  },
120
121  /**
122   * Set the active item's summary element.
123   *
124   * @param DOMElement aSummary
125   */
126  set activeSummary(aSummary) {
127    if (aSummary == this._activeSummary) {
128      return;
129    }
130
131    if (this._activeSummary) {
132      const binding = bindings.get(this._activeSummary);
133
134      if (binding.onHide) {
135        binding.onHide(this._activeSummary, binding._details, binding.data);
136      }
137
138      this._activeSummary.classList.remove("splitview-active");
139      binding._details.classList.remove("splitview-active");
140    }
141
142    if (!aSummary) {
143      return;
144    }
145
146    const binding = bindings.get(aSummary);
147    aSummary.classList.add("splitview-active");
148    binding._details.classList.add("splitview-active");
149
150    this._activeSummary = aSummary;
151
152    if (binding.onShow) {
153      binding.onShow(aSummary, binding._details, binding.data);
154    }
155  },
156
157  /**
158   * Retrieve the active item's details element or null if there is none.
159   * @return DOMElement
160   */
161  get activeDetails() {
162    const summary = this.activeSummary;
163    return summary ? bindings.get(summary)._details : null;
164  },
165
166  /**
167   * Retrieve the summary element for a given ordinal.
168   *
169   * @param number aOrdinal
170   * @return DOMElement
171   *         Summary element with given ordinal or null if not found.
172   * @see appendItem
173   */
174  getSummaryElementByOrdinal: function SEC_getSummaryElementByOrdinal(
175    aOrdinal
176  ) {
177    return this._nav.querySelector("* > li[data-ordinal='" + aOrdinal + "']");
178  },
179
180  /**
181   * Append an item to the split view.
182   *
183   * @param DOMElement aSummary
184   *        The summary element for the item.
185   * @param DOMElement aDetails
186   *        The details element for the item.
187   * @param object aOptions
188   *     Optional object that defines custom behavior and data for the item.
189   *     All properties are optional :
190   *     - function(DOMElement summary, DOMElement details, object data) onCreate
191   *         Called when the item has been added.
192   *     - function(summary, details, data) onShow
193   *         Called when the item is shown/active.
194   *     - function(summary, details, data) onHide
195   *         Called when the item is hidden/inactive.
196   *     - function(summary, details, data) onDestroy
197   *         Called when the item has been removed.
198   *     - object data
199   *         Object to pass to the callbacks above.
200   *     - number ordinal
201   *         Items with a lower ordinal are displayed before those with a
202   *         higher ordinal.
203   */
204  appendItem: function ASV_appendItem(aSummary, aDetails, aOptions) {
205    const binding = aOptions || {};
206
207    binding._summary = aSummary;
208    binding._details = aDetails;
209    bindings.set(aSummary, binding);
210
211    this._nav.appendChild(aSummary);
212
213    aSummary.addEventListener("click", aEvent => {
214      aEvent.stopPropagation();
215      this.activeSummary = aSummary;
216    });
217
218    this._side.appendChild(aDetails);
219
220    if (binding.onCreate) {
221      binding.onCreate(aSummary, aDetails, binding.data);
222    }
223  },
224
225  /**
226   * Append an item to the split view according to two template elements
227   * (one for the item's summary and the other for the item's details).
228   *
229   * @param string aName
230   *        Name of the template elements to instantiate.
231   *        Requires two (hidden) DOM elements with id "splitview-tpl-summary-"
232   *        and "splitview-tpl-details-" suffixed with aName.
233   * @param object aOptions
234   *        Optional object that defines custom behavior and data for the item.
235   *        See appendItem for full description.
236   * @return object{summary:,details:}
237   *         Object with the new DOM elements created for summary and details.
238   * @see appendItem
239   */
240  appendTemplatedItem: function ASV_appendTemplatedItem(aName, aOptions) {
241    aOptions = aOptions || {};
242    let summary = this._root.querySelector("#splitview-tpl-summary-" + aName);
243    let details = this._root.querySelector("#splitview-tpl-details-" + aName);
244
245    summary = summary.cloneNode(true);
246    summary.id = "";
247    if (aOptions.ordinal !== undefined) {
248      // can be zero
249      summary.style.MozBoxOrdinalGroup = aOptions.ordinal;
250      summary.setAttribute("data-ordinal", aOptions.ordinal);
251    }
252    details = details.cloneNode(true);
253    details.id = "";
254
255    this.appendItem(summary, details, aOptions);
256    return { summary: summary, details: details };
257  },
258
259  /**
260   * Remove an item from the split view.
261   *
262   * @param DOMElement aSummary
263   *        Summary element of the item to remove.
264   */
265  removeItem: function ASV_removeItem(aSummary) {
266    if (aSummary == this._activeSummary) {
267      this.activeSummary = null;
268    }
269
270    const binding = bindings.get(aSummary);
271    aSummary.remove();
272    binding._details.remove();
273
274    if (binding.onDestroy) {
275      binding.onDestroy(aSummary, binding._details, binding.data);
276    }
277  },
278
279  /**
280   * Remove all items from the split view.
281   */
282  removeAll: function ASV_removeAll() {
283    while (this._nav.hasChildNodes()) {
284      this.removeItem(this._nav.firstChild);
285    }
286  },
287
288  /**
289   * Set the item's CSS class name.
290   * This sets the class on both the summary and details elements, retaining
291   * any SplitView-specific classes.
292   *
293   * @param DOMElement aSummary
294   *        Summary element of the item to set.
295   * @param string aClassName
296   *        One or more space-separated CSS classes.
297   */
298  setItemClassName: function ASV_setItemClassName(aSummary, aClassName) {
299    const binding = bindings.get(aSummary);
300    let viewSpecific;
301
302    viewSpecific = aSummary.className.match(/(splitview\-[\w-]+)/g);
303    viewSpecific = viewSpecific ? viewSpecific.join(" ") : "";
304    aSummary.className = viewSpecific + " " + aClassName;
305
306    viewSpecific = binding._details.className.match(/(splitview\-[\w-]+)/g);
307    viewSpecific = viewSpecific ? viewSpecific.join(" ") : "";
308    binding._details.className = viewSpecific + " " + aClassName;
309  },
310};
311