michael@0: /* vim:set ts=2 sw=2 sts=2 et: */ michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = ["SplitView"]; michael@0: michael@0: /* this must be kept in sync with CSS (ie. splitview.css) */ michael@0: const LANDSCAPE_MEDIA_QUERY = "(min-width: 551px)"; michael@0: michael@0: let bindings = new WeakMap(); michael@0: michael@0: /** michael@0: * SplitView constructor michael@0: * michael@0: * Initialize the split view UI on an existing DOM element. michael@0: * michael@0: * A split view contains items, each of those having one summary and one details michael@0: * elements. michael@0: * It is adaptive as it behaves similarly to a richlistbox when there the aspect michael@0: * ratio is narrow or as a pair listbox-box otherwise. michael@0: * michael@0: * @param DOMElement aRoot michael@0: * @see appendItem michael@0: */ michael@0: this.SplitView = function SplitView(aRoot) michael@0: { michael@0: this._root = aRoot; michael@0: this._controller = aRoot.querySelector(".splitview-controller"); michael@0: this._nav = aRoot.querySelector(".splitview-nav"); michael@0: this._side = aRoot.querySelector(".splitview-side-details"); michael@0: this._activeSummary = null michael@0: michael@0: this._mql = aRoot.ownerDocument.defaultView.matchMedia(LANDSCAPE_MEDIA_QUERY); michael@0: michael@0: // items list focus and search-on-type handling michael@0: this._nav.addEventListener("keydown", function onKeyCatchAll(aEvent) { michael@0: function getFocusedItemWithin(nav) { michael@0: let node = nav.ownerDocument.activeElement; michael@0: while (node && node.parentNode != nav) { michael@0: node = node.parentNode; michael@0: } michael@0: return node; michael@0: } michael@0: michael@0: // do not steal focus from inside iframes or textboxes michael@0: if (aEvent.target.ownerDocument != this._nav.ownerDocument || michael@0: aEvent.target.tagName == "input" || michael@0: aEvent.target.tagName == "textbox" || michael@0: aEvent.target.tagName == "textarea" || michael@0: aEvent.target.classList.contains("textbox")) { michael@0: return false; michael@0: } michael@0: michael@0: // handle keyboard navigation within the items list michael@0: let newFocusOrdinal; michael@0: if (aEvent.keyCode == aEvent.DOM_VK_PAGE_UP || michael@0: aEvent.keyCode == aEvent.DOM_VK_HOME) { michael@0: newFocusOrdinal = 0; michael@0: } else if (aEvent.keyCode == aEvent.DOM_VK_PAGE_DOWN || michael@0: aEvent.keyCode == aEvent.DOM_VK_END) { michael@0: newFocusOrdinal = this._nav.childNodes.length - 1; michael@0: } else if (aEvent.keyCode == aEvent.DOM_VK_UP) { michael@0: newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute("data-ordinal"); michael@0: newFocusOrdinal--; michael@0: } else if (aEvent.keyCode == aEvent.DOM_VK_DOWN) { michael@0: newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute("data-ordinal"); michael@0: newFocusOrdinal++; michael@0: } michael@0: if (newFocusOrdinal !== undefined) { michael@0: aEvent.stopPropagation(); michael@0: let el = this.getSummaryElementByOrdinal(newFocusOrdinal); michael@0: if (el) { michael@0: el.focus(); michael@0: } michael@0: return false; michael@0: } michael@0: }.bind(this), false); michael@0: } michael@0: michael@0: SplitView.prototype = { michael@0: /** michael@0: * Retrieve whether the UI currently has a landscape orientation. michael@0: * michael@0: * @return boolean michael@0: */ michael@0: get isLandscape() this._mql.matches, michael@0: michael@0: /** michael@0: * Retrieve the root element. michael@0: * michael@0: * @return DOMElement michael@0: */ michael@0: get rootElement() this._root, michael@0: michael@0: /** michael@0: * Retrieve the active item's summary element or null if there is none. michael@0: * michael@0: * @return DOMElement michael@0: */ michael@0: get activeSummary() this._activeSummary, michael@0: michael@0: /** michael@0: * Set the active item's summary element. michael@0: * michael@0: * @param DOMElement aSummary michael@0: */ michael@0: set activeSummary(aSummary) michael@0: { michael@0: if (aSummary == this._activeSummary) { michael@0: return; michael@0: } michael@0: michael@0: if (this._activeSummary) { michael@0: let binding = bindings.get(this._activeSummary); michael@0: michael@0: if (binding.onHide) { michael@0: binding.onHide(this._activeSummary, binding._details, binding.data); michael@0: } michael@0: michael@0: this._activeSummary.classList.remove("splitview-active"); michael@0: binding._details.classList.remove("splitview-active"); michael@0: } michael@0: michael@0: if (!aSummary) { michael@0: return; michael@0: } michael@0: michael@0: let binding = bindings.get(aSummary); michael@0: aSummary.classList.add("splitview-active"); michael@0: binding._details.classList.add("splitview-active"); michael@0: michael@0: this._activeSummary = aSummary; michael@0: michael@0: if (binding.onShow) { michael@0: binding.onShow(aSummary, binding._details, binding.data); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Retrieve the active item's details element or null if there is none. michael@0: * @return DOMElement michael@0: */ michael@0: get activeDetails() michael@0: { michael@0: let summary = this.activeSummary; michael@0: return summary ? bindings.get(summary)._details : null; michael@0: }, michael@0: michael@0: /** michael@0: * Retrieve the summary element for a given ordinal. michael@0: * michael@0: * @param number aOrdinal michael@0: * @return DOMElement michael@0: * Summary element with given ordinal or null if not found. michael@0: * @see appendItem michael@0: */ michael@0: getSummaryElementByOrdinal: function SEC_getSummaryElementByOrdinal(aOrdinal) michael@0: { michael@0: return this._nav.querySelector("* > li[data-ordinal='" + aOrdinal + "']"); michael@0: }, michael@0: michael@0: /** michael@0: * Append an item to the split view. michael@0: * michael@0: * @param DOMElement aSummary michael@0: * The summary element for the item. michael@0: * @param DOMElement aDetails michael@0: * The details element for the item. michael@0: * @param object aOptions michael@0: * Optional object that defines custom behavior and data for the item. michael@0: * All properties are optional : michael@0: * - function(DOMElement summary, DOMElement details, object data) onCreate michael@0: * Called when the item has been added. michael@0: * - function(summary, details, data) onShow michael@0: * Called when the item is shown/active. michael@0: * - function(summary, details, data) onHide michael@0: * Called when the item is hidden/inactive. michael@0: * - function(summary, details, data) onDestroy michael@0: * Called when the item has been removed. michael@0: * - object data michael@0: * Object to pass to the callbacks above. michael@0: * - number ordinal michael@0: * Items with a lower ordinal are displayed before those with a michael@0: * higher ordinal. michael@0: */ michael@0: appendItem: function ASV_appendItem(aSummary, aDetails, aOptions) michael@0: { michael@0: let binding = aOptions || {}; michael@0: michael@0: binding._summary = aSummary; michael@0: binding._details = aDetails; michael@0: bindings.set(aSummary, binding); michael@0: michael@0: this._nav.appendChild(aSummary); michael@0: michael@0: aSummary.addEventListener("click", function onSummaryClick(aEvent) { michael@0: aEvent.stopPropagation(); michael@0: this.activeSummary = aSummary; michael@0: }.bind(this), false); michael@0: michael@0: this._side.appendChild(aDetails); michael@0: michael@0: if (binding.onCreate) { michael@0: // queue onCreate handler michael@0: this._root.ownerDocument.defaultView.setTimeout(function () { michael@0: binding.onCreate(aSummary, aDetails, binding.data); michael@0: }, 0); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Append an item to the split view according to two template elements michael@0: * (one for the item's summary and the other for the item's details). michael@0: * michael@0: * @param string aName michael@0: * Name of the template elements to instantiate. michael@0: * Requires two (hidden) DOM elements with id "splitview-tpl-summary-" michael@0: * and "splitview-tpl-details-" suffixed with aName. michael@0: * @param object aOptions michael@0: * Optional object that defines custom behavior and data for the item. michael@0: * See appendItem for full description. michael@0: * @return object{summary:,details:} michael@0: * Object with the new DOM elements created for summary and details. michael@0: * @see appendItem michael@0: */ michael@0: appendTemplatedItem: function ASV_appendTemplatedItem(aName, aOptions) michael@0: { michael@0: aOptions = aOptions || {}; michael@0: let summary = this._root.querySelector("#splitview-tpl-summary-" + aName); michael@0: let details = this._root.querySelector("#splitview-tpl-details-" + aName); michael@0: michael@0: summary = summary.cloneNode(true); michael@0: summary.id = ""; michael@0: if (aOptions.ordinal !== undefined) { // can be zero michael@0: summary.style.MozBoxOrdinalGroup = aOptions.ordinal; michael@0: summary.setAttribute("data-ordinal", aOptions.ordinal); michael@0: } michael@0: details = details.cloneNode(true); michael@0: details.id = ""; michael@0: michael@0: this.appendItem(summary, details, aOptions); michael@0: return {summary: summary, details: details}; michael@0: }, michael@0: michael@0: /** michael@0: * Remove an item from the split view. michael@0: * michael@0: * @param DOMElement aSummary michael@0: * Summary element of the item to remove. michael@0: */ michael@0: removeItem: function ASV_removeItem(aSummary) michael@0: { michael@0: if (aSummary == this._activeSummary) { michael@0: this.activeSummary = null; michael@0: } michael@0: michael@0: let binding = bindings.get(aSummary); michael@0: aSummary.parentNode.removeChild(aSummary); michael@0: binding._details.parentNode.removeChild(binding._details); michael@0: michael@0: if (binding.onDestroy) { michael@0: binding.onDestroy(aSummary, binding._details, binding.data); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Remove all items from the split view. michael@0: */ michael@0: removeAll: function ASV_removeAll() michael@0: { michael@0: while (this._nav.hasChildNodes()) { michael@0: this.removeItem(this._nav.firstChild); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Set the item's CSS class name. michael@0: * This sets the class on both the summary and details elements, retaining michael@0: * any SplitView-specific classes. michael@0: * michael@0: * @param DOMElement aSummary michael@0: * Summary element of the item to set. michael@0: * @param string aClassName michael@0: * One or more space-separated CSS classes. michael@0: */ michael@0: setItemClassName: function ASV_setItemClassName(aSummary, aClassName) michael@0: { michael@0: let binding = bindings.get(aSummary); michael@0: let viewSpecific; michael@0: michael@0: viewSpecific = aSummary.className.match(/(splitview\-[\w-]+)/g); michael@0: viewSpecific = viewSpecific ? viewSpecific.join(" ") : ""; michael@0: aSummary.className = viewSpecific + " " + aClassName; michael@0: michael@0: viewSpecific = binding._details.className.match(/(splitview\-[\w-]+)/g); michael@0: viewSpecific = viewSpecific ? viewSpecific.join(" ") : ""; michael@0: binding._details.className = viewSpecific + " " + aClassName; michael@0: }, michael@0: };