3 * Copyright (C) 2012 Google Inc. All rights reserved.
5 * Redistribution and use in source and binary forms, with or without
6 * modification, are permitted provided that the following conditions
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.
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.
28 * @param {!Element} element
29 * @param {!Object} config
31 function SuggestionPicker(element, config) {
32 Picker.call(this, element, config);
33 this._isFocusByMouse = false;
34 this._containerElement = null;
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);
42 SuggestionPicker.prototype = Object.create(Picker.prototype);
44 SuggestionPicker.NumberOfVisibleEntries = 20;
46 // An entry needs to be at least this many pixels visible for it to be a visible entry.
47 SuggestionPicker.VisibleEntryThresholdHeight = 4;
49 SuggestionPicker.ActionNames = {
50 OpenCalendarPicker: "openCalendarPicker"
53 SuggestionPicker.ListEntryClass = "suggestion-list-entry";
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.";
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));
79 SuggestionPicker.prototype.cleanup = function() {
80 document.body.removeEventListener("keydown", this._handleBodyKeyDownBound, false);
84 * @param {!string} title
85 * @param {!string} label
86 * @param {!string} value
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);
98 var labelElement = createElement("span", "label", label);
99 content.appendChild(labelElement);
101 entryElement.addEventListener("mouseover", this._handleEntryMouseOver.bind(this), false);
106 * @param {!string} title
107 * @param {!string} actionName
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);
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);
134 this._containerElement.classList.remove("measuring-width");
135 return maxContentWidth;
138 SuggestionPicker.prototype._fixWindowSize = function() {
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;
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))
150 totalHeight += node.offsetHeight;
151 if (maxHeight === 0 && entryCount == SuggestionPicker.NumberOfVisibleEntries)
152 maxHeight = totalHeight;
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";
162 var windowRect = adjustWindowRect(desiredWindowWidth, desiredWindowHeight, desiredWindowWidth, 0);
163 this._containerElement.style.height = (windowRect.height - ListBorder) + "px";
164 setWindowRect(windowRect);
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]));
177 if (this._config.showOtherDateEntry) {
179 var separator = createElement("div", "separator");
180 this._containerElement.appendChild(separator);
182 // Add "Other..." entry
183 var otherEntry = this._createActionEntryElement(this._config.otherDateLabel, SuggestionPicker.ActionNames.OpenCalendarPicker);
184 this._containerElement.appendChild(otherEntry);
186 this._element.appendChild(this._containerElement);
190 * @param {!Element} entry
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);
201 SuggestionPicker._handleWindowDidHide = function() {
202 openCalendarPicker();
203 window.removeEventListener("didHide", SuggestionPicker._handleWindowDidHide);
207 * @param {!Event} event
209 SuggestionPicker.prototype._handleEntryClick = function(event) {
210 var entry = enclosingNodeOrSelfWithClass(event.target, SuggestionPicker.ListEntryClass);
213 this.selectEntry(entry);
214 event.preventDefault();
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))
227 if (node.offsetTop + node.offsetHeight - scrollTop > SuggestionPicker.VisibleEntryThresholdHeight)
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))
243 if (scrollBottom - node.offsetTop > SuggestionPicker.VisibleEntryThresholdHeight)
250 * @param {!Event} event
252 SuggestionPicker.prototype._handleBodyKeyDown = function(event) {
253 var eventHandled = false;
254 var key = event.keyIdentifier;
255 if (key === "U+001B") { // ESC
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;
268 this._element.querySelector("." + SuggestionPicker.ListEntryClass + ":last-child").focus();
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;
281 this._element.querySelector("." + SuggestionPicker.ListEntryClass + ":first-child").focus();
284 } else if (key === "Enter") {
285 this.selectEntry(document.activeElement);
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();
294 } else if (key === "PageDown") {
295 this._containerElement.scrollTop += this._containerElement.clientHeight;
296 if (!this._isFocusByMouse)
297 this._findLastVisibleEntry().focus();
301 event.preventDefault();
305 * @param {!Event} event
307 SuggestionPicker.prototype._handleEntryMouseOver = function(event) {
308 var entry = enclosingNodeOrSelfWithClass(event.target, SuggestionPicker.ListEntryClass);
311 this._isFocusByMouse = true;
313 event.preventDefault();
317 * @param {!Event} event
319 SuggestionPicker.prototype._handleMouseOut = function(event) {
320 if (!document.activeElement.classList.contains(SuggestionPicker.ListEntryClass))
322 this._isFocusByMouse = false;
323 document.activeElement.blur();
324 event.preventDefault();