import {afterNextRender} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {assert} from 'chrome://resources/ash/common/assert.js';
import {focusWithoutInk} from './focus_without_ink_js.js';
import {FocusRow, FocusRowDelegate} from './focus_row_js.js';
class FocusRowBehaviorDelegate {
* @param {{lastFocused: Object,
* overrideCustomEquivalent: boolean,
* getCustomEquivalent: (Function|undefined)}} listItem
*/
constructor(listItem) {
this.listItem_ = listItem;
}
* This function gets called when the [focus-row-control] element receives
* the focus event.
* @override
* @param {!FocusRow} row
* @param {!Event} e
*/
onFocus(row, e) {
const element = (e.composedPath()[0]);
const focusableElement = FocusRow.getFocusableElement(element);
if (element !== focusableElement) {
focusableElement.focus();
}
this.listItem_.lastFocused = focusableElement;
}
* @override
* @param {!FocusRow} row The row that detected a keydown.
* @param {!Event} e
* @return {boolean} Whether the event was handled.
*/
onKeydown(row, e) {
if (e.key === 'Enter') {
e.stopPropagation();
}
return false;
}
getCustomEquivalent(sampleElement) {
return this.listItem_.overrideCustomEquivalent ?
this.listItem_.getCustomEquivalent(sampleElement) :
null;
}
}
class VirtualFocusRow extends FocusRow {
* @param {!Element} root
* @param {FocusRowDelegate} delegate
*/
constructor(root, delegate) {
super(root, null, delegate);
}
getCustomEquivalent(sampleElement) {
return this.delegate.getCustomEquivalent(sampleElement) ||
super.getCustomEquivalent(sampleElement);
}
}
* Any element that is being used as an iron-list row item can extend this
* behavior, which encapsulates focus controls of mouse and keyboards.
* To use this behavior:
* - The parent element should pass a "last-focused" attribute double-bound
* to the row items, to track the last-focused element across rows, and
* a "list-blurred" attribute double-bound to the row items, to track
* whether the list of row items has been blurred.
* - There must be a container in the extending element with the
* [focus-row-container] attribute that contains all focusable controls.
* - On each of the focusable controls, there must be a [focus-row-control]
* attribute, and a [focus-type=] attribute unique for each control.
*
* @polymerBehavior
*/
export const FocusRowBehavior = {
properties: {
row_: Object,
mouseFocused_: Boolean,
id: {
type: String,
reflectToAttribute: true,
},
isFocused: {
type: Boolean,
notify: true,
},
focusRowIndex: {
type: Number,
observer: 'focusRowIndexChanged',
},
lastFocused: {
type: Object,
notify: true,
},
* This is different from tabIndex, since the template only does a one-way
* binding on both attributes, and the behavior actually make use of this
* fact. For example, when a control within a row is focused, it will have
* tabIndex = -1 and ironListTabIndex = 0.
* @type {number}
*/
ironListTabIndex: {
type: Number,
observer: 'ironListTabIndexChanged_',
},
listBlurred: {
type: Boolean,
notify: true,
},
},
* Returns an ID based on the index that was passed in.
* @param {?number} index
* @return {?string}
*/
computeId_(index) {
return index !== undefined ? `frb${index}` : undefined;
},
* Sets |id| if it hasn't been set elsewhere. Also sets |aria-rowindex|.
* @param {number} newIndex
* @param {number} oldIndex
*/
focusRowIndexChanged(newIndex, oldIndex) {
this.setAttribute('aria-rowindex', newIndex + 1);
if (this.id === this.computeId_(oldIndex)) {
this.id = this.computeId_(newIndex);
}
},
firstControl_: null,
controlObservers_: [],
attached() {
this.classList.add('no-outline');
afterNextRender(this, function() {
const rowContainer = this.root.querySelector('[focus-row-container]');
assert(rowContainer);
this.row_ = new VirtualFocusRow(
rowContainer, new FocusRowBehaviorDelegate(this));
this.addItems_();
this.listen(this, 'focus', 'onFocus_');
this.listen(this, 'dom-change', 'addItems_');
this.listen(this, 'mousedown', 'onMouseDown_');
this.listen(this, 'blur', 'onBlur_');
});
},
detached() {
this.unlisten(this, 'focus', 'onFocus_');
this.unlisten(this, 'dom-change', 'addItems_');
this.unlisten(this, 'mousedown', 'onMouseDown_');
this.unlisten(this, 'blur', 'onBlur_');
this.removeObservers_();
if (this.firstControl_) {
this.unlisten(this.firstControl_, 'keydown', 'onFirstControlKeydown_');
}
if (this.row_) {
this.row_.destroy();
}
},
getFocusRow() {
return assert(this.row_);
},
updateFirstControl_() {
const newFirstControl = this.row_.getFirstFocusable();
if (newFirstControl === this.firstControl_) {
return;
}
if (this.firstControl_) {
this.unlisten(this.firstControl_, 'keydown', 'onFirstControlKeydown_');
}
this.firstControl_ = newFirstControl;
if (this.firstControl_) {
this.listen(
(this.firstControl_), 'keydown',
'onFirstControlKeydown_');
}
},
removeObservers_() {
if (this.controlObservers_.length > 0) {
this.controlObservers_.forEach(observer => {
observer.disconnect();
});
}
this.controlObservers_ = [];
},
addItems_() {
this.ironListTabIndexChanged_();
if (this.row_) {
this.removeObservers_();
this.row_.destroy();
const controls = this.root.querySelectorAll('[focus-row-control]');
controls.forEach(control => {
this.row_.addItem(
control.getAttribute('focus-type'),
(FocusRow.getFocusableElement(control)));
this.addMutationObservers_(assert(control));
});
this.updateFirstControl_();
}
},
* @return {!MutationObserver}
* @private
*/
createObserver_() {
return new MutationObserver(mutations => {
const mutation = mutations[0];
if (mutation.attributeName === 'style' && mutation.oldValue) {
const newStyle = window.getComputedStyle(
(mutation.target));
const oldDisplayValue = mutation.oldValue.match(/^display:(.*)(?=;)/);
const oldVisibilityValue =
mutation.oldValue.match(/^visibility:(.*)(?=;)/);
if (oldDisplayValue &&
newStyle.display === oldDisplayValue[1].trim() &&
oldVisibilityValue &&
newStyle.visibility === oldVisibilityValue[1].trim()) {
return;
}
}
this.updateFirstControl_();
});
},
* The first focusable control changes if hidden, disabled, or style.display
* changes for the control or any of its ancestors. Add mutation observers
* to watch for these changes in order to ensure the first control keydown
* listener is always on the correct element.
* @param {!Element} control
* @private
*/
addMutationObservers_(control) {
let current = control;
while (current && current !== this.root) {
const currentObserver = this.createObserver_();
currentObserver.observe(current, {
attributes: true,
attributeFilter: ['hidden', 'disabled', 'style'],
attributeOldValue: true,
});
this.controlObservers_.push(currentObserver);
current = current.parentNode;
}
},
* This function gets called when the row itself receives the focus event.
* @param {!Event} e The focus event
* @private
*/
onFocus_(e) {
if (this.mouseFocused_) {
this.mouseFocused_ = false;
return;
}
const restoreFocusToFirst =
this.listBlurred && e.composedPath()[0] === this;
if (this.lastFocused && !restoreFocusToFirst) {
focusWithoutInk(this.row_.getEquivalentElement(this.lastFocused));
} else {
const firstFocusable = assert(this.firstControl_);
focusWithoutInk(firstFocusable);
}
this.listBlurred = false;
this.isFocused = true;
},
onFirstControlKeydown_(e) {
if (e.shiftKey && e.key === 'Tab') {
this.focus();
}
},
ironListTabIndexChanged_() {
if (this.row_) {
this.row_.makeActive(this.ironListTabIndex === 0);
}
if (this.ironListTabIndex === 0) {
this.listBlurred = false;
}
},
onMouseDown_() {
this.mouseFocused_ = true;
},
* @param {!Event} e
* @private
*/
onBlur_(e) {
this.mouseFocused_ = false;
this.isFocused = false;
const node =
e.relatedTarget ? (e.relatedTarget) : null;
if (!this.parentNode.contains(node)) {
this.listBlurred = true;
}
},
};
export class FocusRowBehaviorInterface {
constructor() {
this.id;
this.isFocused;
this.focusRowIndex;
this.lastFocused;
this.ironListTabIndex;
this.listBlurred;
}
* @param {number} newIndex
* @param {number} oldIndex
*/
focusRowIndexChanged(newIndex, oldIndex) {}
getFocusRow() {}
}