Skip to content
This repository has been archived by the owner on Dec 19, 2024. It is now read-only.

add iron-focusables-helper #200

Merged
merged 9 commits into from
Oct 26, 2016
220 changes: 220 additions & 0 deletions iron-focusables-helper.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<!doctype html>
<!--
@license
Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
-->
<link rel="import" href="../polymer/polymer.html">

<script>
(function() {
'use strict';

var p = Element.prototype;
var matches = p.matches || p.matchesSelector || p.mozMatchesSelector ||
p.msMatchesSelector || p.oMatchesSelector || p.webkitMatchesSelector;

Polymer.IronFocusablesHelper = {

/**
* Returns a sorted array of tabbable nodes, including the root node.
* It searches the tabbable nodes in the light and shadow dom of the chidren,
* sorting the result by tabindex.
* @param {!Node} node
* @return {Array<HTMLElement>}
*/
getTabbableNodes: function(node) {
var result = [];
// If there is at least one element with tabindex > 0, we need to sort
// the final array by tabindex.
var needsSortByTabIndex = this._collectTabbableNodes(node, result);
if (needsSortByTabIndex) {
return this._sortByTabIndex(result);
}
return result;
},

/**
* Returns if a element is focusable.
* @param {!HTMLElement} element
* @return {boolean}
*/
isFocusable: function(element) {
// From http://stackoverflow.com/a/1600194/4228703:
// There isn't a definite list, it's up to the browser. The only
// standard we have is DOM Level 2 HTML https://www.w3.org/TR/DOM-Level-2-HTML/html.html,
// according to which the only elements that have a focus() method are
// HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement and
// HTMLAnchorElement. This notably omits HTMLButtonElement and
// HTMLAreaElement.
// Referring to these tests with tabbables in different browsers
// http://allyjs.io/data-tables/focusable.html

// Elements that cannot be focused if they have [disabled] attribute.
if (matches.call(element, 'input, select, textarea, button, object')) {
return matches.call(element, ':not([disabled])');
}
// Elements that can be focused even if they have [disabled] attribute.
return matches.call(element,
'a[href], area[href], iframe, [tabindex], [contentEditable]');
},

/**
* Returns if a element is tabbable. To be tabbable, a element must be
* focusable, visible, and with a tabindex !== -1.
* @param {!HTMLElement} element
* @return {boolean}
*/
isTabbable: function(element) {
return this.isFocusable(element) &&
matches.call(element, ':not([tabindex="-1"])') &&
this._isVisible(element);
},

/**
* Returns the normalized element tabindex. If not focusable, returns -1.
* It checks for the attribute "tabindex" instead of the element property
* `tabIndex` since browsers assign different values to it.
* e.g. in Firefox `<div contenteditable>` has `tabIndex = -1`
* @param {!HTMLElement} element
* @return {Number}
* @private
*/
_normalizedTabIndex: function(element) {
if (this.isFocusable(element)) {
var tabIndex = element.getAttribute('tabindex') || 0;
return Number(tabIndex);
}
return -1;
},

/**
* Searches for nodes that are tabbable and adds them to the `result` array.
* Returns if the `result` array needs to be sorted by tabindex.
* @param {!Node} node The starting point for the search; added to `result`
* if tabbable.
* @param {!Array<HTMLElement>} result
* @return {boolean}
* @private
*/
_collectTabbableNodes: function(node, result) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this method accounts for focus navigation scopes. Here, all the nodes are sorted at the end but each scope needs to be sorted separately. For example:

<div id="root">
  <!-- shadow -->
    <slot name="a">
    <slot name="b">
  <!-- /shadow -->
  <input id="A" slot="a">
  <input id="B" slot="b">
  <input id="C" slot="b" tabindex="1">
</div>

I think calling getTabbableNodes(#root) would return [#C, #A, #B], but it should return [#A, #C, #B] because slot[name=b] creates a focus navigation scope.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true, but I wouldn't encourage the support of tabindex > 0 for distributed content (I woudn't encourage its usage at all). The current implementation offers a solution that works in 80% of the cases, mainly for shady dom. But there are so many messed up permutations (e.g. ShadowDom v1 + delegatesFocus = false) where it is not clear yet what's the correct, desired behavior for tab order 😕

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something I didn't think about yesterday: if this doesn't intend to support tabindex, what's the point of the sort at all? If everything is assumed to have tabindex 0, document order is sequential focus navigation order.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That wasn't particularly well worded.. What I mean is, if calling getTabbableNodes with an ancestor of an element with tabindex > 0 is expected to break, then why attempt to sort? Why not just filter them out?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uhm, tested your example and indeed slots will scope the focus within their content..even without tabindex sorting this will be a problem for v1. So I should solve it. Will come up with a solution for this ;)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(from offline discussion) As this isn't part of the Polymer 2.0 upgrade yet and Polymer prior to 2 only uses Shadow DOM v0, there's no need for this to actually cover v1 focus behavior. <slot> to <content> conversion in Polymer 1.7 means that Shadow DOM v1-like markup ends up as v0 anyway. However, this means handling Shadow DOM v1 focus behavior will be part of porting to hybrid / 2.0. As long as you're ok with that, ignoring v1 here SGTM.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, I've added a comment for the future maintainer at line 116 👌

// If not an element or not visible, no need to explore children.
if (node.nodeType !== Node.ELEMENT_NODE || !this._isVisible(node)) {
return false;
}
var element = /** @type {HTMLElement} */ (node);
var tabIndex = this._normalizedTabIndex(element);
var needsSortByTabIndex = tabIndex > 0;
if (tabIndex >= 0) {
result.push(element);
}

// In ShadowDOM v1, tab order is affected by the order of distrubution.
// E.g. getTabbableNodes(#root) in ShadowDOM v1 should return [#A, #B];
// in ShadowDOM v0 tab order is not affected by the distrubution order,
// in fact getTabbableNodes(#root) returns [#B, #A].
// <div id="root">
// <!-- shadow -->
// <slot name="a">
// <slot name="b">
// <!-- /shadow -->
// <input id="A" slot="a">
// <input id="B" slot="b" tabindex="1">
// </div>
// TODO(valdrin) support ShadowDOM v1 when upgrading to Polymer v2.0.
var children;
if (element.localName === 'content') {
children = Polymer.dom(element).getDistributedNodes();
} else {
// Use shadow root if possible, will check for distributed nodes.
children = Polymer.dom(element.root || element).children;
}
for (var i = 0; i < children.length; i++) {
// Ensure method is always invoked to collect tabbable children.
var needsSort = this._collectTabbableNodes(children[i], result);
needsSortByTabIndex = needsSortByTabIndex || needsSort;
}
return needsSortByTabIndex;
},

/**
* Returns false if the element has `visibility: hidden` or `display: none`
* @param {!HTMLElement} element
* @return {boolean}
* @private
*/
_isVisible: function(element) {
// Check inline style first to save a re-flow. If looks good, check also
// computed style.
var style = element.style;
if (style.visibility !== 'hidden' && style.display !== 'none') {
style = window.getComputedStyle(element);
return (style.visibility !== 'hidden' && style.display !== 'none');
}
return false;
},

/**
* Sorts an array of tabbable elements by tabindex. Returns a new array.
* @param {!Array<HTMLElement>} tabbables
* @return {Array<HTMLElement>}
* @private
*/
_sortByTabIndex: function(tabbables) {
// Implement a merge sort as Array.prototype.sort does a non-stable sort
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
var len = tabbables.length;
if (len < 2) {
return tabbables;
}
var pivot = Math.ceil(len / 2);
var left = this._sortByTabIndex(tabbables.slice(0, pivot));
var right = this._sortByTabIndex(tabbables.slice(pivot));
return this._mergeSortByTabIndex(left, right);
},

/**
* Merge sort iterator, merges the two arrays into one, sorted by tab index.
* @param {!Array<HTMLElement>} left
* @param {!Array<HTMLElement>} right
* @return {Array<HTMLElement>}
* @private
*/
_mergeSortByTabIndex: function(left, right) {
var result = [];
while ((left.length > 0) && (right.length > 0)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was totally about to leave comments about how everything's going to break if either is empty and the concat with 'empty' arrays until I noticed that this is && and not ||!

if (this._hasLowerTabOrder(left[0], right[0])) {
result.push(right.shift());
} else {
result.push(left.shift());
}
}

return result.concat(left, right);
},

/**
* Returns if element `a` has lower tab order compared to element `b`
* (both elements are assumed to be focusable and tabbable).
* Elements with tabindex = 0 have lower tab order compared to elements
* with tabindex > 0.
* If both have same tabindex, it returns false.
* @param {!HTMLElement} a
* @param {!HTMLElement} b
* @return {boolean}
* @private
*/
_hasLowerTabOrder: function(a, b) {
// Normalize tabIndexes
// e.g. in Firefox `<div contenteditable>` has `tabIndex = -1`
var ati = Math.max(a.tabIndex, 0);
var bti = Math.max(b.tabIndex, 0);
return (ati === 0 || bti === 0) ? bti > ati : ati > bti;
}
};
})();
</script>
41 changes: 2 additions & 39 deletions iron-overlay-behavior.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<link rel="import" href="../iron-fit-behavior/iron-fit-behavior.html">
<link rel="import" href="../iron-resizable-behavior/iron-resizable-behavior.html">
<link rel="import" href="iron-overlay-manager.html">
<link rel="import" href="iron-focusables-helper.html">

<script>
(function() {
Expand Down Expand Up @@ -195,45 +196,7 @@
* @protected
*/
get _focusableNodes() {
// Elements that can be focused even if they have [disabled] attribute.
var FOCUSABLE_WITH_DISABLED = [
'a[href]',
'area[href]',
'iframe',
'[tabindex]',
'[contentEditable=true]'
];

// Elements that cannot be focused if they have [disabled] attribute.
var FOCUSABLE_WITHOUT_DISABLED = [
'input',
'select',
'textarea',
'button'
];

// Discard elements with tabindex=-1 (makes them not focusable).
var selector = FOCUSABLE_WITH_DISABLED.join(':not([tabindex="-1"]),') +
':not([tabindex="-1"]),' +
FOCUSABLE_WITHOUT_DISABLED.join(':not([disabled]):not([tabindex="-1"]),') +
':not([disabled]):not([tabindex="-1"])';

var focusables = Polymer.dom(this).querySelectorAll(selector);
if (this.tabIndex >= 0) {
// Insert at the beginning because we might have all elements with tabIndex = 0,
// and the overlay should be the first of the list.
focusables.splice(0, 0, this);
}
// Sort by tabindex.
return focusables.sort(function (a, b) {
if (a.tabIndex === b.tabIndex) {
return 0;
}
if (a.tabIndex === 0 || a.tabIndex > b.tabIndex) {
return 1;
}
return -1;
});
return Polymer.IronFocusablesHelper.getTabbableNodes(this);
},

ready: function() {
Expand Down
2 changes: 2 additions & 0 deletions test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
WCT.loadSuites([
'iron-overlay-behavior.html',
'iron-overlay-behavior.html?dom=shadow',
'iron-focusables-helper.html',
'iron-focusables-helper.html?dom=shadow',
'iron-overlay-backdrop.html',
'iron-overlay-backdrop.html?dom=shadow',
]);
Expand Down
Loading