diff --git a/.prettierignore b/.prettierignore index 73ec8232..067b0303 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,5 @@ docs/.docusaurus docs/.cache-loader *.yml .pages-deploy +bundle-size/.readable/ +.claude/ diff --git a/.storybook/preview.mjs b/.storybook/preview.mjs index 225b68d4..227d9e42 100644 --- a/.storybook/preview.mjs +++ b/.storybook/preview.mjs @@ -9,6 +9,7 @@ import { getOutline, getCrossOrigin, getRestorer, + getRootDummyInputs, } from "../src"; export const parameters = { @@ -36,6 +37,9 @@ export const decorators = [ controlTab, rootDummyInputs, }); + if (controlTab || rootDummyInputs) { + getRootDummyInputs(tabster); + } console.log( "created tabster", `as ${ diff --git a/babel.config.cjs b/babel.config.cjs index 5d66b691..5a0d380d 100644 --- a/babel.config.cjs +++ b/babel.config.cjs @@ -12,7 +12,16 @@ module.exports = (api) => { ]; return { - presets: ["@babel/preset-typescript", "@babel/preset-react", presetEnv], + presets: [ + // `allowDeclareFields` lets us write `declare field: T;` on + // class members — TS-only typed declarations that don't emit a + // class-field initializer at runtime. Used in src/* to drop the + // `this.x = void 0` writes that the constructor immediately + // overwrites. + ["@babel/preset-typescript", { allowDeclareFields: true }], + "@babel/preset-react", + presetEnv, + ], plugins: [["@babel/plugin-transform-react-jsx"]], }; }; diff --git a/bundle-size/focusableFindAll.fixture.js b/bundle-size/focusableFindAll.fixture.js new file mode 100644 index 00000000..bfea024b --- /dev/null +++ b/bundle-size/focusableFindAll.fixture.js @@ -0,0 +1,21 @@ +import { + createTabster, + disposeTabster, + findAllFocusable, + getTabsterAttribute, + setTabsterAttribute, + Types, +} from "tabster"; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + findAllFocusable, + Types +); + +export default { + name: "findAllFocusable", +}; diff --git a/bundle-size/focusableFindLast.fixture.js b/bundle-size/focusableFindLast.fixture.js new file mode 100644 index 00000000..1d9fb787 --- /dev/null +++ b/bundle-size/focusableFindLast.fixture.js @@ -0,0 +1,21 @@ +import { + createTabster, + disposeTabster, + findLastFocusable, + getTabsterAttribute, + setTabsterAttribute, + Types, +} from "tabster"; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + findLastFocusable, + Types +); + +export default { + name: "findLastFocusable", +}; diff --git a/bundle-size/focusableFindNext.fixture.js b/bundle-size/focusableFindNext.fixture.js new file mode 100644 index 00000000..de34230c --- /dev/null +++ b/bundle-size/focusableFindNext.fixture.js @@ -0,0 +1,21 @@ +import { + createTabster, + disposeTabster, + findNextFocusable, + getTabsterAttribute, + setTabsterAttribute, + Types, +} from "tabster"; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + findNextFocusable, + Types +); + +export default { + name: "findNextFocusable", +}; diff --git a/bundle-size/focusableFindPrev.fixture.js b/bundle-size/focusableFindPrev.fixture.js new file mode 100644 index 00000000..0b88044d --- /dev/null +++ b/bundle-size/focusableFindPrev.fixture.js @@ -0,0 +1,21 @@ +import { + createTabster, + disposeTabster, + findPrevFocusable, + getTabsterAttribute, + setTabsterAttribute, + Types, +} from "tabster"; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + findPrevFocusable, + Types +); + +export default { + name: "findPrevFocusable", +}; diff --git a/bundle-size/getModalizerWithDummyInputs.fixture.js b/bundle-size/getModalizerWithDummyInputs.fixture.js new file mode 100644 index 00000000..b460a182 --- /dev/null +++ b/bundle-size/getModalizerWithDummyInputs.fixture.js @@ -0,0 +1,23 @@ +import { + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + getModalizer, + getRootDummyInputs, + Types, +} from "tabster"; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + getModalizer, + getRootDummyInputs, + Types +); + +export default { + name: "getModalizer (with dummy inputs)", +}; diff --git a/package-lock.json b/package-lock.json index d7aea7a2..d03035b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "tabster", "version": "8.8.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "keyborg": "^2.14.0", @@ -45,6 +46,7 @@ "monosize": "0.8.2", "monosize-bundler-webpack": "0.3.1", "monosize-storage-git": "0.3.1", + "patch-package": "^8.0.1", "prettier": "^3.8.3", "puppeteer": "^24.41.0", "react": "^19.2.5", @@ -6261,6 +6263,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -9175,6 +9184,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -9313,6 +9332,21 @@ "node": ">=0.10.0" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -12488,6 +12522,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12495,6 +12549,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-with-bigint": { "version": "3.5.8", "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", @@ -12515,6 +12576,29 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/keyborg": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/keyborg/-/keyborg-2.14.0.tgz", @@ -12531,6 +12615,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -14196,6 +14290,121 @@ "node": ">=14.13.0" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -16662,6 +16871,16 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -17065,6 +17284,16 @@ "dev": true, "license": "ISC" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unplugin": { "version": "2.3.11", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", @@ -17779,6 +18008,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.json b/package.json index 6bcf6e0a..82d63f8d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test:shadowdom:uncontrolled": "SHADOWDOM=true STORYBOOK_UNCONTROLLED=true npm test", "test:shadowdom:root-dummy-inputs": "SHADOWDOM=true STORYBOOK_UNCONTROLLED=true STORYBOOK_ROOT_DUMMY_INPUTS=true npm test", "test:all": "npm run test && npm run test:uncontrolled && npm run test:root-dummy-inputs && npm run test:shadowdom && npm run test:shadowdom:uncontrolled && npm run test:shadowdom:root-dummy-inputs", + "postinstall": "patch-package", "prepublishOnly": "npm run build", "release": "release-it", "serve": "npx http-serve storybook-static", @@ -86,6 +87,7 @@ "monosize": "0.8.2", "monosize-bundler-webpack": "0.3.1", "monosize-storage-git": "0.3.1", + "patch-package": "^8.0.1", "prettier": "^3.8.3", "puppeteer": "^24.41.0", "react": "^19.2.5", diff --git a/patches/keyborg+2.14.0.patch b/patches/keyborg+2.14.0.patch new file mode 100644 index 00000000..63ebe79f --- /dev/null +++ b/patches/keyborg+2.14.0.patch @@ -0,0 +1,232 @@ +diff --git a/node_modules/keyborg/dist/index.cjs b/node_modules/keyborg/dist/index.cjs +index a30679a..872f2c5 100644 +--- a/node_modules/keyborg/dist/index.cjs ++++ b/node_modules/keyborg/dist/index.cjs +@@ -24,18 +24,6 @@ const FOCUS_IN_HANDLER = 0; + const FOCUS_OUT_HANDLER = 1; + const SHADOW_TARGETS = 2; + const LAST_FOCUSED_PROGRAMMATICALLY = 3; +-function canOverrideNativeFocus(win) { +- const HTMLElement = win.HTMLElement; +- const origFocus = HTMLElement.prototype.focus; +- let isCustomFocusCalled = false; +- HTMLElement.prototype.focus = function focus() { +- isCustomFocusCalled = true; +- }; +- win.document.createElement("button").focus(); +- HTMLElement.prototype.focus = origFocus; +- return isCustomFocusCalled; +-} +-let _canOverrideNativeFocus = false; + /** + * Guarantees that the native `focus` will be used + */ +@@ -51,7 +39,6 @@ function setupFocusEvent(win) { + const kwin = win; + const doc = kwin.document; + const proto = kwin.HTMLElement.prototype; +- if (!_canOverrideNativeFocus) _canOverrideNativeFocus = canOverrideNativeFocus(kwin); + const origFocus = proto.focus; + if (origFocus.__keyborgNativeFocus) return; + proto.focus = focus; +@@ -154,11 +141,8 @@ function setupFocusEvent(win) { + composed: true, + detail: details + }); +- event.details = details; +- if (_canOverrideNativeFocus || data[LAST_FOCUSED_PROGRAMMATICALLY]) { +- details.isFocusedProgrammatically = target === data[LAST_FOCUSED_PROGRAMMATICALLY]?.deref(); +- data[LAST_FOCUSED_PROGRAMMATICALLY] = void 0; +- } ++ details.isFocusedProgrammatically = target === data[LAST_FOCUSED_PROGRAMMATICALLY]?.deref(); ++ data[LAST_FOCUSED_PROGRAMMATICALLY] = void 0; + target.dispatchEvent(event); + }; + const data = [ +@@ -221,19 +205,11 @@ function getLastFocusedProgrammatically(win) { + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ +-const _dismissTimeout = 500; + let _lastId = 0; +-function createKeyborgCore(targetWindow, props) { ++function createKeyborgCore(targetWindow) { + let currentTargetWindow = targetWindow; + let isNavigating = false; + let isMouseOrTouchUsedTimer; +- let dismissTimer; +- let triggerKeys; +- let dismissKeys; +- if (props) { +- if (props.triggerKeys?.length) triggerKeys = new Set(props.triggerKeys); +- if (props.dismissKeys?.length) dismissKeys = new Set(props.dismissKeys); +- } + const broadcast = () => { + const refs = currentTargetWindow?.__keyborg?.refs; + if (refs) for (const id of Object.keys(refs)) refs[id]._notify(isNavigating); +@@ -247,26 +223,8 @@ function createKeyborgCore(targetWindow, props) { + const shouldTrigger = (e) => { + if (e.key === "Tab") return true; + const active = currentTargetWindow?.document.activeElement; +- const isTriggerKey = !triggerKeys || triggerKeys.has(e.keyCode); + const isEditable = active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable); +- return isTriggerKey && !isEditable; +- }; +- const shouldDismiss = (e) => { +- return !!dismissKeys?.has(e.keyCode); +- }; +- const scheduleDismiss = () => { +- const targetWindow = currentTargetWindow; +- if (!targetWindow) return; +- if (dismissTimer) { +- targetWindow.clearTimeout(dismissTimer); +- dismissTimer = void 0; +- } +- const previousActiveElement = targetWindow.document.activeElement; +- dismissTimer = targetWindow.setTimeout(() => { +- dismissTimer = void 0; +- const currentActiveElement = targetWindow.document.activeElement; +- if (previousActiveElement && currentActiveElement && previousActiveElement === currentActiveElement) setNavigating(false); +- }, _dismissTimeout); ++ return !isEditable; + }; + const onFocusIn = (e) => { + if (isMouseOrTouchUsedTimer) return; +@@ -290,9 +248,7 @@ function createKeyborgCore(targetWindow, props) { + onMouseOrTouch(); + }; + const onKeyDown = (e) => { +- if (isNavigating) { +- if (shouldDismiss(e)) scheduleDismiss(); +- } else if (shouldTrigger(e)) setNavigating(true); ++ if (!isNavigating && shouldTrigger(e)) setNavigating(true); + }; + const targetDocument = targetWindow.document; + addEventListener(targetDocument, KEYBORG_FOCUSIN, onFocusIn); +@@ -308,10 +264,6 @@ function createKeyborgCore(targetWindow, props) { + currentTargetWindow.clearTimeout(isMouseOrTouchUsedTimer); + isMouseOrTouchUsedTimer = void 0; + } +- if (dismissTimer) { +- currentTargetWindow.clearTimeout(dismissTimer); +- dismissTimer = void 0; +- } + disposeFocusEvent(currentTargetWindow); + const targetDocument = currentTargetWindow.document; + removeEventListener(targetDocument, KEYBORG_FOCUSIN, onFocusIn); +diff --git a/node_modules/keyborg/dist/index.js b/node_modules/keyborg/dist/index.js +index 3702904..84e609a 100644 +--- a/node_modules/keyborg/dist/index.js ++++ b/node_modules/keyborg/dist/index.js +@@ -22,18 +22,6 @@ const FOCUS_IN_HANDLER = 0; + const FOCUS_OUT_HANDLER = 1; + const SHADOW_TARGETS = 2; + const LAST_FOCUSED_PROGRAMMATICALLY = 3; +-function canOverrideNativeFocus(win) { +- const HTMLElement = win.HTMLElement; +- const origFocus = HTMLElement.prototype.focus; +- let isCustomFocusCalled = false; +- HTMLElement.prototype.focus = function focus() { +- isCustomFocusCalled = true; +- }; +- win.document.createElement("button").focus(); +- HTMLElement.prototype.focus = origFocus; +- return isCustomFocusCalled; +-} +-let _canOverrideNativeFocus = false; + /** + * Guarantees that the native `focus` will be used + */ +@@ -49,7 +37,6 @@ function setupFocusEvent(win) { + const kwin = win; + const doc = kwin.document; + const proto = kwin.HTMLElement.prototype; +- if (!_canOverrideNativeFocus) _canOverrideNativeFocus = canOverrideNativeFocus(kwin); + const origFocus = proto.focus; + if (origFocus.__keyborgNativeFocus) return; + proto.focus = focus; +@@ -152,11 +139,8 @@ function setupFocusEvent(win) { + composed: true, + detail: details + }); +- event.details = details; +- if (_canOverrideNativeFocus || data[LAST_FOCUSED_PROGRAMMATICALLY]) { +- details.isFocusedProgrammatically = target === data[LAST_FOCUSED_PROGRAMMATICALLY]?.deref(); +- data[LAST_FOCUSED_PROGRAMMATICALLY] = void 0; +- } ++ details.isFocusedProgrammatically = target === data[LAST_FOCUSED_PROGRAMMATICALLY]?.deref(); ++ data[LAST_FOCUSED_PROGRAMMATICALLY] = void 0; + target.dispatchEvent(event); + }; + const data = [ +@@ -219,19 +203,11 @@ function getLastFocusedProgrammatically(win) { + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ +-const _dismissTimeout = 500; + let _lastId = 0; +-function createKeyborgCore(targetWindow, props) { ++function createKeyborgCore(targetWindow) { + let currentTargetWindow = targetWindow; + let isNavigating = false; + let isMouseOrTouchUsedTimer; +- let dismissTimer; +- let triggerKeys; +- let dismissKeys; +- if (props) { +- if (props.triggerKeys?.length) triggerKeys = new Set(props.triggerKeys); +- if (props.dismissKeys?.length) dismissKeys = new Set(props.dismissKeys); +- } + const broadcast = () => { + const refs = currentTargetWindow?.__keyborg?.refs; + if (refs) for (const id of Object.keys(refs)) refs[id]._notify(isNavigating); +@@ -245,26 +221,8 @@ function createKeyborgCore(targetWindow, props) { + const shouldTrigger = (e) => { + if (e.key === "Tab") return true; + const active = currentTargetWindow?.document.activeElement; +- const isTriggerKey = !triggerKeys || triggerKeys.has(e.keyCode); + const isEditable = active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable); +- return isTriggerKey && !isEditable; +- }; +- const shouldDismiss = (e) => { +- return !!dismissKeys?.has(e.keyCode); +- }; +- const scheduleDismiss = () => { +- const targetWindow = currentTargetWindow; +- if (!targetWindow) return; +- if (dismissTimer) { +- targetWindow.clearTimeout(dismissTimer); +- dismissTimer = void 0; +- } +- const previousActiveElement = targetWindow.document.activeElement; +- dismissTimer = targetWindow.setTimeout(() => { +- dismissTimer = void 0; +- const currentActiveElement = targetWindow.document.activeElement; +- if (previousActiveElement && currentActiveElement && previousActiveElement === currentActiveElement) setNavigating(false); +- }, _dismissTimeout); ++ return !isEditable; + }; + const onFocusIn = (e) => { + if (isMouseOrTouchUsedTimer) return; +@@ -288,9 +246,7 @@ function createKeyborgCore(targetWindow, props) { + onMouseOrTouch(); + }; + const onKeyDown = (e) => { +- if (isNavigating) { +- if (shouldDismiss(e)) scheduleDismiss(); +- } else if (shouldTrigger(e)) setNavigating(true); ++ if (!isNavigating && shouldTrigger(e)) setNavigating(true); + }; + const targetDocument = targetWindow.document; + addEventListener(targetDocument, KEYBORG_FOCUSIN, onFocusIn); +@@ -306,10 +262,6 @@ function createKeyborgCore(targetWindow, props) { + currentTargetWindow.clearTimeout(isMouseOrTouchUsedTimer); + isMouseOrTouchUsedTimer = void 0; + } +- if (dismissTimer) { +- currentTargetWindow.clearTimeout(dismissTimer); +- dismissTimer = void 0; +- } + disposeFocusEvent(currentTargetWindow); + const targetDocument = currentTargetWindow.document; + removeEventListener(targetDocument, KEYBORG_FOCUSIN, onFocusIn); diff --git a/src/Context.ts b/src/Context.ts new file mode 100644 index 00000000..ff5adae1 --- /dev/null +++ b/src/Context.ts @@ -0,0 +1,203 @@ +/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import type { RootAPI } from "./Root.js"; +import { _isElementVisible } from "./Focusable.js"; +import { getTabsterOnElement } from "./Instance.js"; +import type * as Types from "./Types.js"; + +/** + * Walks up the DOM ancestors of `element`, collecting the nearest enclosing + * Mover/Groupper/Modalizer/Root and related state. Imported by ~30 internal + * call sites. Lives in its own module so importers don't pull the entire + * RootAPI class definition along for the ride. + */ +export function getTabsterContext( + tabster: Types.TabsterCore, + element: Node, + options: Types.GetTabsterContextOptions = {} +): Types.TabsterContext | undefined { + if (!element.ownerDocument) { + return undefined; + } + + const { checkRtl, referenceElement } = options; + + const getParent = tabster.getParent; + + // Normally, the initialization starts on the next tick after the tabster + // instance creation. However, if the application starts using it before + // the next tick, we need to make sure the initialization is done. + tabster.drainInitQueue(); + + let root: Types.Root | undefined; + let modalizer: Types.Modalizer | undefined; + let groupper: Types.Groupper | undefined; + let mover: Types.Mover | undefined; + let excludedFromMover = false; + let groupperBeforeMover: boolean | undefined; + let modalizerInGroupper: Types.Groupper | undefined; + let dirRightToLeft: boolean | undefined; + let uncontrolled: HTMLElement | null | undefined; + let curElement: Node | null = referenceElement || element; + const ignoreKeydown: Types.FocusableProps["ignoreKeydown"] = {}; + + while (curElement && (!root || checkRtl)) { + const tabsterOnElement = getTabsterOnElement( + tabster, + curElement as HTMLElement + ); + + if (checkRtl && dirRightToLeft === undefined) { + const dir = (curElement as HTMLElement).dir; + + if (dir) { + dirRightToLeft = dir.toLowerCase() === "rtl"; + } + } + + if (!tabsterOnElement) { + curElement = getParent(curElement); + continue; + } + + const tagName = (curElement as HTMLElement).tagName; + + if ( + (tabsterOnElement.uncontrolled || + tagName === "IFRAME" || + tagName === "WEBVIEW") && + _isElementVisible(curElement as HTMLElement) + ) { + uncontrolled = curElement as HTMLElement; + } + + if ( + !mover && + tabsterOnElement.focusable?.excludeFromMover && + !groupper + ) { + excludedFromMover = true; + } + + const curModalizer = tabsterOnElement.modalizer; + const curGroupper = tabsterOnElement.groupper; + const curMover = tabsterOnElement.mover; + + if (!modalizer && curModalizer) { + modalizer = curModalizer; + } + + if (!groupper && curGroupper && (!modalizer || curModalizer)) { + if (modalizer) { + // Modalizer dominates the groupper when they are on the same node and the groupper is active. + if ( + !curGroupper.isActive() && + curGroupper.getProps().tabbability && + modalizer.userId !== tabster.modalizer?.activeId + ) { + modalizer = undefined; + groupper = curGroupper; + } + + modalizerInGroupper = curGroupper; + } else { + groupper = curGroupper; + } + } + + if ( + !mover && + curMover && + (!modalizer || curModalizer) && + (!curGroupper || curElement !== element) && + curElement.contains(element) // Mover makes sense only for really inside elements, not for virutal out of the DOM order children. + ) { + mover = curMover; + groupperBeforeMover = !!groupper && groupper !== curGroupper; + } + + if (tabsterOnElement.root) { + root = tabsterOnElement.root; + } + + if (tabsterOnElement.focusable?.ignoreKeydown) { + Object.assign( + ignoreKeydown, + tabsterOnElement.focusable.ignoreKeydown + ); + } + + curElement = getParent(curElement); + } + + // No root element could be found, try to get an auto root + if (!root) { + const rootAPI = tabster.root as RootAPI; + const autoRoot = rootAPI._autoRoot; + + if (autoRoot) { + if (element.ownerDocument?.body) { + root = rootAPI._autoRootCreate(); + } + } + } + + if (groupper && !mover) { + groupperBeforeMover = true; + } + + if (__DEV__ && !root) { + if (modalizer || groupper || mover) { + console.error( + "Tabster Root is required for Mover, Groupper and Modalizer to work." + ); + } + } + + const shouldIgnoreKeydown = (event: KeyboardEvent) => + !!ignoreKeydown[ + event.key as keyof Types.FocusableProps["ignoreKeydown"] + ]; + + return root + ? { + root, + modalizer, + groupper, + mover, + groupperBeforeMover, + modalizerInGroupper, + rtl: checkRtl ? !!dirRightToLeft : undefined, + uncontrolled, + excludedFromMover, + ignoreKeydown: shouldIgnoreKeydown, + } + : undefined; +} + +/** + * Walks up the ancestors of `element` returning the nearest enclosing Root. + */ +export function getRoot( + tabster: Types.TabsterCore, + element: HTMLElement +): Types.Root | undefined { + const getParent = tabster.getParent; + + for ( + let el = element as HTMLElement | null; + el; + el = getParent(el) as HTMLElement | null + ) { + const root = getTabsterOnElement(tabster, el)?.root; + + if (root) { + return root; + } + } + + return undefined; +} diff --git a/src/CrossOrigin.ts b/src/CrossOrigin.ts index 469f0b3e..c33aa99e 100644 --- a/src/CrossOrigin.ts +++ b/src/CrossOrigin.ts @@ -8,17 +8,25 @@ import { DeloserHistoryByRootBase, DeloserItemBase, } from "./Deloser.js"; +import { _isFocusable } from "./Focusable.js"; import { getTabsterOnElement } from "./Instance.js"; -import { RootAPI } from "./Root.js"; -import { Subscribable } from "./State/Subscribable.js"; +import { getTabsterContext } from "./Context.js"; +import { createSubscribable } from "./State/Subscribable.js"; import type * as Types from "./Types.js"; import { ObservedElementAccessibilities } from "./Consts.js"; import { + addListener, + clearTimer, + createTimer, getElementUId, getInstanceContext, getUId, getWindowUId, type HTMLElementWithUID, + isTimerActive, + removeListener, + setTimer, + type Timer, } from "./Utils.js"; import { dom } from "./DOMAPI.js"; @@ -53,8 +61,8 @@ interface KnownTargets { } class CrossOriginDeloserItem extends DeloserItemBase { - private _deloser: CrossOriginDeloser; - private _transactions: CrossOriginTransactions; + declare private _deloser: CrossOriginDeloser; + declare private _transactions: CrossOriginTransactions; constructor( tabster: Types.TabsterCore, @@ -97,7 +105,7 @@ class CrossOriginDeloserHistoryByRoot extends DeloserHistoryByRootBase< CrossOriginDeloser, CrossOriginDeloserItem > { - private _transactions: CrossOriginTransactions; + declare private _transactions: CrossOriginTransactions; constructor( tabster: Types.TabsterCore, @@ -155,20 +163,22 @@ class CrossOriginDeloserHistoryByRoot extends DeloserHistoryByRootBase< abstract class CrossOriginTransaction { abstract type: Types.CrossOriginTransactionType; - readonly id: string; - readonly beginData: I; - readonly timeout?: number; - protected tabster: Types.TabsterCore; - protected endData: O | undefined; - protected owner: Types.GetWindow; - protected ownerId: string; - protected sendUp: Types.CrossOriginTransactionSend | undefined; - private _promise: Promise; - protected _resolve: ((endData: O | PromiseLike) => void) | undefined; - private _reject: ((reason: string) => void) | undefined; - private _knownTargets: KnownTargets; - private _sentTo: Types.CrossOriginSentTo; - protected targetId: string | undefined; + declare readonly id: string; + declare readonly beginData: I; + declare readonly timeout?: number; + declare protected tabster: Types.TabsterCore; + declare protected endData: O | undefined; + declare protected owner: Types.GetWindow; + declare protected ownerId: string; + declare protected sendUp: Types.CrossOriginTransactionSend | undefined; + declare private _promise: Promise; + declare protected _resolve: + | ((endData: O | PromiseLike) => void) + | undefined; + declare private _reject: ((reason: string) => void) | undefined; + declare private _knownTargets: KnownTargets; + declare private _sentTo: Types.CrossOriginSentTo; + declare protected targetId: string | undefined; private _inProgress: { [id: string]: boolean } = {}; private _isDone = false; private _isSelfResponding = false; @@ -429,7 +439,7 @@ class FocusElementTransaction extends CrossOriginTransaction< getOwner, data.beginData ); - return !el || !tabster.focusable.isFocusable(el); + return !el || !_isFocusable(tabster, el); } static async makeResponse( @@ -759,7 +769,7 @@ class GetElementTransaction extends CrossOriginTransaction< element = dom.getElementById(getOwner().document, data.id); if (element && data.rootId) { - const ctx = RootAPI.getTabsterContext(tabster, element); + const ctx = getTabsterContext(tabster, element); if (!ctx || ctx.root.uid !== data.rootId) { return null; @@ -788,7 +798,7 @@ class GetElementTransaction extends CrossOriginTransaction< ownerUId: string ): CrossOriginElementDataOut { const deloser = DeloserAPI.getDeloser(tabster, element); - const ctx = RootAPI.getTabsterContext(tabster, element); + const ctx = getTabsterContext(tabster, element); const tabsterOnElement = getTabsterOnElement(tabster, element); const observed = tabsterOnElement && tabsterOnElement.observed; @@ -950,24 +960,24 @@ class PingTransaction extends CrossOriginTransaction { interface CrossOriginTransactionWrapper { transaction: CrossOriginTransaction; - timer?: number; + timer: Timer; } class CrossOriginTransactions { - private _owner: Types.GetWindow; - private _ownerUId: string; + declare private _owner: Types.GetWindow; + declare private _ownerUId: string; private _knownTargets: KnownTargets = {}; private _transactions: { // eslint-disable-next-line @typescript-eslint/no-explicit-any [id: string]: CrossOriginTransactionWrapper; } = {}; - private _tabster: Types.TabsterCore; - private _pingTimer: number | undefined; + declare private _tabster: Types.TabsterCore; + private _pingTimer: Timer; private _isDefaultSendUp = false; - private _deadPromise: Promise | undefined; + declare private _deadPromise: Promise | undefined; isSetUp = false; - sendUp: Types.CrossOriginTransactionSend | undefined; - ctx: CrossOriginInstanceContext; + declare sendUp: Types.CrossOriginTransactionSend | undefined; + declare ctx: CrossOriginInstanceContext; constructor( tabster: Types.TabsterCore, @@ -978,6 +988,7 @@ class CrossOriginTransactions { this._owner = getOwner; this._ownerUId = getWindowUId(getOwner()); this.ctx = context; + this._pingTimer = createTimer(); } setup( @@ -992,7 +1003,7 @@ class CrossOriginTransactions { this.setSendUp(sendUp); - this._owner().addEventListener("pagehide", this._onPageHide); + addListener(this._owner(), "pagehide", this._onPageHide); this._ping(); } @@ -1029,11 +1040,11 @@ class CrossOriginTransactions { }; } - owner.addEventListener("message", this._onBrowserMessage); + addListener(owner, "message", this._onBrowserMessage); } } } else if (this._isDefaultSendUp) { - owner.removeEventListener("message", this._onBrowserMessage); + removeListener(owner, "message", this._onBrowserMessage); this._isDefaultSendUp = false; } @@ -1043,13 +1054,10 @@ class CrossOriginTransactions { async dispose(): Promise { const owner = this._owner(); - if (this._pingTimer) { - owner.clearTimeout(this._pingTimer); - this._pingTimer = undefined; - } + clearTimer(this._pingTimer, owner); - owner.removeEventListener("message", this._onBrowserMessage); - owner.removeEventListener("pagehide", this._onPageHide); + removeListener(owner, "message", this._onBrowserMessage); + removeListener(owner, "pagehide", this._onPageHide); await this._dead(); @@ -1058,11 +1066,7 @@ class CrossOriginTransactions { for (const id of Object.keys(this._transactions)) { const t = this._transactions[id]; - if (t.timer) { - owner.clearTimeout(t.timer); - delete t.timer; - } - + clearTimer(t.timer, owner); t.transaction.end(); } @@ -1145,15 +1149,18 @@ class CrossOriginTransactions { const wrapper: CrossOriginTransactionWrapper = { transaction, - timer: owner.setTimeout( - () => { - delete wrapper.timer; - transaction.end("Cross origin transaction timed out."); - }, - _transactionTimeout + (timeout || 0) - ), + timer: createTimer(), }; + setTimer( + wrapper.timer, + owner, + () => { + transaction.end("Cross origin transaction timed out."); + }, + _transactionTimeout + (timeout || 0) + ); + this._transactions[transaction.id] = wrapper; const ret = transaction.begin(selfResponse); @@ -1161,9 +1168,7 @@ class CrossOriginTransactions { ret.catch(() => { /**/ }).finally(() => { - if (wrapper.timer) { - owner.clearTimeout(wrapper.timer); - } + clearTimer(wrapper.timer, owner); delete this._transactions[transaction.id]; }); @@ -1341,7 +1346,7 @@ class CrossOriginTransactions { } private async _ping(): Promise { - if (this._pingTimer) { + if (isTimerActive(this._pingTimer)) { return; } @@ -1407,10 +1412,14 @@ class CrossOriginTransactions { } } - this._pingTimer = this._owner().setTimeout(() => { - this._pingTimer = undefined; - this._ping(); - }, _pingTimeout); + setTimer( + this._pingTimer, + this._owner(), + () => { + this._ping(); + }, + _pingTimeout + ); } private _onBrowserMessage = (e: MessageEvent) => { @@ -1438,13 +1447,13 @@ class CrossOriginTransactions { } export class CrossOriginElement implements Types.CrossOriginElement { - private _tabster: Types.TabsterCore; - readonly uid: string; - readonly ownerId: string; - readonly id?: string; - readonly rootId?: string; - readonly observedName?: string; - readonly observedDetails?: string; + declare private _tabster: Types.TabsterCore; + declare readonly uid: string; + declare readonly ownerId: string; + declare readonly id?: string; + declare readonly rootId?: string; + declare readonly observedName?: string; + declare readonly observedDetails?: string; constructor( tabster: Types.TabsterCore, @@ -1477,73 +1486,29 @@ export class CrossOriginElement implements Types.CrossOriginElement { } } -export class CrossOriginFocusedElementState - extends Subscribable< +interface CrossOriginFocusedElementStateInternal + extends Types.CrossOriginFocusedElementState { + _setVal: ( + val: CrossOriginElement | undefined, + detail: Types.FocusedElementDetail + ) => void; +} + +function createCrossOriginFocusedElementState( + transactions: CrossOriginTransactions +): Types.CrossOriginFocusedElementState { + const sub = createSubscribable< CrossOriginElement | undefined, Types.FocusedElementDetail - > - implements Types.CrossOriginFocusedElementState -{ - private _transactions: CrossOriginTransactions; - - constructor(transactions: CrossOriginTransactions) { - super(); - this._transactions = transactions; - } + >(); - async focus( - element: Types.CrossOriginElement, - noFocusedProgrammaticallyFlag?: boolean, - noAccessibleCheck?: boolean - ): Promise { - return this._focus( - { - uid: element.uid, - id: element.id, - rootId: element.rootId, - ownerId: element.ownerId, - observedName: element.observedName, - }, - noFocusedProgrammaticallyFlag, - noAccessibleCheck - ); - } - - async focusById( - elementId: string, - rootId?: string, - noFocusedProgrammaticallyFlag?: boolean, - noAccessibleCheck?: boolean - ): Promise { - return this._focus( - { id: elementId, rootId }, - noFocusedProgrammaticallyFlag, - noAccessibleCheck - ); - } - - async focusByObservedName( - observedName: string, - timeout?: number, - rootId?: string, - noFocusedProgrammaticallyFlag?: boolean, - noAccessibleCheck?: boolean - ): Promise { - return this._focus( - { observedName, rootId }, - noFocusedProgrammaticallyFlag, - noAccessibleCheck, - timeout - ); - } - - private async _focus( + const focusInternal = async ( elementData: CrossOriginElementDataIn, noFocusedProgrammaticallyFlag?: boolean, noAccessibleCheck?: boolean, timeout?: number - ): Promise { - return this._transactions + ): Promise => { + return transactions .beginTransaction( FocusElementTransaction, { @@ -1554,47 +1519,102 @@ export class CrossOriginFocusedElementState timeout ) .then((value) => !!value); - } + }; + + const api: CrossOriginFocusedElementStateInternal = { + subscribe: sub.subscribe, + subscribeFirst: sub.subscribeFirst, + unsubscribe: sub.unsubscribe, + dispose: sub.dispose, + _setVal: sub.setVal, + + async focus( + element: Types.CrossOriginElement, + noFocusedProgrammaticallyFlag?: boolean, + noAccessibleCheck?: boolean + ): Promise { + return focusInternal( + { + uid: element.uid, + id: element.id, + rootId: element.rootId, + ownerId: element.ownerId, + observedName: element.observedName, + }, + noFocusedProgrammaticallyFlag, + noAccessibleCheck + ); + }, + + async focusById( + elementId: string, + rootId?: string, + noFocusedProgrammaticallyFlag?: boolean, + noAccessibleCheck?: boolean + ): Promise { + return focusInternal( + { id: elementId, rootId }, + noFocusedProgrammaticallyFlag, + noAccessibleCheck + ); + }, + + async focusByObservedName( + observedName: string, + timeout?: number, + rootId?: string, + noFocusedProgrammaticallyFlag?: boolean, + noAccessibleCheck?: boolean + ): Promise { + return focusInternal( + { observedName, rootId }, + noFocusedProgrammaticallyFlag, + noAccessibleCheck, + timeout + ); + }, + }; + + return api; +} - static setVal( +export const CrossOriginFocusedElementState = { + setVal( instance: Types.CrossOriginFocusedElementState, val: CrossOriginElement | undefined, detail: Types.FocusedElementDetail ): void { - (instance as CrossOriginFocusedElementState).setVal(val, detail); - } -} - -export class CrossOriginObservedElementState - extends Subscribable - implements Types.CrossOriginObservedElementState -{ - private _tabster: Types.TabsterCore; - private _transactions: CrossOriginTransactions; - private _lastRequestFocusId = 0; - - constructor( - tabster: Types.TabsterCore, - transactions: CrossOriginTransactions - ) { - super(); - this._tabster = tabster; - this._transactions = transactions; - } + (instance as CrossOriginFocusedElementStateInternal)._setVal( + val, + detail + ); + }, +}; - async getElement( - observedName: string, - accessibility?: Types.ObservedElementAccessibility - ): Promise { - return this.waitElement(observedName, 0, accessibility); - } +interface CrossOriginObservedElementStateInternal + extends Types.CrossOriginObservedElementState { + _trigger: ( + element: CrossOriginElement, + details: Types.ObservedElementProps + ) => void; +} - async waitElement( +function createCrossOriginObservedElementState( + tabster: Types.TabsterCore, + transactions: CrossOriginTransactions +): Types.CrossOriginObservedElementState { + const sub = createSubscribable< + CrossOriginElement, + Types.ObservedElementProps + >(); + let lastRequestFocusId = 0; + + const waitElement = async ( observedName: string, timeout: number, accessibility?: Types.ObservedElementAccessibility - ): Promise { - return this._transactions + ): Promise => { + return transactions .beginTransaction( GetElementTransaction, { @@ -1604,222 +1624,234 @@ export class CrossOriginObservedElementState timeout ) .then((value) => - value - ? StateTransaction.createElement(this._tabster, value) - : null + value ? StateTransaction.createElement(tabster, value) : null ); - } + }; - async requestFocus( - observedName: string, - timeout: number - ): Promise { - const requestId = ++this._lastRequestFocusId; - return this.waitElement( - observedName, - timeout, - ObservedElementAccessibilities.Focusable - ).then((element) => - this._lastRequestFocusId === requestId && element - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this._tabster.crossOrigin!.focusedElement.focus(element, true) - : false - ); - } + const api: CrossOriginObservedElementStateInternal = { + subscribe: sub.subscribe, + subscribeFirst: sub.subscribeFirst, + unsubscribe: sub.unsubscribe, + dispose: sub.dispose, + _trigger: sub.trigger, + + async getElement( + observedName: string, + accessibility?: Types.ObservedElementAccessibility + ): Promise { + return waitElement(observedName, 0, accessibility); + }, + + async waitElement( + observedName: string, + timeout: number, + accessibility?: Types.ObservedElementAccessibility + ): Promise { + return waitElement(observedName, timeout, accessibility); + }, + + async requestFocus( + observedName: string, + timeout: number + ): Promise { + const requestId = ++lastRequestFocusId; + return waitElement( + observedName, + timeout, + ObservedElementAccessibilities.Focusable + ).then((element) => + lastRequestFocusId === requestId && element + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + tabster.crossOrigin!.focusedElement.focus(element, true) + : false + ); + }, + }; + + return api; +} - static trigger( +export const CrossOriginObservedElementState = { + trigger( instance: Types.CrossOriginObservedElementState, element: CrossOriginElement, details: Types.ObservedElementProps ): void { - (instance as CrossOriginObservedElementState).trigger(element, details); - } -} - -export class CrossOriginAPI implements Types.CrossOriginAPI { - private _tabster: Types.TabsterCore; - private _win: Types.GetWindow; - private _transactions: CrossOriginTransactions; - private _blurTimer: number | undefined; - private _ctx: CrossOriginInstanceContext; - - focusedElement: Types.CrossOriginFocusedElementState; - observedElement: Types.CrossOriginObservedElementState; - - constructor(tabster: Types.TabsterCore) { - this._tabster = tabster; - this._win = tabster.getWindow; - this._ctx = { - ignoreKeyboardNavigationStateUpdate: false, - deloserByUId: {}, - }; - - this._transactions = new CrossOriginTransactions( - tabster, - this._win, - this._ctx - ); - this.focusedElement = new CrossOriginFocusedElementState( - this._transactions - ); - this.observedElement = new CrossOriginObservedElementState( - tabster, - this._transactions - ); - } - - setup( - sendUp?: Types.CrossOriginTransactionSend | null - ): (msg: Types.CrossOriginMessage) => void { - if (this.isSetUp()) { - return this._transactions.setSendUp(sendUp); - } else { - this._tabster.queueInit(this._init); - return this._transactions.setup(sendUp); - } - } - - isSetUp(): boolean { - return this._transactions.isSetUp; - } - - private _init = (): void => { - const tabster = this._tabster; - - tabster.keyboardNavigation.subscribe( - this._onKeyboardNavigationStateChanged + (instance as CrossOriginObservedElementStateInternal)._trigger( + element, + details ); - tabster.focusedElement.subscribe(this._onFocus); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - tabster.observedElement!.subscribe(this._onObserved); - - if (!this._ctx.origOutlineSetup) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this._ctx.origOutlineSetup = tabster.outline!.setup; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - tabster.outline!.setup = this._outlineSetup; - } + }, +}; - this._transactions - .beginTransaction( - BootstrapTransaction, - undefined, - undefined, - undefined, - _targetIdUp - ) - .then((data) => { - if ( - data && - this._tabster.keyboardNavigation.isNavigatingWithKeyboard() !== - data.isNavigatingWithKeyboard - ) { - this._ctx.ignoreKeyboardNavigationStateUpdate = true; - this._tabster.keyboardNavigation.setNavigatingWithKeyboard( - data.isNavigatingWithKeyboard - ); - this._ctx.ignoreKeyboardNavigationStateUpdate = false; - } - }); +export function createCrossOriginAPI( + tabster: Types.TabsterCore +): Types.CrossOriginAPI { + const win = tabster.getWindow; + const blurTimer = createTimer(); + const ctx: CrossOriginInstanceContext = { + ignoreKeyboardNavigationStateUpdate: false, + deloserByUId: {}, }; - dispose(): void { - const tabster = this._tabster; - - tabster.keyboardNavigation.unsubscribe( - this._onKeyboardNavigationStateChanged - ); - tabster.focusedElement.unsubscribe(this._onFocus); - tabster.observedElement?.unsubscribe(this._onObserved); - - this._transactions.dispose(); - this.focusedElement.dispose(); - this.observedElement.dispose(); + const transactions = new CrossOriginTransactions(tabster, win, ctx); + const focusedElement = createCrossOriginFocusedElementState(transactions); + const observedElement = createCrossOriginObservedElementState( + tabster, + transactions + ); - this._ctx.deloserByUId = {}; - } - - private _onKeyboardNavigationStateChanged = (value: boolean): void => { - if (!this._ctx.ignoreKeyboardNavigationStateUpdate) { - this._transactions.beginTransaction(StateTransaction, { + const onKeyboardNavigationStateChanged = (value: boolean): void => { + if (!ctx.ignoreKeyboardNavigationStateUpdate) { + transactions.beginTransaction(StateTransaction, { state: CrossOriginStates.KeyboardNavigation, - ownerUId: getWindowUId(this._win()), + ownerUId: getWindowUId(win()), isNavigatingWithKeyboard: value, }); } }; - private _onFocus = (element: HTMLElementWithUID | undefined): void => { - const win = this._win(); + const onFocus = (element: HTMLElementWithUID | undefined): void => { + const w = win(); - const ownerUId = getWindowUId(win); + const ownerUId = getWindowUId(w); - if (this._blurTimer) { - win.clearTimeout(this._blurTimer); - this._blurTimer = undefined; - } + clearTimer(blurTimer, win()); if (element) { - this._transactions.beginTransaction(StateTransaction, { + transactions.beginTransaction(StateTransaction, { ...GetElementTransaction.getElementData( - this._tabster, + tabster, element, - this._win, - this._ctx, + win, + ctx, ownerUId ), state: CrossOriginStates.Focused, }); } else { - this._blurTimer = win.setTimeout(() => { - this._blurTimer = undefined; - - if (this._ctx.focusOwner && this._ctx.focusOwner === ownerUId) { - this._transactions - .beginTransaction(GetElementTransaction, undefined) - .then((value) => { - if (!value && this._ctx.focusOwner === ownerUId) { - this._transactions.beginTransaction( - StateTransaction, - { - ownerUId, - state: CrossOriginStates.Blurred, - force: false, - } - ); - } - }); - } - }, 0); + setTimer( + blurTimer, + win(), + () => { + if (ctx.focusOwner && ctx.focusOwner === ownerUId) { + transactions + .beginTransaction(GetElementTransaction, undefined) + .then((value) => { + if (!value && ctx.focusOwner === ownerUId) { + transactions.beginTransaction( + StateTransaction, + { + ownerUId, + state: CrossOriginStates.Blurred, + force: false, + } + ); + } + }); + } + }, + 0 + ); } }; - private _onObserved = ( + const onObserved = ( element: HTMLElement, details: Types.ObservedElementProps ): void => { const d = GetElementTransaction.getElementData( - this._tabster, + tabster, element, - this._win, - this._ctx, - getWindowUId(this._win()) + win, + ctx, + getWindowUId(win()) ) as CrossOriginStateData; d.state = CrossOriginStates.Observed; d.observedName = details.names?.[0]; d.observedDetails = details.details; - this._transactions.beginTransaction(StateTransaction, d); + transactions.beginTransaction(StateTransaction, d); }; - private _outlineSetup = (props?: Partial): void => { - this._transactions.beginTransaction(StateTransaction, { + const outlineSetup = (props?: Partial): void => { + transactions.beginTransaction(StateTransaction, { state: CrossOriginStates.Outline, - ownerUId: getWindowUId(this._win()), + ownerUId: getWindowUId(win()), outline: props, }); }; + + const init = (): void => { + tabster.keyboardNavigation.subscribe(onKeyboardNavigationStateChanged); + tabster.focusedElement.subscribe(onFocus); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + tabster.observedElement!.subscribe(onObserved); + + if (!ctx.origOutlineSetup) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ctx.origOutlineSetup = tabster.outline!.setup; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + tabster.outline!.setup = outlineSetup; + } + + transactions + .beginTransaction( + BootstrapTransaction, + undefined, + undefined, + undefined, + _targetIdUp + ) + .then((data) => { + if ( + data && + tabster.keyboardNavigation.isNavigatingWithKeyboard() !== + data.isNavigatingWithKeyboard + ) { + ctx.ignoreKeyboardNavigationStateUpdate = true; + tabster.keyboardNavigation.setNavigatingWithKeyboard( + data.isNavigatingWithKeyboard + ); + ctx.ignoreKeyboardNavigationStateUpdate = false; + } + }); + }; + + return { + focusedElement, + observedElement, + + setup( + sendUp?: Types.CrossOriginTransactionSend | null + ): (msg: Types.CrossOriginMessage) => void { + if (this.isSetUp()) { + return transactions.setSendUp(sendUp); + } else { + tabster.queueInit(init); + return transactions.setup(sendUp); + } + }, + + isSetUp(): boolean { + return transactions.isSetUp; + }, + + dispose(): void { + tabster.keyboardNavigation.unsubscribe( + onKeyboardNavigationStateChanged + ); + tabster.focusedElement.unsubscribe(onFocus); + tabster.observedElement?.unsubscribe(onObserved); + + transactions.dispose(); + focusedElement.dispose(); + observedElement.dispose(); + + ctx.deloserByUId = {}; + }, + }; } function getDeloserUID( diff --git a/src/Deloser.ts b/src/Deloser.ts index dda58a04..b7da5f59 100644 --- a/src/Deloser.ts +++ b/src/Deloser.ts @@ -3,8 +3,20 @@ * Licensed under the MIT License. */ +import { + _findDefaultFocusable, + _findFocusable, + _isElementVisible, + _isFocusable, +} from "./Focusable.js"; +import { + _focusDefault, + _focusFirst, + _getLastFocusedElement, + _resetFocus, +} from "./State/FocusedElement.js"; import { getTabsterOnElement } from "./Instance.js"; -import { RootAPI } from "./Root.js"; +import { getTabsterContext } from "./Context.js"; import type * as Types from "./Types.js"; import { DeloserStrategies, RestoreFocusOrders } from "./Consts.js"; import { @@ -14,10 +26,17 @@ import { TabsterMoveFocusEvent, } from "./Events.js"; import { + addListener, + clearTimer, + createTimer, + dispatchEvent, documentContains, getElementUId, isDisplayNone, + removeListener, + setTimer, TabsterPart, + type Timer, WeakHTMLElement, } from "./Utils.js"; import { dom } from "./DOMAPI.js"; @@ -30,9 +49,9 @@ export abstract class DeloserItemBase { } export class DeloserItem extends DeloserItemBase { - readonly uid: string; - private _tabster: Types.TabsterCore; - private _deloser: Types.Deloser; + declare readonly uid: string; + declare private _tabster: Types.TabsterCore; + declare private _deloser: Types.Deloser; constructor(tabster: Types.TabsterCore, deloser: Types.Deloser) { super(); @@ -55,7 +74,8 @@ export class DeloserItem extends DeloserItemBase { if (available && deloserElement) { if ( - !deloserElement.dispatchEvent( + !dispatchEvent( + deloserElement, new TabsterMoveFocusEvent({ by: "deloser", owner: deloserElement, @@ -82,9 +102,9 @@ export abstract class DeloserHistoryByRootBase< I, D extends DeloserItemBase, > { - protected _tabster: Types.TabsterCore; + declare protected _tabster: Types.TabsterCore; protected _history: D[] = []; - readonly rootUId: string; + declare readonly rootUId: string; constructor(tabster: Types.TabsterCore, rootUId: string) { this._tabster = tabster; @@ -184,7 +204,7 @@ class DeloserHistoryByRoot extends DeloserHistoryByRootBase< } export class DeloserHistory { - private _tabster: Types.TabsterCore; + declare private _tabster: Types.TabsterCore; private _history: DeloserHistoryByRootBase< unknown, DeloserItemBase @@ -199,7 +219,7 @@ export class DeloserHistory { } process(element: HTMLElement): Types.Deloser | undefined { - const ctx = RootAPI.getTabsterContext(this._tabster, element); + const ctx = getTabsterContext(this._tabster, element); const rootUId = ctx && ctx.root.uid; const deloser = DeloserAPI.getDeloser(this._tabster, element); @@ -400,12 +420,12 @@ export class Deloser extends TabsterPart implements Types.Deloser { - readonly uid: string; - readonly strategy: Types.DeloserStrategy; + declare readonly uid: string; + declare readonly strategy: Types.DeloserStrategy; private _isActive = false; private _history: WeakHTMLElement[][] = [[]]; private _snapshotIndex = 0; - private _onDispose: (deloser: Deloser) => void; + declare private _onDispose: (deloser: Deloser) => void; constructor( tabster: Types.TabsterCore, @@ -492,7 +512,7 @@ export class Deloser focusFirst = (): boolean => { const e = this._element.get(); - return !!e && this._tabster.focusedElement.focusFirst({ container: e }); + return !!e && _focusFirst(this._tabster, { container: e }); }; unshift(element: HTMLElement): void { @@ -512,25 +532,25 @@ export class Deloser focusDefault = (): boolean => { const e = this._element.get(); - return !!e && this._tabster.focusedElement.focusDefault(e); + return !!e && _focusDefault(this._tabster, e); }; resetFocus = (): boolean => { const e = this._element.get(); - return !!e && this._tabster.focusedElement.resetFocus(e); + return !!e && _resetFocus(this._tabster, e); }; findAvailable(): HTMLElement | null { const element = this._element.get(); - if (!element || !this._tabster.focusable.isVisible(element)) { + if (!element || !_isElementVisible(element)) { return null; } let restoreFocusOrder = this._props.restoreFocusOrder; let available: HTMLElement | null = null; - const ctx = RootAPI.getTabsterContext(this._tabster, element); + const ctx = getTabsterContext(this._tabster, element); if (!ctx) { return null; @@ -548,7 +568,7 @@ export class Deloser } if (restoreFocusOrder === RestoreFocusOrders.RootDefault) { - available = this._tabster.focusable.findDefault({ + available = _findDefaultFocusable(this._tabster, { container: rootElement, }); } @@ -570,7 +590,7 @@ export class Deloser return availableInHistory; } - const availableDefault = this._tabster.focusable.findDefault({ + const availableDefault = _findDefaultFocusable(this._tabster, { container: element, }); @@ -611,7 +631,8 @@ export class Deloser }; customFocusLostHandler(element: HTMLElement): boolean { - return element.dispatchEvent( + return dispatchEvent( + element, new DeloserFocusLostEvent(this.getActions()) ); } @@ -627,7 +648,7 @@ export class Deloser const element = this._element.get(); if (e && element && dom.nodeContains(element, e)) { - if (this._tabster.focusable.isFocusable(e)) { + if (_isFocusable(this._tabster, e)) { return e; } } else if (!this._props.noSelectorCheck) { @@ -658,7 +679,7 @@ export class Deloser for (let i = 0; i < els.length; i++) { const el = els[i] as HTMLElement; - if (el && this._tabster.focusable.isFocusable(el)) { + if (el && _isFocusable(this._tabster, el)) { return el; } } @@ -671,7 +692,7 @@ export class Deloser private _findFirst(element: HTMLElement): HTMLElement | null { if (this._tabster.keyboardNavigation.isNavigatingWithKeyboard()) { - const first = this._tabster.focusable.findFirst({ + const first = _findFocusable(this._tabster, { container: element, useActiveModalizer: true, }); @@ -696,139 +717,123 @@ function validateDeloserProps(props: Types.DeloserProps): void { // TODO: Implement validation. } -export class DeloserAPI implements Types.DeloserAPI { - private _tabster: Types.TabsterCore; - private _win: Types.GetWindow; - /** - * Tracks if focus is inside a deloser - */ - private _inDeloser = false; - private _curDeloser: Types.Deloser | undefined; - private _history: DeloserHistory; - private _restoreFocusTimer: number | undefined; - private _isRestoringFocus = false; - private _isPaused = false; - private _autoDeloser: Types.DeloserProps | undefined; - private _autoDeloserInstance: Deloser | undefined; - - constructor( - tabster: Types.TabsterCore, - props?: { autoDeloser: Types.DeloserProps } - ) { - this._tabster = tabster; - this._win = tabster.getWindow; - this._history = new DeloserHistory(tabster); - - tabster.queueInit(() => { - this._tabster.focusedElement.subscribe(this._onFocus); - const doc = this._win().document; - - doc.addEventListener( - DeloserRestoreFocusEventName, - this._onRestoreFocus - ); - - const activeElement = dom.getActiveElement(doc); +interface DeloserAPIInternal extends Types.DeloserAPI { + _history: DeloserHistory; + _autoDeloser: Types.DeloserProps | undefined; + _autoDeloserInstance: Deloser | undefined; + _onDeloserDispose: (deloser: Deloser) => void; + _scheduleRestoreFocus: (force?: boolean) => void; +} - if (activeElement && activeElement !== doc.body) { - // Adding currently focused element to the deloser history. - this._onFocus(activeElement as HTMLElement); - } - }); +export function createDeloserAPI( + tabster: Types.TabsterCore, + props?: { autoDeloser: Types.DeloserProps } +): Types.DeloserAPI { + const win = tabster.getWindow; + const history = new DeloserHistory(tabster); + let inDeloser = false; + let curDeloser: Types.Deloser | undefined; + const restoreFocusTimer: Timer = createTimer(); + let isRestoringFocus = false; + let isPaused = false; + let autoDeloser: Types.DeloserProps | undefined = props?.autoDeloser; + let autoDeloserInstance: Deloser | undefined; - const autoDeloser = props?.autoDeloser; - if (autoDeloser) { - this._autoDeloser = autoDeloser; + /** + * Activates and sets the current deloser + */ + const activate = (deloser: Types.Deloser) => { + if (curDeloser !== deloser) { + inDeloser = true; + curDeloser?.setActive(false); + deloser.setActive(true); + curDeloser = deloser; } - } - - dispose(): void { - const win = this._win(); + }; - if (this._restoreFocusTimer) { - win.clearTimeout(this._restoreFocusTimer); - this._restoreFocusTimer = undefined; - } + /** + * Called when focus should no longer be in a deloser + */ + const deactivate = () => { + inDeloser = false; + curDeloser?.setActive(false); + curDeloser = undefined; + }; - if (this._autoDeloserInstance) { - this._autoDeloserInstance.dispose(); - delete this._autoDeloserInstance; - delete this._autoDeloser; + const scheduleRestoreFocus = (force?: boolean): void => { + if (isPaused || isRestoringFocus) { + return; } - this._tabster.focusedElement.unsubscribe(this._onFocus); - - win.document.removeEventListener( - DeloserRestoreFocusEventName, - this._onRestoreFocus - ); - - this._history.dispose(); + const restoreFocus = async () => { + const lastFocused = _getLastFocusedElement(tabster); - delete this._curDeloser; - } + if ( + !force && + (isRestoringFocus || + !inDeloser || + (lastFocused && !isDisplayNone(lastFocused))) + ) { + return; + } - createDeloser( - element: HTMLElement, - props: Types.DeloserProps - ): Types.Deloser { - if (__DEV__) { - validateDeloserProps(props); - } + const cur = curDeloser; + let isManual = false; - const deloser = new Deloser( - this._tabster, - element, - this._onDeloserDispose, - props - ); + if (cur) { + if (lastFocused && cur.customFocusLostHandler(lastFocused)) { + return; + } - if ( - dom.nodeContains( - element, - this._tabster.focusedElement.getFocusedElement() ?? null - ) - ) { - this._activate(deloser); - } + if (cur.strategy === DeloserStrategies.Manual) { + isManual = true; + } else { + const curDeloserElement = cur.getElement(); + const el = cur.findAvailable(); - return deloser; - } + if ( + el && + (!curDeloserElement || + !dispatchEvent( + curDeloserElement, + new TabsterMoveFocusEvent({ + by: "deloser", + owner: curDeloserElement, + next: el, + }) + ) || + tabster.focusedElement.focus(el)) + ) { + return; + } + } + } - getActions(element: HTMLElement): Types.DeloserElementActions | undefined { - for ( - let e: HTMLElement | null = element; - e; - e = dom.getParentElement(e) - ) { - const tabsterOnElement = getTabsterOnElement(this._tabster, e); + deactivate(); - if (tabsterOnElement && tabsterOnElement.deloser) { - return tabsterOnElement.deloser.getActions(); + if (isManual) { + return; } - } - return undefined; - } + isRestoringFocus = true; - pause(): void { - this._isPaused = true; - - if (this._restoreFocusTimer) { - this._win().clearTimeout(this._restoreFocusTimer); - this._restoreFocusTimer = undefined; - } - } + // focusAvailable returns null when the default action is prevented by the application, false + // when nothing was focused and true when something was focused. + if ((await history.focusAvailable(null)) === false) { + await history.resetFocus(null); + } - resume(restore?: boolean): void { - this._isPaused = false; + isRestoringFocus = false; + }; - if (restore) { - this._scheduleRestoreFocus(); + if (force) { + restoreFocus(); + } else { + setTimer(restoreFocusTimer, win(), restoreFocus, 100); } - } + }; - private _onRestoreFocus = (event: DeloserRestoreFocusEvent): void => { + const onRestoreFocus = (event: DeloserRestoreFocusEvent): void => { const target = event.composedPath()[0] as | HTMLElement | null @@ -836,138 +841,159 @@ export class DeloserAPI implements Types.DeloserAPI { if (target) { const available = DeloserAPI.getDeloser( - this._tabster, + tabster, target )?.findAvailable(); if (available) { - this._tabster.focusedElement.focus(available); + tabster.focusedElement.focus(available); } event.stopImmediatePropagation(); } }; - private _onFocus = (e: HTMLElement | undefined): void => { - if (this._restoreFocusTimer) { - this._win().clearTimeout(this._restoreFocusTimer); - this._restoreFocusTimer = undefined; - } + const onFocus = (e: HTMLElement | undefined): void => { + clearTimer(restoreFocusTimer, win()); if (!e) { - this._scheduleRestoreFocus(); - + scheduleRestoreFocus(); return; } - const deloser = this._history.process(e); + const deloser = history.process(e); if (deloser) { - this._activate(deloser); + activate(deloser); } else { - this._deactivate(); + deactivate(); } }; - /** - * Activates and sets the current deloser - */ - private _activate(deloser: Types.Deloser) { - const curDeloser = this._curDeloser; - if (curDeloser !== deloser) { - this._inDeloser = true; - curDeloser?.setActive(false); - deloser.setActive(true); - this._curDeloser = deloser; + const onDeloserDispose = (deloser: Deloser) => { + history.removeDeloser(deloser); + + if (deloser.isActive()) { + scheduleRestoreFocus(); } - } + }; - /** - * Called when focus should no longer be in a deloser - */ - private _deactivate() { - this._inDeloser = false; - this._curDeloser?.setActive(false); - this._curDeloser = undefined; - } + tabster.queueInit(() => { + tabster.focusedElement.subscribe(onFocus); + const doc = win().document; - private _scheduleRestoreFocus(force?: boolean): void { - if (this._isPaused || this._isRestoringFocus) { - return; + addListener(doc, DeloserRestoreFocusEventName, onRestoreFocus); + + const activeElement = dom.getActiveElement(doc); + + if (activeElement && activeElement !== doc.body) { + // Adding currently focused element to the deloser history. + onFocus(activeElement as HTMLElement); } + }); + + const api: DeloserAPIInternal = { + _history: history, + get _autoDeloser() { + return autoDeloser; + }, + get _autoDeloserInstance() { + return autoDeloserInstance; + }, + set _autoDeloserInstance(value: Deloser | undefined) { + autoDeloserInstance = value; + }, + _onDeloserDispose: onDeloserDispose, + _scheduleRestoreFocus: scheduleRestoreFocus, + + dispose(): void { + const w = win(); + + clearTimer(restoreFocusTimer, w); + + if (autoDeloserInstance) { + autoDeloserInstance.dispose(); + autoDeloserInstance = undefined; + autoDeloser = undefined; + } - const restoreFocus = async () => { - this._restoreFocusTimer = undefined; - const lastFocused = - this._tabster.focusedElement.getLastFocusedElement(); + tabster.focusedElement.unsubscribe(onFocus); + + removeListener( + w.document, + DeloserRestoreFocusEventName, + onRestoreFocus + ); + + history.dispose(); + + curDeloser = undefined; + }, + + createDeloser( + element: HTMLElement, + deloserProps: Types.DeloserProps + ): Types.Deloser { + if (__DEV__) { + validateDeloserProps(deloserProps); + } + + const deloser = new Deloser( + tabster, + element, + onDeloserDispose, + deloserProps + ); if ( - !force && - (this._isRestoringFocus || - !this._inDeloser || - (lastFocused && !isDisplayNone(lastFocused))) + dom.nodeContains( + element, + tabster.focusedElement.getFocusedElement() ?? null + ) ) { - return; + activate(deloser); } - const curDeloser = this._curDeloser; - let isManual = false; - - if (curDeloser) { - if ( - lastFocused && - curDeloser.customFocusLostHandler(lastFocused) - ) { - return; - } + return deloser; + }, - if (curDeloser.strategy === DeloserStrategies.Manual) { - isManual = true; - } else { - const curDeloserElement = curDeloser.getElement(); - const el = curDeloser.findAvailable(); + getActions( + element: HTMLElement + ): Types.DeloserElementActions | undefined { + for ( + let e: HTMLElement | null = element; + e; + e = dom.getParentElement(e) + ) { + const tabsterOnElement = getTabsterOnElement(tabster, e); - if ( - el && - (!curDeloserElement?.dispatchEvent( - new TabsterMoveFocusEvent({ - by: "deloser", - owner: curDeloserElement, - next: el, - }) - ) || - this._tabster.focusedElement.focus(el)) - ) { - return; - } + if (tabsterOnElement && tabsterOnElement.deloser) { + return tabsterOnElement.deloser.getActions(); } } - this._deactivate(); + return undefined; + }, - if (isManual) { - return; - } + pause(): void { + isPaused = true; + clearTimer(restoreFocusTimer, win()); + }, - this._isRestoringFocus = true; + resume(restore?: boolean): void { + isPaused = false; - // focusAvailable returns null when the default action is prevented by the application, false - // when nothing was focused and true when something was focused. - if ((await this._history.focusAvailable(null)) === false) { - await this._history.resetFocus(null); + if (restore) { + scheduleRestoreFocus(); } + }, + }; - this._isRestoringFocus = false; - }; - - if (force) { - restoreFocus(); - } else { - this._restoreFocusTimer = this._win().setTimeout(restoreFocus, 100); - } - } + return api; +} - static getDeloser( +export const DeloserAPI = { + getDeloser( tabster: Types.TabsterCore, element: HTMLElement ): Types.Deloser | undefined { @@ -993,7 +1019,8 @@ export class DeloserAPI implements Types.DeloserAPI { } } - const deloserAPI = tabster.deloser && (tabster.deloser as DeloserAPI); + const deloserAPI = + tabster.deloser && (tabster.deloser as DeloserAPIInternal); if (deloserAPI) { if (deloserAPI._autoDeloserInstance) { @@ -1009,7 +1036,7 @@ export class DeloserAPI implements Types.DeloserAPI { deloserAPI._autoDeloserInstance = new Deloser( tabster, body, - (tabster.deloser as DeloserAPI)._onDeloserDispose, + deloserAPI._onDeloserDispose, autoDeloserProps ); } @@ -1019,21 +1046,13 @@ export class DeloserAPI implements Types.DeloserAPI { } return undefined; - } - - private _onDeloserDispose = (deloser: Deloser) => { - this._history.removeDeloser(deloser); - - if (deloser.isActive()) { - this._scheduleRestoreFocus(); - } - }; + }, - static getHistory(instance: Types.DeloserAPI): DeloserHistory { - return (instance as DeloserAPI)._history; - } + getHistory(instance: Types.DeloserAPI): DeloserHistory { + return (instance as DeloserAPIInternal)._history; + }, - static forceRestoreFocus(instance: Types.DeloserAPI): void { - (instance as DeloserAPI)._scheduleRestoreFocus(true); - } -} + forceRestoreFocus(instance: Types.DeloserAPI): void { + (instance as DeloserAPIInternal)._scheduleRestoreFocus(true); + }, +}; diff --git a/src/DummyInput.ts b/src/DummyInput.ts index ea1b2a36..3d6cdf77 100644 --- a/src/DummyInput.ts +++ b/src/DummyInput.ts @@ -16,8 +16,20 @@ import { TABSTER_DUMMY_INPUT_ATTRIBUTE_NAME, } from "./Consts.js"; import { TabsterMoveFocusEvent } from "./Events.js"; +import { _isFocusable } from "./Focusable.js"; import { dom } from "./DOMAPI.js"; -import { hasSubFocusable, makeFocusIgnored, WeakHTMLElement } from "./Utils.js"; +import { + addListener, + clearTimer, + createTimer, + dispatchEvent, + hasSubFocusable, + isTimerActive, + makeFocusIgnored, + removeListener, + setTimer, + WeakHTMLElement, +} from "./Utils.js"; const _updateDummyInputsTimeout = 100; @@ -25,10 +37,6 @@ interface HTMLElementWithDummyContainer extends HTMLElement { __tabsterDummyContainer?: WeakHTMLElement; } -interface HTMLElementWithDummyInputs extends HTMLElement { - __tabsterDummy?: DummyInputManagerCore; -} - export interface DummyInputProps { /** The input is created to be used only once and autoremoved when focused. */ isPhantom?: boolean; @@ -43,127 +51,71 @@ export type DummyInputFocusCallback = ( ) => void; /** - * Dummy HTML elements that are used as focus sentinels for the DOM enclosed within them + * Dummy HTML elements that are used as focus sentinels for the DOM enclosed within them. + * `DummyInput` is the public shape used by `DummyInputManagerCore`; the writable fields + * are mutated in place by the manager and by the focus event handlers. */ -export class DummyInput { - private _isPhantom: DummyInputProps["isPhantom"]; - private _fixedTarget?: WeakHTMLElement; - private _disposeTimer: number | undefined; - private _clearDisposeTimeout: (() => void) | undefined; - +export interface DummyInput { input: HTMLElement | undefined; useDefaultAction?: boolean; isFirst: DummyInputProps["isFirst"]; isOutside: boolean; - /** Called when the input is focused */ onFocusIn?: DummyInputFocusCallback; - /** Called when the input is blurred */ onFocusOut?: DummyInputFocusCallback; + setTopLeft(top: number, left: number): void; + dispose(): void; +} - constructor( - getWindow: GetWindow, - isOutside: boolean, - props: DummyInputProps, - element?: WeakHTMLElement, - fixedTarget?: WeakHTMLElement - ) { - const win = getWindow(); - const input = win.document.createElement("i"); - - input.tabIndex = 0; - input.setAttribute("role", "none"); - - input.setAttribute(TABSTER_DUMMY_INPUT_ATTRIBUTE_NAME, ""); - input.setAttribute("aria-hidden", "true"); - - const style = input.style; - style.position = "fixed"; - style.width = style.height = "1px"; - style.opacity = "0.001"; - style.zIndex = "-1"; - style.setProperty("content-visibility", "hidden"); - - makeFocusIgnored(input); - - this.input = input; - this.isFirst = props.isFirst; - this.isOutside = isOutside; - this._isPhantom = props.isPhantom ?? false; - this._fixedTarget = fixedTarget; - - input.addEventListener("focusin", this._focusIn); - input.addEventListener("focusout", this._focusOut); - - (input as HTMLElementWithDummyContainer).__tabsterDummyContainer = - element; - - if (this._isPhantom) { - this._disposeTimer = win.setTimeout(() => { - delete this._disposeTimer; - this.dispose(); - }, 0); - - this._clearDisposeTimeout = () => { - if (this._disposeTimer) { - win.clearTimeout(this._disposeTimer); - delete this._disposeTimer; - } - - delete this._clearDisposeTimeout; - }; - } - } - - dispose(): void { - if (this._clearDisposeTimeout) { - this._clearDisposeTimeout(); - } - - const input = this.input; - - if (!input) { - return; - } - - delete this._fixedTarget; - delete this.onFocusIn; - delete this.onFocusOut; - delete this.input; - - input.removeEventListener("focusin", this._focusIn); - input.removeEventListener("focusout", this._focusOut); - - delete (input as HTMLElementWithDummyContainer).__tabsterDummyContainer; - - dom.getParentNode(input)?.removeChild(input); - } - - setTopLeft(top: number, left: number): void { - const style = this.input?.style; - - if (style) { - style.top = `${top}px`; - style.left = `${left}px`; - } - } - - private _isBackward( +/** + * Creates a focus-sentinel element, wires its focus listeners, and returns + * a {@link DummyInput} handle. Phantom inputs auto-dispose on the next tick. + */ +export function createDummyInput( + getWindow: GetWindow, + isOutside: boolean, + props: DummyInputProps, + element?: WeakHTMLElement, + fixedTarget?: WeakHTMLElement +): DummyInput { + const win = getWindow(); + const input: HTMLElement | undefined = win.document.createElement("i"); + + input.tabIndex = 0; + input.setAttribute("role", "none"); + input.setAttribute(TABSTER_DUMMY_INPUT_ATTRIBUTE_NAME, ""); + input.setAttribute("aria-hidden", "true"); + + const style = input.style; + style.position = "fixed"; + style.width = style.height = "1px"; + style.opacity = "0.001"; + style.zIndex = "-1"; + style.setProperty("content-visibility", "hidden"); + + makeFocusIgnored(input); + + (input as HTMLElementWithDummyContainer).__tabsterDummyContainer = element; + + const isPhantom = props.isPhantom ?? false; + const disposeTimer = createTimer(); + + const isBackward = ( isIn: boolean, current: HTMLElement, previous: HTMLElement | null - ): boolean { + ): boolean => { return isIn && !previous - ? !this.isFirst + ? !api.isFirst : !!( previous && current.compareDocumentPosition(previous) & Node.DOCUMENT_POSITION_FOLLOWING ); - } + }; - private _focusIn = (e: FocusEvent): void => { - if (this._fixedTarget) { - const target = this._fixedTarget.get(); + const focusIn = (e: FocusEvent): void => { + if (fixedTarget) { + const target = fixedTarget.get(); if (target) { nativeFocus(target); @@ -172,38 +124,84 @@ export class DummyInput { return; } - const input = this.input; + const currentInput = api.input; - if (this.onFocusIn && input) { + if (api.onFocusIn && currentInput) { const relatedTarget = e.relatedTarget as HTMLElement | null; - this.onFocusIn( - this, - this._isBackward(true, input, relatedTarget), + api.onFocusIn( + api, + isBackward(true, currentInput, relatedTarget), relatedTarget ); } }; - private _focusOut = (e: FocusEvent): void => { - if (this._fixedTarget) { + const focusOut = (e: FocusEvent): void => { + if (fixedTarget) { return; } - this.useDefaultAction = false; + api.useDefaultAction = false; - const input = this.input; + const currentInput = api.input; - if (this.onFocusOut && input) { + if (api.onFocusOut && currentInput) { const relatedTarget = e.relatedTarget as HTMLElement | null; - this.onFocusOut( - this, - this._isBackward(false, input, relatedTarget), + api.onFocusOut( + api, + isBackward(false, currentInput, relatedTarget), relatedTarget ); } }; + + addListener(input, "focusin", focusIn); + addListener(input, "focusout", focusOut); + + const api: DummyInput = { + input, + isFirst: props.isFirst, + isOutside, + + setTopLeft(top: number, left: number): void { + const s = api.input?.style; + if (s) { + s.top = `${top}px`; + s.left = `${left}px`; + } + }, + + dispose(): void { + clearTimer(disposeTimer, win); + + const currentInput = api.input; + + if (!currentInput) { + return; + } + + fixedTarget = undefined; + api.onFocusIn = undefined; + api.onFocusOut = undefined; + api.input = undefined; + + removeListener(currentInput, "focusin", focusIn); + removeListener(currentInput, "focusout", focusOut); + + delete (currentInput as HTMLElementWithDummyContainer) + .__tabsterDummyContainer; + + dom.getParentNode(currentInput)?.removeChild(currentInput); + }, + }; + + if (isPhantom) { + setTimer(disposeTimer, win, api.dispose, 0); + } + + return api; } export const DummyInputManagerPriorities = { @@ -213,71 +211,100 @@ export const DummyInputManagerPriorities = { Groupper: 4, } as const; -export class DummyInputManager { - private _instance?: DummyInputManagerCore; - private _onFocusIn?: DummyInputFocusCallback; - private _onFocusOut?: DummyInputFocusCallback; - protected _element: WeakHTMLElement; - - constructor( - tabster: TabsterCore, - element: WeakHTMLElement, - priority: number, - sys: SysProps | undefined, - outsideByDefault?: boolean, - callForDefaultAction?: boolean - ) { - this._element = element; - - this._instance = new DummyInputManagerCore( - tabster, - element, - this, - priority, - sys, - outsideByDefault, - callForDefaultAction - ); - } - - protected _setHandlers( +/** + * Public handle returned by {@link createDummyInputManager}. Subclasses + * (Root/Modalizer/Mover/Groupper) compose a DummyInputManager and delegate + * focus handlers via {@link DummyInputManager.setHandlers}. + */ +export interface DummyInputManager { + readonly element: WeakHTMLElement; + setHandlers( onFocusIn?: DummyInputFocusCallback, onFocusOut?: DummyInputFocusCallback - ): void { - this._onFocusIn = onFocusIn; - this._onFocusOut = onFocusOut; - } - - moveOut(backwards: boolean): void { - this._instance?.moveOut(backwards); - } - + ): void; + moveOut(backwards: boolean): void; moveOutWithDefaultAction( backwards: boolean, relatedEvent: KeyboardEvent - ): void { - this._instance?.moveOutWithDefaultAction(backwards, relatedEvent); - } + ): void; + getHandler(isIn: boolean): DummyInputFocusCallback | undefined; + setTabbable(tabbable: boolean): void; + dispose(): void; +} - getHandler(isIn: boolean): DummyInputFocusCallback | undefined { - return isIn ? this._onFocusIn : this._onFocusOut; - } +/** + * Creates a dummy-input manager for `element`. If the element already has + * a manager, this registers an additional wrapper on the existing core so + * priorities/handlers can be coordinated across overlapping subsystems. + */ +export function createDummyInputManager( + tabster: TabsterCore, + element: WeakHTMLElement, + priority: number, + sys: SysProps | undefined, + outsideByDefault?: boolean, + callForDefaultAction?: boolean +): DummyInputManager { + let onFocusIn: DummyInputFocusCallback | undefined; + let onFocusOut: DummyInputFocusCallback | undefined; + let instance: DummyInputManagerCore | undefined; + + const manager: DummyInputManager = { + element, + + setHandlers( + inHandler?: DummyInputFocusCallback, + outHandler?: DummyInputFocusCallback + ): void { + onFocusIn = inHandler; + onFocusOut = outHandler; + }, + + moveOut(backwards: boolean): void { + instance?.moveOut(backwards); + }, + + moveOutWithDefaultAction( + backwards: boolean, + relatedEvent: KeyboardEvent + ): void { + instance?.moveOutWithDefaultAction(backwards, relatedEvent); + }, + + getHandler(isIn: boolean): DummyInputFocusCallback | undefined { + return isIn ? onFocusIn : onFocusOut; + }, + + setTabbable(tabbable: boolean) { + instance?.setTabbable(manager, tabbable); + }, + + dispose(): void { + if (instance) { + instance.dispose(manager); + instance = undefined; + } - setTabbable(tabbable: boolean) { - this._instance?.setTabbable(this, tabbable); - } + onFocusIn = undefined; + onFocusOut = undefined; + }, + }; - dispose(): void { - if (this._instance) { - this._instance.dispose(this); - delete this._instance; - } + instance = createDummyInputManagerCore( + tabster, + element, + manager, + priority, + sys, + outsideByDefault, + callForDefaultAction + ); - delete this._onFocusIn; - delete this._onFocusOut; - } + return manager; +} - static moveWithPhantomDummy( +export const DummyInputManager = { + moveWithPhantomDummy( tabster: TabsterCore, element: HTMLElement, // The target element to move to or out of. moveOutOfElement: boolean, // Whether to move out of the element or into it. @@ -294,7 +321,7 @@ export class DummyInputManager { // input element, place it to the specific place in the DOM and focus it, // then the default action of the Tab press will move focus from our dummy // input. And we remove it from the DOM right after that. - const dummy: DummyInput = new DummyInput(tabster.getWindow, true, { + const dummy: DummyInput = createDummyInput(tabster.getWindow, true, { isPhantom: true, isFirst: true, }); @@ -350,12 +377,7 @@ export class DummyInputManager { moveOutOfElement && (!isBackward || (isBackward && - !tabster.focusable.isFocusable( - element, - false, - true, - true - ))) + !_isFocusable(tabster, element, false, true, true))) ) { parent = element; insertBefore = isBackward @@ -404,7 +426,9 @@ export class DummyInputManager { } if ( - parent?.dispatchEvent( + parent && + dispatchEvent( + parent, new TabsterMoveFocusEvent({ by: "root", owner: parent, @@ -417,15 +441,15 @@ export class DummyInputManager { nativeFocus(input); } } - } + }, - static addPhantomDummyWithTarget( + addPhantomDummyWithTarget( tabster: TabsterCore, sourceElement: HTMLElement, isBackward: boolean, targetElement: HTMLElement ): void { - const dummy: DummyInput = new DummyInput( + const dummy: DummyInput = createDummyInput( tabster.getWindow, true, { @@ -460,8 +484,8 @@ export class DummyInputManager { dom.insertBefore(dummyParent, input, insertBefore); } } - } -} + }, +}; interface DummyInputWrapper { manager: DummyInputManager; @@ -492,487 +516,294 @@ function setDummyInputDebugValue( ); } -export class DummyInputObserver implements DummyInputObserverInterface { - private _win?: GetWindow; - private _updateQueue: Set< - ( - scrollTopLeftCache: Map< - HTMLElement, - { scrollTop: number; scrollLeft: number } | null - > - ) => () => void - > = new Set(); - private _updateTimer?: number; - private _lastUpdateQueueTime = 0; - private _changedParents: WeakSet = new WeakSet(); - private _updateDummyInputsTimer?: number; - private _dummyElements: WeakHTMLElement[] = []; - private _dummyCallbacks: WeakMap void> = new WeakMap(); - domChanged?(parent: HTMLElement): void; - - constructor(win: GetWindow) { - this._win = win; - } - - add(dummy: HTMLElement, callback: () => void): void { - if (!this._dummyCallbacks.has(dummy) && this._win) { - this._dummyElements.push(new WeakHTMLElement(dummy)); - this._dummyCallbacks.set(dummy, callback); - this.domChanged = this._domChanged; - } - } +type ScrollTopLeftCache = Map< + HTMLElement, + { scrollTop: number; scrollLeft: number } | null +>; - remove(dummy: HTMLElement): void { - this._dummyElements = this._dummyElements.filter((ref) => { - const element = ref.get(); - return element && element !== dummy; - }); - - this._dummyCallbacks.delete(dummy); - - if (this._dummyElements.length === 0) { - delete this.domChanged; - } - } - - dispose(): void { - const win = this._win?.(); - - if (this._updateTimer) { - win?.clearTimeout(this._updateTimer); - delete this._updateTimer; - } - - if (this._updateDummyInputsTimer) { - win?.clearTimeout(this._updateDummyInputsTimer); - delete this._updateDummyInputsTimer; - } - - this._changedParents = new WeakSet(); - this._dummyCallbacks = new WeakMap(); - this._dummyElements = []; - this._updateQueue.clear(); - - delete this.domChanged; - delete this._win; +/** + * Lazy-initialises `tabster._dummyObserver`. Called from the per-feature + * `getMover`/`getGroupper`/`getModalizer` factories so that opting into + * any feature is enough to make its dummy-input redirection work — the + * consumer doesn't have to call `getRootDummyInputs` first. Idempotent; + * safe to call repeatedly. Default `createTabster(win)` is unchanged + * (no feature factory called → no observer created). + */ +export function ensureDummyInputObserver(tabster: TabsterCore): void { + if (!tabster._dummyObserver) { + const observer = createDummyInputObserver(tabster.getWindow); + tabster._dummyObserver = observer; + tabster.disposers.add(observer); } +} - private _domChanged = (parent: HTMLElement): void => { - if (this._changedParents.has(parent)) { +export function createDummyInputObserver( + getWindow: GetWindow +): DummyInputObserverInterface { + let win: GetWindow | undefined = getWindow; + const updateQueue = new Set<(c: ScrollTopLeftCache) => () => void>(); + const updateTimer = createTimer(); + let lastUpdateQueueTime = 0; + let changedParents: WeakSet = new WeakSet(); + const updateDummyInputsTimer = createTimer(); + let dummyElements: WeakHTMLElement[] = []; + let dummyCallbacks: WeakMap void> = new WeakMap(); + + const domChanged = (parent: HTMLElement): void => { + if (changedParents.has(parent)) { return; } - this._changedParents.add(parent); + changedParents.add(parent); - if (this._updateDummyInputsTimer) { + const w = win?.(); + if (!w || isTimerActive(updateDummyInputsTimer)) { return; } - this._updateDummyInputsTimer = this._win?.().setTimeout(() => { - delete this._updateDummyInputsTimer; - - for (const ref of this._dummyElements) { - const dummyElement = ref.get(); - - if (dummyElement) { - const callback = this._dummyCallbacks.get(dummyElement); - - if (callback) { - const dummyParent = dom.getParentNode(dummyElement); - - if ( - !dummyParent || - this._changedParents.has(dummyParent) - ) { - callback(); + setTimer( + updateDummyInputsTimer, + w, + () => { + for (const ref of dummyElements) { + const dummyElement = ref.get(); + + if (dummyElement) { + const callback = dummyCallbacks.get(dummyElement); + + if (callback) { + const dummyParent = dom.getParentNode(dummyElement); + + if ( + !dummyParent || + changedParents.has(dummyParent) + ) { + callback(); + } } } } - } - this._changedParents = new WeakSet(); - }, _updateDummyInputsTimeout); + changedParents = new WeakSet(); + }, + _updateDummyInputsTimeout + ); }; - updatePositions( - compute: ( - scrollTopLeftCache: Map< - HTMLElement, - { scrollTop: number; scrollLeft: number } | null - > - ) => () => void - ): void { - if (!this._win) { - // As this is a public method, we make sure that it has no effect when - // called after dispose(). - return; - } - - this._updateQueue.add(compute); - - this._lastUpdateQueueTime = Date.now(); - - this._scheduledUpdatePositions(); - } - - private _scheduledUpdatePositions(): void { - if (this._updateTimer) { + const scheduledUpdatePositions = (): void => { + const w = win?.(); + if (!w || isTimerActive(updateTimer)) { return; } - this._updateTimer = this._win?.().setTimeout(() => { - delete this._updateTimer; + setTimer( + updateTimer, + w, + () => { + // updatePositions() might be called quite a lot during the scrolling. + // So, instead of clearing the timeout and scheduling a new one, we + // check if enough time has passed since the last updatePositions() call + // and only schedule a new one if not. + // At maximum, we will update dummy inputs positions + // _updateDummyInputsTimeout * 2 after the last updatePositions() call. + if ( + lastUpdateQueueTime + _updateDummyInputsTimeout <= + Date.now() + ) { + // A cache for current bulk of updates to reduce getComputedStyle() calls. + const scrollTopLeftCache: ScrollTopLeftCache = new Map(); - // updatePositions() might be called quite a lot during the scrolling. - // So, instead of clearing the timeout and scheduling a new one, we - // check if enough time has passed since the last updatePositions() call - // and only schedule a new one if not. - // At maximum, we will update dummy inputs positions - // _updateDummyInputsTimeout * 2 after the last updatePositions() call. - if ( - this._lastUpdateQueueTime + _updateDummyInputsTimeout <= - Date.now() - ) { - // A cache for current bulk of updates to reduce getComputedStyle() calls. - const scrollTopLeftCache = new Map< - HTMLElement, - { scrollTop: number; scrollLeft: number } | null - >(); + const setTopLeftCallbacks: (() => void)[] = []; - const setTopLeftCallbacks: (() => void)[] = []; + for (const compute of updateQueue) { + setTopLeftCallbacks.push(compute(scrollTopLeftCache)); + } - for (const compute of this._updateQueue) { - setTopLeftCallbacks.push(compute(scrollTopLeftCache)); - } + updateQueue.clear(); - this._updateQueue.clear(); + // We're splitting the computation of offsets and setting them to avoid extra + // reflows. + for (const setTopLeft of setTopLeftCallbacks) { + setTopLeft(); + } - // We're splitting the computation of offsets and setting them to avoid extra - // reflows. - for (const setTopLeft of setTopLeftCallbacks) { - setTopLeft(); + // Explicitly clear to not hold references till the next garbage collection. + scrollTopLeftCache.clear(); + } else { + scheduledUpdatePositions(); } + }, + _updateDummyInputsTimeout + ); + }; - // Explicitly clear to not hold references till the next garbage collection. - scrollTopLeftCache.clear(); - } else { - this._scheduledUpdatePositions(); + const api: DummyInputObserverInterface = { + add(dummy: HTMLElement, callback: () => void): void { + if (!dummyCallbacks.has(dummy) && win) { + dummyElements.push(new WeakHTMLElement(dummy)); + dummyCallbacks.set(dummy, callback); + api.domChanged = domChanged; } - }, _updateDummyInputsTimeout); - } -} + }, -/** - * Parent class that encapsulates the behaviour of dummy inputs (focus sentinels) - */ -class DummyInputManagerCore { - private _tabster: TabsterCore; - private _addTimer: number | undefined; - private _getWindow: GetWindow; - private _wrappers: DummyInputWrapper[] = []; - private _element: WeakHTMLElement | undefined; - private _isOutside = false; - private _firstDummy: DummyInput | undefined; - private _lastDummy: DummyInput | undefined; - private _transformElements: Set = new Set(); - private _callForDefaultAction: boolean | undefined; - - constructor( - tabster: TabsterCore, - element: WeakHTMLElement, - manager: DummyInputManager, - priority: number, - sys: SysProps | undefined, - outsideByDefault?: boolean, - callForDefaultAction?: boolean - ) { - const el = element.get() as HTMLElementWithDummyInputs; - - if (!el) { - throw new Error("No element"); - } + remove(dummy: HTMLElement): void { + dummyElements = dummyElements.filter((ref) => { + const element = ref.get(); + return element && element !== dummy; + }); - this._tabster = tabster; - this._getWindow = tabster.getWindow; - this._callForDefaultAction = callForDefaultAction; + dummyCallbacks.delete(dummy); - const instance = el.__tabsterDummy; + if (dummyElements.length === 0) { + api.domChanged = undefined; + } + }, - (instance || this)._wrappers.push({ - manager, - priority, - tabbable: true, - }); + dispose(): void { + const w = win?.(); - if (instance) { - if (__DEV__) { - this._firstDummy && - setDummyInputDebugValue( - this._firstDummy, - instance._wrappers - ); - this._lastDummy && - setDummyInputDebugValue( - this._lastDummy, - instance._wrappers - ); + if (w) { + clearTimer(updateTimer, w); + clearTimer(updateDummyInputsTimer, w); } - return instance; - } + changedParents = new WeakSet(); + dummyCallbacks = new WeakMap(); + dummyElements = []; + updateQueue.clear(); + + api.domChanged = undefined; + win = undefined; + }, + + updatePositions( + compute: (cache: ScrollTopLeftCache) => () => void + ): void { + if (!win) { + // As this is a public method, we make sure that it has no effect when + // called after dispose(). + return; + } - el.__tabsterDummy = this; - - // Some elements allow only specific types of direct descendants and we need to - // put our dummy inputs inside or outside of the element accordingly. - const forcedDummyPosition = sys?.dummyInputsPosition; - const tagName = el.tagName; - this._isOutside = !forcedDummyPosition - ? (outsideByDefault || - tagName === "UL" || - tagName === "OL" || - tagName === "TABLE") && - !(tagName === "LI" || tagName === "TD" || tagName === "TH") - : forcedDummyPosition === SysDummyInputsPositions.Outside; - - this._firstDummy = new DummyInput( - this._getWindow, - this._isOutside, - { - isFirst: true, - }, - element - ); + updateQueue.add(compute); + lastUpdateQueueTime = Date.now(); - this._lastDummy = new DummyInput( - this._getWindow, - this._isOutside, - { - isFirst: false, - }, - element - ); + scheduledUpdatePositions(); + }, + }; - // We will be checking dummy input parents to see if their child list have changed. - // So, it is enough to have just one of the inputs observed, because - // both dummy inputs always have the same parent. - const dummyElement = this._firstDummy.input; - dummyElement && - tabster._dummyObserver.add(dummyElement, this._addDummyInputs); + return api; +} - this._firstDummy.onFocusIn = this._onFocusIn; - this._firstDummy.onFocusOut = this._onFocusOut; - this._lastDummy.onFocusIn = this._onFocusIn; - this._lastDummy.onFocusOut = this._onFocusOut; +/** + * Per-element coordinator for the focus-sentinel pair. If multiple subsystems + * (Root/Modalizer/Mover/Groupper) want sentinels on the same element, they + * share one core via the wrapper list — `createDummyInputManagerCore` returns + * the existing core if one is already attached. + */ +interface DummyInputManagerCore { + moveOut(backwards: boolean): void; + moveOutWithDefaultAction( + backwards: boolean, + relatedEvent: KeyboardEvent + ): void; + setTabbable(manager: DummyInputManager, tabbable: boolean): void; + dispose(manager: DummyInputManager, force?: boolean): void; +} - this._element = element; - this._addDummyInputs(); +interface ElementWithCore extends HTMLElement { + __tabsterDummy?: { + wrappers: DummyInputWrapper[]; + firstDummy: DummyInput | undefined; + lastDummy: DummyInput | undefined; + core: DummyInputManagerCore; + }; +} + +function createDummyInputManagerCore( + tabster: TabsterCore, + element: WeakHTMLElement, + manager: DummyInputManager, + priority: number, + sys: SysProps | undefined, + outsideByDefault?: boolean, + callForDefaultAction?: boolean +): DummyInputManagerCore { + const el = element.get() as ElementWithCore | undefined; + + if (!el) { + throw new Error("No element"); } - dispose(manager: DummyInputManager, force?: boolean): void { - const wrappers = (this._wrappers = this._wrappers.filter( - (w) => w.manager !== manager && !force - )); + const existing = el.__tabsterDummy; + + if (existing) { + existing.wrappers.push({ manager, priority, tabbable: true }); if (__DEV__) { - this._firstDummy && - setDummyInputDebugValue(this._firstDummy, wrappers); - this._lastDummy && - setDummyInputDebugValue(this._lastDummy, wrappers); + existing.firstDummy && + setDummyInputDebugValue(existing.firstDummy, existing.wrappers); + existing.lastDummy && + setDummyInputDebugValue(existing.lastDummy, existing.wrappers); } - if (wrappers.length === 0) { - delete (this._element?.get() as HTMLElementWithDummyInputs) - .__tabsterDummy; - - for (const el of this._transformElements) { - el.removeEventListener("scroll", this._addTransformOffsets); - } - this._transformElements.clear(); - - const win = this._getWindow(); - - if (this._addTimer) { - win.clearTimeout(this._addTimer); - delete this._addTimer; - } - - const dummyElement = this._firstDummy?.input; - dummyElement && this._tabster._dummyObserver.remove(dummyElement); - - this._firstDummy?.dispose(); - this._lastDummy?.dispose(); - } + return existing.core; } - private _onFocus( + const getWindow = tabster.getWindow; + const addTimer = createTimer(); + let transformElements: Set = new Set(); + const wrappers: DummyInputWrapper[] = [ + { manager, priority, tabbable: true }, + ]; + + // Some elements allow only specific types of direct descendants and we need to + // put our dummy inputs inside or outside of the element accordingly. + const forcedDummyPosition = sys?.dummyInputsPosition; + const tagName = el.tagName; + const isOutside = !forcedDummyPosition + ? (outsideByDefault || + tagName === "UL" || + tagName === "OL" || + tagName === "TABLE") && + !(tagName === "LI" || tagName === "TD" || tagName === "TH") + : forcedDummyPosition === SysDummyInputsPositions.Outside; + + const onFocus = ( isIn: boolean, dummyInput: DummyInput, isBackward: boolean, relatedTarget: HTMLElement | null - ): void { - const wrapper = this._getCurrent(); + ): void => { + const wrapper = getCurrent(); - if ( - wrapper && - (!dummyInput.useDefaultAction || this._callForDefaultAction) - ) { + if (wrapper && (!dummyInput.useDefaultAction || callForDefaultAction)) { wrapper.manager.getHandler(isIn)?.( dummyInput, isBackward, relatedTarget ); } - } + }; - private _onFocusIn = ( + const onFocusIn = ( dummyInput: DummyInput, isBackward: boolean, relatedTarget: HTMLElement | null ): void => { - this._onFocus(true, dummyInput, isBackward, relatedTarget); + onFocus(true, dummyInput, isBackward, relatedTarget); }; - private _onFocusOut = ( + const onFocusOut = ( dummyInput: DummyInput, isBackward: boolean, relatedTarget: HTMLElement | null ): void => { - this._onFocus(false, dummyInput, isBackward, relatedTarget); - }; - - moveOut = (backwards: boolean): void => { - const first = this._firstDummy; - const last = this._lastDummy; - - if (first && last) { - // For the sake of performance optimization, the dummy input - // position in the DOM updates asynchronously from the DOM change. - // Calling _ensurePosition() to make sure the position is correct. - this._ensurePosition(); - - const firstInput = first.input; - const lastInput = last.input; - const element = this._element?.get(); - - if (firstInput && lastInput && element) { - let toFocus: HTMLElement | undefined; - - if (backwards) { - firstInput.tabIndex = 0; - toFocus = firstInput; - } else { - lastInput.tabIndex = 0; - toFocus = lastInput; - } - - if (toFocus) { - nativeFocus(toFocus); - } - } - } - }; - - /** - * Prepares to move focus out of the given element by focusing - * one of the dummy inputs and setting the `useDefaultAction` flag - * @param backwards focus moving to an element behind the given element - */ - moveOutWithDefaultAction = ( - backwards: boolean, - relatedEvent: KeyboardEvent - ): void => { - const first = this._firstDummy; - const last = this._lastDummy; - - if (first && last) { - // For the sake of performance optimization, the dummy input - // position in the DOM updates asynchronously from the DOM change. - // Calling _ensurePosition() to make sure the position is correct. - this._ensurePosition(); - - const firstInput = first.input; - const lastInput = last.input; - const element = this._element?.get(); - - if (firstInput && lastInput && element) { - let toFocus: HTMLElement | undefined; - - if (backwards) { - if ( - !first.isOutside && - this._tabster.focusable.isFocusable( - element, - true, - true, - true - ) - ) { - toFocus = element; - } else { - first.useDefaultAction = true; - firstInput.tabIndex = 0; - toFocus = firstInput; - } - } else { - last.useDefaultAction = true; - lastInput.tabIndex = 0; - toFocus = lastInput; - } - - if ( - toFocus && - element.dispatchEvent( - new TabsterMoveFocusEvent({ - by: "root", - owner: element, - next: null, - relatedEvent, - }) - ) - ) { - nativeFocus(toFocus); - } - } - } + onFocus(false, dummyInput, isBackward, relatedTarget); }; - setTabbable = (manager: DummyInputManager, tabbable: boolean) => { - for (const w of this._wrappers) { - if (w.manager === manager) { - w.tabbable = tabbable; - break; - } - } - - const wrapper = this._getCurrent(); - - if (wrapper) { - const tabIndex = wrapper.tabbable ? 0 : -1; - - let input = this._firstDummy?.input; - - if (input) { - input.tabIndex = tabIndex; - } - - input = this._lastDummy?.input; - - if (input) { - input.tabIndex = tabIndex; - } - } - - if (__DEV__) { - this._firstDummy && - setDummyInputDebugValue(this._firstDummy, this._wrappers); - this._lastDummy && - setDummyInputDebugValue(this._lastDummy, this._wrappers); - } - }; - - private _getCurrent(): DummyInputWrapper | undefined { - this._wrappers.sort((a, b) => { + const getCurrent = (): DummyInputWrapper | undefined => { + wrappers.sort((a, b) => { if (a.tabbable !== b.tabbable) { return a.tabbable ? -1 : 1; } @@ -980,48 +811,23 @@ class DummyInputManagerCore { return a.priority - b.priority; }); - return this._wrappers[0]; - } - - /** - * Adds dummy inputs as the first and last child of the given element - * Called each time the children under the element is mutated - */ - private _addDummyInputs = () => { - if (this._addTimer) { - return; - } - - this._addTimer = this._getWindow().setTimeout(() => { - delete this._addTimer; - - this._ensurePosition(); - - if (__DEV__) { - this._firstDummy && - setDummyInputDebugValue(this._firstDummy, this._wrappers); - this._lastDummy && - setDummyInputDebugValue(this._lastDummy, this._wrappers); - } - - this._addTransformOffsets(); - }, 0); + return wrappers[0]; }; - private _ensurePosition(): void { - const element = this._element?.get(); - const firstDummyInput = this._firstDummy?.input; - const lastDummyInput = this._lastDummy?.input; + const ensurePosition = (): void => { + const currentElement = element.get(); + const firstDummyInput = firstDummy?.input; + const lastDummyInput = lastDummy?.input; - if (!element || !firstDummyInput || !lastDummyInput) { + if (!currentElement || !firstDummyInput || !lastDummyInput) { return; } - if (this._isOutside) { - const elementParent = dom.getParentNode(element); + if (isOutside) { + const elementParent = dom.getParentNode(currentElement); if (elementParent) { - const nextSibling = dom.getNextSibling(element); + const nextSibling = dom.getNextSibling(currentElement); if (nextSibling !== lastDummyInput) { dom.insertBefore( @@ -1032,17 +838,22 @@ class DummyInputManagerCore { } if ( - dom.getPreviousElementSibling(element) !== firstDummyInput + dom.getPreviousElementSibling(currentElement) !== + firstDummyInput ) { - dom.insertBefore(elementParent, firstDummyInput, element); + dom.insertBefore( + elementParent, + firstDummyInput, + currentElement + ); } } } else { - if (dom.getLastElementChild(element) !== lastDummyInput) { - dom.appendChild(element, lastDummyInput); + if (dom.getLastElementChild(currentElement) !== lastDummyInput) { + dom.appendChild(currentElement, lastDummyInput); } - const firstElementChild = dom.getFirstElementChild(element); + const firstElementChild = dom.getFirstElementChild(currentElement); if ( firstElementChild && @@ -1056,58 +867,48 @@ class DummyInputManagerCore { ); } } - } - - private _addTransformOffsets = (): void => { - this._tabster._dummyObserver.updatePositions( - this._computeTransformOffsets - ); }; - private _computeTransformOffsets = ( + const computeTransformOffsets = ( scrollTopLeftCache: Map< HTMLElement, { scrollTop: number; scrollLeft: number } | null > ): (() => void) => { - const from = this._firstDummy?.input || this._lastDummy?.input; - const transformElements = this._transformElements; - const newTransformElements: typeof transformElements = new Set(); + const from = firstDummy?.input || lastDummy?.input; + const newTransformElements: Set = new Set(); let scrollTop = 0; let scrollLeft = 0; - const win = this._getWindow(); + const win = getWindow(); for ( - let element: HTMLElement | undefined | null = from; - element && element.nodeType === Node.ELEMENT_NODE; - element = dom.getParentElement(element) + let e: HTMLElement | undefined | null = from; + e && e.nodeType === Node.ELEMENT_NODE; + e = dom.getParentElement(e) ) { - let scrollTopLeft = scrollTopLeftCache.get(element); + let scrollTopLeft = scrollTopLeftCache.get(e); // getComputedStyle() and element.scrollLeft/Top() cause style recalculation, // so we cache the result across all elements in the current bulk. if (scrollTopLeft === undefined) { - const transform = win.getComputedStyle(element).transform; + const transform = win.getComputedStyle(e).transform; if (transform && transform !== "none") { scrollTopLeft = { - scrollTop: element.scrollTop, - scrollLeft: element.scrollLeft, + scrollTop: e.scrollTop, + scrollLeft: e.scrollLeft, }; } - scrollTopLeftCache.set(element, scrollTopLeft || null); + scrollTopLeftCache.set(e, scrollTopLeft || null); } if (scrollTopLeft) { - newTransformElements.add(element); + newTransformElements.add(e); - if (!transformElements.has(element)) { - element.addEventListener( - "scroll", - this._addTransformOffsets - ); + if (!transformElements.has(e)) { + addListener(e, "scroll", addTransformOffsets); } scrollTop += scrollTopLeft.scrollTop; @@ -1115,19 +916,223 @@ class DummyInputManagerCore { } } - for (const el of transformElements) { - if (!newTransformElements.has(el)) { - el.removeEventListener("scroll", this._addTransformOffsets); + for (const e of transformElements) { + if (!newTransformElements.has(e)) { + removeListener(e, "scroll", addTransformOffsets); } } - this._transformElements = newTransformElements; + transformElements = newTransformElements; return () => { - this._firstDummy?.setTopLeft(scrollTop, scrollLeft); - this._lastDummy?.setTopLeft(scrollTop, scrollLeft); + firstDummy?.setTopLeft(scrollTop, scrollLeft); + lastDummy?.setTopLeft(scrollTop, scrollLeft); }; }; + + const addTransformOffsets = (): void => { + tabster._dummyObserver?.updatePositions(computeTransformOffsets); + }; + + /** + * Adds dummy inputs as the first and last child of the given element + * Called each time the children under the element is mutated + */ + const addDummyInputs = () => { + if (isTimerActive(addTimer)) { + return; + } + + setTimer( + addTimer, + getWindow(), + () => { + ensurePosition(); + + if (__DEV__) { + firstDummy && setDummyInputDebugValue(firstDummy, wrappers); + lastDummy && setDummyInputDebugValue(lastDummy, wrappers); + } + + addTransformOffsets(); + }, + 0 + ); + }; + + const firstDummy: DummyInput = createDummyInput( + getWindow, + isOutside, + { isFirst: true }, + element + ); + + const lastDummy: DummyInput = createDummyInput( + getWindow, + isOutside, + { isFirst: false }, + element + ); + + // We will be checking dummy input parents to see if their child list have changed. + // So, it is enough to have just one of the inputs observed, because + // both dummy inputs always have the same parent. + const dummyElement = firstDummy.input; + dummyElement && tabster._dummyObserver?.add(dummyElement, addDummyInputs); + + firstDummy.onFocusIn = onFocusIn; + firstDummy.onFocusOut = onFocusOut; + lastDummy.onFocusIn = onFocusIn; + lastDummy.onFocusOut = onFocusOut; + + const core: DummyInputManagerCore = { + moveOut(backwards: boolean): void { + // For the sake of performance optimization, the dummy input + // position in the DOM updates asynchronously from the DOM change. + // Calling ensurePosition() to make sure the position is correct. + ensurePosition(); + + const firstInput = firstDummy.input; + const lastInput = lastDummy.input; + const currentElement = element.get(); + + if (firstInput && lastInput && currentElement) { + let toFocus: HTMLElement | undefined; + + if (backwards) { + firstInput.tabIndex = 0; + toFocus = firstInput; + } else { + lastInput.tabIndex = 0; + toFocus = lastInput; + } + + if (toFocus) { + nativeFocus(toFocus); + } + } + }, + + /** + * Prepares to move focus out of the given element by focusing + * one of the dummy inputs and setting the `useDefaultAction` flag. + */ + moveOutWithDefaultAction( + backwards: boolean, + relatedEvent: KeyboardEvent + ): void { + ensurePosition(); + + const firstInput = firstDummy.input; + const lastInput = lastDummy.input; + const currentElement = element.get(); + + if (firstInput && lastInput && currentElement) { + let toFocus: HTMLElement | undefined; + + if (backwards) { + if ( + !firstDummy.isOutside && + _isFocusable(tabster, currentElement, true, true, true) + ) { + toFocus = currentElement; + } else { + firstDummy.useDefaultAction = true; + firstInput.tabIndex = 0; + toFocus = firstInput; + } + } else { + lastDummy.useDefaultAction = true; + lastInput.tabIndex = 0; + toFocus = lastInput; + } + + if ( + toFocus && + dispatchEvent( + currentElement, + new TabsterMoveFocusEvent({ + by: "root", + owner: currentElement, + next: null, + relatedEvent, + }) + ) + ) { + nativeFocus(toFocus); + } + } + }, + + setTabbable(m: DummyInputManager, tabbable: boolean) { + for (const w of wrappers) { + if (w.manager === m) { + w.tabbable = tabbable; + break; + } + } + + const wrapper = getCurrent(); + + if (wrapper) { + const tabIndex = wrapper.tabbable ? 0 : -1; + + let input = firstDummy.input; + if (input) { + input.tabIndex = tabIndex; + } + + input = lastDummy.input; + if (input) { + input.tabIndex = tabIndex; + } + } + + if (__DEV__) { + firstDummy && setDummyInputDebugValue(firstDummy, wrappers); + lastDummy && setDummyInputDebugValue(lastDummy, wrappers); + } + }, + + dispose(m: DummyInputManager, force?: boolean): void { + const remaining = wrappers.filter((w) => w.manager !== m && !force); + wrappers.length = 0; + wrappers.push(...remaining); + + if (__DEV__) { + firstDummy && setDummyInputDebugValue(firstDummy, wrappers); + lastDummy && setDummyInputDebugValue(lastDummy, wrappers); + } + + if (wrappers.length === 0) { + const elementWithCore = element.get() as + | ElementWithCore + | undefined; + if (elementWithCore) { + delete elementWithCore.__tabsterDummy; + } + + for (const e of transformElements) { + removeListener(e, "scroll", addTransformOffsets); + } + transformElements.clear(); + + clearTimer(addTimer, getWindow()); + + const input = firstDummy.input; + input && tabster._dummyObserver?.remove(input); + + firstDummy.dispose(); + lastDummy.dispose(); + } + }, + }; + + el.__tabsterDummy = { wrappers, firstDummy, lastDummy, core }; + + addDummyInputs(); + + return core; } export function getDummyInputContainer( diff --git a/src/Events.ts b/src/Events.ts index 07286ed7..8b0687e0 100644 --- a/src/Events.ts +++ b/src/Events.ts @@ -92,7 +92,7 @@ export abstract class TabsterCustomEvent extends CustomEvent_ { /** * @deprecated use `detail`. */ - details?: D; + declare details?: D; constructor(type: string, detail?: D) { super(type, { diff --git a/src/Focusable.ts b/src/Focusable.ts index af2d7d3b..4cf2c9a4 100644 --- a/src/Focusable.ts +++ b/src/Focusable.ts @@ -4,7 +4,7 @@ */ import { getTabsterOnElement } from "./Instance.js"; -import { RootAPI } from "./Root.js"; +import { getTabsterContext } from "./Context.js"; import type * as Types from "./Types.js"; import { FOCUSABLE_SELECTOR } from "./Consts.js"; import { getDummyInputContainer } from "./DummyInput.js"; @@ -19,534 +19,519 @@ import { } from "./Utils.js"; import { dom } from "./DOMAPI.js"; -export class FocusableAPI implements Types.FocusableAPI { - private _tabster: Types.TabsterCore; +// Internal helpers — take TabsterCore. Used by other modules in src/ that +// hold a TabsterCore but no Tabster wrapper. Not re-exported from index.ts. - constructor(tabster: Types.TabsterCore) { - this._tabster = tabster; +export function _getFocusableProps( + core: Types.TabsterCore, + element: HTMLElement +): Types.FocusableProps { + const tabsterOnElement = getTabsterOnElement(core, element); + return (tabsterOnElement && tabsterOnElement.focusable) || {}; +} + +export function _isFocusable( + core: Types.TabsterCore, + el: HTMLElement, + includeProgrammaticallyFocusable?: boolean, + noVisibleCheck?: boolean, + noAccessibleCheck?: boolean +): boolean { + if ( + matchesSelector(el, FOCUSABLE_SELECTOR) && + (includeProgrammaticallyFocusable || el.tabIndex !== -1) + ) { + return ( + (noVisibleCheck || _isElementVisible(el)) && + (noAccessibleCheck || _isElementAccessible(core, el)) + ); } - dispose(): void { - /**/ + return false; +} + +export function _isElementVisible(el: HTMLElement): boolean { + if (!el.ownerDocument || el.nodeType !== Node.ELEMENT_NODE) { + return false; } - getProps(element: HTMLElement): Types.FocusableProps { - const tabsterOnElement = getTabsterOnElement(this._tabster, element); - return (tabsterOnElement && tabsterOnElement.focusable) || {}; + if (isDisplayNone(el)) { + return false; } - isFocusable( - el: HTMLElement, - includeProgrammaticallyFocusable?: boolean, - noVisibleCheck?: boolean, - noAccessibleCheck?: boolean - ): boolean { - if ( - matchesSelector(el, FOCUSABLE_SELECTOR) && - (includeProgrammaticallyFocusable || el.tabIndex !== -1) - ) { - return ( - (noVisibleCheck || this.isVisible(el)) && - (noAccessibleCheck || this.isAccessible(el)) - ); - } + const rect = el.ownerDocument.body.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + // This might happen, for example, if our is in hidden