├── .eslintrc.js ├── .github └── stale.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .nvmrc ├── .size-limit ├── .size.json ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── edge-cases │ └── form.spec.ts ├── focusInside.spec.ts ├── focusIsHidden.spec.ts ├── focusMerge.spec.ts ├── focusMerge.unit.spec.ts ├── focusables.spec.ts ├── footprint.spec.ts ├── iframe.spec.ts ├── return-focus.spec.ts ├── shadow-dom.spec.ts ├── sibling.spec.ts └── tabOrder.spec.ts ├── constants └── package.json ├── jest.config.js ├── package.json ├── src ├── commands.ts ├── constants.ts ├── focusInside.ts ├── focusIsHidden.ts ├── focusSolver.ts ├── focusables.ts ├── index.ts ├── moveFocusInside.ts ├── return-focus.ts ├── sibling.ts ├── solver.ts └── utils │ ├── DOMutils.ts │ ├── all-affected.ts │ ├── array.ts │ ├── auto-focus.ts │ ├── correctFocus.ts │ ├── firstFocus.ts │ ├── getActiveElement.ts │ ├── is.ts │ ├── parenting.ts │ ├── safe.ts │ ├── tabOrder.ts │ ├── tabUtils.ts │ └── tabbables.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/typescript', 'plugin:react-hooks/recommended'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'prettier', 'import'], 5 | rules: { 6 | '@typescript-eslint/ban-ts-comment': 0, 7 | '@typescript-eslint/ban-ts-ignore': 0, 8 | '@typescript-eslint/no-var-requires': 0, 9 | '@typescript-eslint/camelcase': 0, 10 | 'import/order': [ 11 | 'error', 12 | { 13 | 'newlines-between': 'always-and-inside-groups', 14 | alphabetize: { 15 | order: 'asc', 16 | }, 17 | groups: ['builtin', 'external', 'internal', ['parent', 'index', 'sibling']], 18 | }, 19 | ], 20 | 'padding-line-between-statements': [ 21 | 'error', 22 | // IMPORT 23 | { 24 | blankLine: 'always', 25 | prev: 'import', 26 | next: '*', 27 | }, 28 | { 29 | blankLine: 'any', 30 | prev: 'import', 31 | next: 'import', 32 | }, 33 | // EXPORT 34 | { 35 | blankLine: 'always', 36 | prev: '*', 37 | next: 'export', 38 | }, 39 | { 40 | blankLine: 'any', 41 | prev: 'export', 42 | next: 'export', 43 | }, 44 | { 45 | blankLine: 'always', 46 | prev: '*', 47 | next: ['const', 'let'], 48 | }, 49 | { 50 | blankLine: 'any', 51 | prev: ['const', 'let'], 52 | next: ['const', 'let'], 53 | }, 54 | // BLOCKS 55 | { 56 | blankLine: 'always', 57 | prev: ['block', 'block-like', 'class', 'function', 'multiline-expression'], 58 | next: '*', 59 | }, 60 | { 61 | blankLine: 'always', 62 | prev: '*', 63 | next: ['block', 'block-like', 'class', 'function', 'return', 'multiline-expression'], 64 | }, 65 | ], 66 | }, 67 | settings: { 68 | 'import/parsers': { 69 | '@typescript-eslint/parser': ['.ts', '.tsx'], 70 | }, 71 | 'import/resolver': { 72 | typescript: { 73 | alwaysTryTypes: true, 74 | }, 75 | }, 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Label to use when marking an issue as stale 6 | staleLabel: state 7 | # Comment to post when marking an issue as stale. Set to `false` to disable 8 | markComment: > 9 | This issue has been marked as "stale" because there has been no activity for 2 months. 10 | If you have any new information or would like to continue the discussion, please feel free to do so. 11 | If this issue got buried among other tasks, maybe this message will reignite the conversation. 12 | Otherwise, this issue will be closed in 7 days. Thank you for your contributions so far. 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | /dist/ 4 | .DS_Store 5 | coverage -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | src/ 3 | _tests/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.size-limit: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "dist/es2015/index.js", 4 | "limit": "3.6 kB" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /.size.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "dist/es2015/index.js", 4 | "passed": true, 5 | "size": 3455, 6 | "sizeLimit": 3600 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '18' 4 | cache: yarn 5 | script: 6 | - yarn 7 | - yarn test:ci 8 | - codecov 9 | notifications: 10 | email: true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.3.5](https://github.com/theKashey/focus-lock/compare/v1.3.4...v1.3.5) (2024-04-06) 2 | 3 | ### Bug Fixes 4 | 5 | - correct WeakRef for old Safari. fixes [#68](https://github.com/theKashey/focus-lock/issues/68) ([3e79b5f](https://github.com/theKashey/focus-lock/commit/3e79b5f545cf5e9895dc43f824f2ed3239e7db3f)) 6 | 7 | ## [1.3.4](https://github.com/theKashey/focus-lock/compare/v1.3.3...v1.3.4) (2024-03-06) 8 | 9 | ### Bug Fixes 10 | 11 | - correct first of two return case ([9bf3859](https://github.com/theKashey/focus-lock/commit/9bf3859ac68d25117dbc09e042df523ff96c7010)) 12 | 13 | ## [1.3.3](https://github.com/theKashey/focus-lock/compare/v1.3.2...v1.3.3) (2024-02-20) 14 | 15 | ### Bug Fixes 16 | 17 | - handle no activeElement case ([2895218](https://github.com/theKashey/focus-lock/commit/289521888013793e305146c9f68a54258ba55ae7)) 18 | 19 | ## [1.3.2](https://github.com/theKashey/focus-lock/compare/v1.3.1...v1.3.2) (2024-02-19) 20 | 21 | ## [1.3.1](https://github.com/theKashey/focus-lock/compare/v1.3.0...v1.3.1) (2024-02-16) 22 | 23 | ### Bug Fixes 24 | 25 | - support lock reactivation with no tabble nodes, fixes [#63](https://github.com/theKashey/focus-lock/issues/63) ([a8ef771](https://github.com/theKashey/focus-lock/commit/a8ef771bf83001d62402c17000ba0a12a18b67db)) 26 | 27 | # [1.3.0](https://github.com/theKashey/focus-lock/compare/v1.2.1...v1.3.0) (2024-02-16) 28 | 29 | ## [1.2.1](https://github.com/theKashey/focus-lock/compare/v1.2.0...v1.2.1) (2024-02-16) 30 | 31 | ### Bug Fixes 32 | 33 | - add missing API to sibling/return-focus ([33cb860](https://github.com/theKashey/focus-lock/commit/33cb86087466570ec0a900c97b8f4c4c0b4dd6f4)) 34 | 35 | # [1.2.0](https://github.com/theKashey/focus-lock/compare/v1.1.0...v1.2.0) (2024-02-14) 36 | 37 | ### Bug Fixes 38 | 39 | - remove visibility check from expandFocusableNodes ([543b9fe](https://github.com/theKashey/focus-lock/commit/543b9fe9f379e5744f2b51046422b51cf390370c)) 40 | 41 | ### Features 42 | 43 | - implement return focus ([f82447c](https://github.com/theKashey/focus-lock/commit/f82447cedec4d439601e07dc7ed879a5d90de01c)) 44 | 45 | # [1.1.0](https://github.com/theKashey/focus-lock/compare/v1.0.1...v1.1.0) (2024-02-11) 46 | 47 | ### Features 48 | 49 | - extend sibling API for all focuble elements ([ecd666c](https://github.com/theKashey/focus-lock/commit/ecd666c5d92b95251f286b22d9429d816d70e669)) 50 | 51 | ## [1.0.1](https://github.com/theKashey/focus-lock/compare/v1.0.0...v1.0.1) (2024-02-09) 52 | 53 | ### Bug Fixes 54 | 55 | - correct abiity to restore focus on any focusable, fixes [#54](https://github.com/theKashey/focus-lock/issues/54) ([81ba288](https://github.com/theKashey/focus-lock/commit/81ba2883f6ecdb8f9ea367474d77306778e69185)) 56 | - correct tabIndex calculation for exotic components, fixes [#55](https://github.com/theKashey/focus-lock/issues/55) ([ef76a09](https://github.com/theKashey/focus-lock/commit/ef76a098639eeffb800e25680c17a277666d6cbf)) 57 | - support inert Attribute, fixes [#58](https://github.com/theKashey/focus-lock/issues/58) ([601f8d1](https://github.com/theKashey/focus-lock/commit/601f8d1af4a3161495174881f84dcedcdfa4c841)) 58 | 59 | # [1.0.0](https://github.com/theKashey/focus-lock/compare/v0.11.6...v1.0.0) (2023-10-12) 60 | 61 | ## [0.11.6](https://github.com/theKashey/focus-lock/compare/v0.11.5...v0.11.6) (2023-02-16) 62 | 63 | ### Bug Fixes 64 | 65 | - secure access to cross-origin iframes, fixes [#45](https://github.com/theKashey/focus-lock/issues/45) ([860e283](https://github.com/theKashey/focus-lock/commit/860e2831a4b5c346c973b9d57f833e78a58fcc6f)) 66 | 67 | ## [0.11.5](https://github.com/theKashey/focus-lock/compare/v0.11.4...v0.11.5) (2023-01-28) 68 | 69 | ## [0.11.4](https://github.com/theKashey/focus-lock/compare/v0.11.3...v0.11.4) (2022-11-24) 70 | 71 | ### Bug Fixes 72 | 73 | - correct behavior for focusable-less targets. Implements [#41](https://github.com/theKashey/focus-lock/issues/41) ([9466d49](https://github.com/theKashey/focus-lock/commit/9466d49bc9ee72e53cc52821f5c67330aa284005)) 74 | 75 | ## [0.11.3](https://github.com/theKashey/focus-lock/compare/v0.11.2...v0.11.3) (2022-09-19) 76 | 77 | ### Bug Fixes 78 | 79 | - correct autofocus behavior; accept empty data-autofocus prop ([e144d52](https://github.com/theKashey/focus-lock/commit/e144d52eb5448badc4ab3a06b815a3924d1abdf8)) 80 | - Skip `.contains` if it is not available ([8d4c91c](https://github.com/theKashey/focus-lock/commit/8d4c91c457941a0ae668f477c3c5556e786ce929)) 81 | 82 | ## [0.11.2](https://github.com/theKashey/focus-lock/compare/v0.11.1...v0.11.2) (2022-05-07) 83 | 84 | ### Bug Fixes 85 | 86 | - use prototype-based node.contains, fixes [#36](https://github.com/theKashey/focus-lock/issues/36) ([c7eb950](https://github.com/theKashey/focus-lock/commit/c7eb9500adcb37ff2cac8a84b440fc59804d5874)) 87 | 88 | ## [0.11.1](https://github.com/theKashey/focus-lock/compare/v0.11.0...v0.11.1) (2022-05-04) 89 | 90 | # [0.11.0](https://github.com/theKashey/focus-lock/compare/v0.10.2...v0.11.0) (2022-05-01) 91 | 92 | ### Bug Fixes 93 | 94 | - no longer block aria-disabled elements, fixes [#34](https://github.com/theKashey/focus-lock/issues/34) ([2bc8ee3](https://github.com/theKashey/focus-lock/commit/2bc8ee3a58f5c51b6a44df24b4bd443c01977737)) 95 | - restore built-in jsdoc ([edc8a82](https://github.com/theKashey/focus-lock/commit/edc8a82b1fe1e0a349ff60ebb599f63dbc2aa599)) 96 | 97 | ### Features 98 | 99 | - introduce FOCUS_NO_AUTOFOCUS ([5c2dc8f](https://github.com/theKashey/focus-lock/commit/5c2dc8fb371ee83400ae65c7f0923b19eaf99d05)) 100 | 101 | ## [0.10.2](https://github.com/theKashey/focus-lock/compare/v0.10.1...v0.10.2) (2022-02-14) 102 | 103 | ### Bug Fixes 104 | 105 | - correct button management for disabled state ([463682e](https://github.com/theKashey/focus-lock/commit/463682eb938928b3682ba91b1f1e4b2de4788cea)) 106 | 107 | ## [0.10.1](https://github.com/theKashey/focus-lock/compare/v0.9.2...v0.10.1) (2021-12-12) 108 | 109 | ### Features 110 | 111 | - suport focusOptions in setFocus ([69debee](https://github.com/theKashey/focus-lock/commit/69debee17264c44685c63e2d3366a524d1bcdb8b)) 112 | 113 | ## [0.9.2](https://github.com/theKashey/focus-lock/compare/v0.9.1...v0.9.2) (2021-09-02) 114 | 115 | ## [0.9.1](https://github.com/theKashey/focus-lock/compare/v0.9.0...v0.9.1) (2021-05-13) 116 | 117 | ### Performance Improvements 118 | 119 | - track operation complexity ([0d91516](https://github.com/theKashey/focus-lock/commit/0d91516d48a36572507861ca167d93ba16e41a1b)) 120 | 121 | # [0.9.0](https://github.com/theKashey/focus-lock/compare/v0.8.1...v0.9.0) (2021-03-28) 122 | 123 | ### Features 124 | 125 | - allow setting focusOptions for prev/next focus actions ([d2e17d6](https://github.com/theKashey/focus-lock/commit/d2e17d66c59d9d04ac5f2196610a56e18cc1e1cf)) 126 | 127 | ## [0.8.1](https://github.com/theKashey/focus-lock/compare/v0.8.0...v0.8.1) (2020-11-16) 128 | 129 | ### Bug Fixes 130 | 131 | - contants endpoint not exposed ([ab51c37](https://github.com/theKashey/focus-lock/commit/ab51c37008c89348ab26f5efaa7ae159e239faa7)) 132 | 133 | # [0.8.0](https://github.com/theKashey/focus-lock/compare/v0.7.0...v0.8.0) (2020-09-30) 134 | 135 | ### Bug Fixes 136 | 137 | - readonly control can be focused, fixes [#18](https://github.com/theKashey/focus-lock/issues/18) ([842d578](https://github.com/theKashey/focus-lock/commit/842d578cccbfed2b35b9c490229a40861e6c950a)) 138 | - speedup nested nodes resolution O(n^2) to O(nlogn) ([5bc1498](https://github.com/theKashey/focus-lock/commit/5bc1498b6a7885d9e6ce906777395c31eca2bdef)) 139 | 140 | ### Features 141 | 142 | - add relative focusing API ([3086116](https://github.com/theKashey/focus-lock/commit/308611642385f78a7a8b58848a0e1d540a83c9ba)) 143 | - switch to typescript ([fcd5892](https://github.com/theKashey/focus-lock/commit/fcd5892403e1b8de98957440669462e829f4d7c3)) 144 | 145 | # [0.7.0](https://github.com/theKashey/focus-lock/compare/v0.6.7...v0.7.0) (2020-06-18) 146 | 147 | ### Bug Fixes 148 | 149 | - accept all focusable elements for autofocus, fixes [#16](https://github.com/theKashey/focus-lock/issues/16) ([88efbe8](https://github.com/theKashey/focus-lock/commit/88efbe81179e053a107ef20e37feddd1e826320f)) 150 | - dataset of null error ([7cb428b](https://github.com/theKashey/focus-lock/commit/7cb428be8cc61051ac31ef21b3d3b2463e187b9a)) 151 | - update logic for index diff calculations, fixes [#14](https://github.com/theKashey/focus-lock/issues/14) ([4c7e637](https://github.com/theKashey/focus-lock/commit/4c7e63721394716370bdfb9e755af7cd965708cc)) 152 | 153 | ## [0.6.7](https://github.com/theKashey/focus-lock/compare/v0.6.6...v0.6.7) (2020-04-17) 154 | 155 | ### Bug Fixes 156 | 157 | - better handle jump out conditions. Focus on the active radio and look for tailing guards as well ([421e869](https://github.com/theKashey/focus-lock/commit/421e8690d81dbeaaa43231a1be46bd4b235a84bf)) 158 | 159 | ## [0.6.6](https://github.com/theKashey/focus-lock/compare/v0.6.5...v0.6.6) (2019-10-17) 160 | 161 | ### Bug Fixes 162 | 163 | - detect document using nodeType, fixes [#11](https://github.com/theKashey/focus-lock/issues/11) ([c03e6bc](https://github.com/theKashey/focus-lock/commit/c03e6bc99a467dace0c346397480b95dcff7f74d)) 164 | 165 | ## [0.6.5](https://github.com/theKashey/focus-lock/compare/v0.6.4...v0.6.5) (2019-06-10) 166 | 167 | ### Bug Fixes 168 | 169 | - dont use array.find, fixes [#9](https://github.com/theKashey/focus-lock/issues/9) ([cbeec63](https://github.com/theKashey/focus-lock/commit/cbeec6319bb9716c3cf729e2134d4eb7f5702358)) 170 | 171 | ## [0.6.4](https://github.com/theKashey/focus-lock/compare/v0.6.3...v0.6.4) (2019-05-28) 172 | 173 | ### Features 174 | 175 | - sidecar for constants ([8a42017](https://github.com/theKashey/focus-lock/commit/8a4201775b3689bdbda389a0eb15e2afde5d1d2a)) 176 | 177 | ## [0.6.3](https://github.com/theKashey/focus-lock/compare/v0.6.2...v0.6.3) (2019-04-22) 178 | 179 | ### Bug Fixes 180 | 181 | - allow top guard jump ([58237a3](https://github.com/theKashey/focus-lock/commit/58237a358bdab02cf75c0e41d67ef209f833dec5)) 182 | 183 | ## [0.6.2](https://github.com/theKashey/focus-lock/compare/v0.6.1...v0.6.2) (2019-03-11) 184 | 185 | ### Bug Fixes 186 | 187 | - fix guard order ([c390b1a](https://github.com/theKashey/focus-lock/commit/c390b1aa94c42c74015f87a35443444f96a43a6c)) 188 | 189 | ## [0.6.1](https://github.com/theKashey/focus-lock/compare/v0.6.0...v0.6.1) (2019-03-10) 190 | 191 | # [0.6.0](https://github.com/theKashey/focus-lock/compare/v0.5.4...v0.6.0) (2019-03-09) 192 | 193 | ### Features 194 | 195 | - multi target lock ([79bce83](https://github.com/theKashey/focus-lock/commit/79bce837afed02ca9b1e71ee0dcf4f1b74367133)) 196 | 197 | ## [0.5.4](https://github.com/theKashey/focus-lock/compare/v0.5.3...v0.5.4) (2019-01-22) 198 | 199 | ### Bug Fixes 200 | 201 | - failback to focusable node if tabble not exists ([8b9d018](https://github.com/theKashey/focus-lock/commit/8b9d01882d4191cf436606515043d7dfe9bc52a5)) 202 | 203 | ## [0.5.3](https://github.com/theKashey/focus-lock/compare/v0.5.2...v0.5.3) (2018-11-11) 204 | 205 | ### Bug Fixes 206 | 207 | - disabled buttons with tab indexes ([632e08e](https://github.com/theKashey/focus-lock/commit/632e08ec58c1a1d49b6b148edd2f3602298ff1d9)) 208 | 209 | ## [0.5.2](https://github.com/theKashey/focus-lock/compare/v0.5.1...v0.5.2) (2018-11-01) 210 | 211 | ## [0.5.1](https://github.com/theKashey/focus-lock/compare/v0.5.0...v0.5.1) (2018-10-24) 212 | 213 | # [0.5.0](https://github.com/theKashey/focus-lock/compare/v0.4.2...v0.5.0) (2018-10-18) 214 | 215 | ## [0.4.2](https://github.com/theKashey/focus-lock/compare/v0.4.1...v0.4.2) (2018-09-06) 216 | 217 | ## [0.4.1](https://github.com/theKashey/focus-lock/compare/v0.4.0...v0.4.1) (2018-08-28) 218 | 219 | # [0.4.0](https://github.com/theKashey/focus-lock/compare/v0.3.0...v0.4.0) (2018-08-28) 220 | 221 | # [0.3.0](https://github.com/theKashey/focus-lock/compare/v0.2.4...v0.3.0) (2018-05-08) 222 | 223 | ## [0.2.4](https://github.com/theKashey/focus-lock/compare/v0.2.3...v0.2.4) (2018-04-18) 224 | 225 | ## [0.2.3](https://github.com/theKashey/focus-lock/compare/v0.2.2...v0.2.3) (2018-04-18) 226 | 227 | ## [0.2.2](https://github.com/theKashey/focus-lock/compare/v0.2.1...v0.2.2) (2018-04-11) 228 | 229 | ## [0.2.1](https://github.com/theKashey/focus-lock/compare/v0.2.0...v0.2.1) (2018-03-31) 230 | 231 | # 0.2.0 (2018-03-15) 232 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Anton Korzunov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # focus-lock 2 | 3 | It is a trap! We got your focus and will not let him out! 4 | 5 | [![NPM](https://nodei.co/npm/focus-lock.png?downloads=true&stars=true)](https://nodei.co/npm/react-focus-lock/) 6 | 7 | **Important** - this is a low level package to be used in order to create "focus lock". 8 | It does not provide any "lock" capabilities by itself, only helpers you can use to create one 9 | 10 | # Focus-lock implementations 11 | 12 | This is a base package for: 13 | 14 | - [react-focus-lock](https://github.com/theKashey/react-focus-lock) 15 | [![downloads](https://badgen.net/npm/dm/react-focus-lock)](https://www.npmtrends.com/react-focus-lock) 16 | - [vue-focus-lock](https://github.com/theKashey/vue-focus-lock) 17 | [![downloads](https://badgen.net/npm/dm/vue-focus-lock)](https://www.npmtrends.com/vue-focus-lock) 18 | - [dom-focus-lock](https://github.com/theKashey/dom-focus-lock) 19 | [![downloads](https://badgen.net/npm/dm/dom-focus-lock)](https://www.npmtrends.com/dom-focus-lock) 20 | 21 | The common use case will look like final realization. 22 | 23 | ```js 24 | import { moveFocusInside, focusInside } from 'focus-lock'; 25 | 26 | if (someNode && !focusInside(someNode)) { 27 | moveFocusInside(someNode, lastActiveFocus /* very important to know */); 28 | } 29 | ``` 30 | 31 | > note that tracking `lastActiveFocus` is on the end user. 32 | 33 | ## Declarative control 34 | 35 | `focus-lock` provides not only API to be called by some other scripts, but also a way one can leave instructions inside HTML markup 36 | to amend focus behavior in a desired way. 37 | 38 | These are `data-attributes` one can add on the elements: 39 | 40 | - control 41 | - `data-focus-lock=[group-name]` to create a focus group (scattered focus) 42 | - `data-focus-lock-disabled="disabled"` marks such group as disabled and removes from the list. Equal to removing elements from the DOM. 43 | - `data-no-focus-lock` focus-lock will ignore/allow focus inside marked area. Focus on this elements will not be managed by focus-lock. 44 | - autofocus (via `moveFocusInside(someNode, null)`) 45 | - `data-autofocus` will autofocus marked element on activation. 46 | - `data-autofocus-inside` focus-lock will try to autofocus elements within selected area on activation. 47 | - `data-no-autofocus` focus-lock will not autofocus any node within marked area on activation. 48 | 49 | These markers are available as `import * as markers from 'focus-lock/constants'` 50 | 51 | ## Additional API 52 | 53 | ### Get focusable nodes 54 | 55 | Returns visible and focusable nodes 56 | 57 | ```ts 58 | import { expandFocusableNodes, getFocusableNodes, getTabbleNodes } from 'focus-lock'; 59 | 60 | // returns all focusable nodes inside given locations 61 | getFocusableNodes([many, nodes])[0].node.focus(); 62 | 63 | // returns all nodes reacheable in the "taborder" inside given locations 64 | getTabbleNodes([many, nodes])[0].node.focus(); 65 | 66 | // returns an "extended information" about focusable nodes inside. To be used for advances cases (react-focus-lock) 67 | expandFocusableNodes(singleNodes); 68 | ``` 69 | 70 | ### Programmatic focus management 71 | 72 | Allows moving back and forth between focusable/tabbable elements 73 | 74 | ```ts 75 | import { focusNextElement, focusPrevElement } from 'focus-lock'; 76 | focusNextElement(document.activeElement, { 77 | scope: theBoundingDOMNode, 78 | }); // -> next tabbable element 79 | ``` 80 | 81 | ### Return focus 82 | 83 | Advanced API to return focus (from the Modal) to the last or the next best location 84 | 85 | ```ts 86 | import { captureFocusRestore } from 'focus-lock'; 87 | const restore = captureFocusRestore(element); 88 | // .... 89 | restore()?.focus(); // restores focus the the element, or it's siblings in case it no longer exists 90 | ``` 91 | 92 | # WHY? 93 | 94 | From [MDN Article about accessible dialogs](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_dialog_role): 95 | 96 | - The dialog must be properly labeled 97 | - Keyboard **focus must be managed** correctly 98 | 99 | This one is about managing the focus. 100 | 101 | I'v got a good [article about focus management, dialogs and WAI-ARIA](https://medium.com/@antonkorzunov/its-a-focus-trap-699a04d66fb5). 102 | 103 | # Focus fighting 104 | 105 | It is possible, that more that one "focus management system" is present on the site. 106 | For example, you are using FocusLock for your content, and also using some 107 | Modal dialog, with FocusTrap inside. 108 | 109 | Both system will try to do their best, and move focus into their managed areas. 110 | Stack overflow. Both are dead. 111 | 112 | Focus Lock(React-Focus-Lock, Vue-Focus-Lock and so on) implements anti-fighting 113 | protection - once the battle is detected focus-lock will surrender(as long there is no way to win this fight). 114 | 115 | You may also land a peace by special data attribute - `data-no-focus-lock`(constants.FOCUS_ALLOW). It will 116 | remove focus management from all nested elements, letting you open modals, forms, or 117 | use any third party component safely. Focus lock will just do nothing, while focus is on the marked elements. 118 | 119 | # API 120 | 121 | `default(topNode, lastNode)` (aka setFocus), moves focus inside topNode, keeping in mind that last focus inside was - lastNode 122 | 123 | # Licence 124 | 125 | MIT 126 | -------------------------------------------------------------------------------- /__tests__/edge-cases/form.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusInside } from '../../src'; 2 | 3 | describe('form edge cases', () => { 4 | describe('specific input names', () => { 5 | it('contains', () => { 6 | document.body.innerHTML = ` 7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 | `; 15 | 16 | expect(focusInside(document.getElementById('f1')!)).toBe(false); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /__tests__/focusInside.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusInside } from '../src/'; 2 | 3 | describe('smoke', () => { 4 | const createTest = () => { 5 | document.body.innerHTML = ` 6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 |
20 | `; 21 | }; 22 | 23 | const querySelector = (q: string): HTMLElement => document.querySelector(q)!; 24 | 25 | describe('FocusInside', () => { 26 | it('false - when there is no focus', () => { 27 | createTest(); 28 | expect(focusInside(document.body)).toBe(true); 29 | expect(focusInside(querySelector('#d1')!)).toBe(false); 30 | expect(focusInside(querySelector('#d2'))).toBe(false); 31 | expect(focusInside(querySelector('#d3'))).toBe(false); 32 | expect(focusInside(querySelector('#d4'))).toBe(false); 33 | }); 34 | 35 | it('true - when focus in d1', () => { 36 | createTest(); 37 | querySelector('#d1 button').focus(); 38 | expect(focusInside(document.body)).toBe(true); 39 | expect(focusInside(querySelector('#d1'))).toBe(true); 40 | expect(focusInside(querySelector('#d2'))).toBe(false); 41 | }); 42 | 43 | it('true - when focus on d4 (tabbable)', () => { 44 | createTest(); 45 | querySelector('#d4').focus(); 46 | expect(focusInside(document.body)).toBe(true); 47 | expect(focusInside(querySelector('#d4'))).toBe(true); 48 | expect(focusInside(querySelector('#d1'))).toBe(false); 49 | }); 50 | 51 | it('multi-test', () => { 52 | createTest(); 53 | querySelector('#d1 button').focus(); 54 | expect(focusInside(document.body)).toBe(true); 55 | expect(focusInside(querySelector('#d1'))).toBe(true); 56 | expect(focusInside([querySelector('#d1')])).toBe(true); 57 | expect(focusInside([querySelector('#d2')])).toBe(false); 58 | expect(focusInside([querySelector('#d1'), querySelector('#d2')])).toBe(true); 59 | expect(focusInside([querySelector('#d2'), querySelector('#d3')])).toBe(false); 60 | expect(focusInside([querySelector('#d3'), querySelector('#d1')])).toBe(true); 61 | }); 62 | }); 63 | 64 | const createShadowTest = (nested?: boolean) => { 65 | const html = ` 66 |
67 |
68 | 69 | 70 |
71 |
72 |
`; 73 | const shadowHtml = ` 74 |
75 | 76 |
77 | 78 |
79 | `; 80 | document.body.innerHTML = html; 81 | 82 | const shadowContainer = document.getElementById('shadowdom') as HTMLElement; 83 | const root = shadowContainer.attachShadow({ mode: 'open' }); 84 | const shadowDiv = document.createElement('div'); 85 | shadowDiv.innerHTML = shadowHtml; 86 | root.appendChild(shadowDiv); 87 | 88 | if (nested) { 89 | const firstDiv = root.querySelector('#first') as HTMLDivElement; 90 | const nestedRoot = firstDiv.attachShadow({ mode: 'open' }); 91 | const nestedShadowDiv = document.createElement('div'); 92 | 93 | nestedShadowDiv.innerHTML = shadowHtml; 94 | nestedRoot.appendChild(nestedShadowDiv); 95 | } 96 | }; 97 | 98 | describe('with shadow dom', () => { 99 | it('false when the focus is within a shadow dom not within the topNode', () => { 100 | createShadowTest(); 101 | 102 | const nonShadowDiv = querySelector('#nonshadow'); 103 | 104 | const shadowBtn = querySelector('#shadowdom')?.shadowRoot?.querySelector('#firstBtn') as HTMLButtonElement; 105 | 106 | shadowBtn.focus(); 107 | 108 | expect(focusInside(document.body)).toBe(true); 109 | expect(focusInside(nonShadowDiv)).toBe(false); 110 | }); 111 | 112 | it('false when topNode is shadow sibling of focused node', () => { 113 | createShadowTest(); 114 | 115 | const shadowHost = querySelector('#shadowdom'); 116 | 117 | const shadowBtn = shadowHost.shadowRoot?.querySelector('#firstBtn') as HTMLButtonElement; 118 | const shadowDivLast = shadowHost.shadowRoot?.querySelector('#last') as HTMLDivElement; 119 | 120 | shadowBtn.focus(); 121 | 122 | expect(focusInside(document.body)).toBe(true); 123 | expect(focusInside(shadowDivLast)).toBe(false); 124 | }); 125 | 126 | it('true when focus is within shadow dom within topNode', () => { 127 | createShadowTest(); 128 | 129 | const shadowHost = querySelector('#shadowdom'); 130 | 131 | const shadowDivLast = shadowHost.shadowRoot?.querySelector('#last') as HTMLDivElement; 132 | const shadowBtn = shadowHost.shadowRoot?.querySelector('#secondBtn') as HTMLButtonElement; 133 | 134 | shadowBtn.focus(); 135 | 136 | expect(focusInside(document.body)).toBe(true); 137 | expect(focusInside(shadowHost)).toBe(true); 138 | expect(focusInside(shadowDivLast)).toBe(true); 139 | }); 140 | 141 | it('true when focus is within nested shadow dom', () => { 142 | createShadowTest(true); 143 | 144 | const shadowHost = querySelector('#shadowdom'); 145 | const nestedShadowHost = shadowHost.shadowRoot?.querySelector('#first') as HTMLDivElement; 146 | 147 | const nestedShadowDiv = nestedShadowHost.shadowRoot?.querySelector('#first') as HTMLDivElement; 148 | const nestedShadowDivLast = nestedShadowHost.shadowRoot?.querySelector('#last') as HTMLDivElement; 149 | const nestedShadowButton = nestedShadowDivLast.querySelector('#secondBtn') as HTMLButtonElement; 150 | 151 | nestedShadowButton.focus(); 152 | 153 | expect(focusInside(document.body)).toBe(true); 154 | expect(focusInside(shadowHost)).toBe(true); 155 | expect(focusInside(nestedShadowHost)).toBe(true); 156 | expect(focusInside(nestedShadowDiv)).toBe(false); 157 | expect(focusInside(nestedShadowDivLast)).toBe(true); 158 | }); 159 | }); 160 | 161 | const createIframeTest = (nested?: boolean) => { 162 | const html = ` 163 |
164 |
165 | 166 | 167 |
168 | 169 |
`; 170 | const iframeHtml = ` 171 |
172 | 173 |
174 | 175 |
176 | `; 177 | document.body.innerHTML = html; 178 | 179 | const iframe = document.querySelector('iframe') as HTMLIFrameElement; 180 | const root = iframe.contentDocument; 181 | 182 | if (!root) { 183 | throw new Error('Unable to get iframe content document'); 184 | } else { 185 | root.write(iframeHtml); 186 | } 187 | 188 | if (nested) { 189 | const firstDiv = root.querySelector('#first') as HTMLDivElement; 190 | const nestedIframeElement = document.createElement('iframe'); 191 | nestedIframeElement.id = 'nested-iframe'; 192 | 193 | firstDiv.appendChild(nestedIframeElement); 194 | 195 | const nestedRoot = nestedIframeElement.contentDocument; 196 | 197 | if (!nestedRoot) { 198 | throw new Error('Unable to get iframe content document'); 199 | } else { 200 | nestedRoot.write(iframeHtml); 201 | } 202 | } 203 | }; 204 | 205 | describe('with iframe', () => { 206 | it('false when the focus is within an iframe not within the topNode', () => { 207 | createIframeTest(); 208 | 209 | const nonIframeDiv = querySelector('#noniframe'); 210 | 211 | const iframe = querySelector('iframe') as HTMLIFrameElement; 212 | const iframeBtn = iframe?.contentDocument?.querySelector('#firstBtn') as HTMLButtonElement; 213 | 214 | iframeBtn.focus(); 215 | 216 | expect(focusInside(iframe!.contentDocument!.body!)).toBe(true); 217 | expect(focusInside(document.body)).toBe(true); 218 | expect(focusInside(nonIframeDiv)).toBe(false); 219 | }); 220 | 221 | it('false when topNode is iframe sibling of focused node', () => { 222 | createIframeTest(); 223 | 224 | const iframe = querySelector('iframe') as HTMLIFrameElement; 225 | 226 | const iframeBtn = iframe.contentDocument?.querySelector('#firstBtn') as HTMLButtonElement; 227 | const iframeDivLast = iframe.contentDocument?.querySelector('#last') as HTMLDivElement; 228 | 229 | iframeBtn.focus(); 230 | 231 | expect(focusInside(document.body)).toBe(true); 232 | expect(focusInside(iframeDivLast)).toBe(false); 233 | }); 234 | 235 | it('true when focus is within the iframe dom within topNode', () => { 236 | createIframeTest(); 237 | 238 | const iframe = querySelector('iframe') as HTMLIFrameElement; 239 | const iframeRoot = iframe.contentDocument?.body; 240 | 241 | if (!iframeRoot) { 242 | throw new Error('Unable to get iframe content document'); 243 | } 244 | 245 | const iframeDivLast = iframeRoot?.querySelector('#last') as HTMLDivElement; 246 | const iframeBtn = iframeRoot?.querySelector('#secondBtn') as HTMLButtonElement; 247 | 248 | iframeBtn.focus(); 249 | 250 | expect(focusInside(document.body)).toBe(true); 251 | expect(focusInside(iframeRoot)).toBe(true); 252 | expect(focusInside(iframeDivLast)).toBe(true); 253 | }); 254 | 255 | it('true when focus is within nested iframe dom', () => { 256 | createIframeTest(true); 257 | 258 | const iframe = querySelector('iframe') as HTMLIFrameElement; 259 | 260 | const iframeRoot = iframe.contentDocument?.body; 261 | 262 | if (!iframeRoot) { 263 | throw new Error('Unable to get iframe content document'); 264 | } 265 | 266 | const nestedIframe = iframeRoot.querySelector('iframe') as HTMLIFrameElement; 267 | const nestedIframeRoot = nestedIframe.contentDocument?.body; 268 | 269 | if (!nestedIframeRoot) { 270 | throw new Error('Unable to get iframe content document'); 271 | } 272 | 273 | const iframeButton = iframeRoot.querySelector('#secondBtn') as HTMLButtonElement; 274 | const nestedIframeDiv = nestedIframeRoot.querySelector('#first') as HTMLDivElement; 275 | const nestedIframeDivLast = nestedIframeRoot.querySelector('#last') as HTMLDivElement; 276 | const nestedIframeButton = nestedIframeDivLast.querySelector('#secondBtn') as HTMLButtonElement; 277 | 278 | iframeButton.focus(); 279 | nestedIframeButton.focus(); 280 | 281 | expect(focusInside(document.body)).toBe(true); 282 | expect(focusInside(iframeRoot)).toBe(true); 283 | expect(focusInside(nestedIframeRoot)).toBe(true); 284 | expect(focusInside(nestedIframeDiv)).toBe(false); 285 | expect(focusInside(nestedIframeButton)).toBe(true); 286 | }); 287 | }); 288 | }); 289 | -------------------------------------------------------------------------------- /__tests__/focusIsHidden.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusIsHidden, constants } from '../src'; 2 | 3 | describe('focusIsHidden', () => { 4 | describe('normal dom', () => { 5 | const setupTest = () => { 6 | document.body.innerHTML = ` 7 |
8 | 9 |
10 | 11 | `; 12 | }; 13 | 14 | beforeEach(setupTest); 15 | 16 | it('returns true when the focused element is hidden', () => { 17 | const button = document.querySelector('#focus-hidden') as HTMLButtonElement; 18 | 19 | button.focus(); 20 | 21 | expect(focusIsHidden()).toBe(true); 22 | }); 23 | 24 | it('returns false when the focused element is not hidden', () => { 25 | const button = document.querySelector('#focus-not-hidden') as HTMLButtonElement; 26 | 27 | button.focus(); 28 | 29 | expect(focusIsHidden()).toBe(false); 30 | }); 31 | }); 32 | 33 | describe('shadow dom', () => { 34 | const setupShadowRoot = () => { 35 | const html = ` 36 |
37 |
38 | 39 | 40 |
41 |
42 |
`; 43 | const shadowHtml = ` 44 |
45 | 46 |
47 | 48 |
49 | `; 50 | document.body.innerHTML = html; 51 | 52 | const shadowContainer = document.getElementById('shadowdom') as HTMLElement; 53 | const root = shadowContainer.attachShadow({ mode: 'open' }); 54 | const shadowDiv = document.createElement('div'); 55 | shadowDiv.innerHTML = shadowHtml; 56 | root.appendChild(shadowDiv); 57 | 58 | return { root, shadowHtml }; 59 | }; 60 | 61 | const setupNestedShadowRoot = () => { 62 | const { root, shadowHtml } = setupShadowRoot(); 63 | 64 | const firstDiv = root.querySelector('#first') as HTMLDivElement; 65 | const nestedRoot = firstDiv.attachShadow({ mode: 'open' }); 66 | const nestedShadowDiv = document.createElement('div'); 67 | 68 | nestedShadowDiv.innerHTML = shadowHtml; 69 | nestedRoot.appendChild(nestedShadowDiv); 70 | }; 71 | 72 | const runTest = ( 73 | shadowHost: HTMLDivElement, 74 | button: HTMLButtonElement, 75 | shouldBeHidden: boolean, 76 | shouldBeDiscovered: boolean 77 | ) => { 78 | button.focus(); 79 | 80 | expect(focusIsHidden()).toBe(shouldBeHidden); 81 | 82 | shadowHost.setAttribute(constants.FOCUS_ALLOW, ''); 83 | 84 | expect(focusIsHidden()).toBe(shouldBeDiscovered); 85 | }; 86 | 87 | describe('FOCUS_ALLOW behavior', () => { 88 | it('looks for focus within shadow doms', () => { 89 | setupShadowRoot(); 90 | 91 | const shadowHost = document.querySelector('#shadowdom') as HTMLDivElement; 92 | const button = shadowHost.shadowRoot?.querySelector('#firstBtn') as HTMLButtonElement; 93 | 94 | runTest(shadowHost, button, false, true); 95 | }); 96 | 97 | it('looks for focus within nested shadow doms', () => { 98 | setupNestedShadowRoot(); 99 | 100 | const shadowHost = document.querySelector('#shadowdom') as HTMLDivElement; 101 | const nestedShadowHost = shadowHost.shadowRoot?.querySelector('#first') as HTMLDivElement; 102 | const button = nestedShadowHost.shadowRoot?.querySelector('#firstBtn') as HTMLButtonElement; 103 | 104 | runTest(shadowHost, button, false, true); 105 | }); 106 | }); 107 | 108 | it('does not support marking shadow members as FOCUS_ALLOW', () => { 109 | setupShadowRoot(); 110 | 111 | const shadowHost = document.querySelector('#shadowdom') as HTMLDivElement; 112 | const shadowDiv = shadowHost.shadowRoot?.querySelector('#last') as HTMLDivElement; 113 | const button = shadowDiv.children[0] as HTMLButtonElement; 114 | 115 | runTest(shadowDiv, button, false, false); 116 | }); 117 | }); 118 | 119 | describe('iframes', () => { 120 | const setupIFrame = () => { 121 | const html = ` 122 |
123 |
124 | 125 | 126 |
127 |
128 | 129 |
130 |
`; 131 | const iframeHtml = ` 132 |
133 | 134 |
135 | 136 |
137 | `; 138 | 139 | document.body.innerHTML = html; 140 | 141 | const iframe = document.querySelector('iframe') as HTMLIFrameElement; 142 | const root = iframe.contentDocument; 143 | 144 | if (!root) { 145 | throw new Error('Unable to get iframe content document'); 146 | } 147 | 148 | root.write(iframeHtml); 149 | 150 | return { root, iframeHtml }; 151 | }; 152 | 153 | const setupNestedIFrame = () => { 154 | const { root, iframeHtml } = setupIFrame(); 155 | 156 | const firstDiv = root.querySelector('#first') as HTMLDivElement; 157 | const nestedIFrame = document.createElement('iframe'); 158 | firstDiv.append(nestedIFrame); 159 | 160 | const nestedIFrameRoot = nestedIFrame.contentDocument; 161 | 162 | if (!nestedIFrameRoot) { 163 | throw new Error('Unable to get iframe content document'); 164 | } 165 | 166 | nestedIFrameRoot.write(iframeHtml); 167 | 168 | return { root, iframeHtml }; 169 | }; 170 | 171 | const runTest = ( 172 | iframeContainer: HTMLDivElement, 173 | button: HTMLButtonElement, 174 | shouldBeHidden: boolean, 175 | shouldBeDiscovered: boolean 176 | ) => { 177 | button.focus(); 178 | expect(focusIsHidden()).toBe(shouldBeHidden); 179 | 180 | iframeContainer.setAttribute(constants.FOCUS_ALLOW, ''); 181 | 182 | expect(focusIsHidden()).toBe(shouldBeDiscovered); 183 | }; 184 | 185 | describe('FOCUS_ALLOW behavior', () => { 186 | it('looks for focus within iframes', () => { 187 | const { root } = setupIFrame(); 188 | 189 | const iframeContainer = document.querySelector('#iframe-container') as HTMLDivElement; 190 | const button = root.querySelector('#firstBtn') as HTMLButtonElement; 191 | 192 | runTest(iframeContainer, button, false, true); 193 | }); 194 | 195 | it('looks for focus within nested iframes', () => { 196 | const { root } = setupNestedIFrame(); 197 | 198 | const iframeContainer = document.querySelector('#iframe-container') as HTMLDivElement; 199 | const nestedIframe = root.querySelector('iframe') as HTMLIFrameElement; 200 | 201 | const nestedIFrameRoot = nestedIframe.contentDocument; 202 | 203 | if (!nestedIFrameRoot) { 204 | throw new Error('Unable to get iframe content document'); 205 | } 206 | 207 | // JSDom doesn't properly support focusing directly in a nested iframe. 208 | // As a workaround we need to first focus in the iframe and then on the button 209 | nestedIframe.focus(); 210 | 211 | const button = nestedIFrameRoot.querySelector('#firstBtn') as HTMLButtonElement; 212 | runTest(iframeContainer, button, false, true); 213 | }); 214 | }); 215 | 216 | it('does not support marking shadow members as FOCUS_ALLOW', () => { 217 | const { root } = setupIFrame(); 218 | 219 | const iframeDiv = root.querySelector('#last') as HTMLDivElement; 220 | const button = iframeDiv.children[0] as HTMLButtonElement; 221 | 222 | runTest(iframeDiv, button, false, false); 223 | }); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /__tests__/focusMerge.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusInside, focusSolver } from '../src'; 2 | import { FOCUS_AUTO } from '../src/constants'; 3 | 4 | describe('FocusMerge', () => { 5 | const createTest = () => { 6 | document.body.innerHTML = ` 7 |
8 | 9 | 10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 | 18 |
19 |
20 |
21 | `; 22 | }; 23 | 24 | const querySelector = (q: string): HTMLElement => document.querySelector(q)!; 25 | 26 | it('move focus', () => { 27 | createTest(); 28 | querySelector('#d4').focus(); 29 | 30 | expect(focusSolver(querySelector('#d4'), null)).toBe(undefined); 31 | 32 | // @ts-ignore 33 | focusSolver(querySelector('#d1'), null)!.node.focus(); 34 | expect(focusInside(querySelector('#d1'))).toBe(true); 35 | 36 | // @ts-ignore 37 | focusSolver(querySelector('#d2'), null)!.node.focus(); 38 | expect(focusInside(querySelector('#d2'))).toBe(true); 39 | 40 | expect(focusSolver([querySelector('#d2'), querySelector('#d3')], null)).toBe(undefined); 41 | expect(focusInside(querySelector('#d2'))).toBe(true); 42 | 43 | // @ts-ignore 44 | focusSolver([querySelector('#d3'), querySelector('#d4')], null)!.node.focus(); 45 | expect(focusInside(querySelector('#d3'))).toBe(true); 46 | }); 47 | 48 | describe('autofocus', () => { 49 | it('autofocus - should pick first available tabbable', () => { 50 | document.body.innerHTML = ` 51 |
52 | 53 | 54 | 55 | 56 |
57 | `; 58 | 59 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('2'); 60 | }); 61 | 62 | it('autofocus - should pick first available focusable if tabbables are absent', () => { 63 | document.body.innerHTML = ` 64 |
65 | 66 | 67 | 68 |
69 | `; 70 | 71 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('1'); 72 | }); 73 | 74 | it('autofocus - should pick first available exotic tabbable', () => { 75 | document.body.innerHTML = ` 76 |
77 | 78 |
1
79 |
80 | 81 |
82 | `; 83 | 84 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('1'); 85 | }); 86 | 87 | it('autofocus - should pick first available tabbable | first ignored', () => { 88 | document.body.innerHTML = ` 89 |
90 | 91 | 92 | 93 | 94 | 95 |
96 | `; 97 | 98 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('3'); 99 | }); 100 | 101 | it('autofocus - should pick first available focusable if pointed by AUTOFOCUS', () => { 102 | document.body.innerHTML = ` 103 |
104 | 105 | 106 | 107 | 108 |
109 | `; 110 | 111 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('1'); 112 | }); 113 | 114 | it('autofocus - ignores inert attributes', () => { 115 | document.body.innerHTML = ` 116 |
117 | 118 | 119 | 120 | 121 |
122 | `; 123 | 124 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('2'); 125 | }); 126 | }); 127 | 128 | describe('jump case restoration', () => { 129 | it('handles jump out without tabindex', () => { 130 | document.body.innerHTML = ` 131 | 132 |
133 | 134 | 135 |
136 | 137 | 138 | `; 139 | 140 | // circles on "moving forward" 141 | querySelector('#b2').focus(); 142 | expect(focusSolver(querySelector('#d1'), querySelector('#b1-in'))!.node.innerHTML).toBe('0-in'); 143 | // resets on "jump out" 144 | querySelector('#b3').focus(); 145 | expect(focusSolver(querySelector('#d1'), querySelector('#b1-in'))!.node.innerHTML).toBe('1-in'); 146 | 147 | // resets on "jump out" 148 | querySelector('#b2').focus(); 149 | expect(focusSolver(querySelector('#d1'), querySelector('#b0-in'))!.node.innerHTML).toBe('0-in'); 150 | }); 151 | 152 | it('handles jump out with tabindex', () => { 153 | // is unaffected by non-tabbable elements 154 | document.body.innerHTML = ` 155 | 156 |
157 | 158 | 159 | 160 |
161 | 162 | 163 | `; 164 | 165 | // circles on "moving forward" 166 | querySelector('#b2').focus(); 167 | expect(focusSolver(querySelector('#d1'), querySelector('#b2-in'))!.node.innerHTML).toBe('0-in'); 168 | // circles on "moving forward" 169 | querySelector('#b2').focus(); 170 | // !!goes via hidden element!! 171 | expect(focusSolver(querySelector('#d1'), querySelector('#b1-in'))!.node.innerHTML).toBe('0-in'); 172 | // resets on "jump out" 173 | querySelector('#b2').focus(); 174 | expect(focusSolver(querySelector('#d1'), querySelector('#b0-in'))!.node.innerHTML).toBe('0-in'); 175 | }); 176 | }); 177 | 178 | describe('return behavior', () => { 179 | beforeEach(() => { 180 | document.body.innerHTML = ` 181 | 182 |
183 | 184 | 2 185 | 186 |
187 | `; 188 | }); 189 | 190 | it('should first tabbable', () => { 191 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('3'); 192 | }); 193 | 194 | it('should focusable if pointed', () => { 195 | expect(focusSolver(querySelector('#d1'), querySelector('#d2'))!.node.innerHTML).toBe('1'); 196 | }); 197 | 198 | it('should first tabbable if target lost', () => { 199 | // TODO: this test might corrected by smarter returnFocus 200 | expect(focusSolver(querySelector('#d1'), querySelector('#d3'))!.node.innerHTML).toBe('3'); 201 | }); 202 | }); 203 | 204 | describe('data-autofocus', () => { 205 | it('autofocus - should pick first available focusable if pointed directly', () => { 206 | document.body.innerHTML = ` 207 |
208 | 209 | 210 | 211 | 212 |
213 | `; 214 | 215 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('2'); 216 | }); 217 | 218 | it('autofocus - false value', () => { 219 | document.body.innerHTML = ` 220 |
221 | 222 | 223 | 224 |
225 | `; 226 | 227 | expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('1'); 228 | }); 229 | 230 | it('autofocus - nothing to focus', () => { 231 | document.body.innerHTML = ` 232 |
233 | 234 |
235 | `; 236 | 237 | expect(focusSolver(querySelector('#d1'), null)!).toBe(undefined); 238 | }); 239 | }); 240 | }); 241 | -------------------------------------------------------------------------------- /__tests__/focusMerge.unit.spec.ts: -------------------------------------------------------------------------------- 1 | const { NEW_FOCUS, newFocus } = require('../src/solver'); 2 | 3 | describe('focus Merge order', () => { 4 | const guard = { 5 | dataset: { 6 | focusGuard: true, 7 | }, 8 | }; 9 | 10 | it('handle zero values', () => { 11 | // cycle via left 12 | expect(newFocus([], [], [], undefined, 0)).toBe(NEW_FOCUS); 13 | }); 14 | 15 | it('handle no tabbable values', () => { 16 | expect(newFocus([2, 3], [], [1, 2, 3, 4], undefined, 0)).toBe(NEW_FOCUS); 17 | 18 | // behavior prior to v1.0.1, cycling via lock 19 | // expect(newFocus([2,3], [], [1,2,3,4], 1, 2)).toBe(1); 20 | // behavior after v1.0.1, cycling only via (absent) tabbleles 21 | expect(newFocus([2, 3], [], [1, 2, 3, 4], 1, 2)).toBe(0); 22 | }); 23 | 24 | it('should move from start to end', () => { 25 | // cycle via left 26 | expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 2)).toBe(2); 27 | }); 28 | 29 | it('should move from end to start', () => { 30 | // cycle via right 31 | expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 5, 4)).toBe(0); 32 | }); 33 | 34 | it('should keep direction of move', () => { 35 | // cycle via left 36 | expect(newFocus([2, 4, 6], [2, 4, 6], [1, 2, 3, 4, 5, 6], 5, 4)).toBe(2); 37 | }); 38 | 39 | it('should jump back', () => { 40 | // jump back 41 | expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 4)).toBe(2); 42 | // jump back 43 | expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 3)).toBe(1); 44 | }); 45 | 46 | describe('if land on guard', () => { 47 | it('(back) 4 -> 0 -> 4', () => { 48 | // jump to the last 49 | expect(newFocus([2, 3, 4], [2, 3, 4], [guard, 2, 3, 4, 5], guard, 4)).toBe(2); 50 | }); 51 | 52 | it('(back) 3 -> 0 -> 4', () => { 53 | // jump to the last 54 | expect(newFocus([2, 3, 4], [2, 3, 4], [guard, 2, 3, 4, 5], guard, 3)).toBe(2); 55 | }); 56 | 57 | it('(forward) 3 -> 5 -> 1', () => { 58 | // jump to the last 59 | expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, guard], guard, 4)).toBe(0); 60 | }); 61 | 62 | it('(forward) 4 -> 5 -> 1', () => { 63 | // jump to the last 64 | expect(newFocus([2, 3, 4], [2, 3, 4], [2, 2, 3, 4, guard], guard, 3)).toBe(0); 65 | }); 66 | }); 67 | 68 | describe('radios', () => { 69 | const radio = { 70 | tagName: 'INPUT', 71 | type: 'radio', 72 | name: 'x', 73 | }; 74 | const radio1 = Object.assign({}, radio); 75 | const radio2 = Object.assign({}, radio); 76 | const radioChecked = Object.assign({ checked: true }, radio); 77 | 78 | it('picks active radio to left', () => { 79 | const innerNodes = [radio1, radioChecked, 4]; 80 | expect(newFocus(innerNodes, innerNodes, [1, ...innerNodes, 5], 5, 4)).toBe(1); 81 | }); 82 | 83 | it('picks active radio to right', () => { 84 | const innerNodes = [1, radio1, radioChecked, radio2]; 85 | expect(newFocus(innerNodes, innerNodes, [0, ...innerNodes, 5], 0, 1)).toBe(2); 86 | }); 87 | 88 | it('jump out via last node', () => { 89 | const innerNodes = [1, radioChecked]; 90 | expect(newFocus(innerNodes, innerNodes, [0, ...innerNodes, 5], 5, radioChecked)).toBe(0); 91 | }); 92 | 93 | it('jump out via unchecked node', () => { 94 | // radio1 and radio2 should be invisible to algo 95 | const innerNodes = [1, radioChecked, radio1, radio2]; 96 | expect(newFocus(innerNodes, innerNodes, [0, ...innerNodes, 5], 5, radioChecked)).toBe(0); 97 | }); 98 | }); 99 | 100 | it('should select auto focused', () => { 101 | expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 0)).toBe(NEW_FOCUS); 102 | }); 103 | 104 | it('should restore last tabbable', () => { 105 | expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 3)).toBe(1); 106 | }); 107 | 108 | it('should restore last focusable', () => { 109 | expect(newFocus([2, 3, 4], [2, 4], [1, 2, 3, 4, 5], 1, 3)).toBe(1); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /__tests__/focusables.spec.ts: -------------------------------------------------------------------------------- 1 | import { FOCUS_ALLOW, FOCUS_DISABLED, FOCUS_NO_AUTOFOCUS } from '../src/constants'; 2 | import { filterAutoFocusable, getFocusableNodes } from '../src/utils/DOMutils'; 3 | 4 | describe('focusables', () => { 5 | it('should remove disabled buttons', () => { 6 | document.body.innerHTML = ` 7 | 8 | 9 | 10 | 11 | `; 12 | 13 | const cache = new Map(); 14 | const nodes = getFocusableNodes([document.body], cache).map(({ node }) => node); 15 | 16 | expect(nodes.map((el) => el.textContent)).toEqual([ 17 | // because it's normal button 18 | 'normal button', 19 | // because it's "marked" as disabled for ARIA only 20 | 'aria-disabled button', 21 | ]); 22 | }); 23 | }); 24 | 25 | describe('auto-focusables', () => { 26 | it('should pick correct first autofocus', () => { 27 | document.body.innerHTML = ` 28 | 29 | 30 | 31 | 32 | 33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 | 41 |
42 | `; 43 | 44 | const cache = new Map(); 45 | const nodes = getFocusableNodes([document.body], cache).map(({ node }) => node); 46 | 47 | expect(filterAutoFocusable(nodes).map((el) => el.textContent)).toEqual([ 48 | 'normal button', 49 | 'aria-disabled button', 50 | 'tabindex-1 button', 51 | 'normal button in allow group', 52 | 'normal button in disabled group', 53 | ]); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /__tests__/footprint.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusSolver } from '../src'; 2 | 3 | describe('Complexity footprint', () => { 4 | const createTest = (n: number) => { 5 | document.body.innerHTML = ` 6 |
7 | 8 | ${Array(n) 9 | .fill(1) 10 | .map((_, index) => ``) 11 | .join('\n')} 12 |
13 |
14 | 15 | ${Array(n) 16 | .fill(1) 17 | .map((_, index) => ``) 18 | .join('\n')} 19 |
20 | `; 21 | }; 22 | 23 | const querySelector = (q: string): HTMLElement => document.querySelector(q)!; 24 | 25 | beforeEach(() => { 26 | const { getComputedStyle } = window; 27 | jest.spyOn(window, 'getComputedStyle').mockImplementation(getComputedStyle); 28 | }); 29 | 30 | afterEach(() => { 31 | (window.getComputedStyle as unknown as jest.SpyInstance).mockRestore(); 32 | }); 33 | 34 | it('known operation complexity - no focus', () => { 35 | createTest(3); 36 | focusSolver(querySelector('#d1'), null); 37 | expect(window.getComputedStyle).toBeCalledTimes(12); 38 | }); 39 | 40 | it('known operation complexity - no focus + 1', () => { 41 | createTest(3 + 1); 42 | focusSolver(querySelector('#d1'), null); 43 | expect(window.getComputedStyle).toBeCalledTimes(14); 44 | }); 45 | 46 | it('known operation complexity - has focus inside', () => { 47 | createTest(4); 48 | querySelector('#b1').focus(); 49 | focusSolver(querySelector('#d1'), null); 50 | expect(window.getComputedStyle).toBeCalledTimes(8); 51 | }); 52 | 53 | it('known operation complexity - has focus inside + 1', () => { 54 | createTest(4 + 1); 55 | querySelector('#b1').focus(); 56 | focusSolver(querySelector('#d1'), null); 57 | expect(window.getComputedStyle).toBeCalledTimes(9); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /__tests__/iframe.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusSolver, focusNextElement /*, focusPrevElement*/ } from '../src'; 2 | 3 | describe('iframes', () => { 4 | afterEach(() => { 5 | document.getElementsByTagName('html')[0].innerHTML = ''; 6 | }); 7 | 8 | it('support for iframes', () => { 9 | const html = ` 10 |
11 | 12 | 13 | 14 |
`; 15 | const iframeHtml = ` 16 |
17 | 18 | 19 |
20 | `; 21 | document.body.innerHTML = html; 22 | 23 | const iframe = document.querySelector('iframe') as HTMLIFrameElement; 24 | const root = iframe.contentDocument; 25 | 26 | if (!root) { 27 | throw new Error('Unable to get iframe content document'); 28 | } 29 | 30 | root.write(iframeHtml); 31 | 32 | const firstBtn = root.getElementById('firstBtn'); 33 | 34 | expect(focusSolver(root.body, null)).toEqual({ 35 | node: firstBtn, 36 | }); 37 | }); 38 | 39 | it('iframe dom element', () => { 40 | const html = ` 41 |
42 | 43 | 44 | 45 |
`; 47 | const iframeHtml = ` 48 | 49 | 50 | `; 51 | document.body.innerHTML = html; 52 | 53 | const iframe = document.querySelector('iframe') as HTMLIFrameElement; 54 | const root = iframe.contentDocument; 55 | 56 | if (!root) { 57 | throw new Error('Unable to get iframe content document'); 58 | } 59 | 60 | root.write(iframeHtml); 61 | 62 | const input = root.querySelector('input') as HTMLInputElement; 63 | 64 | expect(focusSolver(document.body, null)).toEqual({ 65 | node: input, 66 | }); 67 | }); 68 | 69 | // jsdom has no iframe security. We cannot test this here 70 | it('iframe on another domain', () => { 71 | const html = ` 72 |
73 | 74 | 75 | 76 |
`; 78 | document.body.innerHTML = html; 79 | 80 | expect(focusSolver(document.body, null)).toEqual({ 81 | node: expect.any(HTMLInputElement), 82 | }); 83 | }); 84 | 85 | it('iframe respect tabIndex', () => { 86 | const html = ` 87 |
88 | 89 | 90 | 91 |
`; 93 | const iframeHtml = ` 94 | 95 | 96 | `; 97 | document.body.innerHTML = html; 98 | 99 | const iframe = document.querySelector('iframe') as HTMLIFrameElement; 100 | const root = iframe.contentDocument; 101 | 102 | if (!root) { 103 | throw new Error('Unable to get iframe content document'); 104 | } 105 | 106 | root.write(iframeHtml); 107 | 108 | const input = root.querySelector('input') as HTMLInputElement; 109 | const button = root.querySelector('button') as HTMLButtonElement; 110 | 111 | expect(focusSolver(document.body, null)).toEqual({ 112 | node: document.querySelector('#focused'), 113 | }); 114 | 115 | expect(focusSolver([input, button], null)).toEqual({ 116 | node: input, 117 | }); 118 | }); 119 | 120 | it('focusNextElement w/in iframes respects out of order tabIndex', () => { 121 | document.body.innerHTML = ` 122 | 123 | 124 | 125 | `; 126 | 127 | const iframeHtml = ` 128 |
129 | 130 | 131 |
132 | `; 133 | 134 | const iframe = document.querySelector('iframe') as HTMLIFrameElement; 135 | const root = iframe.contentDocument; 136 | 137 | if (!root) { 138 | throw new Error('Unable to get iframe content document'); 139 | } 140 | 141 | root.write(iframeHtml); 142 | 143 | const iframeInput = root.querySelector('input') as HTMLInputElement; 144 | const iframeButton = root.querySelector('button') as HTMLButtonElement; 145 | const input = document.querySelector('input') as HTMLInputElement; 146 | const button = document.querySelector('button') as HTMLButtonElement; 147 | 148 | focusSolver(document.body, null)?.node?.focus(); 149 | expect(document.activeElement).toBe(input); 150 | 151 | focusNextElement(input); 152 | expect(document.activeElement).toBe(button); 153 | focusNextElement(button); 154 | expect(root.activeElement).toBe(iframeButton); 155 | focusNextElement(iframeButton); 156 | expect(root.activeElement).toBe(iframeInput); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /__tests__/return-focus.spec.ts: -------------------------------------------------------------------------------- 1 | import { captureFocusRestore } from '../src/return-focus'; 2 | 3 | const getb = () => { 4 | const b1 = document.getElementById('b1')! as HTMLButtonElement; 5 | const restore = captureFocusRestore(b1); 6 | 7 | return { b1, restore }; 8 | }; 9 | 10 | test('does nothing for nothing', () => { 11 | document.body.innerHTML = ` 12 |
13 | `; 14 | 15 | const restore = captureFocusRestore(document.body); 16 | expect(restore()).toBe(undefined); 17 | }); 18 | 19 | test('returns focus to the original location - single node', () => { 20 | document.body.innerHTML = ` 21 |
22 | `; 23 | 24 | const { b1, restore } = getb(); 25 | expect(restore()).toBe(b1); 26 | }); 27 | 28 | test('returns focus to the original location - first of two', () => { 29 | document.body.innerHTML = ` 30 |
31 | `; 32 | 33 | const { b1, restore } = getb(); 34 | expect(restore()).toBe(b1); 35 | }); 36 | 37 | test('returns focus to the original location - second of two', () => { 38 | document.body.innerHTML = ` 39 |
40 | `; 41 | 42 | const { b1, restore } = getb(); 43 | expect(restore()).toBe(b1); 44 | }); 45 | 46 | test('on deletion returns focus to the element to the right location', () => { 47 | document.body.innerHTML = ` 48 |
49 | `; 50 | 51 | const { b1, restore } = getb(); 52 | b1.parentElement!.removeChild(b1); 53 | expect(restore()).toBe(document.getElementById('b3')); 54 | }); 55 | 56 | test('on deletion returns focus to the element to the right location with spaces', () => { 57 | document.body.innerHTML = ` 58 |
59 | `; 60 | 61 | const { b1, restore } = getb(); 62 | b1.parentElement!.removeChild(b1); 63 | expect(restore()).toBe(document.getElementById('b3')); 64 | }); 65 | 66 | test('on deletion returns focus to the focusable element', () => { 67 | document.body.innerHTML = ` 68 |
69 | `; 70 | 71 | const { b1, restore } = getb(); 72 | b1.parentElement!.removeChild(b1); 73 | expect(restore()).toBe(document.getElementById('b0')); 74 | }); 75 | 76 | test('moves focus when default element becomes non focusable', () => { 77 | document.body.innerHTML = ` 78 |
79 | `; 80 | 81 | const { b1, restore } = getb(); 82 | b1.disabled = true; 83 | expect(restore()).toBe(document.getElementById('b3')); 84 | }); 85 | 86 | test('on deletion returns where possible to the left', () => { 87 | document.body.innerHTML = ` 88 |
119 | `; 120 | 121 | const { b1, restore } = getb(); 122 | b1.parentElement!.removeChild(b1); 123 | expect(restore()).toBe(undefined); 124 | }); 125 | 126 | test('handle null cases', () => { 127 | const restore = captureFocusRestore(null); 128 | expect(restore).not.toThrow(); 129 | }); 130 | -------------------------------------------------------------------------------- /__tests__/shadow-dom.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusSolver, focusNextElement, focusPrevElement } from '../src'; 2 | 3 | describe('shadow dow ', () => { 4 | afterEach(() => { 5 | document.getElementsByTagName('html')[0].innerHTML = ''; 6 | }); 7 | 8 | it('supports detached elements', () => { 9 | document.body.innerHTML = `
`; 10 | 11 | const frag = document.createDocumentFragment(); 12 | const button = document.createElement('button'); 13 | frag.appendChild(button); 14 | 15 | expect(focusSolver(document.body, null)).toEqual({ 16 | node: button, 17 | }); 18 | }); 19 | 20 | it('support for shadow dom', () => { 21 | const html = ` 22 |
23 | 24 | 25 |
26 |
`; 27 | const shadowHtml = ` 28 |
29 | 30 | 31 |
32 | `; 33 | document.body.innerHTML = html; 34 | 35 | const shadowContainer = document.getElementById('shadowdom') as HTMLElement; 36 | const root = shadowContainer.attachShadow({ mode: 'open' }); 37 | const shadowDiv = document.createElement('div'); 38 | shadowDiv.innerHTML = shadowHtml; 39 | root.appendChild(shadowDiv); 40 | 41 | const firstBtn = root.getElementById('firstBtn'); 42 | 43 | expect(focusSolver(shadowDiv, null)).toEqual({ 44 | node: firstBtn, 45 | }); 46 | }); 47 | 48 | it('web components dom element', () => { 49 | // source: https://github.com/pearofducks/focus-lock-reproduction 50 | expect.assertions(1); 51 | 52 | class FocusWithinShadow extends HTMLElement { 53 | public connectedCallback() { 54 | const html = ` 55 |
56 | 57 | 58 |
59 | `; 60 | const shadow = this.attachShadow({ mode: 'open' }); 61 | shadow.innerHTML = html; 62 | 63 | expect(focusSolver(document.body, null)).toEqual({ 64 | node: shadow.querySelector('input'), 65 | }); 66 | } 67 | } 68 | 69 | customElements.define('focus-within-shadow', FocusWithinShadow); 70 | 71 | document.body.innerHTML = ` 72 | 73 | 86 | 87 | `; 88 | const shadow = this.attachShadow({ mode: 'open' }); 89 | shadow.innerHTML = html; 90 | 91 | const input = shadow.querySelector('input') as HTMLInputElement; 92 | const button = shadow.querySelector('button') as HTMLButtonElement; 93 | 94 | expect(focusSolver(document.body, null)).toEqual({ 95 | node: document.querySelector('#focused'), 96 | }); 97 | 98 | expect(focusSolver([input, button], null)).toEqual({ 99 | node: input, 100 | }); 101 | } 102 | } 103 | 104 | customElements.define('focus-outside-shadow', FocusOutsideShadow); 105 | 106 | document.body.innerHTML = ` 107 | 108 | 121 | 122 | `; 123 | const shadow = this.attachShadow({ mode: 'open' }); 124 | shadow.innerHTML = html; 125 | 126 | const shadowInput = shadow.querySelector('input') as HTMLInputElement; 127 | const shadowButton = shadow.querySelector('button') as HTMLButtonElement; 128 | const input = document.querySelector('input') as HTMLInputElement; 129 | const button = document.querySelector('button') as HTMLButtonElement; 130 | 131 | focusSolver(document.body, null)?.node?.focus(); 132 | expect(document.activeElement).toBe(input); 133 | 134 | focusNextElement(input); 135 | expect(document.activeElement).toBe(button); 136 | 137 | focusNextElement(button); 138 | expect(document.activeElement?.shadowRoot?.activeElement).toBe(shadowButton); 139 | 140 | focusNextElement(shadowButton); 141 | expect(document.activeElement?.shadowRoot?.activeElement).toBe(shadowInput); 142 | } 143 | } 144 | 145 | customElements.define('focus-next-ooo', FocusNextOOO); 146 | 147 | document.body.innerHTML = ` 148 | 149 | 150 | 151 | `; 152 | }); 153 | 154 | it('focusPrevElement w/ web components respects out of order tabIndex', () => { 155 | expect.assertions(4); 156 | 157 | class FocusPrevOOO extends HTMLElement { 158 | public connectedCallback() { 159 | const html = ` 160 |
161 | 162 | 163 |
164 | `; 165 | const shadow = this.attachShadow({ mode: 'open' }); 166 | shadow.innerHTML = html; 167 | 168 | const shadowInput = shadow.querySelector('input') as HTMLInputElement; 169 | const shadowButton = shadow.querySelector('button') as HTMLButtonElement; 170 | const input = document.querySelector('input') as HTMLInputElement; 171 | const button = document.querySelector('button') as HTMLButtonElement; 172 | 173 | focusSolver(document.body, null)?.node?.focus(); 174 | expect(document.activeElement).toBe(input); 175 | 176 | focusPrevElement(input); 177 | expect(document.activeElement?.shadowRoot?.activeElement).toBe(shadowInput); 178 | 179 | focusPrevElement(shadowInput); 180 | expect(document.activeElement?.shadowRoot?.activeElement).toBe(shadowButton); 181 | 182 | focusPrevElement(shadowButton); 183 | expect(document.activeElement).toBe(button); 184 | } 185 | } 186 | 187 | customElements.define('focus-prev-ooo', FocusPrevOOO); 188 | 189 | document.body.innerHTML = ` 190 | 191 | 192 | 193 | `; 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /__tests__/sibling.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusNextElement, focusPrevElement } from '../src/'; 2 | import { focusFirstElement, focusLastElement } from '../src/sibling'; 3 | 4 | describe('smoke', () => { 5 | const createTest = () => { 6 | document.body.innerHTML = ` 7 |
8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 | 21 | `; 22 | }; 23 | 24 | const querySelector = (q: string): HTMLElement => document.querySelector(q)!; 25 | 26 | beforeEach(() => { 27 | createTest(); 28 | document.getElementById('first')?.focus(); 29 | }); 30 | 31 | it('focus button1', () => { 32 | querySelector('#d1 button').focus(); 33 | }); 34 | 35 | it('cycle forward', () => { 36 | expect(document.activeElement!.innerHTML).toBe('1'); 37 | focusNextElement(document.activeElement!); 38 | expect(document.activeElement!.innerHTML).toBe('2'); 39 | focusNextElement(document.activeElement!); 40 | expect(document.activeElement!.innerHTML).toBe('3'); 41 | focusNextElement(document.activeElement!); 42 | expect(document.activeElement!.innerHTML).toBe('4'); 43 | focusNextElement(document.activeElement!); 44 | expect(document.activeElement!.innerHTML).toBe('5'); 45 | focusNextElement(document.activeElement!); 46 | expect(document.activeElement!.innerHTML).toBe('6'); 47 | focusNextElement(document.activeElement!); 48 | expect(document.activeElement!.innerHTML).toBe('1'); 49 | }); 50 | 51 | it('cycle forward via negavite', () => { 52 | expect(document.activeElement!.innerHTML).toBe('1'); 53 | focusNextElement(document.activeElement!, { onlyTabbable: false }); 54 | expect(document.activeElement!.innerHTML).toBe('2'); 55 | focusNextElement(document.activeElement!, { onlyTabbable: false }); 56 | expect(document.activeElement!.innerHTML).toBe('negative'); 57 | focusNextElement(document.activeElement!, { onlyTabbable: false }); 58 | expect(document.activeElement!.innerHTML).toBe('3'); 59 | }); 60 | 61 | it('cycle backward', () => { 62 | expect(document.activeElement!.innerHTML).toBe('1'); 63 | focusPrevElement(document.activeElement!); 64 | expect(document.activeElement!.innerHTML).toBe('6'); 65 | focusPrevElement(document.activeElement!); 66 | expect(document.activeElement!.innerHTML).toBe('5'); 67 | focusPrevElement(document.activeElement!); 68 | expect(document.activeElement!.innerHTML).toBe('4'); 69 | }); 70 | 71 | it('works with a scope', () => { 72 | const parent = querySelector('#d2'); 73 | document.getElementById('b4')?.focus(); 74 | 75 | expect(document.activeElement!.innerHTML).toBe('4'); 76 | focusNextElement(document.activeElement!, { scope: parent }); 77 | expect(document.activeElement!.innerHTML).toBe('3'); 78 | focusNextElement(document.activeElement!, { scope: parent }); 79 | expect(document.activeElement!.innerHTML).toBe('4'); 80 | focusNextElement(document.activeElement!, { scope: parent, cycle: false }); 81 | expect(document.activeElement!.innerHTML).toBe('4'); 82 | }); 83 | 84 | it('picks the boundary edges', () => { 85 | expect(document.activeElement!.innerHTML).toBe('1'); 86 | focusLastElement(document.body); 87 | expect(document.activeElement!.innerHTML).toBe('6'); 88 | focusFirstElement(document.body); 89 | expect(document.activeElement!.innerHTML).toBe('1'); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /__tests__/tabOrder.spec.ts: -------------------------------------------------------------------------------- 1 | import { NodeIndex, tabSort } from '../src/utils/tabOrder'; 2 | 3 | const r = (tabIndex: number, index: number, key: number): NodeIndex & { key: number } => ({ 4 | tabIndex, 5 | index, 6 | key, 7 | node: null as any, 8 | }); 9 | const order = (data: Array<{ key: number }>) => data.map(({ key }) => key).join(','); 10 | 11 | describe('tab order', () => { 12 | it('should order simple row', () => { 13 | const row = [r(0, 1, 1), r(0, 2, 2), r(0, 6, 6), r(0, 3, 3), r(0, 4, 4), r(0, 5, 5)]; 14 | const result = row.sort(tabSort); 15 | expect(order(result)).toBe('1,2,3,4,5,6'); 16 | }); 17 | 18 | it('should use tabIndex', () => { 19 | const row = [r(0, 1, 1), r(0, 2, 2), r(1, 3, 6), r(2, 4, 3), r(2, 7, 7), r(0, 5, 4), r(0, 6, 5)]; 20 | const result = row.sort(tabSort); 21 | expect(order(result)).toBe('6,3,7,1,2,4,5'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /constants/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "separate entrypoint for constants only", 3 | "private": true, 4 | "main": "../dist/es5/constants.js", 5 | "jsnext:main": "../dist/es2015/constants.js", 6 | "module": "../dist/es2015/constants.js", 7 | "sideEffects": false 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "focus-lock", 3 | "version": "1.3.5", 4 | "description": "DOM trap for a focus", 5 | "main": "dist/es5/index.js", 6 | "jsnext:main": "dist/es2015/index.js", 7 | "module": "dist/es2015/index.js", 8 | "sideEffects": false, 9 | "scripts": { 10 | "dev": "lib-builder dev", 11 | "test": "jest", 12 | "test:ci": "jest --runInBand --coverage", 13 | "build": "lib-builder build && yarn size:report", 14 | "release": "yarn build && yarn test", 15 | "size": "yarn size-limit", 16 | "size:report": "yarn --silent size-limit --json > .size.json", 17 | "lint": "lib-builder lint", 18 | "format": "lib-builder format", 19 | "update": "lib-builder update", 20 | "docz:dev": "docz dev", 21 | "docz:build": "docz build", 22 | "prepublish": "yarn build && yarn changelog", 23 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", 24 | "changelog:rewrite": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/theKashey/focus-lock.git" 29 | }, 30 | "keywords": [ 31 | "focus", 32 | "trap", 33 | "vanilla" 34 | ], 35 | "files": [ 36 | "dist", 37 | "constants" 38 | ], 39 | "author": "theKashey ", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/theKashey/focus-lock/issues" 43 | }, 44 | "homepage": "https://github.com/theKashey/focus-lock#readme", 45 | "devDependencies": { 46 | "@size-limit/preset-small-lib": "^11.0.2", 47 | "@theuiteam/lib-builder": "^0.1.4", 48 | "size-limit": "^11.0.2" 49 | }, 50 | "types": "dist/es5/index.d.ts", 51 | "engines": { 52 | "node": ">=10" 53 | }, 54 | "dependencies": { 55 | "tslib": "^2.0.3" 56 | }, 57 | "husky": { 58 | "hooks": { 59 | "pre-commit": "lint-staged" 60 | } 61 | }, 62 | "lint-staged": { 63 | "*.{ts,tsx}": [ 64 | "prettier --write", 65 | "eslint --fix", 66 | "git add" 67 | ], 68 | "*.{js,css,json,md}": [ 69 | "prettier --write", 70 | "git add" 71 | ] 72 | }, 73 | "prettier": { 74 | "printWidth": 120, 75 | "trailingComma": "es5", 76 | "tabWidth": 2, 77 | "semi": true, 78 | "singleQuote": true 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | export const focusOn = ( 2 | target: Element | HTMLFrameElement | HTMLElement | null, 3 | focusOptions?: FocusOptions | undefined 4 | ): void => { 5 | if (!target) { 6 | // not clear how, but is possible https://github.com/theKashey/focus-lock/issues/53 7 | return; 8 | } 9 | 10 | if ('focus' in target) { 11 | target.focus(focusOptions); 12 | } 13 | 14 | if ('contentWindow' in target && target.contentWindow) { 15 | target.contentWindow.focus(); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * defines a focus group 3 | */ 4 | export const FOCUS_GROUP = 'data-focus-lock'; 5 | /** 6 | * disables element discovery inside a group marked by key 7 | */ 8 | export const FOCUS_DISABLED = 'data-focus-lock-disabled'; 9 | /** 10 | * allows uncontrolled focus within the marked area, effectively disabling focus lock for it's content 11 | */ 12 | export const FOCUS_ALLOW = 'data-no-focus-lock'; 13 | /** 14 | * instructs autofocus engine to pick default autofocus inside a given node 15 | * can be set on the element or container 16 | */ 17 | export const FOCUS_AUTO = 'data-autofocus-inside'; 18 | /** 19 | * instructs autofocus to ignore elements within a given node 20 | * can be set on the element or container 21 | */ 22 | export const FOCUS_NO_AUTOFOCUS = 'data-no-autofocus'; 23 | -------------------------------------------------------------------------------- /src/focusInside.ts: -------------------------------------------------------------------------------- 1 | import { contains } from './utils/DOMutils'; 2 | import { getAllAffectedNodes } from './utils/all-affected'; 3 | import { getFirst, toArray } from './utils/array'; 4 | import { getActiveElement } from './utils/getActiveElement'; 5 | 6 | const focusInFrame = (frame: HTMLIFrameElement, activeElement: Element | undefined) => frame === activeElement; 7 | 8 | const focusInsideIframe = (topNode: Element, activeElement: Element | undefined) => 9 | Boolean( 10 | toArray(topNode.querySelectorAll('iframe')).some((node) => focusInFrame(node, activeElement)) 11 | ); 12 | 13 | /** 14 | * @returns {Boolean} true, if the current focus is inside given node or nodes. 15 | * Supports nodes hidden inside shadowDom 16 | */ 17 | export const focusInside = ( 18 | topNode: HTMLElement | HTMLElement[], 19 | activeElement: HTMLElement | undefined = getActiveElement(getFirst(topNode).ownerDocument) 20 | ): boolean => { 21 | // const activeElement = document && getActiveElement(); 22 | 23 | if (!activeElement || (activeElement.dataset && activeElement.dataset.focusGuard)) { 24 | return false; 25 | } 26 | 27 | return getAllAffectedNodes(topNode).some((node) => { 28 | return contains(node, activeElement) || focusInsideIframe(node, activeElement); 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/focusIsHidden.ts: -------------------------------------------------------------------------------- 1 | import { FOCUS_ALLOW } from './constants'; 2 | import { contains } from './utils/DOMutils'; 3 | import { toArray } from './utils/array'; 4 | import { getActiveElement } from './utils/getActiveElement'; 5 | 6 | /** 7 | * checks if focus is hidden FROM the focus-lock 8 | * ie contained inside a node focus-lock shall ignore 9 | * 10 | * This is a utility function coupled with {@link FOCUS_ALLOW} constant 11 | * 12 | * @returns {boolean} focus is currently is in "allow" area 13 | */ 14 | export const focusIsHidden = (inDocument: Document = document): boolean => { 15 | const activeElement = getActiveElement(inDocument); 16 | 17 | if (!activeElement) { 18 | return false; 19 | } 20 | 21 | // this does not support setting FOCUS_ALLOW within shadow dom 22 | return toArray(inDocument.querySelectorAll(`[${FOCUS_ALLOW}]`)).some((node) => contains(node, activeElement)); 23 | }; 24 | -------------------------------------------------------------------------------- /src/focusSolver.ts: -------------------------------------------------------------------------------- 1 | import { NEW_FOCUS, newFocus } from './solver'; 2 | import { getFocusableNodes } from './utils/DOMutils'; 3 | import { getAllAffectedNodes } from './utils/all-affected'; 4 | import { asArray, getFirst } from './utils/array'; 5 | import { pickAutofocus } from './utils/auto-focus'; 6 | import { getActiveElement } from './utils/getActiveElement'; 7 | import { isDefined, isNotAGuard } from './utils/is'; 8 | import { allParentAutofocusables, getTopCommonParent } from './utils/parenting'; 9 | import { NodeIndex } from './utils/tabOrder'; 10 | 11 | const reorderNodes = (srcNodes: Element[], dstNodes: NodeIndex[]): NodeIndex[] => { 12 | const remap = new Map(); 13 | // no Set(dstNodes) for IE11 :( 14 | dstNodes.forEach((entity) => remap.set(entity.node, entity)); 15 | 16 | // remap to dstNodes 17 | return srcNodes.map((node) => remap.get(node)).filter(isDefined); 18 | }; 19 | 20 | /** 21 | * contains the main logic of the `focus-lock` package. 22 | * 23 | * ! you probably dont need this function ! 24 | * 25 | * given top node(s) and the last active element returns the element to be focused next 26 | * @returns element which should be focused to move focus inside 27 | * @param topNode 28 | * @param lastNode 29 | */ 30 | export const focusSolver = ( 31 | topNode: Element | Element[], 32 | lastNode: Element | null 33 | ): undefined | { node: HTMLElement } => { 34 | const activeElement = getActiveElement(asArray(topNode).length > 0 ? document : getFirst(topNode).ownerDocument); 35 | const entries = getAllAffectedNodes(topNode).filter(isNotAGuard); 36 | 37 | const commonParent = getTopCommonParent(activeElement || topNode, topNode, entries); 38 | const visibilityCache = new Map(); 39 | 40 | const anyFocusable = getFocusableNodes(entries, visibilityCache); 41 | const innerElements = anyFocusable.filter(({ node }) => isNotAGuard(node)); 42 | 43 | if (!innerElements[0]) { 44 | return undefined; 45 | } 46 | 47 | const outerNodes = getFocusableNodes([commonParent], visibilityCache).map(({ node }) => node); 48 | const orderedInnerElements = reorderNodes(outerNodes, innerElements); 49 | 50 | // collect inner focusable and separately tabbables 51 | const innerFocusables = orderedInnerElements.map(({ node }) => node); 52 | const innerTabbable = orderedInnerElements.filter(({ tabIndex }) => tabIndex >= 0).map(({ node }) => node); 53 | 54 | const newId = newFocus(innerFocusables, innerTabbable, outerNodes, activeElement, lastNode as HTMLElement); 55 | 56 | if (newId === NEW_FOCUS) { 57 | const focusNode = 58 | // first try only tabbable, and the fallback to all focusable, as long as at least one element should be picked for focus 59 | pickAutofocus(anyFocusable, innerTabbable, allParentAutofocusables(entries, visibilityCache)) || 60 | pickAutofocus(anyFocusable, innerFocusables, allParentAutofocusables(entries, visibilityCache)); 61 | 62 | if (focusNode) { 63 | return { node: focusNode }; 64 | } else { 65 | console.warn('focus-lock: cannot find any node to move focus into'); 66 | 67 | return undefined; 68 | } 69 | } 70 | 71 | if (newId === undefined) { 72 | return newId; 73 | } 74 | 75 | return orderedInnerElements[newId]; 76 | }; 77 | -------------------------------------------------------------------------------- /src/focusables.ts: -------------------------------------------------------------------------------- 1 | import { getAllAffectedNodes } from './utils/all-affected'; 2 | import { isGuard, isNotAGuard } from './utils/is'; 3 | import { getTopCommonParent } from './utils/parenting'; 4 | import { orderByTabIndex } from './utils/tabOrder'; 5 | import { getFocusables } from './utils/tabUtils'; 6 | 7 | interface FocusableNode { 8 | node: HTMLElement; 9 | /** 10 | * index in the tab order 11 | */ 12 | index: number; 13 | /** 14 | * true, if this node belongs to a Lock 15 | */ 16 | lockItem: boolean; 17 | /** 18 | * true, if this node is a focus-guard (system node) 19 | */ 20 | guard: boolean; 21 | } 22 | 23 | /** 24 | * traverses all related nodes (including groups) returning a list of all nodes(outer and internal) with meta information 25 | * This is low-level API! 26 | * @returns list of focusable elements inside a given top(!) node. 27 | * @see {@link getFocusableNodes} providing a simpler API 28 | */ 29 | export const expandFocusableNodes = (topNode: HTMLElement | HTMLElement[]): FocusableNode[] => { 30 | const entries = getAllAffectedNodes(topNode).filter(isNotAGuard); 31 | const commonParent = getTopCommonParent(topNode, topNode, entries); 32 | const outerNodes = orderByTabIndex(getFocusables([commonParent], true), true, true); 33 | const innerElements = getFocusables(entries, false); 34 | 35 | return outerNodes.map( 36 | ({ node, index }): FocusableNode => ({ 37 | node, 38 | index, 39 | lockItem: innerElements.indexOf(node) >= 0, 40 | guard: isGuard(node), 41 | }) 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as allConstants from './constants'; 2 | import { focusInside } from './focusInside'; 3 | import { focusIsHidden } from './focusIsHidden'; 4 | import { focusSolver } from './focusSolver'; 5 | import { expandFocusableNodes } from './focusables'; 6 | import { moveFocusInside } from './moveFocusInside'; 7 | import { captureFocusRestore } from './return-focus'; 8 | import { 9 | focusNextElement, 10 | focusPrevElement, 11 | getRelativeFocusable, 12 | focusFirstElement, 13 | focusLastElement, 14 | } from './sibling'; 15 | import { getFocusableNodes, getTabbableNodes } from './utils/DOMutils'; 16 | 17 | /** 18 | * magic symbols to control focus behavior from DOM 19 | * see description of every particular one 20 | */ 21 | const constants = allConstants; 22 | 23 | export { 24 | constants, 25 | // 26 | focusInside, 27 | focusIsHidden, 28 | // 29 | moveFocusInside, 30 | focusSolver, 31 | // 32 | expandFocusableNodes, 33 | getFocusableNodes, 34 | getTabbableNodes, 35 | // 36 | focusNextElement, 37 | focusPrevElement, 38 | focusFirstElement, 39 | focusLastElement, 40 | getRelativeFocusable, 41 | // 42 | captureFocusRestore, 43 | }; 44 | 45 | /** 46 | * @deprecated - please use {@link moveFocusInside} named export 47 | */ 48 | const deprecated_default_moveFocusInside: typeof moveFocusInside = moveFocusInside; 49 | 50 | export default deprecated_default_moveFocusInside; 51 | // 52 | -------------------------------------------------------------------------------- /src/moveFocusInside.ts: -------------------------------------------------------------------------------- 1 | import { focusOn } from './commands'; 2 | import { focusSolver } from './focusSolver'; 3 | 4 | let guardCount = 0; 5 | let lockDisabled = false; 6 | 7 | interface FocusLockFocusOptions { 8 | focusOptions?: FocusOptions; 9 | } 10 | 11 | /** 12 | * The main functionality of the focus-lock package 13 | * 14 | * Contains focus at a given node. 15 | * The last focused element will help to determine which element(first or last) should be focused. 16 | * The found element will be focused. 17 | * 18 | * This is one time action (move), not a persistent focus-lock 19 | * 20 | * HTML markers (see {@link import('./constants').FOCUS_AUTO} constants) can control autofocus 21 | * @see {@link focusSolver} for the same functionality without autofocus 22 | */ 23 | export const moveFocusInside = (topNode: HTMLElement, lastNode: Element, options: FocusLockFocusOptions = {}): void => { 24 | const focusable = focusSolver(topNode, lastNode); 25 | 26 | // global local side effect to countain recursive lock activation and resolve focus-fighting 27 | if (lockDisabled) { 28 | return; 29 | } 30 | 31 | if (focusable) { 32 | /** +FOCUS-FIGHTING prevention **/ 33 | 34 | if (guardCount > 2) { 35 | // we have recursive entered back the lock activation 36 | console.error( 37 | 'FocusLock: focus-fighting detected. Only one focus management system could be active. ' + 38 | 'See https://github.com/theKashey/focus-lock/#focus-fighting' 39 | ); 40 | 41 | lockDisabled = true; 42 | 43 | setTimeout(() => { 44 | lockDisabled = false; 45 | }, 1); 46 | 47 | return; 48 | } 49 | 50 | guardCount++; 51 | focusOn(focusable.node, options.focusOptions); 52 | guardCount--; 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /src/return-focus.ts: -------------------------------------------------------------------------------- 1 | import { getTabbableNodes } from './utils/DOMutils'; 2 | 3 | type SetRef = () => Element | null; 4 | type Ref = null | (() => Element | null); 5 | 6 | type ElementLocation = { 7 | current: SetRef; 8 | parent: Ref; 9 | left: Ref; 10 | right: Ref; 11 | }; 12 | 13 | function weakRef(value: Element): SetRef; 14 | function weakRef(value: Element | null): null | Ref; 15 | function weakRef(value: Element | null): SetRef | Ref | null { 16 | if (!value) return null; 17 | 18 | // #68 Safari 14.1 dont have it yet 19 | // FIXME: remove in 2025 20 | if (typeof WeakRef === 'undefined') { 21 | return () => value || null; 22 | } 23 | 24 | const w = value ? new WeakRef(value) : null; 25 | 26 | return () => w?.deref() || null; 27 | } 28 | 29 | type Location = { 30 | stack: ReadonlyArray; 31 | ownerDocument: Document; 32 | element: SetRef; 33 | }; 34 | 35 | export const recordElementLocation = (element: Element | null): Location | null => { 36 | if (!element) { 37 | return null; 38 | } 39 | 40 | const stack: ElementLocation[] = []; 41 | let currentElement: Element | null = element; 42 | 43 | while (currentElement && currentElement !== document.body) { 44 | stack.push({ 45 | current: weakRef(currentElement), 46 | parent: weakRef(currentElement.parentElement), 47 | left: weakRef(currentElement.previousElementSibling), 48 | right: weakRef(currentElement.nextElementSibling), 49 | }); 50 | 51 | currentElement = currentElement.parentElement; 52 | } 53 | 54 | return { 55 | element: weakRef(element), 56 | stack, 57 | ownerDocument: element.ownerDocument, 58 | }; 59 | }; 60 | 61 | const restoreFocusTo = (location: Location | null): Element | undefined => { 62 | if (!location) { 63 | return undefined; 64 | } 65 | 66 | const { stack, ownerDocument } = location; 67 | const visibilityCache = new Map(); 68 | 69 | for (const line of stack) { 70 | const parent = line.parent?.(); 71 | 72 | // is it still here? 73 | if (parent && ownerDocument.contains(parent)) { 74 | const left = line.left?.(); 75 | const savedCurrent = line.current(); 76 | const current = parent.contains(savedCurrent) ? savedCurrent : undefined; 77 | const right = line.right?.(); 78 | const focusables = getTabbableNodes([parent], visibilityCache); 79 | let aim = 80 | // that is element itself 81 | current ?? 82 | // or something in it's place 83 | left?.nextElementSibling ?? 84 | // or somebody to the right, still close enough 85 | right ?? 86 | // or somebody to the left, something? 87 | left; 88 | 89 | while (aim) { 90 | for (const focusable of focusables) { 91 | if (aim?.contains(focusable.node)) { 92 | return focusable.node; 93 | } 94 | } 95 | 96 | aim = aim.nextElementSibling; 97 | } 98 | 99 | if (focusables.length) { 100 | // if parent contains a focusable - move there 101 | return focusables[0].node; 102 | } 103 | } 104 | } 105 | 106 | // nothing matched 107 | return undefined; 108 | }; 109 | 110 | /** 111 | * Captures the current focused element to restore focus as close as possible in the future 112 | * Handles situations where the focused element is removed from the DOM or no longer focusable 113 | * moving focus to the closest focusable element 114 | * @param targetElement - element where focus should be restored 115 | * @returns a function returning a new element to focus 116 | */ 117 | export const captureFocusRestore = (targetElement: Element | null): (() => Element | undefined) => { 118 | const location = recordElementLocation(targetElement); 119 | 120 | return () => { 121 | return restoreFocusTo(location); 122 | }; 123 | }; 124 | -------------------------------------------------------------------------------- /src/sibling.ts: -------------------------------------------------------------------------------- 1 | import { focusOn } from './commands'; 2 | import { getTabbableNodes, contains, getFocusableNodes } from './utils/DOMutils'; 3 | import { asArray } from './utils/array'; 4 | import { NodeIndex } from './utils/tabOrder'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/ban-types 7 | type UnresolvedSolution = {}; 8 | type ResolvedSolution = { 9 | prev: NodeIndex; 10 | next: NodeIndex; 11 | first: NodeIndex; 12 | last: NodeIndex; 13 | }; 14 | 15 | /** 16 | * for a given `element` in a given `scope` returns focusable siblings 17 | * @param element - base element 18 | * @param scope - common parent. Can be document, but better to narrow it down for performance reasons 19 | * @returns {prev,next} - references to a focusable element before and after 20 | * @returns undefined - if operation is not applicable 21 | */ 22 | export const getRelativeFocusable = ( 23 | element: Element, 24 | scope: HTMLElement | HTMLElement[], 25 | useTabbables: boolean 26 | ): UnresolvedSolution | ResolvedSolution | undefined => { 27 | if (!element || !scope) { 28 | console.error('no element or scope given'); 29 | 30 | return {}; 31 | } 32 | 33 | const shards = asArray(scope); 34 | 35 | if (shards.every((shard) => !contains(shard as Element, element))) { 36 | console.error('Active element is not contained in the scope'); 37 | 38 | return {}; 39 | } 40 | 41 | const focusables = useTabbables 42 | ? getTabbableNodes(shards as HTMLElement[], new Map()) 43 | : getFocusableNodes(shards as HTMLElement[], new Map()); 44 | const current = focusables.findIndex(({ node }) => node === element); 45 | 46 | if (current === -1) { 47 | // an edge case, when anchor element is not found 48 | return undefined; 49 | } 50 | 51 | return { 52 | prev: focusables[current - 1], 53 | next: focusables[current + 1], 54 | first: focusables[0], 55 | last: focusables[focusables.length - 1], 56 | }; 57 | }; 58 | 59 | const getBoundary = (shards: HTMLElement | HTMLElement[], useTabbables: boolean) => { 60 | const set = useTabbables 61 | ? getTabbableNodes(asArray(shards) as HTMLElement[], new Map()) 62 | : getFocusableNodes(asArray(shards) as HTMLElement[], new Map()); 63 | 64 | return { 65 | first: set[0], 66 | last: set[set.length - 1], 67 | }; 68 | }; 69 | 70 | type ScopeRef = HTMLElement | HTMLElement[]; 71 | interface FocusNextOptions { 72 | /** 73 | * the component to "scope" focus in 74 | * @default document.body 75 | */ 76 | scope?: ScopeRef; 77 | /** 78 | * enables cycling inside the scope 79 | * @default true 80 | */ 81 | cycle?: boolean; 82 | /** 83 | * options for focus action to control it more precisely (ie. `{ preventScroll: true }`) 84 | */ 85 | focusOptions?: FocusOptions; 86 | /** 87 | * scopes to only tabbable elements 88 | * set to false to include all focusable elements (tabindex -1) 89 | * @default true 90 | */ 91 | onlyTabbable?: boolean; 92 | } 93 | 94 | const defaultOptions = (options: FocusNextOptions) => 95 | Object.assign( 96 | { 97 | scope: document.body, 98 | cycle: true, 99 | onlyTabbable: true, 100 | }, 101 | options 102 | ); 103 | 104 | const moveFocus = ( 105 | fromElement: Element | undefined, 106 | options: FocusNextOptions = {}, 107 | cb: (solution: Partial, cycle: boolean) => NodeIndex | undefined | false 108 | ) => { 109 | const newOptions = defaultOptions(options); 110 | const solution = getRelativeFocusable(fromElement as Element, newOptions.scope, newOptions.onlyTabbable); 111 | 112 | if (!solution) { 113 | return; 114 | } 115 | 116 | const target = cb(solution, newOptions.cycle); 117 | 118 | if (target) { 119 | focusOn(target.node, newOptions.focusOptions); 120 | } 121 | }; 122 | 123 | /** 124 | * focuses next element in the tab-order 125 | * @param fromElement - common parent to scope active element search or tab cycle order 126 | * @param {FocusNextOptions} [options] - focus options 127 | */ 128 | export const focusNextElement = (fromElement: Element, options: FocusNextOptions = {}): void => { 129 | moveFocus(fromElement, options, ({ next, first }, cycle) => next || (cycle && first)); 130 | }; 131 | 132 | /** 133 | * focuses prev element in the tab order 134 | * @param fromElement - common parent to scope active element search or tab cycle order 135 | * @param {FocusNextOptions} [options] - focus options 136 | */ 137 | export const focusPrevElement = (fromElement: Element, options: FocusNextOptions = {}): void => { 138 | moveFocus(fromElement, options, ({ prev, last }, cycle) => prev || (cycle && last)); 139 | }; 140 | 141 | type FocusBoundaryOptions = Pick; 142 | 143 | const pickBoundary = (scope: ScopeRef, options: FocusBoundaryOptions, what: 'last' | 'first') => { 144 | const boundary = getBoundary(scope, options.onlyTabbable ?? true); 145 | const node = boundary[what]; 146 | 147 | if (node) { 148 | focusOn(node.node, options.focusOptions); 149 | } 150 | }; 151 | 152 | /** 153 | * focuses first element in the tab-order 154 | * @param {FocusNextOptions} options - focus options 155 | */ 156 | export const focusFirstElement = (scope: ScopeRef, options: FocusBoundaryOptions = {}): void => { 157 | pickBoundary(scope, options, 'first'); 158 | }; 159 | 160 | /** 161 | * focuses last element in the tab order 162 | * @param {FocusNextOptions} options - focus options 163 | */ 164 | export const focusLastElement = (scope: ScopeRef, options: FocusBoundaryOptions = {}): void => { 165 | pickBoundary(scope, options, 'last'); 166 | }; 167 | -------------------------------------------------------------------------------- /src/solver.ts: -------------------------------------------------------------------------------- 1 | import { correctNodes } from './utils/correctFocus'; 2 | import { pickFocusable } from './utils/firstFocus'; 3 | import { isGuard } from './utils/is'; 4 | 5 | export const NEW_FOCUS = 'NEW_FOCUS'; 6 | 7 | /** 8 | * Main solver for the "find next focus" question 9 | * @param innerNodes - used to control "return focus" 10 | * @param innerTabbables - used to control "autofocus" 11 | * @param outerNodes 12 | * @param activeElement 13 | * @param lastNode 14 | * @returns {number|string|undefined|*} 15 | */ 16 | export const newFocus = ( 17 | innerNodes: HTMLElement[], 18 | innerTabbables: HTMLElement[], 19 | outerNodes: HTMLElement[], 20 | activeElement: HTMLElement | undefined, 21 | lastNode: HTMLElement | null 22 | ): number | undefined | typeof NEW_FOCUS => { 23 | const cnt = innerNodes.length; 24 | const firstFocus = innerNodes[0]; 25 | const lastFocus = innerNodes[cnt - 1]; 26 | const isOnGuard = isGuard(activeElement); 27 | 28 | // focus is inside 29 | if (activeElement && innerNodes.indexOf(activeElement) >= 0) { 30 | return undefined; 31 | } 32 | 33 | const activeIndex = activeElement !== undefined ? outerNodes.indexOf(activeElement) : -1; 34 | const lastIndex = lastNode ? outerNodes.indexOf(lastNode) : activeIndex; 35 | const lastNodeInside = lastNode ? innerNodes.indexOf(lastNode) : -1; 36 | 37 | // no active focus (or focus is on the body) 38 | if (activeIndex === -1) { 39 | // known fallback 40 | if (lastNodeInside !== -1) { 41 | return lastNodeInside; 42 | } 43 | 44 | return NEW_FOCUS; 45 | } 46 | 47 | // new focus, nothing to calculate 48 | if (lastNodeInside === -1) { 49 | return NEW_FOCUS; 50 | } 51 | 52 | const indexDiff = activeIndex - lastIndex; 53 | const firstNodeIndex = outerNodes.indexOf(firstFocus); 54 | const lastNodeIndex = outerNodes.indexOf(lastFocus); 55 | 56 | const correctedNodes = correctNodes(outerNodes); 57 | const currentFocusableIndex = activeElement !== undefined ? correctedNodes.indexOf(activeElement) : -1; 58 | const previousFocusableIndex = lastNode ? correctedNodes.indexOf(lastNode) : currentFocusableIndex; 59 | 60 | const tabbableNodes = correctedNodes.filter((node) => node.tabIndex >= 0); 61 | const currentTabbableIndex = activeElement !== undefined ? tabbableNodes.indexOf(activeElement) : -1; 62 | const previousTabbableIndex = lastNode ? tabbableNodes.indexOf(lastNode) : currentTabbableIndex; 63 | 64 | const focusIndexDiff = 65 | currentTabbableIndex >= 0 && previousTabbableIndex >= 0 66 | ? // old/new are tabbables, measure distance in tabbable space 67 | previousTabbableIndex - currentTabbableIndex 68 | : // or else measure in focusable space 69 | previousFocusableIndex - currentFocusableIndex; 70 | 71 | // old focus 72 | if (!indexDiff && lastNodeInside >= 0) { 73 | return lastNodeInside; 74 | } 75 | 76 | // no tabbable elements, autofocus is not possible 77 | if (innerTabbables.length === 0) { 78 | // an edge case with no tabbable elements 79 | // return the last focusable one 80 | // with some probability this will prevent focus from cycling across the lock, but there is no tabbale elements to cycle to 81 | return lastNodeInside; 82 | } 83 | 84 | const returnFirstNode = pickFocusable(innerNodes, innerTabbables[0]); 85 | const returnLastNode = pickFocusable(innerNodes, innerTabbables[innerTabbables.length - 1]); 86 | 87 | // first element 88 | if (activeIndex <= firstNodeIndex && isOnGuard && Math.abs(indexDiff) > 1) { 89 | return returnLastNode; 90 | } 91 | 92 | // last element 93 | if (activeIndex >= lastNodeIndex && isOnGuard && Math.abs(indexDiff) > 1) { 94 | return returnFirstNode; 95 | } 96 | 97 | // jump out, but not on the guard 98 | if (indexDiff && Math.abs(focusIndexDiff) > 1) { 99 | return lastNodeInside; 100 | } 101 | 102 | // focus above lock 103 | if (activeIndex <= firstNodeIndex) { 104 | return returnLastNode; 105 | } 106 | 107 | // focus below lock 108 | if (activeIndex > lastNodeIndex) { 109 | return returnFirstNode; 110 | } 111 | 112 | // index is inside tab order, but outside Lock 113 | if (indexDiff) { 114 | if (Math.abs(indexDiff) > 1) { 115 | return lastNodeInside; 116 | } 117 | 118 | return (cnt + lastNodeInside + indexDiff) % cnt; 119 | } 120 | 121 | // do nothing 122 | return undefined; 123 | }; 124 | -------------------------------------------------------------------------------- /src/utils/DOMutils.ts: -------------------------------------------------------------------------------- 1 | import { toArray } from './array'; 2 | import { isAutoFocusAllowedCached, isVisibleCached, notHiddenInput, VisibilityCache } from './is'; 3 | import { NodeIndex, orderByTabIndex } from './tabOrder'; 4 | import { getFocusables, getParentAutofocusables } from './tabUtils'; 5 | 6 | /** 7 | * given list of focusable elements keeps the ones user can interact with 8 | * @param nodes 9 | * @param visibilityCache 10 | */ 11 | export const filterFocusable = (nodes: HTMLElement[], visibilityCache: VisibilityCache): HTMLElement[] => 12 | toArray(nodes) 13 | .filter((node) => isVisibleCached(visibilityCache, node)) 14 | .filter((node) => notHiddenInput(node)); 15 | 16 | export const filterAutoFocusable = (nodes: HTMLElement[], cache: VisibilityCache = new Map()): HTMLElement[] => 17 | toArray(nodes).filter((node) => isAutoFocusAllowedCached(cache, node)); 18 | 19 | /** 20 | * !__WARNING__! Low level API. 21 | * @returns all tabbable nodes 22 | * 23 | * @see {@link getFocusableNodes} to get any focusable element 24 | * 25 | * @param topNodes - array of top level HTMLElements to search inside 26 | * @param visibilityCache - an cache to store intermediate measurements. Expected to be a fresh `new Map` on every call 27 | */ 28 | export const getTabbableNodes = ( 29 | topNodes: Element[], 30 | visibilityCache: VisibilityCache, 31 | withGuards?: boolean 32 | ): NodeIndex[] => 33 | orderByTabIndex(filterFocusable(getFocusables(topNodes, withGuards), visibilityCache), true, withGuards); 34 | 35 | /** 36 | * !__WARNING__! Low level API. 37 | * 38 | * @returns anything "focusable", not only tabbable. The difference is in `tabIndex=-1` 39 | * (without guards, as long as they are not expected to be ever focused) 40 | * 41 | * @see {@link getTabbableNodes} to get only tabble nodes element 42 | * 43 | * @param topNodes - array of top level HTMLElements to search inside 44 | * @param visibilityCache - an cache to store intermediate measurements. Expected to be a fresh `new Map` on every call 45 | */ 46 | export const getFocusableNodes = (topNodes: Element[], visibilityCache: VisibilityCache): NodeIndex[] => 47 | orderByTabIndex(filterFocusable(getFocusables(topNodes), visibilityCache), false); 48 | 49 | /** 50 | * return list of nodes which are expected to be auto-focused 51 | * @param topNode 52 | * @param visibilityCache 53 | */ 54 | export const parentAutofocusables = (topNode: Element, visibilityCache: VisibilityCache): Element[] => 55 | filterFocusable(getParentAutofocusables(topNode), visibilityCache); 56 | 57 | /* 58 | * Determines if element is contained in scope, including nested shadow DOMs 59 | */ 60 | export const contains = (scope: Element | ShadowRoot, element: Element): boolean => { 61 | if ((scope as HTMLElement).shadowRoot) { 62 | return contains((scope as HTMLElement).shadowRoot as ShadowRoot, element); 63 | } else { 64 | if ( 65 | Object.getPrototypeOf(scope).contains !== undefined && 66 | Object.getPrototypeOf(scope).contains.call(scope, element) 67 | ) { 68 | return true; 69 | } 70 | 71 | return toArray(scope.children).some((child) => { 72 | if (child instanceof HTMLIFrameElement) { 73 | const iframeBody = (child as HTMLIFrameElement).contentDocument?.body; 74 | 75 | if (iframeBody) { 76 | return contains(iframeBody, element); 77 | } 78 | 79 | return false; 80 | } 81 | 82 | return contains(child, element); 83 | }); 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/utils/all-affected.ts: -------------------------------------------------------------------------------- 1 | import { FOCUS_DISABLED, FOCUS_GROUP } from '../constants'; 2 | import { asArray, toArray } from './array'; 3 | 4 | /** 5 | * in case of multiple nodes nested inside each other 6 | * keeps only top ones 7 | * this is O(nlogn) 8 | * @param nodes 9 | * @returns {*} 10 | */ 11 | const filterNested = (nodes: T[]): T[] => { 12 | const contained = new Set(); 13 | const l = nodes.length; 14 | 15 | for (let i = 0; i < l; i += 1) { 16 | for (let j = i + 1; j < l; j += 1) { 17 | const position = nodes[i].compareDocumentPosition(nodes[j]); 18 | 19 | /* eslint-disable no-bitwise */ 20 | if ((position & Node.DOCUMENT_POSITION_CONTAINED_BY) > 0) { 21 | contained.add(j); 22 | } 23 | 24 | if ((position & Node.DOCUMENT_POSITION_CONTAINS) > 0) { 25 | contained.add(i); 26 | } 27 | /* eslint-enable */ 28 | } 29 | } 30 | 31 | return nodes.filter((_, index) => !contained.has(index)); 32 | }; 33 | 34 | /** 35 | * finds top most parent for a node 36 | * @param node 37 | * @returns {*} 38 | */ 39 | const getTopParent = (node: Element): Element => 40 | node.parentNode ? getTopParent(node.parentNode as HTMLElement) : node; 41 | 42 | /** 43 | * returns all "focus containers" inside a given node 44 | * @param node - node or nodes to look inside 45 | * @returns Element[] 46 | */ 47 | export const getAllAffectedNodes = (node: Element | Element[]): Element[] => { 48 | const nodes = asArray(node); 49 | 50 | return nodes.filter(Boolean).reduce((acc, currentNode) => { 51 | const group = currentNode.getAttribute(FOCUS_GROUP); 52 | 53 | acc.push( 54 | ...(group 55 | ? filterNested( 56 | toArray( 57 | getTopParent(currentNode).querySelectorAll( 58 | `[${FOCUS_GROUP}="${group}"]:not([${FOCUS_DISABLED}="disabled"])` 59 | ) 60 | ) 61 | ) 62 | : [currentNode as Element]) 63 | ); 64 | 65 | return acc; 66 | }, [] as Element[]); 67 | }; 68 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | /* 2 | IE11 support 3 | */ 4 | 5 | interface ListOf { 6 | length: number; 7 | [index: number]: TNode; 8 | } 9 | 10 | export const toArray = (a: T[] | ListOf): T[] => { 11 | const ret = Array(a.length); 12 | 13 | for (let i = 0; i < a.length; ++i) { 14 | ret[i] = a[i]; 15 | } 16 | 17 | return ret; 18 | }; 19 | 20 | export const asArray = (a: T | T[]): T[] => (Array.isArray(a) ? a : [a]); 21 | 22 | export const getFirst = (a: T | T[]): T => (Array.isArray(a) ? a[0] : a); 23 | -------------------------------------------------------------------------------- /src/utils/auto-focus.ts: -------------------------------------------------------------------------------- 1 | import { filterAutoFocusable } from './DOMutils'; 2 | import { pickFirstFocus } from './firstFocus'; 3 | import { getDataset } from './is'; 4 | import { NodeIndex } from './tabOrder'; 5 | 6 | const findAutoFocused = 7 | (autoFocusables: Element[]) => 8 | (node: Element): boolean => { 9 | const autofocus = getDataset(node)?.autofocus; 10 | 11 | return ( 12 | // @ts-expect-error 13 | node.autofocus || 14 | // 15 | (autofocus !== undefined && autofocus !== 'false') || 16 | // 17 | autoFocusables.indexOf(node) >= 0 18 | ); 19 | }; 20 | 21 | export const pickAutofocus = ( 22 | nodesIndexes: NodeIndex[], 23 | orderedNodes: HTMLElement[], 24 | groups: Element[] 25 | ): HTMLElement | undefined => { 26 | const nodes = nodesIndexes.map(({ node }) => node); 27 | 28 | const autoFocusable = filterAutoFocusable(nodes.filter(findAutoFocused(groups))); 29 | 30 | if (autoFocusable && autoFocusable.length) { 31 | return pickFirstFocus(autoFocusable); 32 | } 33 | 34 | return pickFirstFocus(filterAutoFocusable(orderedNodes)); 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/correctFocus.ts: -------------------------------------------------------------------------------- 1 | import { isRadioElement } from './is'; 2 | 3 | const findSelectedRadio = (node: HTMLInputElement, nodes: Element[]): HTMLElement => 4 | nodes 5 | .filter(isRadioElement) 6 | .filter((el) => el.name === node.name) 7 | .filter((el) => el.checked)[0] || node; 8 | 9 | export const correctNode = (node: HTMLElement, nodes: HTMLElement[]): HTMLElement => { 10 | if (isRadioElement(node) && node.name) { 11 | return findSelectedRadio(node, nodes); 12 | } 13 | 14 | return node; 15 | }; 16 | 17 | /** 18 | * giving a set of radio inputs keeps only selected (tabbable) ones 19 | * @param nodes 20 | */ 21 | export const correctNodes = (nodes: HTMLElement[]): HTMLElement[] => { 22 | // IE11 has no Set(array) constructor 23 | const resultSet = new Set(); 24 | nodes.forEach((node) => resultSet.add(correctNode(node, nodes))); 25 | 26 | // using filter to support IE11 27 | return nodes.filter((node) => resultSet.has(node)); 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/firstFocus.ts: -------------------------------------------------------------------------------- 1 | import { correctNode } from './correctFocus'; 2 | 3 | export const pickFirstFocus = (nodes: HTMLElement[]): HTMLElement | undefined => { 4 | if (nodes[0] && nodes.length > 1) { 5 | return correctNode(nodes[0], nodes); 6 | } 7 | 8 | return nodes[0]; 9 | }; 10 | 11 | export const pickFocusable = (nodes: HTMLElement[], node: HTMLElement): number => { 12 | return nodes.indexOf(correctNode(node, nodes)); 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/getActiveElement.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * returns active element from document or from nested shadowdoms 3 | */ 4 | import { safeProbe } from './safe'; 5 | 6 | /** 7 | * returns current active element. If the active element is a "container" itself(shadowRoot or iframe) returns active element inside it 8 | * @param [inDocument] 9 | */ 10 | export const getActiveElement = (inDocument: Document | ShadowRoot | undefined = document): HTMLElement | undefined => { 11 | if (!inDocument || !inDocument.activeElement) { 12 | return undefined; 13 | } 14 | 15 | const { activeElement } = inDocument; 16 | 17 | return ( 18 | activeElement.shadowRoot 19 | ? getActiveElement(activeElement.shadowRoot) 20 | : activeElement instanceof HTMLIFrameElement && safeProbe(() => activeElement.contentWindow!.document) 21 | ? getActiveElement(activeElement.contentWindow!.document) 22 | : activeElement 23 | ) as HTMLElement; 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/is.ts: -------------------------------------------------------------------------------- 1 | import { FOCUS_NO_AUTOFOCUS } from '../constants'; 2 | 3 | const isElementHidden = (node: Element): boolean => { 4 | // we can measure only "elements" 5 | // consider others as "visible" 6 | if (node.nodeType !== Node.ELEMENT_NODE) { 7 | return false; 8 | } 9 | 10 | const computedStyle: CSSStyleDeclaration = window.getComputedStyle(node, null); 11 | 12 | if (!computedStyle || !computedStyle.getPropertyValue) { 13 | return false; 14 | } 15 | 16 | return ( 17 | computedStyle.getPropertyValue('display') === 'none' || computedStyle.getPropertyValue('visibility') === 'hidden' 18 | ); 19 | }; 20 | 21 | type CheckParentCallback = (node: Element | undefined) => boolean; 22 | 23 | const getParentNode = (node: Element): Element | undefined => 24 | // DOCUMENT_FRAGMENT_NODE can also point on ShadowRoot. In this case .host will point on the next node 25 | node.parentNode && node.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE 26 | ? // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | (node.parentNode as any).host 28 | : node.parentNode; 29 | 30 | const isTopNode = (node: Element): boolean => 31 | // @ts-ignore 32 | node === document || (node && node.nodeType === Node.DOCUMENT_NODE); 33 | 34 | const isInert = (node: Element): boolean => node.hasAttribute('inert'); 35 | 36 | /** 37 | * @see https://github.com/testing-library/jest-dom/blob/main/src/to-be-visible.js 38 | */ 39 | const isVisibleUncached = (node: Element | undefined, checkParent: CheckParentCallback): boolean => 40 | !node || isTopNode(node) || (!isElementHidden(node) && !isInert(node) && checkParent(getParentNode(node))); 41 | 42 | export type VisibilityCache = Map; 43 | 44 | export const isVisibleCached = (visibilityCache: VisibilityCache, node: Element | undefined): boolean => { 45 | const cached = visibilityCache.get(node); 46 | 47 | if (cached !== undefined) { 48 | return cached; 49 | } 50 | 51 | const result = isVisibleUncached(node, isVisibleCached.bind(undefined, visibilityCache)); 52 | visibilityCache.set(node, result); 53 | 54 | return result; 55 | }; 56 | 57 | const isAutoFocusAllowedUncached = (node: Element | undefined, checkParent: CheckParentCallback) => 58 | node && !isTopNode(node) ? (isAutoFocusAllowed(node) ? checkParent(getParentNode(node)) : false) : true; 59 | 60 | export const isAutoFocusAllowedCached = (cache: VisibilityCache, node: Element | undefined): boolean => { 61 | const cached = cache.get(node); 62 | 63 | if (cached !== undefined) { 64 | return cached; 65 | } 66 | 67 | const result = isAutoFocusAllowedUncached(node, isAutoFocusAllowedCached.bind(undefined, cache)); 68 | cache.set(node, result); 69 | 70 | return result; 71 | }; 72 | 73 | export const getDataset = (node: Element): HTMLElement['dataset'] | undefined => 74 | // @ts-ignore 75 | node.dataset; 76 | 77 | export const isHTMLButtonElement = (node: Element): node is HTMLInputElement => node.tagName === 'BUTTON'; 78 | export const isHTMLInputElement = (node: Element): node is HTMLInputElement => node.tagName === 'INPUT'; 79 | 80 | export const isRadioElement = (node: Element): node is HTMLInputElement => 81 | isHTMLInputElement(node) && node.type === 'radio'; 82 | 83 | export const notHiddenInput = (node: Element): boolean => 84 | !((isHTMLInputElement(node) || isHTMLButtonElement(node)) && (node.type === 'hidden' || node.disabled)); 85 | 86 | export const isAutoFocusAllowed = (node: Element): boolean => { 87 | const attribute = node.getAttribute(FOCUS_NO_AUTOFOCUS); 88 | 89 | return ![true, 'true', ''].includes(attribute as never); 90 | }; 91 | 92 | export const isGuard = (node: Element | undefined): boolean => Boolean(node && getDataset(node)?.focusGuard); 93 | export const isNotAGuard = (node: Element | undefined): boolean => !isGuard(node); 94 | 95 | export const isDefined = (x: T | null | undefined): x is T => Boolean(x); 96 | -------------------------------------------------------------------------------- /src/utils/parenting.ts: -------------------------------------------------------------------------------- 1 | import { parentAutofocusables } from './DOMutils'; 2 | import { contains } from './DOMutils'; 3 | import { asArray } from './array'; 4 | import { VisibilityCache } from './is'; 5 | 6 | const getParents = (node: Element, parents: Element[] = []): Element[] => { 7 | parents.push(node); 8 | 9 | if (node.parentNode) { 10 | getParents((node.parentNode as ShadowRoot).host || node.parentNode, parents); 11 | } 12 | 13 | return parents; 14 | }; 15 | 16 | /** 17 | * finds a parent for both nodeA and nodeB 18 | * @param nodeA 19 | * @param nodeB 20 | * @returns {boolean|*} 21 | */ 22 | export const getCommonParent = (nodeA: Element, nodeB: Element): Element | false => { 23 | const parentsA = getParents(nodeA); 24 | const parentsB = getParents(nodeB); 25 | 26 | // tslint:disable-next-line:prefer-for-of 27 | for (let i = 0; i < parentsA.length; i += 1) { 28 | const currentParent = parentsA[i]; 29 | 30 | if (parentsB.indexOf(currentParent) >= 0) { 31 | return currentParent; 32 | } 33 | } 34 | 35 | return false; 36 | }; 37 | 38 | export const getTopCommonParent = ( 39 | baseActiveElement: Element | Element[], 40 | leftEntry: Element | Element[], 41 | rightEntries: Element[] 42 | ): Element => { 43 | const activeElements = asArray(baseActiveElement); 44 | const leftEntries = asArray(leftEntry); 45 | const activeElement = activeElements[0]; 46 | let topCommon: Element | false = false; 47 | 48 | leftEntries.filter(Boolean).forEach((entry) => { 49 | topCommon = getCommonParent(topCommon || entry, entry) || topCommon; 50 | 51 | rightEntries.filter(Boolean).forEach((subEntry) => { 52 | const common = getCommonParent(activeElement, subEntry); 53 | 54 | if (common) { 55 | if (!topCommon || contains(common, topCommon)) { 56 | topCommon = common; 57 | } else { 58 | topCommon = getCommonParent(common, topCommon); 59 | } 60 | } 61 | }); 62 | }); 63 | 64 | // TODO: add assert here? 65 | return topCommon as unknown as Element; 66 | }; 67 | 68 | /** 69 | * return list of nodes which are expected to be autofocused inside a given top nodes 70 | * @param entries 71 | * @param visibilityCache 72 | */ 73 | export const allParentAutofocusables = (entries: Element[], visibilityCache: VisibilityCache): Element[] => 74 | entries.reduce((acc, node) => acc.concat(parentAutofocusables(node, visibilityCache)), [] as Element[]); 75 | -------------------------------------------------------------------------------- /src/utils/safe.ts: -------------------------------------------------------------------------------- 1 | export const safeProbe = (cb: () => T): T | undefined => { 2 | try { 3 | return cb(); 4 | } catch (e) { 5 | return undefined; 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/tabOrder.ts: -------------------------------------------------------------------------------- 1 | import { toArray } from './array'; 2 | 3 | export interface NodeIndex { 4 | node: HTMLElement; 5 | tabIndex: number; 6 | index: number; 7 | } 8 | 9 | export const tabSort = (a: NodeIndex, b: NodeIndex): number => { 10 | const aTab = Math.max(0, a.tabIndex); 11 | const bTab = Math.max(0, b.tabIndex); 12 | const tabDiff = aTab - bTab; 13 | const indexDiff = a.index - b.index; 14 | 15 | if (tabDiff) { 16 | if (!aTab) { 17 | return 1; 18 | } 19 | 20 | if (!bTab) { 21 | return -1; 22 | } 23 | } 24 | 25 | return tabDiff || indexDiff; 26 | }; 27 | 28 | const getTabIndex = (node: HTMLElement): number => { 29 | if (node.tabIndex < 0) { 30 | // all "focusable" elements are already preselected 31 | // but some might have implicit negative tabIndex 32 | // return 0 for