Comparing version 2.0.0 to 3.0.0
# Changelog | ||
## 3.0.0 | ||
- Add `[contenteditable]` elements. | ||
## 2.0.0 | ||
@@ -4,0 +8,0 @@ |
136
index.js
@@ -10,4 +10,7 @@ var candidateSelectors = [ | ||
'video[controls]', | ||
'[contenteditable]:not([contenteditable="false"])', | ||
]; | ||
var matches = Element.prototype.matches || Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector; | ||
module.exports = function tabbable(el, options) { | ||
@@ -17,16 +20,13 @@ options = options || {}; | ||
var elementDocument = el.ownerDocument || el; | ||
var basicTabbables = []; | ||
var regularTabbables = []; | ||
var orderedTabbables = []; | ||
var isHiddenByCss = createIsHiddenByCssChecker(elementDocument); | ||
var untouchabilityChecker = new UntouchabilityChecker(elementDocument); | ||
var candidates = el.querySelectorAll(candidateSelectors.join(',')); | ||
if (options.includeContainer) { | ||
var matches = Element.prototype.matches || Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector; | ||
if ( | ||
candidateSelectors.some(function(candidateSelector) { | ||
return matches.call(el, candidateSelector); | ||
}) | ||
) { | ||
var containerIsCandidate = candidateSelectors.some(function(selector) { | ||
return matches.call(el, selector); | ||
}); | ||
if (containerIsCandidate) { | ||
candidates = Array.prototype.slice.apply(candidates); | ||
@@ -37,14 +37,13 @@ candidates.unshift(el); | ||
var i, candidate, candidateTabindexAttr, candidateTabindex; | ||
var i, candidate, candidateTabindex; | ||
for (i = 0; i < candidates.length; i++) { | ||
candidate = candidates[i]; | ||
candidateTabindexAttr = parseInt(candidate.getAttribute('tabindex'), 10) | ||
candidateTabindex = isNaN(candidateTabindexAttr) ? candidate.tabIndex : candidateTabindexAttr; | ||
candidateTabindex = getTabindex(candidate); | ||
if ( | ||
candidateTabindex < 0 | ||
|| candidate.disabled | ||
|| isHiddenInput(candidate) | ||
|| isNonTabbableRadio(candidate) | ||
|| candidate.disabled | ||
|| isHiddenByCss(candidate, elementDocument) | ||
|| untouchabilityChecker.isUntouchable(candidate) | ||
) { | ||
@@ -55,3 +54,3 @@ continue; | ||
if (candidateTabindex === 0) { | ||
basicTabbables.push(candidate); | ||
regularTabbables.push(candidate); | ||
} else { | ||
@@ -68,53 +67,15 @@ orderedTabbables.push({ | ||
.sort(sortOrderedTabbables) | ||
.map(function(a) { | ||
return a.node | ||
}); | ||
.map(function(a) { return a.node }) | ||
.concat(regularTabbables); | ||
Array.prototype.push.apply(tabbableNodes, basicTabbables); | ||
return tabbableNodes; | ||
} | ||
function createIsHiddenByCssChecker(elementDocument) { | ||
// Node cache must be refreshed on every check, in case | ||
// the content of the element has changed. The cache contains tuples | ||
// mapping nodes to their boolean result. | ||
var isHiddenByCssCache = []; | ||
// getComputedStyle accurately reflects `visiblity: "hidden"` | ||
// in context but not `display: "none"`, so we need to recursively check parents. | ||
function hasDisplayNone(node, nodeComputedStyle) { | ||
if (node === elementDocument.documentElement) return false; | ||
// Search for a cached result. | ||
var cached = find(isHiddenByCssCache, function(item) { | ||
return item === node; | ||
}); | ||
if (cached) return cached[1]; | ||
nodeComputedStyle = nodeComputedStyle || elementDocument.defaultView.getComputedStyle(node); | ||
var result = false; | ||
if (nodeComputedStyle.display === 'none') { | ||
result = true; | ||
} else if (node.parentNode) { | ||
result = hasDisplayNone(node.parentNode); | ||
} | ||
isHiddenByCssCache.push([node, result]); | ||
return result; | ||
} | ||
return function isHiddenByCss(innerNode) { | ||
if (innerNode === elementDocument.documentElement) return false; | ||
var computedStyle = elementDocument.defaultView.getComputedStyle(innerNode); | ||
if (hasDisplayNone(innerNode, computedStyle)) return true; | ||
return computedStyle.visibility === 'hidden'; | ||
} | ||
function getTabindex(node) { | ||
var tabindexAttr = parseInt(node.getAttribute('tabindex'), 10); | ||
if (!isNaN(tabindexAttr)) return tabindexAttr; | ||
// Browsers do not return `tabIndex` correctly for contentEditable nodes; | ||
// so if they don't have a tabindex attribute specifically set, assume it's 0. | ||
if (isContentEditable(node)) return 0; | ||
return node.tabIndex; | ||
} | ||
@@ -126,3 +87,3 @@ | ||
// Array.prototype.find not available in IE | ||
// Array.prototype.find not available in IE. | ||
function find(list, predicate) { | ||
@@ -134,2 +95,6 @@ for (var i = 0, length = list.length; i < length; i++) { | ||
function isContentEditable(node) { | ||
return node.contentEditable === "true"; | ||
} | ||
function isInput(node) { | ||
@@ -161,2 +126,4 @@ return node.tagName === 'INPUT'; | ||
if (!node.name) return true; | ||
// This won't account for the edge case where you have radio groups with the same | ||
// in separate forms on the same page. | ||
var radioSet = node.ownerDocument.querySelectorAll('input[type="radio"][name="' + node.name + '"]'); | ||
@@ -166,1 +133,44 @@ var checked = getCheckedRadio(radioSet); | ||
} | ||
// An element is "untouchable" if *it or one of its ancestors* has | ||
// `visibility: hidden` or `display: none`. | ||
function UntouchabilityChecker(elementDocument) { | ||
this.doc = elementDocument; | ||
// Node cache must be refreshed on every check, in case | ||
// the content of the element has changed. The cache contains tuples | ||
// mapping nodes to their boolean result. | ||
this.cache = []; | ||
} | ||
// getComputedStyle accurately reflects `visibility: hidden` of ancestors | ||
// but not `display: none`, so we need to recursively check parents. | ||
UntouchabilityChecker.prototype.hasDisplayNone = function hasDisplayNone(node, nodeComputedStyle) { | ||
if (node === this.doc.documentElement) return false; | ||
// Search for a cached result. | ||
var cached = find(this.cache, function(item) { | ||
return item === node; | ||
}); | ||
if (cached) return cached[1]; | ||
nodeComputedStyle = nodeComputedStyle || this.doc.defaultView.getComputedStyle(node); | ||
var result = false; | ||
if (nodeComputedStyle.display === 'none') { | ||
result = true; | ||
} else if (node.parentNode) { | ||
result = this.hasDisplayNone(node.parentNode); | ||
} | ||
this.cache.push([node, result]); | ||
return result; | ||
} | ||
UntouchabilityChecker.prototype.isUntouchable = function isUntouchable(node) { | ||
if (node === this.doc.documentElement) return false; | ||
var computedStyle = this.doc.defaultView.getComputedStyle(node); | ||
if (this.hasDisplayNone(node, computedStyle)) return true; | ||
return computedStyle.visibility === 'hidden'; | ||
} |
{ | ||
"name": "tabbable", | ||
"version": "2.0.0", | ||
"version": "3.0.0", | ||
"description": "Returns an array of all tabbable DOM nodes within a containing node.", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
@@ -5,14 +5,15 @@ # tabbable | ||
Returns an array of all\* tabbable DOM nodes within a containing node, in their actual tab order (cf. [Sequential focus navigation and the tabindex attribute](http://www.w3.org/TR/html5/editing.html#sequential-focus-navigation-and-the-tabindex-attribute)). | ||
Returns an array of all\* tabbable DOM nodes within a containing node. | ||
<small>\* "all" has some necessary caveats, which you'll learn about by reading below.</small> | ||
(\* "all" has some necessary caveats, which you'll learn about by reading below.) | ||
The array of tabbable nodes should include the following: | ||
- `<button>`s, | ||
- `<input>`s, | ||
- `<select>`s, | ||
- `<textarea>`s, | ||
- `<a>`s and `<area>`s with `href` attributes, | ||
- `<audio>`s and `<videos>`s with `controls` attributes, | ||
- `<button>`s | ||
- `<input>`s | ||
- `<select>`s | ||
- `<textarea>`s | ||
- `<a>`s with `href` or `xlink:href` attributes | ||
- `<audio>`s and `<videos>`s with `controls` attributes | ||
- `[contenteditable]` elements | ||
- anything with a non-negative `tabindex` | ||
@@ -25,5 +26,5 @@ | ||
- either the node itself *or an ancestor of it* is hidden via `display: none` or `visibility: hidden` | ||
- it's an `<input type="radio">` and a different radio in its group is `checked`. | ||
- it's an `<input type="radio">` and a different radio in its group is `checked` | ||
**If you think a node should be included in your array of tabbables *but it's not*, all you need to do is add `tabindex="0"` to deliberately include it.** This will also result in more consistent cross-browser behavior. For information about why your special node might *not* be included, see ["More details"](#more-details), below. | ||
**If you think a node should be included in your array of tabbables *but it's not*, all you need to do is add `tabindex="0"` to deliberately include it.** (Or if it is in your array but you don't want it, you can add `tabindex="-1" to deliberately exclude it.) This will also result in more consistent cross-browser behavior. For information about why your special node might *not* be included, see ["More details"](#more-details), below. | ||
@@ -80,8 +81,8 @@ ## Goals | ||
- **Tabbable tries to identify elements that are reliably tabbable across (not dead) browsers.** Browsers are stupidly inconsistent in their behavior, though — especially for edge-case elements like `<object>` and `<iframe>` — so this means *some* elements that you *can* tab to in *some* browsers will be left out of the results. (To learn more about that stupid inconsistency, see this [amazing table](https://allyjs.io/data-tables/focusable.html)). To provide better consistency across browsers and ensure the elements you *want* in your tabbables list show up there, **try adding `tabindex="0"` to edge-case elements that Tabbable ignores**. | ||
- (As an example of the above:) Although browsers allow tabbing into elements marked `contenteditable`, outstanding bugs in the `tabIndex` API prevents Tabbable from registering them. If you have `contenteditable` elements that you need included in the array, you'll have to additionally specify `tabindex="0"`. (See [issue #7](https://github.com/davidtheclark/tabbable/issues/7).) | ||
- Although Tabbable tries to deal with positive tabindexes, **you should not use positive tabindexes**. Accessibility experts seem to be in (rare) unanimous and clear consent about this: rely on the order of elements in the document. | ||
- (Exemplifying the above ^^:) **The tabbability of `<iframe>`s, `<embed>`s, `<object>`s, and `<svg>`s is [inconsistent across browsers](https://allyjs.io/data-tables/focusable.html), so if you need an accurate read on one of these elements you should give it a `tabindex`. (You'll also need to pay attention to the `focusable` attribute on SVGs in IE & Edge.) | ||
- **Radio groups have some edge cases, which you can avoid by always having a `checked` one in each group** (and that is what you should usually do anyway). If there is no `checked` radio in the radio group, *all* of the radios will be considered tabbable. (Some browsers do this, otherwise don't — there's not consistency.) | ||
- If you're thinking, "Why not just use the right `querySelectorAll`?", you *may* be on to something ... but, as with most "just" statements, you're probably not. For example, a simple `querySelectorAll` approach will not figure out whether an element is *hidden*, and therefore not actually tabbable. (That said, if you do think Tabbable can be simplified or otherwise improved, I'd love to hear your idea.) | ||
- jQuery UI's `:tabbable` selector ignores elements with height and width of `0`. I'm not sure why — because I've found that I can still tab to those elements. So I kept them in. Only elements hidden with `display: none` or `visibility: hidden` are left out. | ||
- **Radio groups have some edge cases, which you can avoid by always having a `checked` one in each group** (and that is what you should usually do anyway). If there is no `checked` radio in the radio group, *all* of the radios will be considered tabbable. (Some browsers do this, otherwise don't — there's not consistency.) | ||
- Although Tabbable tries to deal with positive tabindexes, **you should not use positive tabindexes**. Accessibility experts seem to be in (rare) unanimous and clear consent about this: rely on the order of elements in the document. | ||
***Feedback and contributions more than welcome!*** |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
135965
165
86