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