inline_editable_list.js revision dc0f95d653279beabeb9817299e2902918ba123e
1// Copyright (c) 2010 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 DeletableItem = options.DeletableItem;
7  const 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 the current edit should be considered cancelled, rather
42     * than committed, when editing ends.
43     * @type {boolean}
44     * @private
45     */
46    editCancelled_: true,
47
48    /**
49     * The editable item corresponding to the last click, if any. Used to decide
50     * initial focus when entering edit mode.
51     * @type {HTMLElement}
52     * @private
53     */
54    editClickTarget_: null,
55
56    /** @inheritDoc */
57    decorate: function() {
58      DeletableItem.prototype.decorate.call(this);
59
60      this.addEventListener('mousedown', this.handleMouseDown_.bind(this));
61      this.addEventListener('keydown', this.handleKeyDown_.bind(this));
62      this.addEventListener('leadChange', this.handleLeadChange_);
63    },
64
65    /** @inheritDoc */
66    selectionChanged: function() {
67      this.updateEditState();
68    },
69
70    /**
71     * Called when this element gains or loses 'lead' status. Updates editing
72     * mode accordingly.
73     * @private
74     */
75    handleLeadChange_: function() {
76      this.updateEditState();
77    },
78
79    /**
80     * Updates the edit state based on the current selected and lead states.
81     */
82    updateEditState: function() {
83      if (this.editable)
84        this.editing = this.selected && this.lead;
85    },
86
87    /**
88     * Whether the user is currently editing the list item.
89     * @type {boolean}
90     */
91    get editing() {
92      return this.hasAttribute('editing');
93    },
94    set editing(editing) {
95      if (this.editing == editing)
96        return;
97
98      if (editing)
99        this.setAttribute('editing', '');
100      else
101        this.removeAttribute('editing');
102
103      if (editing) {
104        this.editCancelled_ = false;
105
106        cr.dispatchSimpleEvent(this, 'edit', true);
107
108        var focusElement = this.editClickTarget_ || this.initialFocusElement;
109        this.editClickTarget_ = null;
110
111        // When this is called in response to the selectedChange event,
112        // the list grabs focus immediately afterwards. Thus we must delay
113        // our focus grab.
114        var self = this;
115        if (focusElement) {
116          window.setTimeout(function() {
117            // Make sure we are still in edit mode by the time we execute.
118            if (self.editing) {
119              focusElement.focus();
120              focusElement.select();
121            }
122          }, 50);
123        }
124      } else {
125        if (!this.editCancelled_ && this.hasBeenEdited &&
126            this.currentInputIsValid) {
127          this.updateStaticValues_();
128          cr.dispatchSimpleEvent(this, 'commitedit', true);
129        } else {
130          this.resetEditableValues_();
131          cr.dispatchSimpleEvent(this, 'canceledit', true);
132        }
133      }
134    },
135
136    /**
137     * Whether the item is editable.
138     * @type {boolean}
139     */
140    get editable() {
141      return this.editable_;
142    },
143    set editable(editable) {
144      this.editable_ = editable;
145      if (!editable)
146        this.editing = false;
147    },
148
149    /**
150     * The HTML element that should have focus initially when editing starts,
151     * if a specific element wasn't clicked.
152     * Defaults to the first <input> element; can be overriden by subclasses if
153     * a different element should be focused.
154     * @type {HTMLElement}
155     */
156    get initialFocusElement() {
157      return this.contentElement.querySelector('input');
158    },
159
160    /**
161     * Whether the input in currently valid to submit. If this returns false
162     * when editing would be submitted, either editing will not be ended,
163     * or it will be cancelled, depending on the context.
164     * Can be overrided by subclasses to perform input validation.
165     * @type {boolean}
166     */
167    get currentInputIsValid() {
168      return true;
169    },
170
171    /**
172     * Returns true if the item has been changed by an edit.
173     * Can be overrided by subclasses to return false when nothing has changed
174     * to avoid unnecessary commits.
175     * @type {boolean}
176     */
177    get hasBeenEdited() {
178      return true;
179    },
180
181    /**
182     * Returns a div containing an <input>, as well as static text if
183     * opt_alwaysEditable is not true.
184     * @param {string} text The text of the cell.
185     * @param {bool} opt_alwaysEditable True if the cell always shows the input.
186     * @return {HTMLElement} The HTML element for the cell.
187     * @private
188     */
189    createEditableTextCell: function(text, opt_alwaysEditable) {
190      var container = this.ownerDocument.createElement('div');
191
192      if (!opt_alwaysEditable) {
193        var textEl = this.ownerDocument.createElement('div');
194        textEl.className = 'static-text';
195        textEl.textContent = text;
196        textEl.setAttribute('displaymode', 'static');
197        container.appendChild(textEl);
198      }
199
200      var inputEl = this.ownerDocument.createElement('input');
201      inputEl.type = 'text';
202      inputEl.value = text;
203      if (!opt_alwaysEditable) {
204        inputEl.setAttribute('displaymode', 'edit');
205        inputEl.staticVersion = textEl;
206      }
207      container.appendChild(inputEl);
208
209      return container;
210    },
211
212    /**
213     * Resets the editable version of any controls created by createEditable*
214     * to match the static text.
215     * @private
216     */
217    resetEditableValues_: function() {
218      var editFields = this.querySelectorAll('[displaymode=edit]');
219      for (var i = 0; i < editFields.length; i++) {
220        var staticLabel = editFields[i].staticVersion;
221        if (!staticLabel)
222          continue;
223        if (editFields[i].tagName == 'INPUT')
224          editFields[i].value = staticLabel.textContent;
225        // Add more tag types here as new createEditable* methods are added.
226
227        editFields[i].setCustomValidity('');
228      }
229    },
230
231    /**
232     * Sets the static version of any controls created by createEditable*
233     * to match the current value of the editable version. Called on commit so
234     * that there's no flicker of the old value before the model updates.
235     * @private
236     */
237    updateStaticValues_: function() {
238      var editFields = this.querySelectorAll('[displaymode=edit]');
239      for (var i = 0; i < editFields.length; i++) {
240        var staticLabel = editFields[i].staticVersion;
241        if (!staticLabel)
242          continue;
243        if (editFields[i].tagName == 'INPUT')
244          staticLabel.textContent = editFields[i].value;
245        // Add more tag types here as new createEditable* methods are added.
246      }
247    },
248
249    /**
250     * Called a key is pressed. Handles committing and cancelling edits.
251     * @param {Event} e The key down event.
252     * @private
253     */
254    handleKeyDown_: function(e) {
255      if (!this.editing)
256        return;
257
258      var endEdit = false;
259      switch (e.keyIdentifier) {
260        case 'U+001B':  // Esc
261          this.editCancelled_ = true;
262          endEdit = true;
263          break;
264        case 'Enter':
265          if (this.currentInputIsValid)
266            endEdit = true;
267          break;
268      }
269
270      if (endEdit) {
271        // Blurring will trigger the edit to end; see InlineEditableItemList.
272        this.ownerDocument.activeElement.blur();
273        // Make sure that handled keys aren't passed on and double-handled.
274        // (e.g., esc shouldn't both cancel an edit and close a subpage)
275        e.stopPropagation();
276      }
277    },
278
279    /**
280     * Called when the list item is clicked. If the click target corresponds to
281     * an editable item, stores that item to focus when edit mode is started.
282     * @param {Event} e The mouse down event.
283     * @private
284     */
285    handleMouseDown_: function(e) {
286      if (!this.editable || this.editing)
287        return;
288
289      var clickTarget = e.target;
290      var editFields = this.querySelectorAll('[displaymode=edit]');
291      for (var i = 0; i < editFields.length; i++) {
292        if (editFields[i].staticVersion == clickTarget) {
293          this.editClickTarget_ = editFields[i];
294          return;
295        }
296      }
297    },
298  };
299
300  var InlineEditableItemList = cr.ui.define('list');
301
302  InlineEditableItemList.prototype = {
303    __proto__: DeletableItemList.prototype,
304
305    /** @inheritDoc */
306    decorate: function() {
307      DeletableItemList.prototype.decorate.call(this);
308      this.setAttribute('inlineeditable', '');
309      this.addEventListener('hasElementFocusChange',
310                            this.handleListFocusChange_);
311    },
312
313    /**
314     * Called when the list hierarchy as a whole loses or gains focus; starts
315     * or ends editing for the lead item if necessary.
316     * @param {Event} e The change event.
317     * @private
318     */
319    handleListFocusChange_: function(e) {
320      var leadItem = this.getListItemByIndex(this.selectionModel.leadIndex);
321      if (leadItem) {
322        if (e.newValue)
323          leadItem.updateEditState();
324        else
325          leadItem.editing = false;
326      }
327    },
328  };
329
330  // Export
331  return {
332    InlineEditableItem: InlineEditableItem,
333    InlineEditableItemList: InlineEditableItemList,
334  };
335});
336