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 */ 31 function 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 }; 42 SuggestionPicker.prototype = Object.create(Picker.prototype); 43 44 SuggestionPicker.NumberOfVisibleEntries = 20; 45 46 // An entry needs to be at least this many pixels visible for it to be a visible entry. 47 SuggestionPicker.VisibleEntryThresholdHeight = 4; 48 49 SuggestionPicker.ActionNames = { 50 OpenCalendarPicker: "openCalendarPicker" 51 }; 52 53 SuggestionPicker.ListEntryClass = "suggestion-list-entry"; 54 55 SuggestionPicker.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 71 SuggestionPicker.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 79 SuggestionPicker.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 */ 89 SuggestionPicker.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 */ 110 SuggestionPicker.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 */ 125 SuggestionPicker.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 138 SuggestionPicker.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 167 SuggestionPicker.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 */ 192 SuggestionPicker.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 201 SuggestionPicker._handleWindowDidHide = function() { 202 openCalendarPicker(); 203 window.removeEventListener("didHide", SuggestionPicker._handleWindowDidHide); 204 }; 205 206 /** 207 * @param {!Event} event 208 */ 209 SuggestionPicker.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 */ 220 SuggestionPicker.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 */ 236 SuggestionPicker.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 */ 252 SuggestionPicker.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 */ 307 SuggestionPicker.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 */ 319 SuggestionPicker.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