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