diff --git a/.all-contributorsrc b/.all-contributorsrc index f4c07fe9..42d4b596 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -140,6 +140,15 @@ "contributions": [ "bug" ] + }, + { + "login": "BFrost", + "name": "bfrost", + "avatar_url": "https://avatars.githubusercontent.com/u/3368761?v=4", + "profile": "https://github.com/BFrost", + "contributions": [ + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/.changeset/violet-mugs-whisper.md b/.changeset/violet-mugs-whisper.md new file mode 100644 index 00000000..67f5632a --- /dev/null +++ b/.changeset/violet-mugs-whisper.md @@ -0,0 +1,5 @@ +--- +'tabbable': patch +--- + +fix: align with browser behaviour when a web component has a negative tabindex diff --git a/README.md b/README.md index 0ad04695..a26ba15c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # tabbable [![CI](https://github.com/focus-trap/tabbable/workflows/CI/badge.svg?branch=master&event=push)](https://github.com/focus-trap/tabbable/actions?query=workflow:CI+branch:master) [![Codecov](https://img.shields.io/codecov/c/github/focus-trap/tabbable)](https://codecov.io/gh/focus-trap/tabbable) [![license](https://badgen.now.sh/badge/license/MIT)](./LICENSE) -[![All Contributors](https://img.shields.io/badge/all_contributors-12-orange.svg?style=flat-square)](#contributors) +[![All Contributors](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat-square)](#contributors) Small utility that returns an array of all\* tabbable DOM nodes within a containing node. @@ -238,6 +238,7 @@ In alphabetical order:
Richard VΕ‘ianskΓ½

πŸ“–
Stefan Cameron

πŸ’» πŸ› πŸš‡ ⚠️ πŸ“– 🚧
Tyler Hawkins

πŸ”§ ⚠️ πŸš‡ πŸ“– +
bfrost

πŸ›
pebble2050

πŸ› diff --git a/src/index.js b/src/index.js index ab2cc1ea..1123dd81 100644 --- a/src/index.js +++ b/src/index.js @@ -49,6 +49,12 @@ const getCandidates = function (el, includeContainer, filter) { * @returns {ShadowRoot|boolean} ShadowRoot if available or boolean indicating if a shadowRoot is attached but not available. */ +/** + * @callback ShadowRootFilter + * @param {Element} shadowHostNode the element which contains shadow content + * @returns {boolean} true if a shadow root could potentially contain valid candidates. + */ + /** * @typedef {Object} CandidatesScope * @property {Element} scope contains inner candidates @@ -62,6 +68,7 @@ const getCandidates = function (el, includeContainer, filter) { * or a boolean stating if it has an undisclosed shadow root * @property {(node: Element) => boolean} filter filter candidates * @property {boolean} flatten if true then result will flatten any CandidatesScope into the returned list + * @property {ShadowRootFilter} shadowRootFilter filter shadow roots; */ /** @@ -110,7 +117,10 @@ const getCandidatesIteratively = function ( (typeof options.getShadowRoot === 'function' && options.getShadowRoot(element)); - if (shadowRoot) { + const validShadowRoot = + !options.shadowRootFilter || options.shadowRootFilter(element); + + if (shadowRoot && validShadowRoot) { // add shadow dom scope IIF a shadow root node was given; otherwise, an undisclosed // shadow exists, so look at light dom children as fallback BUT create a scope for any // child candidates found because they're likely slotted elements (elements that are @@ -415,6 +425,16 @@ const isNodeMatchingSelectorTabbable = function (options, node) { return true; }; +const isValidShadowRootTabbable = function (shadowHostNode) { + const tabIndex = parseInt(shadowHostNode.getAttribute('tabindex'), 10); + if (isNaN(tabIndex) || tabIndex >= 0) { + return true; + } + // If a custom element has an explicit negative tabindex, + // browsers will not allow tab targeting said element's children. + return false; +}; + /** * @param {Array.} candidates * @returns Element[] @@ -462,6 +482,7 @@ const tabbable = function (el, options) { filter: isNodeMatchingSelectorTabbable.bind(null, options), flatten: false, getShadowRoot: options.getShadowRoot, + shadowRootFilter: isValidShadowRootTabbable, }); } else { candidates = getCandidates( diff --git a/test/e2e/shadow-dom.e2e.js b/test/e2e/shadow-dom.e2e.js index b74c84b9..111681be 100644 --- a/test/e2e/shadow-dom.e2e.js +++ b/test/e2e/shadow-dom.e2e.js @@ -245,6 +245,22 @@ describe('web-components', () => { expect(getIdsFromElementsArray(result), 'using `true`').to.eql(expected); }); + it('should not find elements inside shadow dom that browsers will skip due to -1 tabindex on host', () => { + const expected = []; + const { container } = setupFixture(fixtures['shadow-dom-untabbable'], { + window, + }); + const shadowElement = container.querySelector('test-shadow'); + + let result = tabbable(shadowElement, { getShadowRoot() {} }); + expect(getIdsFromElementsArray(result), 'using `() => {}`').to.eql( + expected + ); + + result = tabbable(shadowElement, { getShadowRoot: true }); + expect(getIdsFromElementsArray(result), 'using `true`').to.eql(expected); + }); + it('should sort slots inside shadow dom', () => { const expected = [ 'light-before', diff --git a/test/fixtures/index.js b/test/fixtures/index.js index 9f6c998b..356268db 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -36,4 +36,8 @@ module.exports = { path.join(__dirname, 'shadow-dom-query.html'), 'utf8' ), + 'shadow-dom-untabbable': fs.readFileSync( + path.join(__dirname, 'shadow-dom-untabbable.html'), + 'utf8' + ), }; diff --git a/test/fixtures/shadow-dom-untabbable.html b/test/fixtures/shadow-dom-untabbable.html new file mode 100644 index 00000000..5d474acf --- /dev/null +++ b/test/fixtures/shadow-dom-untabbable.html @@ -0,0 +1,7 @@ + + +