inline_editable_list.js revision ca12bfac764ba476d6cd062bf1dde12cc64c3f40
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 !this.isExtraFocusableControl(document.activeElement); 101 } 102 }, 103 104 /** 105 * Whether the user is currently editing the list item. 106 * @type {boolean} 107 */ 108 get editing() { 109 return this.hasAttribute('editing'); 110 }, 111 set editing(editing) { 112 if (this.editing == editing) 113 return; 114 115 if (editing) 116 this.setAttribute('editing', ''); 117 else 118 this.removeAttribute('editing'); 119 120 if (editing) { 121 this.editCancelled_ = false; 122 123 cr.dispatchSimpleEvent(this, 'edit', true); 124 125 var focusElement = this.editClickTarget_ || this.initialFocusElement; 126 this.editClickTarget_ = null; 127 128 // When this is called in response to the selectedChange event, 129 // the list grabs focus immediately afterwards. Thus we must delay 130 // our focus grab. 131 var self = this; 132 if (focusElement) { 133 window.setTimeout(function() { 134 // Make sure we are still in edit mode by the time we execute. 135 if (self.editing) { 136 focusElement.focus(); 137 focusElement.select(); 138 } 139 }, 50); 140 } 141 } else { 142 if (!this.editCancelled_ && this.hasBeenEdited && 143 this.currentInputIsValid) { 144 if (this.isPlaceholder) 145 this.parentNode.focusPlaceholder = true; 146 147 this.updateStaticValues_(); 148 cr.dispatchSimpleEvent(this, 'commitedit', true); 149 } else { 150 this.resetEditableValues_(); 151 cr.dispatchSimpleEvent(this, 'canceledit', true); 152 } 153 } 154 }, 155 156 /** 157 * Whether the item is editable. 158 * @type {boolean} 159 */ 160 get editable() { 161 return this.editable_; 162 }, 163 set editable(editable) { 164 this.editable_ = editable; 165 if (!editable) 166 this.editing = false; 167 }, 168 169 /** 170 * Whether the item is a new item placeholder. 171 * @type {boolean} 172 */ 173 get isPlaceholder() { 174 return this.isPlaceholder_; 175 }, 176 set isPlaceholder(isPlaceholder) { 177 this.isPlaceholder_ = isPlaceholder; 178 if (isPlaceholder) 179 this.deletable = false; 180 }, 181 182 /** 183 * The HTML element that should have focus initially when editing starts, 184 * if a specific element wasn't clicked. 185 * Defaults to the first <input> element; can be overridden by subclasses if 186 * a different element should be focused. 187 * @type {HTMLElement} 188 */ 189 get initialFocusElement() { 190 return this.contentElement.querySelector('input'); 191 }, 192 193 /** 194 * Whether the input in currently valid to submit. If this returns false 195 * when editing would be submitted, either editing will not be ended, 196 * or it will be cancelled, depending on the context. 197 * Can be overridden by subclasses to perform input validation. 198 * @type {boolean} 199 */ 200 get currentInputIsValid() { 201 return true; 202 }, 203 204 /** 205 * Returns true if the item has been changed by an edit. 206 * Can be overridden by subclasses to return false when nothing has changed 207 * to avoid unnecessary commits. 208 * @type {boolean} 209 */ 210 get hasBeenEdited() { 211 return true; 212 }, 213 214 /** 215 * Returns a div containing an <input>, as well as static text if 216 * isPlaceholder is not true. 217 * @param {string} text The text of the cell. 218 * @return {HTMLElement} The HTML element for the cell. 219 * @private 220 */ 221 createEditableTextCell: function(text) { 222 var container = this.ownerDocument.createElement('div'); 223 224 if (!this.isPlaceholder) { 225 var textEl = this.ownerDocument.createElement('div'); 226 textEl.className = 'static-text'; 227 textEl.textContent = text; 228 textEl.setAttribute('displaymode', 'static'); 229 container.appendChild(textEl); 230 } 231 232 var inputEl = this.ownerDocument.createElement('input'); 233 inputEl.type = 'text'; 234 inputEl.value = text; 235 if (!this.isPlaceholder) { 236 inputEl.setAttribute('displaymode', 'edit'); 237 inputEl.staticVersion = textEl; 238 } else { 239 // At this point |this| is not attached to the parent list yet, so give 240 // a short timeout in order for the attachment to occur. 241 var self = this; 242 window.setTimeout(function() { 243 var list = self.parentNode; 244 if (list && list.focusPlaceholder) { 245 list.focusPlaceholder = false; 246 if (list.shouldFocusPlaceholder()) 247 inputEl.focus(); 248 } 249 }, 50); 250 } 251 252 inputEl.addEventListener('focus', this.handleFocus_.bind(this)); 253 container.appendChild(inputEl); 254 this.editFields_.push(inputEl); 255 256 return container; 257 }, 258 259 /** 260 * Resets the editable version of any controls created by createEditable* 261 * to match the static text. 262 * @private 263 */ 264 resetEditableValues_: function() { 265 var editFields = this.editFields_; 266 for (var i = 0; i < editFields.length; i++) { 267 var staticLabel = editFields[i].staticVersion; 268 if (!staticLabel && !this.isPlaceholder) 269 continue; 270 271 if (editFields[i].tagName == 'INPUT') { 272 editFields[i].value = 273 this.isPlaceholder ? '' : staticLabel.textContent; 274 } 275 // Add more tag types here as new createEditable* methods are added. 276 277 editFields[i].setCustomValidity(''); 278 } 279 }, 280 281 /** 282 * Sets the static version of any controls created by createEditable* 283 * to match the current value of the editable version. Called on commit so 284 * that there's no flicker of the old value before the model updates. 285 * @private 286 */ 287 updateStaticValues_: function() { 288 var editFields = this.editFields_; 289 for (var i = 0; i < editFields.length; i++) { 290 var staticLabel = editFields[i].staticVersion; 291 if (!staticLabel) 292 continue; 293 294 if (editFields[i].tagName == 'INPUT') 295 staticLabel.textContent = editFields[i].value; 296 // Add more tag types here as new createEditable* methods are added. 297 } 298 }, 299 300 /** 301 * Called when a key is pressed. Handles committing and canceling edits. 302 * @param {Event} e The key down event. 303 * @private 304 */ 305 handleKeyDown_: function(e) { 306 if (!this.editing) 307 return; 308 309 var endEdit = false; 310 var handledKey = true; 311 switch (e.keyIdentifier) { 312 case 'U+001B': // Esc 313 this.editCancelled_ = true; 314 endEdit = true; 315 break; 316 case 'Enter': 317 if (this.currentInputIsValid) 318 endEdit = true; 319 break; 320 default: 321 handledKey = false; 322 } 323 if (handledKey) { 324 // Make sure that handled keys aren't passed on and double-handled. 325 // (e.g., esc shouldn't both cancel an edit and close a subpage) 326 e.stopPropagation(); 327 } 328 if (endEdit) { 329 // Blurring will trigger the edit to end; see InlineEditableItemList. 330 this.ownerDocument.activeElement.blur(); 331 } 332 }, 333 334 /** 335 * Called when the list item is clicked. If the click target corresponds to 336 * an editable item, stores that item to focus when edit mode is started. 337 * @param {Event} e The mouse down event. 338 * @private 339 */ 340 handleMouseDown_: function(e) { 341 if (!this.editable || this.editing) 342 return; 343 344 var clickTarget = e.target; 345 if (this.isExtraFocusableControl(clickTarget)) { 346 clickTarget.focus(); 347 return; 348 } 349 350 var editFields = this.editFields_; 351 for (var i = 0; i < editFields.length; i++) { 352 if (editFields[i] == clickTarget || 353 editFields[i].staticVersion == clickTarget) { 354 this.editClickTarget_ = editFields[i]; 355 return; 356 } 357 } 358 }, 359 360 /** 361 * Check if the specified element is a focusable form control which is in 362 * the list item and not in |editFields_|. 363 * @param {!Element} element An element. 364 * @return {boolean} Returns true if the element is one of focusable 365 * controls in this list item. 366 */ 367 isExtraFocusableControl: function(element) { 368 return false; 369 }, 370 }; 371 372 /** 373 * Takes care of committing changes to inline editable list items when the 374 * window loses focus. 375 */ 376 function handleWindowBlurs() { 377 window.addEventListener('blur', function(e) { 378 var itemAncestor = findAncestor(document.activeElement, function(node) { 379 return node instanceof InlineEditableItem; 380 }); 381 if (itemAncestor) 382 document.activeElement.blur(); 383 }); 384 } 385 handleWindowBlurs(); 386 387 var InlineEditableItemList = cr.ui.define('list'); 388 389 InlineEditableItemList.prototype = { 390 __proto__: DeletableItemList.prototype, 391 392 /** 393 * Focuses the input element of the placeholder if true. 394 * @type {boolean} 395 */ 396 focusPlaceholder: false, 397 398 /** @override */ 399 decorate: function() { 400 DeletableItemList.prototype.decorate.call(this); 401 this.setAttribute('inlineeditable', ''); 402 this.addEventListener('hasElementFocusChange', 403 this.handleListFocusChange_); 404 }, 405 406 /** 407 * Called when the list hierarchy as a whole loses or gains focus; starts 408 * or ends editing for the lead item if necessary. 409 * @param {Event} e The change event. 410 * @private 411 */ 412 handleListFocusChange_: function(e) { 413 var leadItem = this.getListItemByIndex(this.selectionModel.leadIndex); 414 if (leadItem) { 415 if (e.newValue) 416 leadItem.updateEditState(); 417 else 418 leadItem.editing = false; 419 } 420 }, 421 422 /** 423 * May be overridden by subclasses to disable focusing the placeholder. 424 * @return {boolean} True if the placeholder element should be focused on 425 * edit commit. 426 */ 427 shouldFocusPlaceholder: function() { 428 return true; 429 }, 430 }; 431 432 // Export 433 return { 434 InlineEditableItem: InlineEditableItem, 435 InlineEditableItemList: InlineEditableItemList, 436 }; 437}); 438