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