inline_editable_list.js revision 2a99a7e74a7f215066514fe81d2bfa6639d9eddd
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('options', function() { 6 /** @const */ var DeletableItem = options.DeletableItem; 7 /** @const */ var DeletableItemList = options.DeletableItemList; 8 9 /** 10 * Creates a new list item with support for inline editing. 11 * @constructor 12 * @extends {options.DeletableListItem} 13 */ 14 function InlineEditableItem() { 15 var el = cr.doc.createElement('div'); 16 InlineEditableItem.decorate(el); 17 return el; 18 } 19 20 /** 21 * Decorates an element as a inline-editable list item. Note that this is 22 * a subclass of DeletableItem. 23 * @param {!HTMLElement} el The element to decorate. 24 */ 25 InlineEditableItem.decorate = function(el) { 26 el.__proto__ = InlineEditableItem.prototype; 27 el.decorate(); 28 }; 29 30 InlineEditableItem.prototype = { 31 __proto__: DeletableItem.prototype, 32 33 /** 34 * Whether or not this item can be edited. 35 * @type {boolean} 36 * @private 37 */ 38 editable_: true, 39 40 /** 41 * Whether or not this is a placeholder for adding a new item. 42 * @type {boolean} 43 * @private 44 */ 45 isPlaceholder_: false, 46 47 /** 48 * Fields associated with edit mode. 49 * @type {array} 50 * @private 51 */ 52 editFields_: null, 53 54 /** 55 * Whether or not the current edit should be considered cancelled, rather 56 * than committed, when editing ends. 57 * @type {boolean} 58 * @private 59 */ 60 editCancelled_: true, 61 62 /** 63 * The editable item corresponding to the last click, if any. Used to decide 64 * initial focus when entering edit mode. 65 * @type {HTMLElement} 66 * @private 67 */ 68 editClickTarget_: null, 69 70 /** @override */ 71 decorate: function() { 72 DeletableItem.prototype.decorate.call(this); 73 74 this.editFields_ = []; 75 this.addEventListener('mousedown', this.handleMouseDown_); 76 this.addEventListener('keydown', this.handleKeyDown_); 77 this.addEventListener('leadChange', this.handleLeadChange_); 78 }, 79 80 /** @override */ 81 selectionChanged: function() { 82 this.updateEditState(); 83 }, 84 85 /** 86 * Called when this element gains or loses 'lead' status. Updates editing 87 * mode accordingly. 88 * @private 89 */ 90 handleLeadChange_: function() { 91 this.updateEditState(); 92 }, 93 94 /** 95 * Updates the edit state based on the current selected and lead states. 96 */ 97 updateEditState: function() { 98 if (this.editable) 99 this.editing = this.selected && this.lead; 100 }, 101 102 /** 103 * Whether the user is currently editing the list item. 104 * @type {boolean} 105 */ 106 get editing() { 107 return this.hasAttribute('editing'); 108 }, 109 set editing(editing) { 110 if (this.editing == editing) 111 return; 112 113 if (editing) 114 this.setAttribute('editing', ''); 115 else 116 this.removeAttribute('editing'); 117 118 if (editing) { 119 this.editCancelled_ = false; 120 121 cr.dispatchSimpleEvent(this, 'edit', true); 122 123 var focusElement = this.editClickTarget_ || this.initialFocusElement; 124 this.editClickTarget_ = null; 125 126 // When this is called in response to the selectedChange event, 127 // the list grabs focus immediately afterwards. Thus we must delay 128 // our focus grab. 129 var self = this; 130 if (focusElement) { 131 window.setTimeout(function() { 132 // Make sure we are still in edit mode by the time we execute. 133 if (self.editing) { 134 focusElement.focus(); 135 focusElement.select(); 136 } 137 }, 50); 138 } 139 } else { 140 if (!this.editCancelled_ && this.hasBeenEdited && 141 this.currentInputIsValid) { 142 if (this.isPlaceholder) 143 this.parentNode.focusPlaceholder = true; 144 145 this.updateStaticValues_(); 146 cr.dispatchSimpleEvent(this, 'commitedit', true); 147 } else { 148 this.resetEditableValues_(); 149 cr.dispatchSimpleEvent(this, 'canceledit', true); 150 } 151 } 152 }, 153 154 /** 155 * Whether the item is editable. 156 * @type {boolean} 157 */ 158 get editable() { 159 return this.editable_; 160 }, 161 set editable(editable) { 162 this.editable_ = editable; 163 if (!editable) 164 this.editing = false; 165 }, 166 167 /** 168 * Whether the item is a new item placeholder. 169 * @type {boolean} 170 */ 171 get isPlaceholder() { 172 return this.isPlaceholder_; 173 }, 174 set isPlaceholder(isPlaceholder) { 175 this.isPlaceholder_ = isPlaceholder; 176 if (isPlaceholder) 177 this.deletable = false; 178 }, 179 180 /** 181 * The HTML element that should have focus initially when editing starts, 182 * if a specific element wasn't clicked. 183 * Defaults to the first <input> element; can be overridden by subclasses if 184 * a different element should be focused. 185 * @type {HTMLElement} 186 */ 187 get initialFocusElement() { 188 return this.contentElement.querySelector('input'); 189 }, 190 191 /** 192 * Whether the input in currently valid to submit. If this returns false 193 * when editing would be submitted, either editing will not be ended, 194 * or it will be cancelled, depending on the context. 195 * Can be overridden by subclasses to perform input validation. 196 * @type {boolean} 197 */ 198 get currentInputIsValid() { 199 return true; 200 }, 201 202 /** 203 * Returns true if the item has been changed by an edit. 204 * Can be overridden by subclasses to return false when nothing has changed 205 * to avoid unnecessary commits. 206 * @type {boolean} 207 */ 208 get hasBeenEdited() { 209 return true; 210 }, 211 212 /** 213 * Returns a div containing an <input>, as well as static text if 214 * isPlaceholder is not true. 215 * @param {string} text The text of the cell. 216 * @return {HTMLElement} The HTML element for the cell. 217 * @private 218 */ 219 createEditableTextCell: function(text) { 220 var container = this.ownerDocument.createElement('div'); 221 222 if (!this.isPlaceholder) { 223 var textEl = this.ownerDocument.createElement('div'); 224 textEl.className = 'static-text'; 225 textEl.textContent = text; 226 textEl.setAttribute('displaymode', 'static'); 227 container.appendChild(textEl); 228 } 229 230 var inputEl = this.ownerDocument.createElement('input'); 231 inputEl.type = 'text'; 232 inputEl.value = text; 233 if (!this.isPlaceholder) { 234 inputEl.setAttribute('displaymode', 'edit'); 235 inputEl.staticVersion = textEl; 236 } else { 237 // At this point |this| is not attached to the parent list yet, so give 238 // a short timeout in order for the attachment to occur. 239 var self = this; 240 window.setTimeout(function() { 241 var list = self.parentNode; 242 if (list && list.focusPlaceholder) { 243 list.focusPlaceholder = false; 244 if (list.shouldFocusPlaceholder()) 245 inputEl.focus(); 246 } 247 }, 50); 248 } 249 250 inputEl.addEventListener('focus', this.handleFocus_.bind(this)); 251 container.appendChild(inputEl); 252 this.editFields_.push(inputEl); 253 254 return container; 255 }, 256 257 /** 258 * Resets the editable version of any controls created by createEditable* 259 * to match the static text. 260 * @private 261 */ 262 resetEditableValues_: function() { 263 var editFields = this.editFields_; 264 for (var i = 0; i < editFields.length; i++) { 265 var staticLabel = editFields[i].staticVersion; 266 if (!staticLabel && !this.isPlaceholder) 267 continue; 268 269 if (editFields[i].tagName == 'INPUT') { 270 editFields[i].value = 271 this.isPlaceholder ? '' : staticLabel.textContent; 272 } 273 // Add more tag types here as new createEditable* methods are added. 274 275 editFields[i].setCustomValidity(''); 276 } 277 }, 278 279 /** 280 * Sets the static version of any controls created by createEditable* 281 * to match the current value of the editable version. Called on commit so 282 * that there's no flicker of the old value before the model updates. 283 * @private 284 */ 285 updateStaticValues_: function() { 286 var editFields = this.editFields_; 287 for (var i = 0; i < editFields.length; i++) { 288 var staticLabel = editFields[i].staticVersion; 289 if (!staticLabel) 290 continue; 291 292 if (editFields[i].tagName == 'INPUT') 293 staticLabel.textContent = editFields[i].value; 294 // Add more tag types here as new createEditable* methods are added. 295 } 296 }, 297 298 /** 299 * Called when a key is pressed. Handles committing and canceling edits. 300 * @param {Event} e The key down event. 301 * @private 302 */ 303 handleKeyDown_: function(e) { 304 if (!this.editing) 305 return; 306 307 var endEdit = false; 308 switch (e.keyIdentifier) { 309 case 'U+001B': // Esc 310 this.editCancelled_ = true; 311 endEdit = true; 312 break; 313 case 'Enter': 314 if (this.currentInputIsValid) 315 endEdit = true; 316 break; 317 } 318 319 if (endEdit) { 320 // Blurring will trigger the edit to end; see InlineEditableItemList. 321 this.ownerDocument.activeElement.blur(); 322 // Make sure that handled keys aren't passed on and double-handled. 323 // (e.g., esc shouldn't both cancel an edit and close a subpage) 324 e.stopPropagation(); 325 } 326 }, 327 328 /** 329 * Called when the list item is clicked. If the click target corresponds to 330 * an editable item, stores that item to focus when edit mode is started. 331 * @param {Event} e The mouse down event. 332 * @private 333 */ 334 handleMouseDown_: function(e) { 335 if (!this.editable || this.editing) 336 return; 337 338 var clickTarget = e.target; 339 var editFields = this.editFields_; 340 for (var i = 0; i < editFields.length; i++) { 341 if (editFields[i] == clickTarget || 342 editFields[i].staticVersion == clickTarget) { 343 this.editClickTarget_ = editFields[i]; 344 return; 345 } 346 } 347 }, 348 }; 349 350 /** 351 * Takes care of committing changes to inline editable list items when the 352 * window loses focus. 353 */ 354 function handleWindowBlurs() { 355 window.addEventListener('blur', function(e) { 356 var itemAncestor = findAncestor(document.activeElement, function(node) { 357 return node instanceof InlineEditableItem; 358 }); 359 if (itemAncestor) 360 document.activeElement.blur(); 361 }); 362 } 363 handleWindowBlurs(); 364 365 var InlineEditableItemList = cr.ui.define('list'); 366 367 InlineEditableItemList.prototype = { 368 __proto__: DeletableItemList.prototype, 369 370 /** 371 * Focuses the input element of the placeholder if true. 372 * @type {boolean} 373 */ 374 focusPlaceholder: false, 375 376 /** @override */ 377 decorate: function() { 378 DeletableItemList.prototype.decorate.call(this); 379 this.setAttribute('inlineeditable', ''); 380 this.addEventListener('hasElementFocusChange', 381 this.handleListFocusChange_); 382 }, 383 384 /** 385 * Called when the list hierarchy as a whole loses or gains focus; starts 386 * or ends editing for the lead item if necessary. 387 * @param {Event} e The change event. 388 * @private 389 */ 390 handleListFocusChange_: function(e) { 391 var leadItem = this.getListItemByIndex(this.selectionModel.leadIndex); 392 if (leadItem) { 393 if (e.newValue) 394 leadItem.updateEditState(); 395 else 396 leadItem.editing = false; 397 } 398 }, 399 400 /** 401 * May be overridden by subclasses to disable focusing the placeholder. 402 * @return {boolean} True if the placeholder element should be focused on 403 * edit commit. 404 */ 405 shouldFocusPlaceholder: function() { 406 return true; 407 }, 408 }; 409 410 // Export 411 return { 412 InlineEditableItem: InlineEditableItem, 413 InlineEditableItemList: InlineEditableItemList, 414 }; 415}); 416