// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Note: This file is deprecated, and should only be used by legacy code that
// still relies on closure compiler for typechecking. All new code should use
// focus_row.ts.
// clang-format off
import {assert, assertInstanceof} from 'chrome://resources/ash/common/assert.js';
import {EventTracker} from './event_tracker.js';
import {hasKeyModifiers, isRTL} from 'chrome://resources/ash/common/util.js';
// clang-format on
/**
* A class to manage focus between given horizontally arranged elements.
*
* Pressing left cycles backward and pressing right cycles forward in item
* order. Pressing Home goes to the beginning of the list and End goes to the
* end of the list.
*
* If an item in this row is focused, it'll stay active (accessible via tab).
* If no items in this row are focused, the row can stay active until focus
* changes to a node inside |this.boundary_|. If |boundary| isn't specified,
* any focus change deactivates the row.
*/
export class FocusRow {
/**
* @param {!Element} root The root of this focus row. Focus classes are
* applied to |root| and all added elements must live within |root|.
* @param {?Element} boundary Focus events are ignored outside of this
* element.
* @param {FocusRowDelegate=} delegate An optional event
* delegate.
*/
constructor(root, boundary, delegate) {
/** @type {!Element} */
this.root = root;
/** @private {!Element} */
this.boundary_ = boundary || document.documentElement;
/** @type {FocusRowDelegate|undefined} */
this.delegate = delegate;
/** @protected {!EventTracker} */
this.eventTracker = new EventTracker();
}
/**
* Whether it's possible that |element| can be focused.
* @param {Element} element
* @return {boolean} Whether the item is focusable.
*/
static isFocusable(element) {
if (!element || element.disabled) {
return false;
}
// We don't check that element.tabIndex >= 0 here because inactive rows
// set a tabIndex of -1.
let current = element;
while (true) {
assertInstanceof(current, Element);
const style = window.getComputedStyle(current);
if (style.visibility === 'hidden' || style.display === 'none') {
return false;
}
const parent = current.parentNode;
if (!parent) {
return false;
}
if (parent === current.ownerDocument ||
parent instanceof DocumentFragment) {
return true;
}
current = /** @type {Element} */ (parent);
}
}
/**
* A focus override is a function that returns an element that should gain
* focus. The element may not be directly selectable for example the element
* that can gain focus is in a shadow DOM. Allowing an override via a
* function leaves the details of how the element is retrieved to the
* component.
* @param {!HTMLElement} element
* @return {!HTMLElement}
*/
static getFocusableElement(element) {
if (element.getFocusableElement) {
return element.getFocusableElement();
}
return element;
}
/**
* Register a new type of focusable element (or add to an existing one).
*
* Example: an (X) button might be 'delete' or 'close'.
*
* When FocusRow is used within a FocusGrid, these types are used to
* determine equivalent controls when Up/Down are pressed to change rows.
*
* Another example: mutually exclusive controls that hide each other on
* activation (i.e. Play/Pause) could use the same type (i.e. 'play-pause')
* to indicate they're equivalent.
*
* @param {string} type The type of element to track focus of.
* @param {string|HTMLElement} selectorOrElement The selector of the element
* from this row's root, or the element itself.
* @return {boolean} Whether a new item was added.
*/
addItem(type, selectorOrElement) {
assert(type);
let element;
if (typeof selectorOrElement === 'string') {
element = this.root.querySelector(selectorOrElement);
} else {
element = selectorOrElement;
}
if (!element) {
return false;
}
element.setAttribute('focus-type', type);
element.tabIndex = this.isActive() ? 0 : -1;
this.eventTracker.add(element, 'blur', this.onBlur_.bind(this));
this.eventTracker.add(element, 'focus', this.onFocus_.bind(this));
this.eventTracker.add(element, 'keydown', this.onKeydown_.bind(this));
this.eventTracker.add(element, 'mousedown', this.onMousedown_.bind(this));
return true;
}
/** Dereferences nodes and removes event handlers. */
destroy() {
this.eventTracker.removeAll();
}
/**
* @param {!HTMLElement} sampleElement An element for to find an equivalent
* for.
* @return {!HTMLElement} An equivalent element to focus for
* |sampleElement|.
* @protected
*/
getCustomEquivalent(sampleElement) {
return /** @type {!HTMLElement} */ (assert(this.getFirstFocusable()));
}
/**
* @return {!Array<!HTMLElement>} All registered elements (regardless of
* focusability).
*/
getElements() {
return Array.from(this.root.querySelectorAll('[focus-type]'))
.map(FocusRow.getFocusableElement);
}
/**
* Find the element that best matches |sampleElement|.
* @param {!HTMLElement} sampleElement An element from a row of the same
* type which previously held focus.
* @return {!HTMLElement} The element that best matches sampleElement.
*/
getEquivalentElement(sampleElement) {
if (this.getFocusableElements().indexOf(sampleElement) >= 0) {
return sampleElement;
}
const sampleFocusType = this.getTypeForElement(sampleElement);
if (sampleFocusType) {
const sameType = this.getFirstFocusable(sampleFocusType);
if (sameType) {
return sameType;
}
}
return this.getCustomEquivalent(sampleElement);
}
/**
* @param {string=} opt_type An optional type to search for.
* @return {?HTMLElement} The first focusable element with |type|.
*/
getFirstFocusable(opt_type) {
const element = this.getFocusableElements().find(
el => !opt_type || el.getAttribute('focus-type') === opt_type);
return element || null;
}
/** @return {!Array<!HTMLElement>} Registered, focusable elements. */
getFocusableElements() {
return this.getElements().filter(FocusRow.isFocusable);
}
/**
* @param {!Element} element An element to determine a focus type for.
* @return {string} The focus type for |element| or '' if none.
*/
getTypeForElement(element) {
return element.getAttribute('focus-type') || '';
}
/** @return {boolean} Whether this row is currently active. */
isActive() {
return this.root.classList.contains(FocusRow.ACTIVE_CLASS);
}
/**
* Enables/disables the tabIndex of the focusable elements in the FocusRow.
* tabIndex can be set properly.
* @param {boolean} active True if tab is allowed for this row.
*/
makeActive(active) {
if (active === this.isActive()) {
return;
}
this.getElements().forEach(function(element) {
element.tabIndex = active ? 0 : -1;
});
this.root.classList.toggle(FocusRow.ACTIVE_CLASS, active);
}
/**
* @param {!Event} e
* @private
*/
onBlur_(e) {
if (!this.boundary_.contains(/** @type {Element} */ (e.relatedTarget))) {
return;
}
const currentTarget = /** @type {!HTMLElement} */ (e.currentTarget);
if (this.getFocusableElements().indexOf(currentTarget) >= 0) {
this.makeActive(false);
}
}
/**
* @param {!Event} e
* @private
*/
onFocus_(e) {
if (this.delegate) {
this.delegate.onFocus(this, e);
}
}
/**
* @param {!Event} e A mousedown event.
* @private
*/
onMousedown_(e) {
// Only accept left mouse clicks.
if (e.button) {
return;
}
// Allow the element under the mouse cursor to be focusable.
if (!e.currentTarget.disabled) {
e.currentTarget.tabIndex = 0;
}
}
/**
* @param {!Event} e The keydown event.
* @private
*/
onKeydown_(e) {
const elements = this.getFocusableElements();
const currentElement = FocusRow.getFocusableElement(
/** @type {!HTMLElement} */ (e.currentTarget));
const elementIndex = elements.indexOf(currentElement);
assert(elementIndex >= 0);
if (this.delegate && this.delegate.onKeydown(this, e)) {
return;
}
const isShiftTab = !e.altKey && !e.ctrlKey && !e.metaKey && e.shiftKey &&
e.key === 'Tab';
if (hasKeyModifiers(e) && !isShiftTab) {
return;
}
let index = -1;
let shouldStopPropagation = true;
if (isShiftTab) {
// This always moves back one element, even in RTL.
index = elementIndex - 1;
if (index < 0) {
// Bubble up to focus on the previous element outside the row.
return;
}
} else if (e.key === 'ArrowLeft') {
index = elementIndex + (isRTL() ? 1 : -1);
} else if (e.key === 'ArrowRight') {
index = elementIndex + (isRTL() ? -1 : 1);
} else if (e.key === 'Home') {
index = 0;
} else if (e.key === 'End') {
index = elements.length - 1;
} else {
shouldStopPropagation = false;
}
const elementToFocus = elements[index];
if (elementToFocus) {
this.getEquivalentElement(elementToFocus).focus();
e.preventDefault();
}
if (shouldStopPropagation) {
e.stopPropagation();
}
}
}
/** @const {string} */
FocusRow.ACTIVE_CLASS = 'focus-row-active';
/** @interface */
export class FocusRowDelegate {
/**
* Called when a key is pressed while on a FocusRow's item. If true is
* returned, further processing is skipped.
* @param {!FocusRow} row The row that detected a keydown.
* @param {!Event} e
* @return {boolean} Whether the event was handled.
*/
onKeydown(row, e) {}
/**
* @param {!FocusRow} row
* @param {!Event} e
*/
onFocus(row, e) {}
/**
* @param {!HTMLElement} sampleElement An element to find an equivalent for.
* @return {?HTMLElement} An equivalent element to focus, or null to use the
* default FocusRow element.
*/
getCustomEquivalent(sampleElement) {}
}