├── .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 | [](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 | [](https://www.npmtrends.com/react-focus-lock)
16 | - [vue-focus-lock](https://github.com/theKashey/vue-focus-lock)
17 | [](https://www.npmtrends.com/vue-focus-lock)
18 | - [dom-focus-lock](https://github.com/theKashey/dom-focus-lock)
19 | [](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 |
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 | I am a button
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 | I am a button
75 |
76 | I should be not be focused
77 |
`;
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 | I am a button
90 |
91 | I should be focused
92 |
`;
93 | const iframeHtml = `
94 |
95 | I am a button
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 |
89 | `;
90 |
91 | const { b1, restore } = getb();
92 | b1.parentElement!.removeChild(b1);
93 | expect(restore()).toBe(document.getElementById('b0'));
94 | });
95 |
96 | test('picks some focusable at the same level', () => {
97 | document.body.innerHTML = `
98 |
99 | `;
100 |
101 | const { b1, restore } = getb();
102 | b1.parentElement!.removeChild(b1);
103 | expect(restore()).toBe(document.getElementById('b0'));
104 | });
105 |
106 | test('moves parents to find focusable', () => {
107 | document.body.innerHTML = `
108 |
109 | `;
110 |
111 | const { b1, restore } = getb();
112 | b1.parentElement!.removeChild(b1);
113 | expect(restore()).toBe(document.getElementById('b3'));
114 | });
115 |
116 | test('returns undefined if nothing focusable', () => {
117 | document.body.innerHTML = `
118 |
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 |
I am a button
25 |
26 |
`;
27 | const shadowHtml = `
28 |
29 | first button
30 | second button
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 | I am a button
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 | I should not be focused
74 | `;
75 | });
76 |
77 | it('web components respect tabIndex', () => {
78 | expect.assertions(2);
79 |
80 | class FocusOutsideShadow extends HTMLElement {
81 | public connectedCallback() {
82 | const html = `
83 |
84 |
85 | I am a button
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 | I should be focused
109 | `;
110 | });
111 |
112 | it('focusNextElement w/ web components respects out of order tabIndex', () => {
113 | expect.assertions(4);
114 |
115 | class FocusNextOOO extends HTMLElement {
116 | public connectedCallback() {
117 | const html = `
118 |
119 |
120 |
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 | 1
9 | 2
10 |
11 | negative
12 |
13 | 3
14 | 4
15 |
16 |
17 | 5
18 | 6
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