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