Home | History | Annotate | Download | only in pagepopups
      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