• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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