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