1// Copyright (c) 2012 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5cr.define('cr.ui', function() { 6 /** @const */ var ArrayDataModel = cr.ui.ArrayDataModel; 7 /** @const */ var List = cr.ui.List; 8 /** @const */ var ListItem = cr.ui.ListItem; 9 10 /** 11 * Creates a new autocomplete list item. 12 * This is suitable for selecting a web site, and used by default. 13 * A different behavior can be set by AutocompleteListItem.itemConstructor. 14 * @param {Object} pageInfo The page this item represents. 15 * @constructor 16 * @extends {cr.ui.ListItem} 17 */ 18 function AutocompleteListItem(pageInfo) { 19 var el = cr.doc.createElement('div'); 20 el.pageInfo_ = pageInfo; 21 AutocompleteListItem.decorate(el); 22 return el; 23 } 24 25 /** 26 * Decorates an element as an autocomplete list item. 27 * @param {!HTMLElement} el The element to decorate. 28 */ 29 AutocompleteListItem.decorate = function(el) { 30 el.__proto__ = AutocompleteListItem.prototype; 31 el.decorate(); 32 }; 33 34 AutocompleteListItem.prototype = { 35 __proto__: ListItem.prototype, 36 37 /** @override */ 38 decorate: function() { 39 ListItem.prototype.decorate.call(this); 40 41 var title = this.pageInfo_['title']; 42 var url = this.pageInfo_['displayURL']; 43 var titleEl = this.ownerDocument.createElement('span'); 44 titleEl.className = 'title'; 45 titleEl.textContent = title || url; 46 this.appendChild(titleEl); 47 48 if (title && title.length > 0 && url != title) { 49 var separatorEl = this.ownerDocument.createTextNode(' - '); 50 this.appendChild(separatorEl); 51 52 var urlEl = this.ownerDocument.createElement('span'); 53 urlEl.className = 'url'; 54 urlEl.textContent = url; 55 this.appendChild(urlEl); 56 } 57 }, 58 }; 59 60 /** 61 * Creates a new autocomplete list popup. 62 * @constructor 63 * @extends {cr.ui.List} 64 */ 65 var AutocompleteList = cr.ui.define('list'); 66 67 AutocompleteList.prototype = { 68 __proto__: List.prototype, 69 70 /** 71 * The text field the autocomplete popup is currently attached to, if any. 72 * @type {HTMLElement} 73 * @private 74 */ 75 targetInput_: null, 76 77 /** 78 * Keydown event listener to attach to a text field. 79 * @type {Function} 80 * @private 81 */ 82 textFieldKeyHandler_: null, 83 84 /** 85 * Input event listener to attach to a text field. 86 * @type {Function} 87 * @private 88 */ 89 textFieldInputHandler_: null, 90 91 /** @override */ 92 decorate: function() { 93 List.prototype.decorate.call(this); 94 this.classList.add('autocomplete-suggestions'); 95 this.selectionModel = new cr.ui.ListSingleSelectionModel; 96 97 this.itemConstructor = AutocompleteListItem; 98 this.textFieldKeyHandler_ = this.handleAutocompleteKeydown_.bind(this); 99 var self = this; 100 this.textFieldInputHandler_ = function(e) { 101 self.requestSuggestions(self.targetInput_.value); 102 }; 103 this.addEventListener('change', function(e) { 104 if (self.selectedItem) 105 self.handleSelectedSuggestion(self.selectedItem); 106 }); 107 // Start hidden; adding suggestions will unhide. 108 this.hidden = true; 109 }, 110 111 /** @override */ 112 createItem: function(pageInfo) { 113 return new this.itemConstructor(pageInfo); 114 }, 115 116 /** 117 * The suggestions to show. 118 * @type {Array} 119 */ 120 set suggestions(suggestions) { 121 this.dataModel = new ArrayDataModel(suggestions); 122 this.hidden = !this.targetInput_ || suggestions.length == 0; 123 }, 124 125 /** 126 * Requests new suggestions. Called when new suggestions are needed. 127 * @param {string} query the text to autocomplete from. 128 */ 129 requestSuggestions: function(query) { 130 }, 131 132 /** 133 * Handles the Enter keydown event. 134 * By default, clears and hides the autocomplete popup. Note that the 135 * keydown event bubbles up, so the input field can handle the event. 136 */ 137 handleEnterKeydown: function() { 138 this.suggestions = []; 139 }, 140 141 /** 142 * Handles the selected suggestion. Called when a suggestion is selected. 143 * By default, sets the target input element's value to the 'url' field 144 * of the selected suggestion. 145 * @param {Object} selectedSuggestion 146 */ 147 handleSelectedSuggestion: function(selectedSuggestion) { 148 var input = this.targetInput_; 149 if (!input) 150 return; 151 input.value = selectedSuggestion['url']; 152 // Programatically change the value won't trigger a change event, but 153 // clients are likely to want to know when changes happen, so fire one. 154 cr.dispatchSimpleEvent(input, 'change', true); 155 }, 156 157 /** 158 * Attaches the popup to the given input element. Requires 159 * that the input be wrapped in a block-level container of the same width. 160 * @param {HTMLElement} input The input element to attach to. 161 */ 162 attachToInput: function(input) { 163 if (this.targetInput_ == input) 164 return; 165 166 this.detach(); 167 this.targetInput_ = input; 168 this.style.width = input.getBoundingClientRect().width + 'px'; 169 this.hidden = false; // Necessary for positionPopupAroundElement to work. 170 cr.ui.positionPopupAroundElement(input, this, cr.ui.AnchorType.BELOW); 171 // Start hidden; when the data model gets results the list will show. 172 this.hidden = true; 173 174 input.addEventListener('keydown', this.textFieldKeyHandler_, true); 175 input.addEventListener('input', this.textFieldInputHandler_); 176 177 if (!this.boundSyncWidthAndPositionToInput_) { 178 this.boundSyncWidthAndPositionToInput_ = 179 this.syncWidthAndPositionToInput.bind(this); 180 } 181 // We need to call syncWidthAndPositionToInput whenever page zoom level or 182 // page size is changed. 183 window.addEventListener('resize', this.boundSyncWidthAndPositionToInput_); 184 }, 185 186 /** 187 * Detaches the autocomplete popup from its current input element, if any. 188 */ 189 detach: function() { 190 var input = this.targetInput_; 191 if (!input) 192 return; 193 194 input.removeEventListener('keydown', this.textFieldKeyHandler_, true); 195 input.removeEventListener('input', this.textFieldInputHandler_); 196 this.targetInput_ = null; 197 this.suggestions = []; 198 if (this.boundSyncWidthAndPositionToInput_) { 199 window.removeEventListener( 200 'resize', this.boundSyncWidthAndPositionToInput_); 201 } 202 }, 203 204 /** 205 * Makes sure that the suggestion list matches the width and the position 206 * of the input it is attached to. Should be called any time the input is 207 * resized. 208 */ 209 syncWidthAndPositionToInput: function() { 210 var input = this.targetInput_; 211 if (input) { 212 this.style.width = input.getBoundingClientRect().width + 'px'; 213 cr.ui.positionPopupAroundElement(input, this, cr.ui.AnchorType.BELOW); 214 } 215 }, 216 217 /** 218 * syncWidthAndPositionToInput function bound to |this|. 219 * @type {!Function|undefined} 220 * @private 221 */ 222 boundSyncWidthAndPositionToInput_: undefined, 223 224 /** 225 * @return {HTMLElement} The text field the autocomplete popup is currently 226 * attached to, if any. 227 */ 228 get targetInput() { 229 return this.targetInput_; 230 }, 231 232 /** 233 * Handles input field key events that should be interpreted as autocomplete 234 * commands. 235 * @param {Event} event The keydown event. 236 * @private 237 */ 238 handleAutocompleteKeydown_: function(event) { 239 if (this.hidden) 240 return; 241 var handled = false; 242 switch (event.keyIdentifier) { 243 case 'U+001B': // Esc 244 this.suggestions = []; 245 handled = true; 246 break; 247 case 'Enter': 248 // If the user has already selected an item using the arrow keys then 249 // presses Enter, keep |handled| = false, so the input field can 250 // handle the event as well. 251 this.handleEnterKeydown(); 252 break; 253 case 'Up': 254 case 'Down': 255 var newEvent = new Event(event.type); 256 newEvent.keyIdentifier = event.keyIdentifier; 257 this.dispatchEvent(newEvent); 258 handled = true; 259 break; 260 } 261 // Don't let arrow keys affect the text field, or bubble up to, e.g., 262 // an enclosing list item. 263 if (handled) { 264 event.preventDefault(); 265 event.stopPropagation(); 266 } 267 }, 268 }; 269 270 return { 271 AutocompleteList: AutocompleteList 272 }; 273}); 274