Only grant permissions to new extensions from sync if they have the expected version
[chromium-blink-merge.git] / chrome / browser / resources / options / inline_editable_list.js
blobc2db84aab2711f44380bb0553677e03343c2377f
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.
5 cr.define('options', function() {
6   /** @const */ var DeletableItem = options.DeletableItem;
7   /** @const */ var DeletableItemList = options.DeletableItemList;
9   /**
10    * Creates a new list item with support for inline editing.
11    * @constructor
12    * @extends {options.DeletableItem}
13    */
14   function InlineEditableItem() {
15     var el = cr.doc.createElement('div');
16     InlineEditableItem.decorate(el);
17     return el;
18   }
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   };
30   InlineEditableItem.prototype = {
31     __proto__: DeletableItem.prototype,
33     /**
34      * Index of currently focused column, or -1 for none.
35      * @type {number}
36      */
37     focusedColumnIndex: -1,
39     /**
40      * Whether or not this item can be edited.
41      * @type {boolean}
42      * @private
43      */
44     editable_: true,
46     /**
47      * Whether or not this is a placeholder for adding a new item.
48      * @type {boolean}
49      * @private
50      */
51     isPlaceholder_: false,
53     /**
54      * Fields associated with edit mode.
55      * @type {Array}
56      * @private
57      */
58     editFields_: null,
60     /**
61      * Whether or not the current edit should be considered cancelled, rather
62      * than committed, when editing ends.
63      * @type {boolean}
64      * @private
65      */
66     editCancelled_: true,
68     /**
69      * The editable item corresponding to the last click, if any. Used to decide
70      * initial focus when entering edit mode.
71      * @type {HTMLElement}
72      * @private
73      */
74     editClickTarget_: null,
76     /** @override */
77     decorate: function() {
78       DeletableItem.prototype.decorate.call(this);
80       this.editFields_ = [];
81       this.addEventListener('mousedown', this.handleMouseDown_);
82       this.addEventListener('keydown', this.handleKeyDown_);
83       this.addEventListener('focusin', this.handleFocusIn_);
84     },
86     /** @override */
87     selectionChanged: function() {
88       if (!this.parentNode.ignoreChangeEvents_)
89         this.updateEditState();
90     },
92     /**
93      * Called when this element gains or loses 'lead' status. Updates editing
94      * mode accordingly.
95      */
96     updateLeadState: function() {
97       // Add focusability before call to updateEditState.
98       if (this.lead) {
99         this.setEditableValuesFocusable(true);
100         this.setCloseButtonFocusable(true);
101       }
103       this.updateEditState();
105       // Remove focusability after call to updateEditState.
106       this.setStaticValuesFocusable(false);
107       if (!this.lead) {
108         this.setEditableValuesFocusable(false);
109         this.setCloseButtonFocusable(false);
110       }
111     },
113     /**
114      * Updates the edit state based on the current selected and lead states.
115      */
116     updateEditState: function() {
117       if (this.editable)
118         this.editing = this.selected && this.lead;
119     },
121     /**
122      * Whether the user is currently editing the list item.
123      * @type {boolean}
124      */
125     get editing() {
126       return this.hasAttribute('editing');
127     },
128     set editing(editing) {
129       if (this.editing == editing)
130         return;
132       if (editing)
133         this.setAttribute('editing', '');
134       else
135         this.removeAttribute('editing');
137       if (editing) {
138         this.editCancelled_ = false;
140         cr.dispatchSimpleEvent(this, 'edit', true);
142         var isMouseClick = this.editClickTarget_;
143         var focusElement = this.getEditFocusElement_();
144         if (focusElement) {
145           if (isMouseClick) {
146             // Delay focus to fix http://crbug.com/436789
147             setTimeout(function() {
148               this.focusAndMaybeSelect_(focusElement);
149             }.bind(this), 0);
150           } else {
151             this.focusAndMaybeSelect_(focusElement);
152           }
153         }
154       } else {
155         if (!this.editCancelled_ && this.hasBeenEdited &&
156             this.currentInputIsValid) {
157           this.parentNode.needsToFocusPlaceholder_ = this.isPlaceholder &&
158               this.parentNode.shouldFocusPlaceholderOnEditCommit();
159           this.updateStaticValues_();
160           cr.dispatchSimpleEvent(this, 'commitedit', true);
161         } else {
162           this.parentNode.needsToFocusPlaceholder_ = false;
163           this.resetEditableValues_();
164           cr.dispatchSimpleEvent(this, 'canceledit', true);
165         }
166       }
167     },
169     /**
170      * Return editable element that should be focused, or null for none.
171      * @private
172      */
173     getEditFocusElement_: function() {
174       // If an edit field was clicked on then use the clicked element.
175       if (this.editClickTarget_) {
176         var result = this.editClickTarget_;
177         this.editClickTarget_ = null;
178         return result;
179       }
181       // If focusedColumnIndex is valid then use the element in that column.
182       if (this.focusedColumnIndex != -1) {
183         var nearestColumn =
184             this.getNearestColumnByIndex_(this.focusedColumnIndex);
185         if (nearestColumn)
186           return nearestColumn;
187       }
189       // It's possible that focusedColumnIndex hasn't been updated yet.
190       // Check getFocusedColumnIndex_ directly.
191       // This can't completely replace the above focusedColumnIndex check
192       // because InlineEditableItemList may have set focusedColumnIndex to a
193       // different value.
194       var columnIndex = this.getFocusedColumnIndex_();
195       if (columnIndex != -1) {
196         var nearestColumn = this.getNearestColumnByIndex_(columnIndex);
197         if (nearestColumn)
198           return nearestColumn;
199       }
201       // Everything else failed so return the default.
202       return this.initialFocusElement;
203     },
205     /**
206      * Focus on the specified element, and select the editable text in it
207      * if possible.
208      * @param {!Element} control An element to be focused.
209      * @private
210      */
211     focusAndMaybeSelect_: function(control) {
212       control.focus();
213       if (control.tagName == 'INPUT')
214         control.select();
215     },
217     /**
218      * Whether the item is editable.
219      * @type {boolean}
220      */
221     get editable() {
222       return this.editable_;
223     },
224     set editable(editable) {
225       this.editable_ = editable;
226       if (!editable)
227         this.editing = false;
228     },
230     /**
231      * Whether the item is a new item placeholder.
232      * @type {boolean}
233      */
234     get isPlaceholder() {
235       return this.isPlaceholder_;
236     },
237     set isPlaceholder(isPlaceholder) {
238       this.isPlaceholder_ = isPlaceholder;
239       if (isPlaceholder)
240         this.deletable = false;
241     },
243     /**
244      * The HTML element that should have focus initially when editing starts,
245      * if a specific element wasn't clicked.
246      * Defaults to the first <input> element; can be overridden by subclasses if
247      * a different element should be focused.
248      * @type {HTMLElement}
249      */
250     get initialFocusElement() {
251       return this.contentElement.querySelector('input');
252     },
254     /**
255      * Whether the input in currently valid to submit. If this returns false
256      * when editing would be submitted, either editing will not be ended,
257      * or it will be cancelled, depending on the context.
258      * Can be overridden by subclasses to perform input validation.
259      * @type {boolean}
260      */
261     get currentInputIsValid() {
262       return true;
263     },
265     /**
266      * Returns true if the item has been changed by an edit.
267      * Can be overridden by subclasses to return false when nothing has changed
268      * to avoid unnecessary commits.
269      * @type {boolean}
270      */
271     get hasBeenEdited() {
272       return true;
273     },
275     /**
276      * Sets whether the editable values can be given focus using the keyboard.
277      * @param {boolean} focusable The desired focusable state.
278      */
279     setEditableValuesFocusable: function(focusable) {
280       focusable = focusable && this.editable;
281       var editFields = this.editFields_;
282       for (var i = 0; i < editFields.length; i++) {
283         editFields[i].tabIndex = focusable ? 0 : -1;
284       }
285     },
287     /**
288      * Sets whether the static values can be given focus using the keyboard.
289      * @param {boolean} focusable The desired focusable state.
290      */
291     setStaticValuesFocusable: function(focusable) {
292       var editFields = this.editFields_;
293       for (var i = 0; i < editFields.length; i++) {
294         var staticVersion = editFields[i].staticVersion;
295         if (!staticVersion)
296           continue;
297         if (this.editable) {
298           staticVersion.tabIndex = focusable ? 0 : -1;
299         } else {
300           // staticVersion remains visible when !this.editable. Remove
301           // tabindex so that it will not become focused by clicking on it and
302           // have selection box drawn around it.
303           staticVersion.removeAttribute('tabindex');
304         }
305       }
306     },
308     /**
309      * Sets whether the close button can be focused using the keyboard.
310      * @param {boolean} focusable The desired focusable state.
311      */
312     setCloseButtonFocusable: function(focusable) {
313       this.closeButtonElement.tabIndex =
314           focusable && this.closeButtonFocusAllowed ? 0 : -1;
315     },
317     /**
318      * Returns a div containing an <input>, as well as static text if
319      * isPlaceholder is not true.
320      * @param {string} text The text of the cell.
321      * @return {HTMLElement} The HTML element for the cell.
322      * @private
323      */
324     createEditableTextCell: function(text) {
325       var container = /** @type {HTMLElement} */(
326           this.ownerDocument.createElement('div'));
327       var textEl = null;
328       if (!this.isPlaceholder) {
329         textEl = this.ownerDocument.createElement('div');
330         textEl.className = 'static-text';
331         textEl.textContent = text;
332         textEl.setAttribute('displaymode', 'static');
333         container.appendChild(textEl);
334       }
336       var inputEl = this.ownerDocument.createElement('input');
337       inputEl.type = 'text';
338       inputEl.value = text;
339       if (!this.isPlaceholder)
340         inputEl.setAttribute('displaymode', 'edit');
342       // In some cases 'focus' event may arrive before 'input'.
343       // To make sure revalidation is triggered we postpone 'focus' handling.
344       var handler = this.handleFocus.bind(this);
345       inputEl.addEventListener('focus', function() {
346         window.setTimeout(function() {
347           if (inputEl.ownerDocument.activeElement == inputEl)
348             handler();
349         }, 0);
350       });
351       container.appendChild(inputEl);
352       this.addEditField(inputEl, textEl);
354       return container;
355     },
357     /**
358      * Register an edit field.
359      * @param {!Element} control An editable element. It's a form control
360      *     element typically.
361      * @param {Element} staticElement An element representing non-editable
362      *     state.
363      */
364     addEditField: function(control, staticElement) {
365       control.staticVersion = staticElement;
366       if (this.editable)
367         control.tabIndex = -1;
369       if (control.staticVersion) {
370         if (this.editable)
371           control.staticVersion.tabIndex = -1;
372         control.staticVersion.editableVersion = control;
373         control.staticVersion.addEventListener('focus',
374                                                this.handleFocus.bind(this));
375       }
376       this.editFields_.push(control);
377     },
379     /**
380      * Set the column index for a child element of this InlineEditableItem.
381      * Only elements with a column index will be keyboard focusable, e.g. by
382      * pressing the tab key.
383      * @param {Element} element Element whose column index to set. Method does
384      *     nothing if element is null.
385      * @param {number} columnIndex The new column index to set on the element.
386      *     -1 removes the column index.
387      */
388     setFocusableColumnIndex: function(element, columnIndex) {
389       if (!element)
390         return;
392       if (columnIndex >= 0)
393         element.setAttribute('inlineeditable-column', columnIndex);
394       else
395         element.removeAttribute('inlineeditable-column');
396     },
398     /**
399      * Resets the editable version of any controls created by createEditable*
400      * to match the static text.
401      * @private
402      */
403     resetEditableValues_: function() {
404       var editFields = this.editFields_;
405       for (var i = 0; i < editFields.length; i++) {
406         var staticLabel = editFields[i].staticVersion;
407         if (!staticLabel && !this.isPlaceholder)
408           continue;
410         if (editFields[i].tagName == 'INPUT') {
411           editFields[i].value =
412             this.isPlaceholder ? '' : staticLabel.textContent;
413         }
414         // Add more tag types here as new createEditable* methods are added.
416         editFields[i].setCustomValidity('');
417       }
418     },
420     /**
421      * Sets the static version of any controls created by createEditable*
422      * to match the current value of the editable version. Called on commit so
423      * that there's no flicker of the old value before the model updates.
424      * @private
425      */
426     updateStaticValues_: function() {
427       var editFields = this.editFields_;
428       for (var i = 0; i < editFields.length; i++) {
429         var staticLabel = editFields[i].staticVersion;
430         if (!staticLabel)
431           continue;
433         if (editFields[i].tagName == 'INPUT')
434           staticLabel.textContent = editFields[i].value;
435         // Add more tag types here as new createEditable* methods are added.
436       }
437     },
439     /**
440      * Returns the index of the column that currently has focus, or -1 if no
441      * column has focus.
442      * @return {number}
443      * @private
444      */
445     getFocusedColumnIndex_: function() {
446       var element = document.activeElement.editableVersion ||
447                     document.activeElement;
449       if (element.hasAttribute('inlineeditable-column'))
450         return parseInt(element.getAttribute('inlineeditable-column'), 10);
451       return -1;
452     },
454     /**
455      * Returns the element from the column that has the largest index where:
456      * where:
457      *   + index <= startIndex, and
458      *   + the element exists, and
459      *   + the element is not disabled
460      * @return {Element}
461      * @private
462      */
463     getNearestColumnByIndex_: function(startIndex) {
464       for (var i = startIndex; i >= 0; --i) {
465         var el = this.querySelector('[inlineeditable-column="' + i + '"]');
466         if (el && !el.disabled)
467           return el;
468       }
469       return null;
470     },
472     /**
473      * Called when a key is pressed. Handles committing and canceling edits.
474      * @param {Event} e The key down event.
475      * @private
476      */
477     handleKeyDown_: function(e) {
478       if (!this.editing)
479         return;
481       var endEdit = false;
482       var handledKey = true;
483       switch (e.keyIdentifier) {
484         case 'U+001B':  // Esc
485           this.editCancelled_ = true;
486           endEdit = true;
487           break;
488         case 'Enter':
489           if (this.currentInputIsValid)
490             endEdit = true;
491           break;
492         default:
493           handledKey = false;
494       }
495       if (handledKey) {
496         // Make sure that handled keys aren't passed on and double-handled.
497         // (e.g., esc shouldn't both cancel an edit and close a subpage)
498         e.stopPropagation();
499       }
500       if (endEdit) {
501         // Blurring will trigger the edit to end; see InlineEditableItemList.
502         this.ownerDocument.activeElement.blur();
503       }
504     },
506     /**
507      * Called when the list item is clicked. If the click target corresponds to
508      * an editable item, stores that item to focus when edit mode is started.
509      * @param {Event} e The mouse down event.
510      * @private
511      */
512     handleMouseDown_: function(e) {
513       if (!this.editable)
514         return;
516       var clickTarget = e.target;
517       var editFields = this.editFields_;
518       var editClickTarget;
519       for (var i = 0; i < editFields.length; i++) {
520         if (editFields[i] == clickTarget ||
521             editFields[i].staticVersion == clickTarget) {
522           editClickTarget = editFields[i];
523           break;
524         }
525       }
527       if (this.editing) {
528         if (!editClickTarget) {
529           // Clicked on the list item outside of an edit field. Don't lose focus
530           // from currently selected edit field.
531           e.stopPropagation();
532           e.preventDefault();
533         }
534         return;
535       }
537       if (editClickTarget && !editClickTarget.disabled)
538         this.editClickTarget_ = editClickTarget;
539     },
541     /**
542      * Called when this InlineEditableItem or any of its children are given
543      * focus. Updates focusedColumnIndex with the index of the newly focused
544      * column, or -1 if the focused element does not have a column index.
545      * @param {Event} e The focusin event.
546      * @private
547      */
548     handleFocusIn_: function(e) {
549       var target = e.target.editableVersion || e.target;
550       this.focusedColumnIndex = target.hasAttribute('inlineeditable-column') ?
551           parseInt(target.getAttribute('inlineeditable-column'), 10) : -1;
552     },
553   };
555   /**
556    * Takes care of committing changes to inline editable list items when the
557    * window loses focus.
558    */
559   function handleWindowBlurs() {
560     window.addEventListener('blur', function(e) {
561       var itemAncestor = findAncestor(document.activeElement, function(node) {
562         return node instanceof InlineEditableItem;
563       });
564       if (itemAncestor)
565         document.activeElement.blur();
566     });
567   }
568   handleWindowBlurs();
570   /**
571    * @constructor
572    * @extends {options.DeletableItemList}
573    */
574   var InlineEditableItemList = cr.ui.define('list');
576   InlineEditableItemList.prototype = {
577     __proto__: DeletableItemList.prototype,
579     /**
580      * Whether to ignore list change events.
581      * Used to modify the list without processing selection change and lead
582      * change events.
583      * @type {boolean}
584      * @private
585      */
586     ignoreChangeEvents_: false,
588     /**
589      * Focuses the input element of the placeholder if true.
590      * @type {boolean}
591      * @private
592      */
593     needsToFocusPlaceholder_: false,
595     /** @override */
596     decorate: function() {
597       DeletableItemList.prototype.decorate.call(this);
598       this.setAttribute('inlineeditable', '');
599       this.addEventListener('hasElementFocusChange',
600                             this.handleListFocusChange_);
601       // <list> isn't focusable by default, but cr.ui.List defaults tabindex to
602       // 0 if it's not set.
603       this.tabIndex = -1;
604     },
606     /**
607      * Called when the list hierarchy as a whole loses or gains focus; starts
608      * or ends editing for the lead item if necessary.
609      * @param {Event} e The change event.
610      * @private
611      */
612     handleListFocusChange_: function(e) {
613       var leadItem = this.getListItemByIndex(this.selectionModel.leadIndex);
614       if (leadItem) {
615         if (e.newValue) {
616           // Add focusability before making other changes.
617           leadItem.setEditableValuesFocusable(true);
618           leadItem.setCloseButtonFocusable(true);
619           leadItem.focusedColumnIndex = -1;
620           leadItem.updateEditState();
621           // Remove focusability after making other changes.
622           leadItem.setStaticValuesFocusable(false);
623         } else {
624           // Add focusability before making other changes.
625           leadItem.setStaticValuesFocusable(true);
626           leadItem.setCloseButtonFocusable(true);
627           leadItem.editing = false;
628           // Remove focusability after making other changes.
629           if (!leadItem.isPlaceholder)
630             leadItem.setEditableValuesFocusable(false);
631         }
632       }
633     },
635     /** @override */
636     handleLeadChange: function(e) {
637       if (this.ignoreChangeEvents_)
638         return;
640       DeletableItemList.prototype.handleLeadChange.call(this, e);
642       var focusedColumnIndex = -1;
643       if (e.oldValue != -1) {
644         var element = this.getListItemByIndex(e.oldValue);
645         if (element) {
646           focusedColumnIndex = element.focusedColumnIndex;
647           element.updateLeadState();
648         }
649       }
651       if (e.newValue != -1) {
652         var element = this.getListItemByIndex(e.newValue);
653         if (element) {
654           element.focusedColumnIndex = focusedColumnIndex;
655           element.updateLeadState();
656         }
657       }
658     },
660     /** @override */
661     onSetDataModelComplete: function() {
662       DeletableItemList.prototype.onSetDataModelComplete.call(this);
664       if (this.needsToFocusPlaceholder_) {
665         this.focusPlaceholder();
666       } else {
667         var item = this.getInitialFocusableItem();
668         if (item) {
669           item.setStaticValuesFocusable(true);
670           item.setCloseButtonFocusable(true);
671           if (item.isPlaceholder)
672             item.setEditableValuesFocusable(true);
673         }
674       }
675     },
677     /**
678      * Execute |callback| with list change events disabled. Selection change and
679      * lead change events will not be processed.
680      * @param {!Function} callback The function to execute.
681      * @protected
682      */
683     ignoreChangeEvents: function(callback) {
684       assert(!this.ignoreChangeEvents_);
685       this.ignoreChangeEvents_ = true;
686       callback();
687       this.ignoreChangeEvents_ = false;
688     },
690     /**
691      * Set the selected index without changing the focused element on the page.
692      * Used to change the selected index when the list doesn't have focus (and
693      * doesn't want to take focus).
694      * @param {number} index The index to select.
695      */
696     selectIndexWithoutFocusing: function(index) {
697       // Remove focusability from old item.
698       var oldItem = this.getListItemByIndex(this.selectionModel.leadIndex) ||
699                     this.getInitialFocusableItem();
700       if (oldItem) {
701         oldItem.setEditableValuesFocusable(false);
702         oldItem.setStaticValuesFocusable(false);
703         oldItem.setCloseButtonFocusable(false);
704         oldItem.lead = false;
705       }
707       // Select the new item.
708       this.ignoreChangeEvents(function() {
709         this.selectionModel.selectedIndex = index;
710       }.bind(this));
712       // Add focusability to new item.
713       var newItem = this.getListItemByIndex(index);
714       if (newItem) {
715         if (newItem.isPlaceholder)
716           newItem.setEditableValuesFocusable(true);
717         else
718           newItem.setStaticValuesFocusable(true);
720         newItem.setCloseButtonFocusable(true);
721         newItem.lead = true;
722       }
723     },
725     /**
726      * Focus the placeholder's first input field.
727      * Should only be called immediately after the list has been repopulated.
728      */
729     focusPlaceholder: function() {
730       // Remove focusability from initial item.
731       var item = this.getInitialFocusableItem();
732       if (item) {
733         item.setStaticValuesFocusable(false);
734         item.setCloseButtonFocusable(false);
735       }
736       // Find placeholder and focus it.
737       for (var i = 0; i < this.dataModel.length; i++) {
738         var item = this.getListItemByIndex(i);
739         if (item.isPlaceholder) {
740           item.setEditableValuesFocusable(true);
741           item.setCloseButtonFocusable(true);
742           item.querySelector('input').focus();
743           return;
744         }
745       }
746     },
748     /**
749      * May be overridden by subclasses to disable focusing the placeholder.
750      * @return {boolean} True if the placeholder element should be focused on
751      *     edit commit.
752      * @protected
753      */
754     shouldFocusPlaceholderOnEditCommit: function() {
755       return true;
756     },
758     /**
759     * Override to change which item is initially focusable.
760     * @return {options.InlineEditableItem} Initially focusable item or null.
761     * @protected
762     */
763     getInitialFocusableItem: function() {
764       return /** @type {options.InlineEditableItem} */(
765           this.getListItemByIndex(0));
766     },
767   };
769   // Export
770   return {
771     InlineEditableItem: InlineEditableItem,
772     InlineEditableItemList: InlineEditableItemList,
773   };