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();