1"use strict"; 2/* 3 * Copyright (C) 2012 Google Inc. All rights reserved. 4 * 5 * Redistribution and use in source and binary forms, with or without 6 * modification, are permitted provided that the following conditions 7 * are met: 8 * 1. Redistributions of source code must retain the above copyright 9 * notice, this list of conditions and the following disclaimer. 10 * 2. Redistributions in binary form must reproduce the above copyright 11 * notice, this list of conditions and the following disclaimer in the 12 * documentation and/or other materials provided with the distribution. 13 * 14 * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND ANY 15 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 * DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR ANY 18 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 */ 25 26/** 27 * @constructor 28 * @param {!Element} element 29 * @param {!Object} config 30 */ 31function SuggestionPicker(element, config) { 32 Picker.call(this, element, config); 33 this._isFocusByMouse = false; 34 this._containerElement = null; 35 this._setColors(); 36 this._layout(); 37 this._fixWindowSize(); 38 this._handleBodyKeyDownBound = this._handleBodyKeyDown.bind(this); 39 document.body.addEventListener("keydown", this._handleBodyKeyDownBound); 40 this._element.addEventListener("mouseout", this._handleMouseOut.bind(this), false); 41}; 42SuggestionPicker.prototype = Object.create(Picker.prototype); 43 44SuggestionPicker.NumberOfVisibleEntries = 20; 45 46// An entry needs to be at least this many pixels visible for it to be a visible entry. 47SuggestionPicker.VisibleEntryThresholdHeight = 4; 48 49SuggestionPicker.ActionNames = { 50 OpenCalendarPicker: "openCalendarPicker" 51}; 52 53SuggestionPicker.ListEntryClass = "suggestion-list-entry"; 54 55SuggestionPicker.validateConfig = function(config) { 56 if (config.showOtherDateEntry && !config.otherDateLabel) 57 return "No otherDateLabel."; 58 if (config.suggestionHighlightColor && !config.suggestionHighlightColor) 59 return "No suggestionHighlightColor."; 60 if (config.suggestionHighlightTextColor && !config.suggestionHighlightTextColor) 61 return "No suggestionHighlightTextColor."; 62 if (config.suggestionValues.length !== config.localizedSuggestionValues.length) 63 return "localizedSuggestionValues.length must equal suggestionValues.length."; 64 if (config.suggestionValues.length !== config.suggestionLabels.length) 65 return "suggestionLabels.length must equal suggestionValues.length."; 66 if (typeof config.inputWidth === "undefined") 67 return "No inputWidth."; 68 return null; 69}; 70 71SuggestionPicker.prototype._setColors = function() { 72 var text = "." + SuggestionPicker.ListEntryClass + ":focus {\ 73 background-color: " + this._config.suggestionHighlightColor + ";\ 74 color: " + this._config.suggestionHighlightTextColor + "; }"; 75 text += "." + SuggestionPicker.ListEntryClass + ":focus .label { color: " + this._config.suggestionHighlightTextColor + "; }"; 76 document.head.appendChild(createElement("style", null, text)); 77}; 78 79SuggestionPicker.prototype.cleanup = function() { 80 document.body.removeEventListener("keydown", this._handleBodyKeyDownBound, false); 81}; 82 83/** 84 * @param {!string} title 85 * @param {!string} label 86 * @param {!string} value 87 * @return {!Element} 88 */ 89SuggestionPicker.prototype._createSuggestionEntryElement = function(title, label, value) { 90 var entryElement = createElement("li", SuggestionPicker.ListEntryClass); 91 entryElement.tabIndex = 0; 92 entryElement.dataset.value = value; 93 var content = createElement("span", "content"); 94 entryElement.appendChild(content); 95 var titleElement = createElement("span", "title", title); 96 content.appendChild(titleElement); 97 if (label) { 98 var labelElement = createElement("span", "label", label); 99 content.appendChild(labelElement); 100 } 101 entryElement.addEventListener("mouseover", this._handleEntryMouseOver.bind(this), false); 102 return entryElement; 103}; 104 105/** 106 * @param {!string} title 107 * @param {!string} actionName 108 * @return {!Element} 109 */ 110SuggestionPicker.prototype._createActionEntryElement = function(title, actionName) { 111 var entryElement = createElement("li", SuggestionPicker.ListEntryClass); 112 entryElement.tabIndex = 0; 113 entryElement.dataset.action = actionName; 114 var content = createElement("span", "content"); 115 entryElement.appendChild(content); 116 var titleElement = createElement("span", "title", title); 117 content.appendChild(titleElement); 118 entryElement.addEventListener("mouseover", this._handleEntryMouseOver.bind(this), false); 119 return entryElement; 120}; 121 122/** 123* @return {!number} 124*/ 125SuggestionPicker.prototype._measureMaxContentWidth = function() { 126 // To measure the required width, we first set the class to "measuring-width" which 127 // left aligns all the content including label. 128 this._containerElement.classList.add("measuring-width"); 129 var maxContentWidth = 0; 130 var contentElements = this._containerElement.getElementsByClassName("content"); 131 for (var i=0; i < contentElements.length; ++i) { 132 maxContentWidth = Math.max(maxContentWidth, contentElements[i].offsetWidth); 133 } 134 this._containerElement.classList.remove("measuring-width"); 135 return maxContentWidth; 136}; 137 138SuggestionPicker.prototype._fixWindowSize = function() { 139 var ListBorder = 2; 140 var desiredWindowWidth = this._measureMaxContentWidth() + ListBorder; 141 if (typeof this._config.inputWidth === "number") 142 desiredWindowWidth = Math.max(this._config.inputWidth, desiredWindowWidth); 143 var totalHeight = ListBorder; 144 var maxHeight = 0; 145 var entryCount = 0; 146 for (var i = 0; i < this._containerElement.childNodes.length; ++i) { 147 var node = this._containerElement.childNodes[i]; 148 if (node.classList.contains(SuggestionPicker.ListEntryClass)) 149 entryCount++; 150 totalHeight += node.offsetHeight; 151 if (maxHeight === 0 && entryCount == SuggestionPicker.NumberOfVisibleEntries) 152 maxHeight = totalHeight; 153 } 154 var desiredWindowHeight = totalHeight; 155 if (maxHeight !== 0 && totalHeight > maxHeight) { 156 this._containerElement.style.maxHeight = (maxHeight - ListBorder) + "px"; 157 desiredWindowWidth += getScrollbarWidth(); 158 desiredWindowHeight = maxHeight; 159 this._containerElement.style.overflowY = "scroll"; 160 } 161 162 var windowRect = adjustWindowRect(desiredWindowWidth, desiredWindowHeight, desiredWindowWidth, 0); 163 this._containerElement.style.height = (windowRect.height - ListBorder) + "px"; 164 setWindowRect(windowRect); 165}; 166 167SuggestionPicker.prototype._layout = function() { 168 if (this._config.isRTL) 169 this._element.classList.add("rtl"); 170 if (this._config.isLocaleRTL) 171 this._element.classList.add("locale-rtl"); 172 this._containerElement = createElement("ul", "suggestion-list"); 173 this._containerElement.addEventListener("click", this._handleEntryClick.bind(this), false); 174 for (var i = 0; i < this._config.suggestionValues.length; ++i) { 175 this._containerElement.appendChild(this._createSuggestionEntryElement(this._config.localizedSuggestionValues[i], this._config.suggestionLabels[i], this._config.suggestionValues[i])); 176 } 177 if (this._config.showOtherDateEntry) { 178 // Add separator 179 var separator = createElement("div", "separator"); 180 this._containerElement.appendChild(separator); 181 182 // Add "Other..." entry 183 var otherEntry = this._createActionEntryElement(this._config.otherDateLabel, SuggestionPicker.ActionNames.OpenCalendarPicker); 184 this._containerElement.appendChild(otherEntry); 185 } 186 this._element.appendChild(this._containerElement); 187}; 188 189/** 190 * @param {!Element} entry 191 */ 192SuggestionPicker.prototype.selectEntry = function(entry) { 193 if (typeof entry.dataset.value !== "undefined") { 194 this.submitValue(entry.dataset.value); 195 } else if (entry.dataset.action === SuggestionPicker.ActionNames.OpenCalendarPicker) { 196 window.addEventListener("didHide", SuggestionPicker._handleWindowDidHide, false); 197 hideWindow(); 198 } 199}; 200 201SuggestionPicker._handleWindowDidHide = function() { 202 openCalendarPicker(); 203 window.removeEventListener("didHide", SuggestionPicker._handleWindowDidHide); 204}; 205 206/** 207 * @param {!Event} event 208 */ 209SuggestionPicker.prototype._handleEntryClick = function(event) { 210 var entry = enclosingNodeOrSelfWithClass(event.target, SuggestionPicker.ListEntryClass); 211 if (!entry) 212 return; 213 this.selectEntry(entry); 214 event.preventDefault(); 215}; 216 217/** 218 * @return {?Element} 219 */ 220SuggestionPicker.prototype._findFirstVisibleEntry = function() { 221 var scrollTop = this._containerElement.scrollTop; 222 var childNodes = this._containerElement.childNodes; 223 for (var i = 0; i < childNodes.length; ++i) { 224 var node = childNodes[i]; 225 if (node.nodeType !== Node.ELEMENT_NODE || !node.classList.contains(SuggestionPicker.ListEntryClass)) 226 continue; 227 if (node.offsetTop + node.offsetHeight - scrollTop > SuggestionPicker.VisibleEntryThresholdHeight) 228 return node; 229 } 230 return null; 231}; 232 233/** 234 * @return {?Element} 235 */ 236SuggestionPicker.prototype._findLastVisibleEntry = function() { 237 var scrollBottom = this._containerElement.scrollTop + this._containerElement.offsetHeight; 238 var childNodes = this._containerElement.childNodes; 239 for (var i = childNodes.length - 1; i >= 0; --i){ 240 var node = childNodes[i]; 241 if (node.nodeType !== Node.ELEMENT_NODE || !node.classList.contains(SuggestionPicker.ListEntryClass)) 242 continue; 243 if (scrollBottom - node.offsetTop > SuggestionPicker.VisibleEntryThresholdHeight) 244 return node; 245 } 246 return null; 247}; 248 249/** 250 * @param {!Event} event 251 */ 252SuggestionPicker.prototype._handleBodyKeyDown = function(event) { 253 var eventHandled = false; 254 var key = event.keyIdentifier; 255 if (key === "U+001B") { // ESC 256 this.handleCancel(); 257 eventHandled = true; 258 } else if (key == "Up") { 259 if (document.activeElement && document.activeElement.classList.contains(SuggestionPicker.ListEntryClass)) { 260 for (var node = document.activeElement.previousElementSibling; node; node = node.previousElementSibling) { 261 if (node.classList.contains(SuggestionPicker.ListEntryClass)) { 262 this._isFocusByMouse = false; 263 node.focus(); 264 break; 265 } 266 } 267 } else { 268 this._element.querySelector("." + SuggestionPicker.ListEntryClass + ":last-child").focus(); 269 } 270 eventHandled = true; 271 } else if (key == "Down") { 272 if (document.activeElement && document.activeElement.classList.contains(SuggestionPicker.ListEntryClass)) { 273 for (var node = document.activeElement.nextElementSibling; node; node = node.nextElementSibling) { 274 if (node.classList.contains(SuggestionPicker.ListEntryClass)) { 275 this._isFocusByMouse = false; 276 node.focus(); 277 break; 278 } 279 } 280 } else { 281 this._element.querySelector("." + SuggestionPicker.ListEntryClass + ":first-child").focus(); 282 } 283 eventHandled = true; 284 } else if (key === "Enter") { 285 this.selectEntry(document.activeElement); 286 eventHandled = true; 287 } else if (key === "PageUp") { 288 this._containerElement.scrollTop -= this._containerElement.clientHeight; 289 // Scrolling causes mouseover event to be called and that tries to move the focus too. 290 // To prevent flickering we won't focus if the current focus was caused by the mouse. 291 if (!this._isFocusByMouse) 292 this._findFirstVisibleEntry().focus(); 293 eventHandled = true; 294 } else if (key === "PageDown") { 295 this._containerElement.scrollTop += this._containerElement.clientHeight; 296 if (!this._isFocusByMouse) 297 this._findLastVisibleEntry().focus(); 298 eventHandled = true; 299 } 300 if (eventHandled) 301 event.preventDefault(); 302}; 303 304/** 305 * @param {!Event} event 306 */ 307SuggestionPicker.prototype._handleEntryMouseOver = function(event) { 308 var entry = enclosingNodeOrSelfWithClass(event.target, SuggestionPicker.ListEntryClass); 309 if (!entry) 310 return; 311 this._isFocusByMouse = true; 312 entry.focus(); 313 event.preventDefault(); 314}; 315 316/** 317 * @param {!Event} event 318 */ 319SuggestionPicker.prototype._handleMouseOut = function(event) { 320 if (!document.activeElement.classList.contains(SuggestionPicker.ListEntryClass)) 321 return; 322 this._isFocusByMouse = false; 323 document.activeElement.blur(); 324 event.preventDefault(); 325}; 326