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;
10 * Creates a new list item with support for inline editing.
12 * @extends {options.DeletableItem}
14 function InlineEditableItem() {
15 var el = cr.doc.createElement('div');
16 InlineEditableItem.decorate(el);
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.
25 InlineEditableItem.decorate = function(el) {
26 el.__proto__ = InlineEditableItem.prototype;
30 InlineEditableItem.prototype = {
31 __proto__: DeletableItem.prototype,
34 * Index of currently focused column, or -1 for none.
37 focusedColumnIndex: -1,
40 * Whether or not this item can be edited.
47 * Whether or not this is a placeholder for adding a new item.
51 isPlaceholder_: false,
54 * Fields associated with edit mode.
61 * Whether or not the current edit should be considered cancelled, rather
62 * than committed, when editing ends.
69 * The editable item corresponding to the last click, if any. Used to decide
70 * initial focus when entering edit mode.
74 editClickTarget_: null,
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_);
87 selectionChanged: function() {
88 if (!this.parentNode.ignoreChangeEvents_)
89 this.updateEditState();
93 * Called when this element gains or loses 'lead' status. Updates editing
96 updateLeadState: function() {
97 // Add focusability before call to updateEditState.
99 this.setEditableValuesFocusable(true);
100 this.setCloseButtonFocusable(true);
103 this.updateEditState();
105 // Remove focusability after call to updateEditState.
106 this.setStaticValuesFocusable(false);
108 this.setEditableValuesFocusable(false);
109 this.setCloseButtonFocusable(false);
114 * Updates the edit state based on the current selected and lead states.
116 updateEditState: function() {
118 this.editing = this.selected && this.lead;
122 * Whether the user is currently editing the list item.
126 return this.hasAttribute('editing');
128 set editing(editing) {
129 if (this.editing == editing)
133 this.setAttribute('editing', '');
135 this.removeAttribute('editing');
138 this.editCancelled_ = false;
140 cr.dispatchSimpleEvent(this, 'edit', true);
142 var isMouseClick = this.editClickTarget_;
143 var focusElement = this.getEditFocusElement_();
146 // Delay focus to fix http://crbug.com/436789
147 setTimeout(function() {
148 this.focusAndMaybeSelect_(focusElement);
151 this.focusAndMaybeSelect_(focusElement);
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);
162 this.parentNode.needsToFocusPlaceholder_ = false;
163 this.resetEditableValues_();
164 cr.dispatchSimpleEvent(this, 'canceledit', true);
170 * Return editable element that should be focused, or null for none.
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;
181 // If focusedColumnIndex is valid then use the element in that column.
182 if (this.focusedColumnIndex != -1) {
184 this.getNearestColumnByIndex_(this.focusedColumnIndex);
186 return nearestColumn;
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
194 var columnIndex = this.getFocusedColumnIndex_();
195 if (columnIndex != -1) {
196 var nearestColumn = this.getNearestColumnByIndex_(columnIndex);
198 return nearestColumn;
201 // Everything else failed so return the default.
202 return this.initialFocusElement;
206 * Focus on the specified element, and select the editable text in it
208 * @param {!Element} control An element to be focused.
211 focusAndMaybeSelect_: function(control) {
213 if (control.tagName == 'INPUT')
218 * Whether the item is editable.
222 return this.editable_;
224 set editable(editable) {
225 this.editable_ = editable;
227 this.editing = false;
231 * Whether the item is a new item placeholder.
234 get isPlaceholder() {
235 return this.isPlaceholder_;
237 set isPlaceholder(isPlaceholder) {
238 this.isPlaceholder_ = isPlaceholder;
240 this.deletable = false;
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}
250 get initialFocusElement() {
251 return this.contentElement.querySelector('input');
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.
261 get currentInputIsValid() {
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.
271 get hasBeenEdited() {
276 * Sets whether the editable values can be given focus using the keyboard.
277 * @param {boolean} focusable The desired focusable state.
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;
288 * Sets whether the static values can be given focus using the keyboard.
289 * @param {boolean} focusable The desired focusable state.
291 setStaticValuesFocusable: function(focusable) {
292 var editFields = this.editFields_;
293 for (var i = 0; i < editFields.length; i++) {
294 var staticVersion = editFields[i].staticVersion;
298 staticVersion.tabIndex = focusable ? 0 : -1;
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');
309 * Sets whether the close button can be focused using the keyboard.
310 * @param {boolean} focusable The desired focusable state.
312 setCloseButtonFocusable: function(focusable) {
313 this.closeButtonElement.tabIndex =
314 focusable && this.closeButtonFocusAllowed ? 0 : -1;
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.
324 createEditableTextCell: function(text) {
325 var container = /** @type {HTMLElement} */(
326 this.ownerDocument.createElement('div'));
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);
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)
351 container.appendChild(inputEl);
352 this.addEditField(inputEl, textEl);
358 * Register an edit field.
359 * @param {!Element} control An editable element. It's a form control
361 * @param {Element} staticElement An element representing non-editable
364 addEditField: function(control, staticElement) {
365 control.staticVersion = staticElement;
367 control.tabIndex = -1;
369 if (control.staticVersion) {
371 control.staticVersion.tabIndex = -1;
372 control.staticVersion.editableVersion = control;
373 control.staticVersion.addEventListener('focus',
374 this.handleFocus.bind(this));
376 this.editFields_.push(control);
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.
388 setFocusableColumnIndex: function(element, columnIndex) {
392 if (columnIndex >= 0)
393 element.setAttribute('inlineeditable-column', columnIndex);
395 element.removeAttribute('inlineeditable-column');
399 * Resets the editable version of any controls created by createEditable*
400 * to match the static text.
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)
410 if (editFields[i].tagName == 'INPUT') {
411 editFields[i].value =
412 this.isPlaceholder ? '' : staticLabel.textContent;
414 // Add more tag types here as new createEditable* methods are added.
416 editFields[i].setCustomValidity('');
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.
426 updateStaticValues_: function() {
427 var editFields = this.editFields_;
428 for (var i = 0; i < editFields.length; i++) {
429 var staticLabel = editFields[i].staticVersion;
433 if (editFields[i].tagName == 'INPUT')
434 staticLabel.textContent = editFields[i].value;
435 // Add more tag types here as new createEditable* methods are added.
440 * Returns the index of the column that currently has focus, or -1 if no
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);
455 * Returns the element from the column that has the largest index where:
457 * + index <= startIndex, and
458 * + the element exists, and
459 * + the element is not disabled
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)
473 * Called when a key is pressed. Handles committing and canceling edits.
474 * @param {Event} e The key down event.
477 handleKeyDown_: function(e) {
482 var handledKey = true;
483 switch (e.keyIdentifier) {
484 case 'U+001B': // Esc
485 this.editCancelled_ = true;
489 if (this.currentInputIsValid)
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)
501 // Blurring will trigger the edit to end; see InlineEditableItemList.
502 this.ownerDocument.activeElement.blur();
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.
512 handleMouseDown_: function(e) {
516 var clickTarget = e.target;
517 var editFields = this.editFields_;
519 for (var i = 0; i < editFields.length; i++) {
520 if (editFields[i] == clickTarget ||
521 editFields[i].staticVersion == clickTarget) {
522 editClickTarget = editFields[i];
528 if (!editClickTarget) {
529 // Clicked on the list item outside of an edit field. Don't lose focus
530 // from currently selected edit field.
537 if (editClickTarget && !editClickTarget.disabled)
538 this.editClickTarget_ = editClickTarget;
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.
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;
556 * Takes care of committing changes to inline editable list items when the
557 * window loses focus.
559 function handleWindowBlurs() {
560 window.addEventListener('blur', function(e) {
561 var itemAncestor = findAncestor(document.activeElement, function(node) {
562 return node instanceof InlineEditableItem;
565 document.activeElement.blur();
572 * @extends {options.DeletableItemList}
574 var InlineEditableItemList = cr.ui.define('list');
576 InlineEditableItemList.prototype = {
577 __proto__: DeletableItemList.prototype,
580 * Whether to ignore list change events.
581 * Used to modify the list without processing selection change and lead
586 ignoreChangeEvents_: false,
589 * Focuses the input element of the placeholder if true.
593 needsToFocusPlaceholder_: false,
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.
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.
612 handleListFocusChange_: function(e) {
613 var leadItem = this.getListItemByIndex(this.selectionModel.leadIndex);
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);
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);
636 handleLeadChange: function(e) {
637 if (this.ignoreChangeEvents_)
640 DeletableItemList.prototype.handleLeadChange.call(this, e);
642 var focusedColumnIndex = -1;
643 if (e.oldValue != -1) {
644 var element = this.getListItemByIndex(e.oldValue);
646 focusedColumnIndex = element.focusedColumnIndex;
647 element.updateLeadState();
651 if (e.newValue != -1) {
652 var element = this.getListItemByIndex(e.newValue);
654 element.focusedColumnIndex = focusedColumnIndex;
655 element.updateLeadState();
661 onSetDataModelComplete: function() {
662 DeletableItemList.prototype.onSetDataModelComplete.call(this);
664 if (this.needsToFocusPlaceholder_) {
665 this.focusPlaceholder();
667 var item = this.getInitialFocusableItem();
669 item.setStaticValuesFocusable(true);
670 item.setCloseButtonFocusable(true);
671 if (item.isPlaceholder)
672 item.setEditableValuesFocusable(true);
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.
683 ignoreChangeEvents: function(callback) {
684 assert(!this.ignoreChangeEvents_);
685 this.ignoreChangeEvents_ = true;
687 this.ignoreChangeEvents_ = false;
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.
696 selectIndexWithoutFocusing: function(index) {
697 // Remove focusability from old item.
698 var oldItem = this.getListItemByIndex(this.selectionModel.leadIndex) ||
699 this.getInitialFocusableItem();
701 oldItem.setEditableValuesFocusable(false);
702 oldItem.setStaticValuesFocusable(false);
703 oldItem.setCloseButtonFocusable(false);
704 oldItem.lead = false;
707 // Select the new item.
708 this.ignoreChangeEvents(function() {
709 this.selectionModel.selectedIndex = index;
712 // Add focusability to new item.
713 var newItem = this.getListItemByIndex(index);
715 if (newItem.isPlaceholder)
716 newItem.setEditableValuesFocusable(true);
718 newItem.setStaticValuesFocusable(true);
720 newItem.setCloseButtonFocusable(true);
726 * Focus the placeholder's first input field.
727 * Should only be called immediately after the list has been repopulated.
729 focusPlaceholder: function() {
730 // Remove focusability from initial item.
731 var item = this.getInitialFocusableItem();
733 item.setStaticValuesFocusable(false);
734 item.setCloseButtonFocusable(false);
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();
749 * May be overridden by subclasses to disable focusing the placeholder.
750 * @return {boolean} True if the placeholder element should be focused on
754 shouldFocusPlaceholderOnEditCommit: function() {
759 * Override to change which item is initially focusable.
760 * @return {options.InlineEditableItem} Initially focusable item or null.
763 getInitialFocusableItem: function() {
764 return /** @type {options.InlineEditableItem} */(
765 this.getListItemByIndex(0));
771 InlineEditableItem: InlineEditableItem,
772 InlineEditableItemList: InlineEditableItemList,