blob: da5751e2719efb9eba3f2f007d01885ae0690c4c [file] [log] [blame]
// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
cr.define('options', function() {
/** @const */ var DeletableItem = options.DeletableItem;
/** @const */ var DeletableItemList = options.DeletableItemList;
/**
* Creates a new list item with support for inline editing.
* @constructor
* @extends {options.DeletableItem}
*/
function InlineEditableItem() {
var el = cr.doc.createElement('div');
InlineEditableItem.decorate(el);
return el;
}
/**
* Decorates an element as a inline-editable list item. Note that this is
* a subclass of DeletableItem.
* @param {!HTMLElement} el The element to decorate.
*/
InlineEditableItem.decorate = function(el) {
el.__proto__ = InlineEditableItem.prototype;
el.decorate();
};
InlineEditableItem.prototype = {
__proto__: DeletableItem.prototype,
/**
* Index of currently focused column, or -1 for none.
* @type {number}
*/
focusedColumnIndex: -1,
/**
* Whether or not this item can be edited.
* @type {boolean}
* @private
*/
editable_: true,
/**
* Whether or not this is a placeholder for adding a new item.
* @type {boolean}
* @private
*/
isPlaceholder_: false,
/**
* Fields associated with edit mode.
* @type {Array}
* @private
*/
editFields_: null,
/**
* Whether or not the current edit should be considered cancelled, rather
* than committed, when editing ends.
* @type {boolean}
* @private
*/
editCancelled_: true,
/**
* The editable item corresponding to the last click, if any. Used to decide
* initial focus when entering edit mode.
* @type {HTMLElement}
* @private
*/
editClickTarget_: null,
/** @override */
decorate: function() {
DeletableItem.prototype.decorate.call(this);
this.editFields_ = [];
this.addEventListener('mousedown', this.handleMouseDown_);
this.addEventListener('keydown', this.handleKeyDown_);
this.addEventListener('focusin', this.handleFocusIn_);
},
/** @override */
selectionChanged: function() {
if (!this.parentNode.ignoreChangeEvents_)
this.updateEditState();
},
/**
* Called when this element gains or loses 'lead' status. Updates editing
* mode accordingly.
*/
updateLeadState: function() {
// Add focusability before call to updateEditState.
if (this.lead) {
this.setEditableValuesFocusable(true);
this.setCloseButtonFocusable(true);
}
this.updateEditState();
// Remove focusability after call to updateEditState.
this.setStaticValuesFocusable(false);
if (!this.lead) {
this.setEditableValuesFocusable(false);
this.setCloseButtonFocusable(false);
}
},
/**
* Updates the edit state based on the current selected and lead states.
*/
updateEditState: function() {
if (this.editable)
this.editing = this.selected && this.lead;
},
/**
* Whether the user is currently editing the list item.
* @type {boolean}
*/
get editing() {
return this.hasAttribute('editing');
},
set editing(editing) {
if (this.editing == editing)
return;
if (editing)
this.setAttribute('editing', '');
else
this.removeAttribute('editing');
if (editing) {
this.editCancelled_ = false;
cr.dispatchSimpleEvent(this, 'edit', true);
var isMouseClick = this.editClickTarget_;
var focusElement = this.getEditFocusElement_();
if (focusElement) {
if (isMouseClick) {
// Delay focus to fix http://crbug.com/436789
setTimeout(function() {
this.focusAndMaybeSelect_(focusElement);
}.bind(this), 0);
} else {
this.focusAndMaybeSelect_(focusElement);
}
}
} else {
if (!this.editCancelled_ && this.hasBeenEdited &&
this.currentInputIsValid) {
this.parentNode.needsToFocusPlaceholder_ = this.isPlaceholder &&
this.parentNode.shouldFocusPlaceholderOnEditCommit();
this.updateStaticValues_();
cr.dispatchSimpleEvent(this, 'commitedit', true);
} else {
this.parentNode.needsToFocusPlaceholder_ = false;
this.resetEditableValues_();
cr.dispatchSimpleEvent(this, 'canceledit', true);
}
}
},
/**
* Return editable element that should be focused, or null for none.
* @private
*/
getEditFocusElement_: function() {
// If an edit field was clicked on then use the clicked element.
if (this.editClickTarget_) {
var result = this.editClickTarget_;
this.editClickTarget_ = null;
return result;
}
// If focusedColumnIndex is valid then use the element in that column.
if (this.focusedColumnIndex != -1) {
var nearestColumn =
this.getNearestColumnByIndex_(this.focusedColumnIndex);
if (nearestColumn)
return nearestColumn;
}
// It's possible that focusedColumnIndex hasn't been updated yet.
// Check getFocusedColumnIndex_ directly.
// This can't completely replace the above focusedColumnIndex check
// because InlineEditableItemList may have set focusedColumnIndex to a
// different value.
var columnIndex = this.getFocusedColumnIndex_();
if (columnIndex != -1) {
var nearestColumn = this.getNearestColumnByIndex_(columnIndex);
if (nearestColumn)
return nearestColumn;
}
// Everything else failed so return the default.
return this.initialFocusElement;
},
/**
* Focus on the specified element, and select the editable text in it
* if possible.
* @param {!Element} control An element to be focused.
* @private
*/
focusAndMaybeSelect_: function(control) {
control.focus();
if (control.tagName == 'INPUT')
control.select();
},
/**
* Whether the item is editable.
* @type {boolean}
*/
get editable() {
return this.editable_;
},
set editable(editable) {
this.editable_ = editable;
if (!editable)
this.editing = false;
},
/**
* Whether the item is a new item placeholder.
* @type {boolean}
*/
get isPlaceholder() {
return this.isPlaceholder_;
},
set isPlaceholder(isPlaceholder) {
this.isPlaceholder_ = isPlaceholder;
if (isPlaceholder)
this.deletable = false;
},
/**
* The HTML element that should have focus initially when editing starts,
* if a specific element wasn't clicked.
* Defaults to the first <input> element; can be overridden by subclasses if
* a different element should be focused.
* @type {HTMLElement}
*/
get initialFocusElement() {
return this.contentElement.querySelector('input');
},
/**
* Whether the input in currently valid to submit. If this returns false
* when editing would be submitted, either editing will not be ended,
* or it will be cancelled, depending on the context.
* Can be overridden by subclasses to perform input validation.
* @type {boolean}
*/
get currentInputIsValid() {
return true;
},
/**
* Returns true if the item has been changed by an edit.
* Can be overridden by subclasses to return false when nothing has changed
* to avoid unnecessary commits.
* @type {boolean}
*/
get hasBeenEdited() {
return true;
},
/**
* Sets whether the editable values can be given focus using the keyboard.
* @param {boolean} focusable The desired focusable state.
*/
setEditableValuesFocusable: function(focusable) {
focusable = focusable && this.editable;
var editFields = this.editFields_;
for (var i = 0; i < editFields.length; i++) {
editFields[i].tabIndex = focusable ? 0 : -1;
}
},
/**
* Sets whether the static values can be given focus using the keyboard.
* @param {boolean} focusable The desired focusable state.
*/
setStaticValuesFocusable: function(focusable) {
var editFields = this.editFields_;
for (var i = 0; i < editFields.length; i++) {
var staticVersion = editFields[i].staticVersion;
if (!staticVersion)
continue;
if (this.editable) {
staticVersion.tabIndex = focusable ? 0 : -1;
} else {
// staticVersion remains visible when !this.editable. Remove
// tabindex so that it will not become focused by clicking on it and
// have selection box drawn around it.
staticVersion.removeAttribute('tabindex');
}
}
},
/**
* Sets whether the close button can be focused using the keyboard.
* @param {boolean} focusable The desired focusable state.
*/
setCloseButtonFocusable: function(focusable) {
this.closeButtonElement.tabIndex =
focusable && this.closeButtonFocusAllowed ? 0 : -1;
},
/**
* Returns a div containing an <input>, as well as static text if
* isPlaceholder is not true.
* @param {string} text The text of the cell.
* @return {HTMLElement} The HTML element for the cell.
* @private
*/
createEditableTextCell: function(text) {
var container = /** @type {HTMLElement} */(
this.ownerDocument.createElement('div'));
var textEl = null;
if (!this.isPlaceholder) {
textEl = this.ownerDocument.createElement('div');
textEl.className = 'static-text overruleable';
textEl.textContent = text;
textEl.setAttribute('displaymode', 'static');
container.appendChild(textEl);
}
var inputEl = this.ownerDocument.createElement('input');
inputEl.type = 'text';
inputEl.value = text;
if (!this.isPlaceholder)
inputEl.setAttribute('displaymode', 'edit');
// In some cases 'focus' event may arrive before 'input'.
// To make sure revalidation is triggered we postpone 'focus' handling.
var handler = this.handleFocus.bind(this);
inputEl.addEventListener('focus', function() {
window.setTimeout(function() {
if (inputEl.ownerDocument.activeElement == inputEl)
handler();
}, 0);
});
container.appendChild(inputEl);
this.addEditField(inputEl, textEl);
return container;
},
/**
* Register an edit field.
* @param {!Element} control An editable element. It's a form control
* element typically.
* @param {Element} staticElement An element representing non-editable
* state.
*/
addEditField: function(control, staticElement) {
control.staticVersion = staticElement;
if (this.editable)
control.tabIndex = -1;
if (control.staticVersion) {
if (this.editable)
control.staticVersion.tabIndex = -1;
control.staticVersion.editableVersion = control;
control.staticVersion.addEventListener('focus',
this.handleFocus.bind(this));
}
this.editFields_.push(control);
},
/**
* Set the column index for a child element of this InlineEditableItem.
* Only elements with a column index will be keyboard focusable, e.g. by
* pressing the tab key.
* @param {Element} element Element whose column index to set. Method does
* nothing if element is null.
* @param {number} columnIndex The new column index to set on the element.
* -1 removes the column index.
*/
setFocusableColumnIndex: function(element, columnIndex) {
if (!element)
return;
if (columnIndex >= 0)
element.setAttribute('inlineeditable-column', columnIndex);
else
element.removeAttribute('inlineeditable-column');
},
/**
* Resets the editable version of any controls created by createEditable*
* to match the static text.
* @private
*/
resetEditableValues_: function() {
var editFields = this.editFields_;
for (var i = 0; i < editFields.length; i++) {
var staticLabel = editFields[i].staticVersion;
if (!staticLabel && !this.isPlaceholder)
continue;
if (editFields[i].tagName == 'INPUT') {
editFields[i].value =
this.isPlaceholder ? '' : staticLabel.textContent;
}
// Add more tag types here as new createEditable* methods are added.
editFields[i].setCustomValidity('');
}
},
/**
* Sets the static version of any controls created by createEditable*
* to match the current value of the editable version. Called on commit so
* that there's no flicker of the old value before the model updates.
* @private
*/
updateStaticValues_: function() {
var editFields = this.editFields_;
for (var i = 0; i < editFields.length; i++) {
var staticLabel = editFields[i].staticVersion;
if (!staticLabel)
continue;
if (editFields[i].tagName == 'INPUT')
staticLabel.textContent = editFields[i].value;
// Add more tag types here as new createEditable* methods are added.
}
},
/**
* Returns the index of the column that currently has focus, or -1 if no
* column has focus.
* @return {number}
* @private
*/
getFocusedColumnIndex_: function() {
var element = document.activeElement.editableVersion ||
document.activeElement;
if (element.hasAttribute('inlineeditable-column'))
return parseInt(element.getAttribute('inlineeditable-column'), 10);
return -1;
},
/**
* Returns the element from the column that has the largest index where:
* where:
* + index <= startIndex, and
* + the element exists, and
* + the element is not disabled
* @return {Element}
* @private
*/
getNearestColumnByIndex_: function(startIndex) {
for (var i = startIndex; i >= 0; --i) {
var el = this.querySelector('[inlineeditable-column="' + i + '"]');
if (el && !el.disabled)
return el;
}
return null;
},
/**
* Called when a key is pressed. Handles committing and canceling edits.
* @param {Event} e The key down event.
* @private
*/
handleKeyDown_: function(e) {
if (!this.editing)
return;
var endEdit = false;
var handledKey = true;
switch (e.key) {
case 'Escape':
this.editCancelled_ = true;
endEdit = true;
break;
case 'Enter':
if (this.currentInputIsValid)
endEdit = true;
break;
default:
handledKey = false;
}
if (handledKey) {
// Make sure that handled keys aren't passed on and double-handled.
// (e.g., esc shouldn't both cancel an edit and close a subpage)
e.stopPropagation();
}
if (endEdit) {
// Blurring will trigger the edit to end; see InlineEditableItemList.
this.ownerDocument.activeElement.blur();
}
},
/**
* Called when the list item is clicked. If the click target corresponds to
* an editable item, stores that item to focus when edit mode is started.
* @param {Event} e The mouse down event.
* @private
*/
handleMouseDown_: function(e) {
if (!this.editable)
return;
var clickTarget = e.target;
var editFields = this.editFields_;
var editClickTarget;
for (var i = 0; i < editFields.length; i++) {
if (editFields[i] == clickTarget ||
editFields[i].staticVersion == clickTarget) {
editClickTarget = editFields[i];
break;
}
}
if (this.editing) {
if (!editClickTarget) {
// Clicked on the list item outside of an edit field. Don't lose focus
// from currently selected edit field.
e.stopPropagation();
e.preventDefault();
}
return;
}
if (editClickTarget && !editClickTarget.disabled)
this.editClickTarget_ = editClickTarget;
},
/**
* Called when this InlineEditableItem or any of its children are given
* focus. Updates focusedColumnIndex with the index of the newly focused
* column, or -1 if the focused element does not have a column index.
* @param {Event} e The focusin event.
* @private
*/
handleFocusIn_: function(e) {
var target = e.target.editableVersion || e.target;
this.focusedColumnIndex = target.hasAttribute('inlineeditable-column') ?
parseInt(target.getAttribute('inlineeditable-column'), 10) : -1;
},
};
/**
* Takes care of committing changes to inline editable list items when the
* window loses focus.
*/
function handleWindowBlurs() {
window.addEventListener('blur', function(e) {
var itemAncestor = findAncestor(document.activeElement, function(node) {
return node instanceof InlineEditableItem;
});
if (itemAncestor)
document.activeElement.blur();
});
}
handleWindowBlurs();
/**
* @constructor
* @extends {options.DeletableItemList}
*/
var InlineEditableItemList = cr.ui.define('list');
InlineEditableItemList.prototype = {
__proto__: DeletableItemList.prototype,
/**
* Whether to ignore list change events.
* Used to modify the list without processing selection change and lead
* change events.
* @type {boolean}
* @private
*/
ignoreChangeEvents_: false,
/**
* Focuses the input element of the placeholder if true.
* @type {boolean}
* @private
*/
needsToFocusPlaceholder_: false,
/** @override */
decorate: function() {
DeletableItemList.prototype.decorate.call(this);
this.setAttribute('inlineeditable', '');
this.addEventListener('hasElementFocusChange',
this.handleListFocusChange_);
// <list> isn't focusable by default, but cr.ui.List defaults tabindex to
// 0 if it's not set.
this.tabIndex = -1;
},
/**
* Called when the list hierarchy as a whole loses or gains focus; starts
* or ends editing for the lead item if necessary.
* @param {Event} e The change event.
* @private
*/
handleListFocusChange_: function(e) {
var leadItem = this.getListItemByIndex(this.selectionModel.leadIndex);
if (leadItem) {
if (e.newValue) {
// Add focusability before making other changes.
leadItem.setEditableValuesFocusable(true);
leadItem.setCloseButtonFocusable(true);
leadItem.focusedColumnIndex = -1;
leadItem.updateEditState();
// Remove focusability after making other changes.
leadItem.setStaticValuesFocusable(false);
} else {
// Add focusability before making other changes.
leadItem.setStaticValuesFocusable(true);
leadItem.setCloseButtonFocusable(true);
leadItem.editing = false;
// Remove focusability after making other changes.
if (!leadItem.isPlaceholder)
leadItem.setEditableValuesFocusable(false);
}
}
},
/** @override */
handleLeadChange: function(e) {
if (this.ignoreChangeEvents_)
return;
DeletableItemList.prototype.handleLeadChange.call(this, e);
var focusedColumnIndex = -1;
if (e.oldValue != -1) {
var element = this.getListItemByIndex(e.oldValue);
if (element) {
focusedColumnIndex = element.focusedColumnIndex;
element.updateLeadState();
}
}
if (e.newValue != -1) {
var element = this.getListItemByIndex(e.newValue);
if (element) {
element.focusedColumnIndex = focusedColumnIndex;
element.updateLeadState();
}
}
},
/** @override */
onSetDataModelComplete: function() {
DeletableItemList.prototype.onSetDataModelComplete.call(this);
if (this.needsToFocusPlaceholder_) {
this.focusPlaceholder();
} else {
var item = this.getInitialFocusableItem();
if (item) {
item.setStaticValuesFocusable(true);
item.setCloseButtonFocusable(true);
if (item.isPlaceholder)
item.setEditableValuesFocusable(true);
}
}
},
/**
* Execute |callback| with list change events disabled. Selection change and
* lead change events will not be processed.
* @param {!Function} callback The function to execute.
* @protected
*/
ignoreChangeEvents: function(callback) {
assert(!this.ignoreChangeEvents_);
this.ignoreChangeEvents_ = true;
callback();
this.ignoreChangeEvents_ = false;
},
/**
* Set the selected index without changing the focused element on the page.
* Used to change the selected index when the list doesn't have focus (and
* doesn't want to take focus).
* @param {number} index The index to select.
*/
selectIndexWithoutFocusing: function(index) {
// Remove focusability from old item.
var oldItem = this.getListItemByIndex(this.selectionModel.leadIndex) ||
this.getInitialFocusableItem();
if (oldItem) {
oldItem.setEditableValuesFocusable(false);
oldItem.setStaticValuesFocusable(false);
oldItem.setCloseButtonFocusable(false);
oldItem.lead = false;
}
// Select the new item.
this.ignoreChangeEvents(function() {
this.selectionModel.selectedIndex = index;
}.bind(this));
// Add focusability to new item.
var newItem = this.getListItemByIndex(index);
if (newItem) {
if (newItem.isPlaceholder)
newItem.setEditableValuesFocusable(true);
else
newItem.setStaticValuesFocusable(true);
newItem.setCloseButtonFocusable(true);
newItem.lead = true;
}
},
/**
* Focus the placeholder's first input field.
* Should only be called immediately after the list has been repopulated.
*/
focusPlaceholder: function() {
// Remove focusability from initial item.
var item = this.getInitialFocusableItem();
if (item) {
item.setStaticValuesFocusable(false);
item.setCloseButtonFocusable(false);
}
// Find placeholder and focus it.
for (var i = 0; i < this.dataModel.length; i++) {
var item = this.getListItemByIndex(i);
if (item.isPlaceholder) {
item.setEditableValuesFocusable(true);
item.setCloseButtonFocusable(true);
item.querySelector('input').focus();
return;
}
}
},
/**
* May be overridden by subclasses to disable focusing the placeholder.
* @return {boolean} True if the placeholder element should be focused on
* edit commit.
* @protected
*/
shouldFocusPlaceholderOnEditCommit: function() {
return true;
},
/**
* Override to change which item is initially focusable.
* @return {options.InlineEditableItem} Initially focusable item or null.
* @protected
*/
getInitialFocusableItem: function() {
return /** @type {options.InlineEditableItem} */(
this.getListItemByIndex(0));
},
};
// Export
return {
InlineEditableItem: InlineEditableItem,
InlineEditableItemList: InlineEditableItemList,
};
});