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
,