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