From 9112d7afe7df470cf6826e5c069a717890cc86a6 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 11 May 2026 11:35:07 +0200 Subject: [PATCH 01/17] chore(bundle-size): add fixtures for Focusable find* methods --- bundle-size/focusableFindAll.fixture.js | 23 +++++++++++++++++++++++ bundle-size/focusableFindLast.fixture.js | 23 +++++++++++++++++++++++ bundle-size/focusableFindNext.fixture.js | 23 +++++++++++++++++++++++ bundle-size/focusableFindPrev.fixture.js | 23 +++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 bundle-size/focusableFindAll.fixture.js create mode 100644 bundle-size/focusableFindLast.fixture.js create mode 100644 bundle-size/focusableFindNext.fixture.js create mode 100644 bundle-size/focusableFindPrev.fixture.js diff --git a/bundle-size/focusableFindAll.fixture.js b/bundle-size/focusableFindAll.fixture.js new file mode 100644 index 00000000..33f90019 --- /dev/null +++ b/bundle-size/focusableFindAll.fixture.js @@ -0,0 +1,23 @@ +import { + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + Types, +} from "tabster"; + +/** @param {ReturnType} tabster */ +const useFocusable = (tabster) => tabster.focusable.findAll; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + useFocusable, + Types +); + +export default { + name: "focusable.findAll", +}; diff --git a/bundle-size/focusableFindLast.fixture.js b/bundle-size/focusableFindLast.fixture.js new file mode 100644 index 00000000..c49fe4c6 --- /dev/null +++ b/bundle-size/focusableFindLast.fixture.js @@ -0,0 +1,23 @@ +import { + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + Types, +} from "tabster"; + +/** @param {ReturnType} tabster */ +const useFocusable = (tabster) => tabster.focusable.findLast; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + useFocusable, + Types +); + +export default { + name: "focusable.findLast", +}; diff --git a/bundle-size/focusableFindNext.fixture.js b/bundle-size/focusableFindNext.fixture.js new file mode 100644 index 00000000..f591c764 --- /dev/null +++ b/bundle-size/focusableFindNext.fixture.js @@ -0,0 +1,23 @@ +import { + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + Types, +} from "tabster"; + +/** @param {ReturnType} tabster */ +const useFocusable = (tabster) => tabster.focusable.findNext; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + useFocusable, + Types +); + +export default { + name: "focusable.findNext", +}; diff --git a/bundle-size/focusableFindPrev.fixture.js b/bundle-size/focusableFindPrev.fixture.js new file mode 100644 index 00000000..fc1459d5 --- /dev/null +++ b/bundle-size/focusableFindPrev.fixture.js @@ -0,0 +1,23 @@ +import { + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + Types, +} from "tabster"; + +/** @param {ReturnType} tabster */ +const useFocusable = (tabster) => tabster.focusable.findPrev; + +console.log( + createTabster, + disposeTabster, + getTabsterAttribute, + setTabsterAttribute, + useFocusable, + Types +); + +export default { + name: "focusable.findPrev", +}; From 90a71efe32bd9bd3096bb27dc6654bdcd2b0f764 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 5 May 2026 18:16:50 +0200 Subject: [PATCH 02/17] chore(deps): patch keyborg to drop unused / legacy code paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`patch-package\` postinstall hook applies three changes to keyborg@2.14.0 covering both the ESM (\`dist/index.js\`) and CJS (\`dist/index.cjs\`) bundles: 1. \`event.details = details\` — drop the \`@deprecated\` alias of \`event.detail\`. Tabster reads \`e.detail\` exclusively (verified across src/State/FocusedElement.ts and the rest of the codebase). 2. \`triggerKeys\` / \`dismissKeys\` props + the supporting \`shouldDismiss\` / \`scheduleDismiss\` / \`dismissTimer\` machinery. Tabster only ever calls \`createKeyborg(getWindow())\` with no props. 3. \`canOverrideNativeFocus\` runtime probe. Replaces the \`_canOverrideNativeFocus\` flag with the implicit-true assumption modern browsers (everything since IE9) already satisfy. The conditional \`details.isFocusedProgrammatically\` write becomes unconditional — semantically identical when override works. Bundle deltas (createTabster default-mode): keyborg slice: 3.71 → 3.12 kB (-590 B, -16%) createTabster: 30.78 → 30.18 kB (-600 B) getModalizer: 38.47 → 37.87 kB (-600 B) getMover: 44.54 → 43.94 kB (-600 B) getCrossOrigin: 89.64 → 89.04 kB (-600 B) allExports: 92.09 → 91.50 kB (-590 B) Tests pass: 3 pre-existing failures, no regressions across default, uncontrolled, and root-dummy-inputs modes. Stop-gap until upstream microsoft/keyborg can release the same trims (the changes belong there, not as a Tabster-side fork). Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 245 +++++++++++++++++++++++++++++++++++ package.json | 2 + patches/keyborg+2.14.0.patch | 232 +++++++++++++++++++++++++++++++++ 3 files changed, 479 insertions(+) create mode 100644 patches/keyborg+2.14.0.patch 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); From 7a1be1c20074038bab3302f935ac9010341753a1 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 11 May 2026 12:12:57 +0200 Subject: [PATCH 03/17] refactor(bundle-size): drop dead code paths + declare class fields Co-Authored-By: Claude Sonnet 4.6 --- babel.config.cjs | 11 +++- src/CrossOrigin.ts | 62 +++++++++---------- src/Deloser.ts | 18 +++--- src/Groupper.ts | 6 +- src/Modalizer.ts | 10 ++-- src/Mover.ts | 22 +++---- src/MutationEvent.ts | 7 +-- src/Outline.ts | 54 +++++------------ src/Restorer.ts | 2 +- src/Root.ts | 20 ++++--- src/Tabster.ts | 43 +++++++------ src/Utils.ts | 139 ++++++++++++++----------------------------- 12 files changed, 169 insertions(+), 225 deletions(-) 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/src/CrossOrigin.ts b/src/CrossOrigin.ts index 469f0b3e..c8d35497 100644 --- a/src/CrossOrigin.ts +++ b/src/CrossOrigin.ts @@ -53,8 +53,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 +97,7 @@ class CrossOriginDeloserHistoryByRoot extends DeloserHistoryByRootBase< CrossOriginDeloser, CrossOriginDeloserItem > { - private _transactions: CrossOriginTransactions; + declare private _transactions: CrossOriginTransactions; constructor( tabster: Types.TabsterCore, @@ -155,20 +155,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; @@ -954,20 +956,20 @@ interface CrossOriginTransactionWrapper { } 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; + declare private _tabster: Types.TabsterCore; private _pingTimer: number | undefined; 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, @@ -1438,13 +1440,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, diff --git a/src/Deloser.ts b/src/Deloser.ts index dda58a04..490abb89 100644 --- a/src/Deloser.ts +++ b/src/Deloser.ts @@ -30,9 +30,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(); @@ -82,9 +82,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 +184,7 @@ class DeloserHistoryByRoot extends DeloserHistoryByRootBase< } export class DeloserHistory { - private _tabster: Types.TabsterCore; + declare private _tabster: Types.TabsterCore; private _history: DeloserHistoryByRootBase< unknown, DeloserItemBase @@ -400,12 +400,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, diff --git a/src/Groupper.ts b/src/Groupper.ts index 893e1098..c1d2bbfa 100644 --- a/src/Groupper.ts +++ b/src/Groupper.ts @@ -98,10 +98,10 @@ export class Groupper implements Types.Groupper { private _shouldTabInside = false; - private _first: WeakHTMLElement | undefined; - private _onDispose: (groupper: Groupper) => void; + declare private _first: WeakHTMLElement | undefined; + declare private _onDispose: (groupper: Groupper) => void; - dummyManager: GroupperDummyManager | undefined; + declare dummyManager: GroupperDummyManager | undefined; constructor( tabster: Types.TabsterCore, diff --git a/src/Modalizer.ts b/src/Modalizer.ts index 0402dece..76ba28f2 100644 --- a/src/Modalizer.ts +++ b/src/Modalizer.ts @@ -104,14 +104,14 @@ export class Modalizer extends TabsterPart implements Types.Modalizer { - userId: string; + declare userId: string; - private _isActive: boolean | undefined; + declare private _isActive: boolean | undefined; private _wasFocused = 0; - private _onDispose: (modalizer: Modalizer) => void; - private _activeElements: WeakHTMLElement[]; + declare private _onDispose: (modalizer: Modalizer) => void; + declare private _activeElements: WeakHTMLElement[]; - dummyManager: ModalizerDummyManager | undefined; + declare dummyManager: ModalizerDummyManager | undefined; constructor( tabster: Types.TabsterCore, diff --git a/src/Mover.ts b/src/Mover.ts index 0bca09a5..eeae66d5 100644 --- a/src/Mover.ts +++ b/src/Mover.ts @@ -107,21 +107,21 @@ export class Mover extends TabsterPart implements Types.Mover { - private _unobserve: (() => void) | undefined; - private _intersectionObserver: IntersectionObserver | undefined; + declare private _unobserve: (() => void) | undefined; + declare private _intersectionObserver: IntersectionObserver | undefined; private _setCurrentTimer: number | undefined; - private _current: WeakHTMLElement | undefined; - private _prevCurrent: WeakHTMLElement | undefined; + declare private _current: WeakHTMLElement | undefined; + declare private _prevCurrent: WeakHTMLElement | undefined; private _visible: Record = {}; - private _fullyVisible: string | undefined; - private _win: Types.GetWindow; - private _onDispose: (mover: Mover) => void; - private _allElements: WeakMap | undefined; - private _updateQueue: MoverUpdateQueueItem[] | undefined; + declare private _fullyVisible: string | undefined; + declare private _win: Types.GetWindow; + declare private _onDispose: (mover: Mover) => void; + declare private _allElements: WeakMap | undefined; + declare private _updateQueue: MoverUpdateQueueItem[] | undefined; private _updateTimer: number | undefined; - visibilityTolerance: number; - dummyManager: MoverDummyManager | undefined; + declare visibilityTolerance: number; + declare dummyManager: MoverDummyManager | undefined; constructor( tabster: Types.TabsterCore, diff --git a/src/MutationEvent.ts b/src/MutationEvent.ts index 75202dd9..e5db6b17 100644 --- a/src/MutationEvent.ts +++ b/src/MutationEvent.ts @@ -60,15 +60,14 @@ export function observeMutations( } } } else { - for (let i = 0; i < removed.length; i++) { - const removedNode = removed[i]; + for (const removedNode of removed) { removedNodes.add(removedNode); updateTabsterElements(removedNode, true); tabster._dummyObserver.domChanged?.(target as HTMLElement); } - for (let i = 0; i < added.length; i++) { - updateTabsterElements(added[i]); + for (const addedNode of added) { + updateTabsterElements(addedNode); tabster._dummyObserver.domChanged?.(target as HTMLElement); } } diff --git a/src/Outline.ts b/src/Outline.ts index 4dba0087..83277e41 100644 --- a/src/Outline.ts +++ b/src/Outline.ts @@ -25,10 +25,10 @@ const defaultProps: Types.OutlineProps = { let _props: Types.OutlineProps = defaultProps; class OutlinePosition { - public left: number; - public top: number; - public right: number; - public bottom: number; + declare public left: number; + declare public top: number; + declare public right: number; + declare public bottom: number; constructor(left: number, top: number, right: number, bottom: number) { this.left = left; @@ -66,30 +66,12 @@ export class OutlineAPI implements Types.OutlineAPI { private _curOutlineElements: Types.OutlineElements | undefined; private _allOutlineElements: Types.OutlineElements[] = []; private _fullScreenElement: HTMLElement | undefined; - private _fullScreenEventName: string | undefined; - private _fullScreenElementName: string | undefined; constructor(tabster: Types.TabsterCore) { this._tabster = tabster; this._win = tabster.getWindow; tabster.queueInit(this._init); - - if (typeof document !== "undefined") { - if ("onfullscreenchange" in document) { - this._fullScreenEventName = "fullscreenchange"; - this._fullScreenElementName = "fullscreenElement"; - } else if ("onwebkitfullscreenchange" in document) { - this._fullScreenEventName = "webkitfullscreenchange"; - this._fullScreenElementName = "webkitFullscreenElement"; - } else if ("onmozfullscreenchange" in document) { - this._fullScreenEventName = "mozfullscreenchange"; - this._fullScreenElementName = "mozFullScreenElement"; - } else if ("onmsfullscreenchange" in document) { - this._fullScreenEventName = "msfullscreenchange"; - this._fullScreenElementName = "msFullscreenElement"; - } - } } private _init = (): void => { @@ -102,12 +84,10 @@ export class OutlineAPI implements Types.OutlineAPI { win.addEventListener("scroll", this._onScroll, true); // Capture! - if (this._fullScreenEventName) { - win.document.addEventListener( - this._fullScreenEventName, - this._onFullScreenChanged - ); - } + win.document.addEventListener( + "fullscreenchange", + this._onFullScreenChanged + ); }; setup(props?: Partial): void { @@ -145,12 +125,10 @@ export class OutlineAPI implements Types.OutlineAPI { win.removeEventListener("scroll", this._onScroll, true); - if (this._fullScreenEventName) { - win.document.removeEventListener( - this._fullScreenEventName, - this._onFullScreenChanged - ); - } + win.document.removeEventListener( + "fullscreenchange", + this._onFullScreenChanged + ); this._allOutlineElements.forEach((outlineElements) => this._removeDOM(outlineElements.container) @@ -164,7 +142,7 @@ export class OutlineAPI implements Types.OutlineAPI { } private _onFullScreenChanged = (e: Event): void => { - if (!this._fullScreenElementName || !e.target) { + if (!e.target) { return; } @@ -172,10 +150,8 @@ export class OutlineAPI implements Types.OutlineAPI { const outlineElements = this._getDOM(target); if (target.ownerDocument && outlineElements) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const fsElement: HTMLElement | null = (target.ownerDocument as any)[ - this._fullScreenElementName - ]; + const fsElement = target.ownerDocument + .fullscreenElement as HTMLElement | null; if (fsElement) { fsElement.appendChild(outlineElements.container); diff --git a/src/Restorer.ts b/src/Restorer.ts index ae7eb367..1020892d 100644 --- a/src/Restorer.ts +++ b/src/Restorer.ts @@ -78,7 +78,7 @@ class Restorer extends TabsterPart implements RestorerInterface { class History { private static readonly DEPTH = 10; private _stack: WeakHTMLElement[] = []; - private _getWindow: GetWindow; + declare private _getWindow: GetWindow; constructor(getWindow: GetWindow) { this._getWindow = getWindow; } diff --git a/src/Root.ts b/src/Root.ts index 31b84124..f44e9c80 100644 --- a/src/Root.ts +++ b/src/Root.ts @@ -99,13 +99,14 @@ export class Root extends TabsterPart implements Types.Root { - readonly uid: string; - - private _dummyManager?: RootDummyManager; - private _sys?: Types.SysProps; + // `declare` keeps the type info for TS without emitting a `this.x = void 0` + // class-field initializer that the constructor immediately overwrites. + declare readonly uid: string; + declare private _dummyManager?: RootDummyManager; + declare private _sys?: Types.SysProps; private _isFocused = false; - private _setFocusedTimer: number | undefined; - private _onDispose: (root: Root) => void; + declare private _setFocusedTimer: number | undefined; + declare private _onDispose: (root: Root) => void; constructor( tabster: Types.TabsterCore, @@ -255,9 +256,10 @@ function validateRootProps(props: Types.RootProps): void { } export class RootAPI implements Types.RootAPI { - private _tabster: Types.TabsterCore; - private _win: Types.GetWindow; - private _autoRoot: Types.RootProps | undefined; + declare private _tabster: Types.TabsterCore; + declare private _win: Types.GetWindow; + /** @internal — read by `getTabsterContext` (src/Context.ts) for auto-root fallback. */ + declare _autoRoot: Types.RootProps | undefined; private _autoRootWaiting = false; private _roots: Record = {}; private _forceDummy = false; diff --git a/src/Tabster.ts b/src/Tabster.ts index 9f8cc0a9..f67804df 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -22,12 +22,12 @@ import { dom, setDOMAPI } from "./DOMAPI.js"; import * as shadowDOMAPI from "./Shadowdomize/index.js"; class Tabster implements Types.Tabster { - keyboardNavigation: Types.KeyboardNavigationState; - focusedElement: Types.FocusedElementState; - focusable: Types.FocusableAPI; - root: Types.RootAPI; - uncontrolled: Types.UncontrolledAPI; - core: Types.TabsterCore; + declare keyboardNavigation: Types.KeyboardNavigationState; + declare focusedElement: Types.FocusedElementState; + declare focusable: Types.FocusableAPI; + declare root: Types.RootAPI; + declare uncontrolled: Types.UncontrolledAPI; + declare core: Types.TabsterCore; constructor(tabster: Types.TabsterCore) { this.keyboardNavigation = tabster.keyboardNavigation; @@ -43,9 +43,14 @@ class Tabster implements Types.Tabster { * Extends Window to include an internal Tabster instance. */ class TabsterCore implements Types.TabsterCore { - private _storage: WeakMap; - private _unobserve: (() => void) | undefined; - private _win: WindowWithTabsterInstance | undefined; + // `declare` on typed fields suppresses the runtime field-initializer + // emit (target ES2022 compiles plain `field: T;` to `this.x = void 0`, + // which the constructor immediately overwrites). Initializer-bearing + // fields below stay as plain class-field syntax — that's where the + // initial value actually comes from. + declare private _storage: WeakMap; + declare private _unobserve: (() => void) | undefined; + declare private _win: WindowWithTabsterInstance | undefined; private _forgetMemorizedTimer: number | undefined; private _forgetMemorizedElements: HTMLElement[] = []; private _wrappers: Set = new Set(); @@ -54,8 +59,8 @@ class TabsterCore implements Types.TabsterCore { _version: string = __VERSION__; _noop = false; - controlTab: boolean; - rootDummyInputs: boolean; + declare controlTab: boolean; + declare rootDummyInputs: boolean; // Variance gap: per-key handler types are contravariant in their // parameters, so a fully-typed Map> can't unify // them. Cast a plain Map to the typed view; the override on `set` keeps @@ -64,13 +69,13 @@ class TabsterCore implements Types.TabsterCore { attrHandlers = new Map() as Types.TabsterAttrHandlerRegistry; // Core APIs - keyboardNavigation: Types.KeyboardNavigationState; - focusedElement: Types.FocusedElementState; - focusable: Types.FocusableAPI; - root: Types.RootAPI; - uncontrolled: Types.UncontrolledAPI; - internal: Types.InternalAPI; - _dummyObserver: Types.DummyInputObserver; + declare keyboardNavigation: Types.KeyboardNavigationState; + declare focusedElement: Types.FocusedElementState; + declare focusable: Types.FocusableAPI; + declare root: Types.RootAPI; + declare uncontrolled: Types.UncontrolledAPI; + declare internal: Types.InternalAPI; + declare _dummyObserver: Types.DummyInputObserver; // Extended APIs groupper?: Types.GroupperAPI; @@ -81,7 +86,7 @@ class TabsterCore implements Types.TabsterCore { observedElement?: Types.ObservedElementAPI; crossOrigin?: Types.CrossOriginAPI; restorer?: Types.RestorerAPI; - getParent: (el: Node) => Node | null; + declare getParent: (el: Node) => Node | null; constructor(win: Window, props?: Types.TabsterCoreProps) { this._storage = new WeakMap(); diff --git a/src/Utils.ts b/src/Utils.ts index 4744724d..26882df7 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -62,35 +62,24 @@ interface WindowWithUtilsConext extends Window { export function getInstanceContext(getWindow: GetWindow): InstanceContext { const win = getWindow() as WindowWithUtilsConext; - - let ctx = win.__tabsterInstanceContext; - - if (!ctx) { - ctx = { - elementByUId: {}, - containerBoundingRectCache: {}, - lastContainerBoundingRectCacheId: 0, - }; - - win.__tabsterInstanceContext = ctx; - } - - return ctx; + return (win.__tabsterInstanceContext ??= { + elementByUId: {}, + containerBoundingRectCache: {}, + lastContainerBoundingRectCacheId: 0, + }); } export function disposeInstanceContext(win: Window): void { - const ctx = (win as WindowWithUtilsConext).__tabsterInstanceContext; - + const w = win as WindowWithUtilsConext; + const ctx = w.__tabsterInstanceContext; if (ctx) { - ctx.elementByUId = {}; - - ctx.containerBoundingRectCache = {}; - + // The maps held only WeakHTMLElement wrappers (WeakRef-backed) and + // bounding-rect snapshots — both are dropped when the context object + // is unreached. Just cancel the timer and unhook from the window. if (ctx.containerBoundingRectCacheTimer) { win.clearTimeout(ctx.containerBoundingRectCacheTimer); } - - delete (win as WindowWithUtilsConext).__tabsterInstanceContext; + delete w.__tabsterInstanceContext; } } @@ -102,8 +91,8 @@ export class WeakHTMLElement< T extends HTMLElement = HTMLElement, D = undefined, > implements WeakHTMLElementInterface { - private _ref: WeakRef | undefined; - private _data: D | undefined; + declare private _ref: WeakRef | undefined; + declare private _data: D | undefined; constructor(element: T, data?: D) { this._ref = new WeakRef(element); @@ -333,21 +322,14 @@ export function shouldIgnoreFocus(element: HTMLElement): boolean { export function getUId(wnd: Window): string { const rnd = new Uint32Array(4); - wnd.crypto.getRandomValues(rnd); - - const srnd: string[] = []; - - for (let i = 0; i < rnd.length; i++) { - srnd.push(rnd[i].toString(36)); - } - - srnd.push("|"); - srnd.push((++_uidCounter).toString(36)); - srnd.push("|"); - srnd.push(Date.now().toString(36)); - - return srnd.join(""); + return ( + Array.from(rnd, (n) => n.toString(36)).join("") + + "|" + + (++_uidCounter).toString(36) + + "|" + + Date.now().toString(36) + ); } export function getElementUId( @@ -355,19 +337,13 @@ export function getElementUId( element: HTMLElementWithUID ): string { const context = getInstanceContext(getWindow); - let uid = element.__tabsterElementUID; - - if (!uid) { - uid = element.__tabsterElementUID = getUId(getWindow()); - } - + const uid = (element.__tabsterElementUID ??= getUId(getWindow())); if ( !context.elementByUId[uid] && documentContains(element.ownerDocument, element) ) { context.elementByUId[uid] = new WeakHTMLElement(element); } - return uid; } @@ -379,32 +355,22 @@ export function getElementByUId( } export function getWindowUId(win: WindowWithUID): string { - let uid = win.__tabsterCrossOriginWindowUID; - - if (!uid) { - uid = win.__tabsterCrossOriginWindowUID = getUId(win); - } - - return uid; + return (win.__tabsterCrossOriginWindowUID ??= getUId(win)); } export function clearElementCache( getWindow: GetWindow, parent?: HTMLElement ): void { - const context = getInstanceContext(getWindow); - - for (const key of Object.keys(context.elementByUId)) { - const wel = context.elementByUId[key]; - const el = wel && wel.get(); - - if (el && parent) { - if (!dom.nodeContains(parent, el)) { - continue; - } + const cache = getInstanceContext(getWindow).elementByUId; + for (const key of Object.keys(cache)) { + const el = cache[key]?.get(); + // When `parent` is given, only entries inside it are pruned; otherwise + // the entire cache is cleared. + if (parent && el && !dom.nodeContains(parent, el)) { + continue; } - - delete context.elementByUId[key]; + delete cache[key]; } } @@ -429,11 +395,11 @@ export abstract class TabsterPart< P, D = undefined, > implements TabsterPartInterface

{ - protected _tabster: TabsterCore; - protected _element: WeakHTMLElement; - protected _props: P; + declare protected _tabster: TabsterCore; + declare protected _element: WeakHTMLElement; + declare protected _props: P; - readonly id: string; + declare readonly id: string; constructor(tabster: TabsterCore, element: HTMLElement, props: P) { this._tabster = tabster; @@ -551,22 +517,18 @@ export function augmentAttribute( export function getTabsterAttributeOnElement( element: HTMLElement ): TabsterAttributeProps | null { - if (!element.hasAttribute(TABSTER_ATTRIBUTE_NAME)) { + // `getAttribute` already returns null when the attribute is absent — + // no need for a separate `hasAttribute` probe. + const rawAttribute = element.getAttribute(TABSTER_ATTRIBUTE_NAME); + if (rawAttribute === null) { return null; } - - // We already checked the presence with `hasAttribute` - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const rawAttribute = element.getAttribute(TABSTER_ATTRIBUTE_NAME)!; - let tabsterAttribute: TabsterAttributeProps; try { - tabsterAttribute = JSON.parse(rawAttribute); + return JSON.parse(rawAttribute); } catch { console.error("Tabster: failed to parse attribute", rawAttribute); - tabsterAttribute = {}; + return {}; } - - return tabsterAttribute; } export function isDisplayNone(element: HTMLElement): boolean { @@ -621,25 +583,14 @@ export function getRadioButtonGroup( if (!isRadio(element)) { return; } - const name = (element as HTMLInputElement).name; - let radioButtons = Array.from(dom.getElementsByName(element, name)); - let checked: HTMLInputElement | undefined; - - radioButtons = radioButtons.filter((el) => { - if (isRadio(el)) { - if ((el as HTMLInputElement).checked) { - checked = el as HTMLInputElement; - } - return true; - } - return false; - }); - + const radioButtons = Array.from( + dom.getElementsByName(element, name) + ).filter(isRadio) as HTMLInputElement[]; return { name, - buttons: new Set(radioButtons as HTMLInputElement[]), - checked, + buttons: new Set(radioButtons), + checked: radioButtons.find((el) => el.checked), }; } From e0fa9eefb1ebf25a1015ffd7736d11ed21d08096 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 11 May 2026 12:39:11 +0200 Subject: [PATCH 04/17] refactor(bundle-size): use free-function helpers for events and timers Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CrossOrigin.ts | 125 ++++++++++++++++++++---------------- src/Deloser.ts | 31 +++++---- src/DummyInput.ts | 33 ++++++---- src/Groupper.ts | 27 +++++--- src/Modalizer.ts | 15 +++-- src/Mover.ts | 24 ++++--- src/Outline.ts | 12 ++-- src/Restorer.ts | 26 +++++--- src/Root.ts | 56 +++++++++------- src/State/FocusedElement.ts | 32 +++++---- src/Tabster.ts | 72 ++++++++++++--------- src/Utils.ts | 118 +++++++++++++++++++++++++++++----- 12 files changed, 376 insertions(+), 195 deletions(-) diff --git a/src/CrossOrigin.ts b/src/CrossOrigin.ts index c8d35497..270732be 100644 --- a/src/CrossOrigin.ts +++ b/src/CrossOrigin.ts @@ -14,11 +14,18 @@ import { Subscribable } 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"; @@ -952,7 +959,7 @@ class PingTransaction extends CrossOriginTransaction { interface CrossOriginTransactionWrapper { transaction: CrossOriginTransaction; - timer?: number; + timer: Timer; } class CrossOriginTransactions { @@ -964,7 +971,7 @@ class CrossOriginTransactions { [id: string]: CrossOriginTransactionWrapper; } = {}; declare private _tabster: Types.TabsterCore; - private _pingTimer: number | undefined; + private _pingTimer: Timer; private _isDefaultSendUp = false; declare private _deadPromise: Promise | undefined; isSetUp = false; @@ -980,6 +987,7 @@ class CrossOriginTransactions { this._owner = getOwner; this._ownerUId = getWindowUId(getOwner()); this.ctx = context; + this._pingTimer = createTimer(); } setup( @@ -994,7 +1002,7 @@ class CrossOriginTransactions { this.setSendUp(sendUp); - this._owner().addEventListener("pagehide", this._onPageHide); + addListener(this._owner(), "pagehide", this._onPageHide); this._ping(); } @@ -1031,11 +1039,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; } @@ -1045,13 +1053,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(); @@ -1060,11 +1065,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(); } @@ -1147,15 +1148,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); @@ -1163,9 +1167,7 @@ class CrossOriginTransactions { ret.catch(() => { /**/ }).finally(() => { - if (wrapper.timer) { - owner.clearTimeout(wrapper.timer); - } + clearTimer(wrapper.timer, owner); delete this._transactions[transaction.id]; }); @@ -1343,7 +1345,7 @@ class CrossOriginTransactions { } private async _ping(): Promise { - if (this._pingTimer) { + if (isTimerActive(this._pingTimer)) { return; } @@ -1409,10 +1411,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) => { @@ -1642,7 +1648,7 @@ export class CrossOriginAPI implements Types.CrossOriginAPI { private _tabster: Types.TabsterCore; private _win: Types.GetWindow; private _transactions: CrossOriginTransactions; - private _blurTimer: number | undefined; + private _blurTimer: Timer; private _ctx: CrossOriginInstanceContext; focusedElement: Types.CrossOriginFocusedElementState; @@ -1651,6 +1657,7 @@ export class CrossOriginAPI implements Types.CrossOriginAPI { constructor(tabster: Types.TabsterCore) { this._tabster = tabster; this._win = tabster.getWindow; + this._blurTimer = createTimer(); this._ctx = { ignoreKeyboardNavigationStateUpdate: false, deloserByUId: {}, @@ -1756,10 +1763,7 @@ export class CrossOriginAPI implements Types.CrossOriginAPI { const ownerUId = getWindowUId(win); - if (this._blurTimer) { - win.clearTimeout(this._blurTimer); - this._blurTimer = undefined; - } + clearTimer(this._blurTimer, win); if (element) { this._transactions.beginTransaction(StateTransaction, { @@ -1773,26 +1777,35 @@ export class CrossOriginAPI implements Types.CrossOriginAPI { 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( + this._blurTimer, + win, + () => { + 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 + ); } }; diff --git a/src/Deloser.ts b/src/Deloser.ts index 490abb89..3d010ada 100644 --- a/src/Deloser.ts +++ b/src/Deloser.ts @@ -14,9 +14,12 @@ import { TabsterMoveFocusEvent, } from "./Events.js"; import { + addListener, + dispatchEvent, documentContains, getElementUId, isDisplayNone, + removeListener, TabsterPart, WeakHTMLElement, } from "./Utils.js"; @@ -55,7 +58,8 @@ export class DeloserItem extends DeloserItemBase { if (available && deloserElement) { if ( - !deloserElement.dispatchEvent( + !dispatchEvent( + deloserElement, new TabsterMoveFocusEvent({ by: "deloser", owner: deloserElement, @@ -611,7 +615,8 @@ export class Deloser }; customFocusLostHandler(element: HTMLElement): boolean { - return element.dispatchEvent( + return dispatchEvent( + element, new DeloserFocusLostEvent(this.getActions()) ); } @@ -723,7 +728,8 @@ export class DeloserAPI implements Types.DeloserAPI { this._tabster.focusedElement.subscribe(this._onFocus); const doc = this._win().document; - doc.addEventListener( + addListener( + doc, DeloserRestoreFocusEventName, this._onRestoreFocus ); @@ -758,7 +764,8 @@ export class DeloserAPI implements Types.DeloserAPI { this._tabster.focusedElement.unsubscribe(this._onFocus); - win.document.removeEventListener( + removeListener( + win.document, DeloserRestoreFocusEventName, this._onRestoreFocus ); @@ -929,13 +936,15 @@ export class DeloserAPI implements Types.DeloserAPI { if ( el && - (!curDeloserElement?.dispatchEvent( - new TabsterMoveFocusEvent({ - by: "deloser", - owner: curDeloserElement, - next: el, - }) - ) || + (!curDeloserElement || + !dispatchEvent( + curDeloserElement, + new TabsterMoveFocusEvent({ + by: "deloser", + owner: curDeloserElement, + next: el, + }) + ) || this._tabster.focusedElement.focus(el)) ) { return; diff --git a/src/DummyInput.ts b/src/DummyInput.ts index ea1b2a36..4f1f9d82 100644 --- a/src/DummyInput.ts +++ b/src/DummyInput.ts @@ -17,7 +17,14 @@ import { } from "./Consts.js"; import { TabsterMoveFocusEvent } from "./Events.js"; import { dom } from "./DOMAPI.js"; -import { hasSubFocusable, makeFocusIgnored, WeakHTMLElement } from "./Utils.js"; +import { + addListener, + dispatchEvent, + hasSubFocusable, + makeFocusIgnored, + removeListener, + WeakHTMLElement, +} from "./Utils.js"; const _updateDummyInputsTimeout = 100; @@ -91,8 +98,8 @@ export class DummyInput { this._isPhantom = props.isPhantom ?? false; this._fixedTarget = fixedTarget; - input.addEventListener("focusin", this._focusIn); - input.addEventListener("focusout", this._focusOut); + addListener(input, "focusin", this._focusIn); + addListener(input, "focusout", this._focusOut); (input as HTMLElementWithDummyContainer).__tabsterDummyContainer = element; @@ -130,8 +137,8 @@ export class DummyInput { delete this.onFocusOut; delete this.input; - input.removeEventListener("focusin", this._focusIn); - input.removeEventListener("focusout", this._focusOut); + removeListener(input, "focusin", this._focusIn); + removeListener(input, "focusout", this._focusOut); delete (input as HTMLElementWithDummyContainer).__tabsterDummyContainer; @@ -404,7 +411,9 @@ export class DummyInputManager { } if ( - parent?.dispatchEvent( + parent && + dispatchEvent( + parent, new TabsterMoveFocusEvent({ by: "root", owner: parent, @@ -786,7 +795,7 @@ class DummyInputManagerCore { .__tabsterDummy; for (const el of this._transformElements) { - el.removeEventListener("scroll", this._addTransformOffsets); + removeListener(el, "scroll", this._addTransformOffsets); } this._transformElements.clear(); @@ -922,7 +931,8 @@ class DummyInputManagerCore { if ( toFocus && - element.dispatchEvent( + dispatchEvent( + element, new TabsterMoveFocusEvent({ by: "root", owner: element, @@ -1104,10 +1114,7 @@ class DummyInputManagerCore { newTransformElements.add(element); if (!transformElements.has(element)) { - element.addEventListener( - "scroll", - this._addTransformOffsets - ); + addListener(element, "scroll", this._addTransformOffsets); } scrollTop += scrollTopLeft.scrollTop; @@ -1117,7 +1124,7 @@ class DummyInputManagerCore { for (const el of transformElements) { if (!newTransformElements.has(el)) { - el.removeEventListener("scroll", this._addTransformOffsets); + removeListener(el, "scroll", this._addTransformOffsets); } } diff --git a/src/Groupper.ts b/src/Groupper.ts index c1d2bbfa..0f4487c5 100644 --- a/src/Groupper.ts +++ b/src/Groupper.ts @@ -26,7 +26,14 @@ import { DummyInputManagerPriorities, getDummyInputContainer, } from "./DummyInput.js"; -import { getAdjacentElement, TabsterPart, WeakHTMLElement } from "./Utils.js"; +import { + addListener, + dispatchEvent, + getAdjacentElement, + removeListener, + TabsterPart, + WeakHTMLElement, +} from "./Utils.js"; import { dom } from "./DOMAPI.js"; class GroupperDummyManager extends DummyInputManager { @@ -451,9 +458,9 @@ export class GroupperAPI implements Types.GroupperAPI { this._onFocus(activeElement as HTMLElement); } - doc.addEventListener("mousedown", this._onMouseDown, true); - win.addEventListener("keydown", this._onKeyDown, true); - win.addEventListener(GroupperMoveFocusEventName, this._onMoveFocus); + addListener(doc, "mousedown", this._onMouseDown, true); + addListener(win, "keydown", this._onKeyDown, true); + addListener(win, GroupperMoveFocusEventName, this._onMoveFocus); }; dispose(): void { @@ -472,9 +479,9 @@ export class GroupperAPI implements Types.GroupperAPI { this._tabster.focusedElement.unsubscribe(this._onFocus); - win.document.removeEventListener("mousedown", this._onMouseDown, true); - win.removeEventListener("keydown", this._onKeyDown, true); - win.removeEventListener(GroupperMoveFocusEventName, this._onMoveFocus); + removeListener(win.document, "mousedown", this._onMouseDown, true); + removeListener(win, "keydown", this._onKeyDown, true); + removeListener(win, GroupperMoveFocusEventName, this._onMoveFocus); Object.keys(this._grouppers).forEach((groupperId) => { if (this._grouppers[groupperId]) { @@ -656,7 +663,8 @@ export class GroupperAPI implements Types.GroupperAPI { next && (!relatedEvent || (relatedEvent && - groupperElement.dispatchEvent( + dispatchEvent( + groupperElement, new TabsterMoveFocusEvent({ by: "groupper", owner: groupperElement, @@ -716,7 +724,8 @@ export class GroupperAPI implements Types.GroupperAPI { next && (!relatedEvent || (relatedEvent && - groupperElement.dispatchEvent( + dispatchEvent( + groupperElement, new TabsterMoveFocusEvent({ by: "groupper", owner: groupperElement, diff --git a/src/Modalizer.ts b/src/Modalizer.ts index 76ba28f2..90c89d97 100644 --- a/src/Modalizer.ts +++ b/src/Modalizer.ts @@ -17,7 +17,14 @@ import { DummyInputManagerPriorities, getDummyInputContainer, } from "./DummyInput.js"; -import { augmentAttribute, TabsterPart, WeakHTMLElement } from "./Utils.js"; +import { + addListener, + augmentAttribute, + dispatchEvent, + removeListener, + TabsterPart, + WeakHTMLElement, +} from "./Utils.js"; import { dom } from "./DOMAPI.js"; let _wasFocusedCounter = 0; @@ -304,7 +311,7 @@ export class Modalizer ? new ModalizerActiveEvent(eventDetail) : new ModalizerInactiveEvent(eventDetail); - el.dispatchEvent(event); + dispatchEvent(el, event); if (event.defaultPrevented) { defaultPrevented = true; @@ -367,7 +374,7 @@ export class ModalizerAPI implements Types.ModalizerAPI { } const win = this._win(); - win.addEventListener("keydown", this._onKeyDown, true); + addListener(win, "keydown", this._onKeyDown, true); tabster.queueInit(() => { this._tabster.focusedElement.subscribe(this._onFocus); @@ -377,7 +384,7 @@ export class ModalizerAPI implements Types.ModalizerAPI { dispose(): void { const win = this._win(); - win.removeEventListener("keydown", this._onKeyDown, true); + removeListener(win, "keydown", this._onKeyDown, true); // Dispose all modalizers managed by the API Object.keys(this._modalizers).forEach((modalizerId) => { diff --git a/src/Mover.ts b/src/Mover.ts index eeae66d5..e57bb598 100644 --- a/src/Mover.ts +++ b/src/Mover.ts @@ -25,10 +25,13 @@ import { getDummyInputContainer, } from "./DummyInput.js"; import { + addListener, createElementTreeWalker, + dispatchEvent, getElementUId, isElementVerticallyVisibleInContainer, matchesSelector, + removeListener, scrollIntoView, TabsterPart, WeakHTMLElement, @@ -227,7 +230,7 @@ export class Mover const state = this.getState(el); if (state) { - el.dispatchEvent(new MoverStateEvent(state)); + dispatchEvent(el, new MoverStateEvent(state)); } } } @@ -399,7 +402,7 @@ export class Mover const state = this.getState(el); if (state) { - el.dispatchEvent(new MoverStateEvent(state)); + dispatchEvent(el, new MoverStateEvent(state)); } } } @@ -701,9 +704,10 @@ export class MoverAPI implements Types.MoverAPI { private _init = (): void => { const win = this._win(); - win.addEventListener("keydown", this._onKeyDown, true); - win.addEventListener(MoverMoveFocusEventName, this._onMoveFocus); - win.addEventListener( + addListener(win, "keydown", this._onKeyDown, true); + addListener(win, MoverMoveFocusEventName, this._onMoveFocus); + addListener( + win, MoverMemorizedElementEventName, this._onMemorizedElement ); @@ -723,9 +727,10 @@ export class MoverAPI implements Types.MoverAPI { delete this._ignoredInputTimer; } - win.removeEventListener("keydown", this._onKeyDown, true); - win.removeEventListener(MoverMoveFocusEventName, this._onMoveFocus); - win.removeEventListener( + removeListener(win, "keydown", this._onKeyDown, true); + removeListener(win, MoverMoveFocusEventName, this._onMoveFocus); + removeListener( + win, MoverMemorizedElementEventName, this._onMemorizedElement ); @@ -1197,7 +1202,8 @@ export class MoverAPI implements Types.MoverAPI { next && (!relatedEvent || (relatedEvent && - container.dispatchEvent( + dispatchEvent( + container, new TabsterMoveFocusEvent({ by: "mover", owner: container, diff --git a/src/Outline.ts b/src/Outline.ts index 83277e41..7531f577 100644 --- a/src/Outline.ts +++ b/src/Outline.ts @@ -5,7 +5,7 @@ import { getTabsterOnElement } from "./Instance.js"; import type * as Types from "./Types.js"; -import { getBoundingRect } from "./Utils.js"; +import { addListener, getBoundingRect, removeListener } from "./Utils.js"; interface WindowWithOutlineStyle extends Window { __tabsterOutline?: { @@ -82,9 +82,10 @@ export class OutlineAPI implements Types.OutlineAPI { const win = this._win(); - win.addEventListener("scroll", this._onScroll, true); // Capture! + addListener(win, "scroll", this._onScroll, true); // Capture! - win.document.addEventListener( + addListener( + win.document, "fullscreenchange", this._onFullScreenChanged ); @@ -123,9 +124,10 @@ export class OutlineAPI implements Types.OutlineAPI { ); this._tabster.focusedElement.unsubscribe(this._onFocus); - win.removeEventListener("scroll", this._onScroll, true); + removeListener(win, "scroll", this._onScroll, true); - win.document.removeEventListener( + removeListener( + win.document, "fullscreenchange", this._onFullScreenChanged ); diff --git a/src/Restorer.ts b/src/Restorer.ts index 1020892d..e9485e4b 100644 --- a/src/Restorer.ts +++ b/src/Restorer.ts @@ -18,7 +18,13 @@ import { RestorerRestoreFocusEvent, RestorerRestoreFocusEventName, } from "./Events.js"; -import { TabsterPart, WeakHTMLElement } from "./Utils.js"; +import { + addListener, + dispatchEvent, + removeListener, + TabsterPart, + WeakHTMLElement, +} from "./Utils.js"; import { dom } from "./DOMAPI.js"; class Restorer extends TabsterPart implements RestorerInterface { @@ -33,8 +39,8 @@ class Restorer extends TabsterPart implements RestorerInterface { if (this._props.type === RestorerTypes.Source) { const element = this._element?.get(); - element?.addEventListener("focusout", this._onFocusOut); - element?.addEventListener("focusin", this._onFocusIn); + addListener(element, "focusout", this._onFocusOut); + addListener(element, "focusin", this._onFocusIn); // set hasFocus when the instance is created, in case focus has already moved within it this._hasFocus = dom.nodeContains( @@ -47,12 +53,12 @@ class Restorer extends TabsterPart implements RestorerInterface { dispose(): void { if (this._props.type === RestorerTypes.Source) { const element = this._element?.get(); - element?.removeEventListener("focusout", this._onFocusOut); - element?.removeEventListener("focusin", this._onFocusIn); + removeListener(element, "focusout", this._onFocusOut); + removeListener(element, "focusin", this._onFocusIn); if (this._hasFocus) { const doc = this._tabster.getWindow().document; - doc.body.dispatchEvent(new RestorerRestoreFocusEvent()); + dispatchEvent(doc.body, new RestorerRestoreFocusEvent()); } } } @@ -60,7 +66,7 @@ class Restorer extends TabsterPart implements RestorerInterface { private _onFocusOut = (e: FocusEvent) => { const element = this._element?.get(); if (element && e.relatedTarget === null) { - element.dispatchEvent(new RestorerRestoreFocusEvent()); + dispatchEvent(element, new RestorerRestoreFocusEvent()); } if ( element && @@ -140,7 +146,8 @@ export class RestorerAPI implements RestorerAPIType { constructor(tabster: TabsterCore) { this._tabster = tabster; this._getWindow = tabster.getWindow; - this._getWindow().addEventListener( + addListener( + this._getWindow(), RestorerRestoreFocusEventName, this._onRestoreFocus ); @@ -158,7 +165,8 @@ export class RestorerAPI implements RestorerAPIType { this._focusedElementState.cancelAsyncFocus(AsyncFocusSources.Restorer); - win.removeEventListener( + removeListener( + win, RestorerRestoreFocusEventName, this._onRestoreFocus ); diff --git a/src/Root.ts b/src/Root.ts index f44e9c80..563731ed 100644 --- a/src/Root.ts +++ b/src/Root.ts @@ -12,7 +12,18 @@ import { DummyInputManager, DummyInputManagerPriorities, } from "./DummyInput.js"; -import { getElementUId, TabsterPart, type WeakHTMLElement } from "./Utils.js"; +import { + addListener, + clearTimer, + createTimer, + dispatchEvent, + getElementUId, + removeListener, + setTimer, + TabsterPart, + type Timer, + type WeakHTMLElement, +} from "./Utils.js"; import { setTabsterAttribute } from "./AttributeHelpers.js"; export interface WindowWithTabsterInstance extends Window { @@ -105,7 +116,7 @@ export class Root declare private _dummyManager?: RootDummyManager; declare private _sys?: Types.SysProps; private _isFocused = false; - declare private _setFocusedTimer: number | undefined; + declare private _setFocusedTimer: Timer; declare private _onDispose: (root: Root) => void; constructor( @@ -118,6 +129,7 @@ export class Root super(tabster, element, props); this._onDispose = onDispose; + this._setFocusedTimer = createTimer(); const win = tabster.getWindow; this.uid = getElementUId(win, element); @@ -131,8 +143,8 @@ export class Root const w = win(); const doc = w.document; - doc.addEventListener(KEYBORG_FOCUSIN, this._onFocusIn); - doc.addEventListener(KEYBORG_FOCUSOUT, this._onFocusOut); + addListener(doc, KEYBORG_FOCUSIN, this._onFocusIn); + addListener(doc, KEYBORG_FOCUSOUT, this._onFocusOut); this._add(); } @@ -154,13 +166,10 @@ export class Root const win = this._tabster.getWindow(); const doc = win.document; - doc.removeEventListener(KEYBORG_FOCUSIN, this._onFocusIn); - doc.removeEventListener(KEYBORG_FOCUSOUT, this._onFocusOut); + removeListener(doc, KEYBORG_FOCUSIN, this._onFocusIn); + removeListener(doc, KEYBORG_FOCUSOUT, this._onFocusOut); - if (this._setFocusedTimer) { - win.clearTimeout(this._setFocusedTimer); - delete this._setFocusedTimer; - } + clearTimer(this._setFocusedTimer, win); this._dummyManager?.dispose(); this._remove(); @@ -187,10 +196,8 @@ export class Root } private _setFocused = (hasFocused: boolean): void => { - if (this._setFocusedTimer) { - this._tabster.getWindow().clearTimeout(this._setFocusedTimer); - delete this._setFocusedTimer; - } + const win = this._tabster.getWindow(); + clearTimer(this._setFocusedTimer, win); if (this._isFocused === hasFocused) { return; @@ -202,17 +209,18 @@ export class Root if (hasFocused) { this._isFocused = true; this._dummyManager?.setTabbable(false); - element.dispatchEvent(new RootFocusEvent({ element })); + dispatchEvent(element, new RootFocusEvent({ element })); } else { - this._setFocusedTimer = this._tabster - .getWindow() - .setTimeout(() => { - delete this._setFocusedTimer; - + setTimer( + this._setFocusedTimer, + win, + () => { this._isFocused = false; this._dummyManager?.setTabbable(true); - element.dispatchEvent(new RootBlurEvent({ element })); - }, 0); + dispatchEvent(element, new RootBlurEvent({ element })); + }, + 0 + ); } } }; @@ -293,14 +301,14 @@ export class RootAPI implements Types.RootAPI { } } else if (!this._autoRootWaiting) { this._autoRootWaiting = true; - doc.addEventListener("readystatechange", this._autoRootCreate); + addListener(doc, "readystatechange", this._autoRootCreate); } return undefined; }; private _autoRootUnwait(doc: Document): void { - doc.removeEventListener("readystatechange", this._autoRootCreate); + removeListener(doc, "readystatechange", this._autoRootCreate); this._autoRootWaiting = false; } diff --git a/src/State/FocusedElement.ts b/src/State/FocusedElement.ts index e92e04d9..498ec2da 100644 --- a/src/State/FocusedElement.ts +++ b/src/State/FocusedElement.ts @@ -22,8 +22,11 @@ import { } from "../Events.js"; import { DummyInputManager } from "../DummyInput.js"; import { + addListener, + dispatchEvent, documentContains, getLastChild, + removeListener, shouldIgnoreFocus, WeakHTMLElement, } from "../Utils.js"; @@ -104,17 +107,19 @@ export class FocusedElementState const doc = win.document; // Add these event listeners as capture - we want Tabster to run before user event handlers - doc.addEventListener( + addListener( + doc, KEYBORG_FOCUSIN, this._onFocusIn as EventListener, true ); - doc.addEventListener( + addListener( + doc, KEYBORG_FOCUSOUT, this._onFocusOut as EventListener, true ); - win.addEventListener("keydown", this._onKeyDown, true); + addListener(win, "keydown", this._onKeyDown, true); const activeElement = dom.getActiveElement(doc); @@ -131,17 +136,19 @@ export class FocusedElementState const win = this._win(); const doc = win.document; - doc.removeEventListener( + removeListener( + doc, KEYBORG_FOCUSIN, this._onFocusIn as EventListener, true ); - doc.removeEventListener( + removeListener( + doc, KEYBORG_FOCUSOUT, this._onFocusOut as EventListener, true ); - win.removeEventListener("keydown", this._onKeyDown, true); + removeListener(win, "keydown", this._onKeyDown, true); this.unsubscribe(this._onChanged); @@ -657,7 +664,8 @@ export class FocusedElementState // For iframes and uncontrolled areas we always want to use default action to // move focus into. if ( - rootElement.dispatchEvent( + dispatchEvent( + rootElement, new TabsterMoveFocusEvent({ by: "root", owner: rootElement, @@ -680,7 +688,8 @@ export class FocusedElementState if (controlTab || next?.outOfDOMOrder) { if ( - rootElement.dispatchEvent( + dispatchEvent( + rootElement, new TabsterMoveFocusEvent({ by: "root", owner: rootElement, @@ -701,7 +710,8 @@ export class FocusedElementState } else { if ( !uncontrolledCompletelyContainer && - rootElement.dispatchEvent( + dispatchEvent( + rootElement, new TabsterMoveFocusEvent({ by: "root", owner: rootElement, @@ -720,7 +730,7 @@ export class FocusedElementState detail: Types.FocusedElementDetail ): void => { if (element) { - element.dispatchEvent(new TabsterFocusInEvent(detail)); + dispatchEvent(element, new TabsterFocusInEvent(detail)); } else { const last = this._lastVal?.get(); @@ -733,7 +743,7 @@ export class FocusedElementState d.modalizerId = modalizerId; } - last.dispatchEvent(new TabsterFocusOutEvent(d)); + dispatchEvent(last, new TabsterFocusOutEvent(d)); } } }; diff --git a/src/Tabster.ts b/src/Tabster.ts index f67804df..6d81a6a5 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -15,8 +15,13 @@ import { UncontrolledAPI } from "./Uncontrolled.js"; import { DummyInputObserver } from "./DummyInput.js"; import { clearElementCache, + clearTimer, createElementTreeWalker, + createTimer, disposeInstanceContext, + isTimerActive, + setTimer, + type Timer, } from "./Utils.js"; import { dom, setDOMAPI } from "./DOMAPI.js"; import * as shadowDOMAPI from "./Shadowdomize/index.js"; @@ -51,10 +56,10 @@ class TabsterCore implements Types.TabsterCore { declare private _storage: WeakMap; declare private _unobserve: (() => void) | undefined; declare private _win: WindowWithTabsterInstance | undefined; - private _forgetMemorizedTimer: number | undefined; + private _forgetMemorizedTimer: Timer; private _forgetMemorizedElements: HTMLElement[] = []; private _wrappers: Set = new Set(); - private _initTimer: number | undefined; + private _initTimer: Timer; private _initQueue: (() => void)[] = []; _version: string = __VERSION__; @@ -91,6 +96,8 @@ class TabsterCore implements Types.TabsterCore { constructor(win: Window, props?: Types.TabsterCoreProps) { this._storage = new WeakMap(); this._win = win; + this._forgetMemorizedTimer = createTimer(); + this._initTimer = createTimer(); const getWindow = this.getWindow; @@ -187,17 +194,14 @@ class TabsterCore implements Types.TabsterCore { const win = this._win; - win?.clearTimeout(this._initTimer); - delete this._initTimer; - this._initQueue = []; + if (win) { + clearTimer(this._initTimer, win); + clearTimer(this._forgetMemorizedTimer, win); + } + this._initQueue = []; this._forgetMemorizedElements = []; - if (win && this._forgetMemorizedTimer) { - win.clearTimeout(this._forgetMemorizedTimer); - delete this._forgetMemorizedTimer; - } - this.outline?.dispose(); this.crossOrigin?.dispose(); this.deloser?.dispose(); @@ -265,23 +269,29 @@ class TabsterCore implements Types.TabsterCore { this._forgetMemorizedElements.push(this._win.document.body); - if (this._forgetMemorizedTimer) { + if (isTimerActive(this._forgetMemorizedTimer)) { return; } - this._forgetMemorizedTimer = this._win.setTimeout(() => { - delete this._forgetMemorizedTimer; - - for ( - let el: HTMLElement | undefined = - this._forgetMemorizedElements.shift(); - el; - el = this._forgetMemorizedElements.shift() - ) { - clearElementCache(this.getWindow, el); - FocusedElementState.forgetMemorized(this.focusedElement, el); - } - }, 0); + setTimer( + this._forgetMemorizedTimer, + this._win, + () => { + for ( + let el: HTMLElement | undefined = + this._forgetMemorizedElements.shift(); + el; + el = this._forgetMemorizedElements.shift() + ) { + clearElementCache(this.getWindow, el); + FocusedElementState.forgetMemorized( + this.focusedElement, + el + ); + } + }, + 0 + ); } queueInit(callback: () => void): void { @@ -291,11 +301,15 @@ class TabsterCore implements Types.TabsterCore { this._initQueue.push(callback); - if (!this._initTimer) { - this._initTimer = this._win?.setTimeout(() => { - delete this._initTimer; - this.drainInitQueue(); - }, 0); + if (!isTimerActive(this._initTimer)) { + setTimer( + this._initTimer, + this._win, + () => { + this.drainInitQueue(); + }, + 0 + ); } } diff --git a/src/Utils.ts b/src/Utils.ts index 26882df7..4fba19bf 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -51,7 +51,7 @@ export interface InstanceContext { }; }; lastContainerBoundingRectCacheId: number; - containerBoundingRectCacheTimer?: number; + containerBoundingRectCacheTimer: Timer; } let _uidCounter = 0; @@ -66,6 +66,7 @@ export function getInstanceContext(getWindow: GetWindow): InstanceContext { elementByUId: {}, containerBoundingRectCache: {}, lastContainerBoundingRectCacheId: 0, + containerBoundingRectCacheTimer: createTimer(), }); } @@ -76,9 +77,7 @@ export function disposeInstanceContext(win: Window): void { // The maps held only WeakHTMLElement wrappers (WeakRef-backed) and // bounding-rect snapshots — both are dropped when the context object // is unreached. Just cancel the timer and unhook from the window. - if (ctx.containerBoundingRectCacheTimer) { - win.clearTimeout(ctx.containerBoundingRectCacheTimer); - } + clearTimer(ctx.containerBoundingRectCacheTimer, win); delete w.__tabsterInstanceContext; } } @@ -186,17 +185,22 @@ export function getBoundingRect( element, }; - if (!context.containerBoundingRectCacheTimer) { - context.containerBoundingRectCacheTimer = window.setTimeout(() => { - context.containerBoundingRectCacheTimer = undefined; - - for (const cId of Object.keys(context.containerBoundingRectCache)) { - delete context.containerBoundingRectCache[cId].element - .__tabsterCacheId; - } - - context.containerBoundingRectCache = {}; - }, 50); + if (!isTimerActive(context.containerBoundingRectCacheTimer)) { + setTimer( + context.containerBoundingRectCacheTimer, + window, + () => { + for (const cId of Object.keys( + context.containerBoundingRectCache + )) { + delete context.containerBoundingRectCache[cId].element + .__tabsterCacheId; + } + + context.containerBoundingRectCache = {}; + }, + 50 + ); } return rect; @@ -594,6 +598,90 @@ export function getRadioButtonGroup( }; } +/** + * Opaque handle for a single setTimeout id. Use {@link setTimer}, + * {@link clearTimer}, and {@link isTimerActive} to operate on it. + * + * Built as a free-function API rather than methods because the function names + * mangle to single characters (1 char per call site) while property names like + * `.clear` would be preserved by the minifier (~5 chars per call site). + */ +export interface Timer { + id: number | null; +} + +export function createTimer(): Timer { + return { id: null }; +} + +/** Cancels any pending timer on `t` and schedules `callback` after `delay` ms. */ +export function setTimer( + t: Timer, + window: Window, + callback: () => void, + delay: number +): void { + if (t.id !== null) { + window.clearTimeout(t.id); + } + t.id = window.setTimeout(() => { + t.id = null; + callback(); + }, delay) as unknown as number; +} + +/** Cancels the pending timer on `t`; no-op if there isn't one. */ +export function clearTimer(t: Timer, window: Window): void { + if (t.id !== null) { + window.clearTimeout(t.id); + t.id = null; + } +} + +/** Whether `t` has a pending timer. */ +export function isTimerActive(t: Timer): boolean { + return t.id !== null; +} + +/** + * Thin wrappers around `addEventListener` / `removeEventListener`. Their + * names get mangled to single chars by the minifier, while the inlined + * property accesses on `target` would not — so each call site shrinks by + * the difference between the helper's mangled name and the property name. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyEventHandler = (event: any) => void; + +export function addListener( + target: EventTarget | null | undefined, + type: string, + handler: AnyEventHandler, + options?: boolean | AddEventListenerOptions +): void { + target?.addEventListener(type, handler, options); +} + +export function removeListener( + target: EventTarget | null | undefined, + type: string, + handler: AnyEventHandler, + options?: boolean | EventListenerOptions +): void { + target?.removeEventListener(type, handler, options); +} + +/** + * Thin wrapper around `target.dispatchEvent`. Returns `false` if the dispatch + * was canceled (preventDefault) OR if `target` is nullish — both shapes the + * existing call sites already handle the same way. + */ +export function dispatchEvent( + target: EventTarget | null | undefined, + event: Event +): boolean { + return !!target && target.dispatchEvent(event); +} + /** * If the passed element is Tabster dummy input, returns the container element this dummy input belongs to. * @param element Element to check for being dummy input. From 05009ddcf385dbd06bb9dbf7e8ec1d8db8be2637 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 11 May 2026 13:01:39 +0200 Subject: [PATCH 05/17] refactor(bundle-size): convert Subscribable family to factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts the `Subscribable` abstract class and its consumers to factory functions returning plain objects. - `Subscribable` → `createSubscribable()` factory returning a `SubscribableCore` interface. The public surface (subscribe/ subscribeFirst/unsubscribe) matches `Types.Subscribable`; setVal/ getVal/trigger are exposed for the composing factory only. - `KeyboardNavigationState` class → `createKeyboardNavigationState` factory. - `FocusedElementState` class → `createFocusedElementState` factory. `FocusedElementState` is preserved as a const namespace for the `forgetMemorized` and `findNextTabbable` static helpers. - `ObservedElementAPI` class → `createObservedElementAPI` factory. - `CrossOriginFocusedElementState` class → `createCrossOriginFocusedElementState` factory, with `setVal` static kept under same-named const namespace. - `CrossOriginObservedElementState` class → `createCrossOriginObservedElementState` factory, with `trigger` static kept under same-named const namespace. Also trims `Subscribable` itself: drops the `callCallbacks` helper in favour of the inlined `trigger` closure, and adds `declare` to `TabsterCustomEvent.details` to drop the redundant initializer SWC was emitting. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CrossOrigin.ts | 275 +++++----- src/Events.ts | 2 +- src/State/FocusedElement.ts | 898 ++++++++++++++++---------------- src/State/KeyboardNavigation.ts | 58 +-- src/State/ObservedElement.ts | 863 +++++++++++++++--------------- src/State/Subscribable.ts | 115 ++-- src/Tabster.ts | 11 +- src/get/getObservedElement.ts | 7 +- 8 files changed, 1135 insertions(+), 1094 deletions(-) diff --git a/src/CrossOrigin.ts b/src/CrossOrigin.ts index 270732be..87370f0f 100644 --- a/src/CrossOrigin.ts +++ b/src/CrossOrigin.ts @@ -10,7 +10,7 @@ import { } from "./Deloser.js"; import { getTabsterOnElement } from "./Instance.js"; import { RootAPI } from "./Root.js"; -import { Subscribable } from "./State/Subscribable.js"; +import { createSubscribable } from "./State/Subscribable.js"; import type * as Types from "./Types.js"; import { ObservedElementAccessibilities } from "./Consts.js"; import { @@ -1485,73 +1485,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, { @@ -1562,47 +1518,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, { @@ -1612,37 +1623,65 @@ 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); - } -} + (instance as CrossOriginObservedElementStateInternal)._trigger( + element, + details + ); + }, +}; export class CrossOriginAPI implements Types.CrossOriginAPI { private _tabster: Types.TabsterCore; @@ -1668,10 +1707,10 @@ export class CrossOriginAPI implements Types.CrossOriginAPI { this._win, this._ctx ); - this.focusedElement = new CrossOriginFocusedElementState( + this.focusedElement = createCrossOriginFocusedElementState( this._transactions ); - this.observedElement = new CrossOriginObservedElementState( + this.observedElement = createCrossOriginObservedElementState( tabster, this._transactions ); 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/State/FocusedElement.ts b/src/State/FocusedElement.ts index 498ec2da..c0bbc580 100644 --- a/src/State/FocusedElement.ts +++ b/src/State/FocusedElement.ts @@ -32,7 +32,7 @@ import { } from "../Utils.js"; import { getTabsterOnElement } from "../Instance.js"; import { dom } from "../DOMAPI.js"; -import { Subscribable } from "./Subscribable.js"; +import { createSubscribable } from "./Subscribable.js"; function getUncontrolledCompletelyContainer( tabster: Types.TabsterCore, @@ -75,319 +75,65 @@ interface AsyncFocus { timeout: number; } -export class FocusedElementState - extends Subscribable - implements Types.FocusedElementState -{ - private static _lastResetElement: WeakHTMLElement | undefined; - private static _isTabbingTimer: number | undefined; - static isTabbing = false; - - private _tabster: Types.TabsterCore; - private _win: Types.GetWindow; - private _nextVal: +interface FocusedElementStateInternal extends Types.FocusedElementState { + _nextVal: | { element: WeakHTMLElement | undefined; detail: Types.FocusedElementDetail; } | undefined; - private _lastVal: WeakHTMLElement | undefined; - private _asyncFocus?: AsyncFocus; - - constructor(tabster: Types.TabsterCore, getWindow: Types.GetWindow) { - super(); - - this._tabster = tabster; - this._win = getWindow; - tabster.queueInit(this._init); - } + _lastVal: WeakHTMLElement | undefined; +} - private _init = (): void => { - const win = this._win(); - const doc = win.document; +let _lastResetElement: WeakHTMLElement | undefined; - // Add these event listeners as capture - we want Tabster to run before user event handlers - addListener( - doc, - KEYBORG_FOCUSIN, - this._onFocusIn as EventListener, - true - ); - addListener( - doc, - KEYBORG_FOCUSOUT, - this._onFocusOut as EventListener, - true - ); - addListener(win, "keydown", this._onKeyDown, true); +export function createFocusedElementState( + tabster: Types.TabsterCore, + getWindow: Types.GetWindow +): Types.FocusedElementState { + const sub = createSubscribable< + HTMLElement | undefined, + Types.FocusedElementDetail + >(); + let nextVal: + | { + element: WeakHTMLElement | undefined; + detail: Types.FocusedElementDetail; + } + | undefined; + let lastVal: WeakHTMLElement | undefined; + let asyncFocus: AsyncFocus | undefined; - const activeElement = dom.getActiveElement(doc); + const setVal = ( + val: HTMLElement | undefined, + detail: Types.FocusedElementDetail + ): void => { + sub.setVal(val, detail); - if (activeElement && activeElement !== doc.body) { - this._setFocusedElement(activeElement as HTMLElement); + if (val) { + lastVal = new WeakHTMLElement(val); } - - this.subscribe(this._onChanged); }; - dispose(): void { - super.dispose(); - - const win = this._win(); - const doc = win.document; - - removeListener( - doc, - KEYBORG_FOCUSIN, - this._onFocusIn as EventListener, - true - ); - removeListener( - doc, - KEYBORG_FOCUSOUT, - this._onFocusOut as EventListener, - true - ); - removeListener(win, "keydown", this._onKeyDown, true); - - this.unsubscribe(this._onChanged); - - const asyncFocus = this._asyncFocus; - if (asyncFocus) { - win.clearTimeout(asyncFocus.timeout); - delete this._asyncFocus; - } - - delete FocusedElementState._lastResetElement; - - delete this._nextVal; - delete this._lastVal; - } - - static forgetMemorized( - instance: Types.FocusedElementState, - parent: HTMLElement - ): void { - let wel = FocusedElementState._lastResetElement; - let el = wel && wel.get(); - if (el && dom.nodeContains(parent, el)) { - delete FocusedElementState._lastResetElement; - } - - el = (instance as FocusedElementState)._nextVal?.element?.get(); - if (el && dom.nodeContains(parent, el)) { - delete (instance as FocusedElementState)._nextVal; - } - - wel = (instance as FocusedElementState)._lastVal; - el = wel && wel.get(); - if (el && dom.nodeContains(parent, el)) { - delete (instance as FocusedElementState)._lastVal; - } - } - - getFocusedElement(): HTMLElement | undefined { - return this.getVal(); - } - - getLastFocusedElement(): HTMLElement | undefined { - let el = this._lastVal?.get(); - - if (!el || (el && !documentContains(el.ownerDocument, el))) { - this._lastVal = el = undefined; - } - - return el; - } - - focus( - element: HTMLElement, - noFocusedProgrammaticallyFlag?: boolean, - noAccessibleCheck?: boolean, - preventScroll?: boolean - ): boolean { - if ( - !this._tabster.focusable.isFocusable( - element, - noFocusedProgrammaticallyFlag, - false, - noAccessibleCheck - ) - ) { - return false; - } - - element.focus({ preventScroll }); - - return true; - } - - focusDefault(container: HTMLElement): boolean { - const el = this._tabster.focusable.findDefault({ container }); - - if (el) { - this._tabster.focusedElement.focus(el); - - return true; - } - - return false; - } - - getFirstOrLastTabbable( - isFirst: boolean, - props: Pick< - Types.FindFocusableProps, - "container" | "ignoreAccessibility" - > - ): HTMLElement | undefined { - const { container, ignoreAccessibility } = props; - let toFocus: HTMLElement | null | undefined; - - if (container) { - const ctx = RootAPI.getTabsterContext(this._tabster, container); - - if (ctx) { - toFocus = FocusedElementState.findNextTabbable( - this._tabster, - ctx, - container, - undefined, - undefined, - !isFirst, - ignoreAccessibility - )?.element; - } - } - - if (toFocus && !dom.nodeContains(container, toFocus)) { - toFocus = undefined; - } - - return toFocus || undefined; - } - - private _focusFirstOrLast( - isFirst: boolean, - props: Types.FindFirstProps - ): boolean { - const toFocus = this.getFirstOrLastTabbable(isFirst, props); - - if (toFocus) { - this.focus(toFocus, false, true); - - return true; - } - - return false; - } - - focusFirst(props: Types.FindFirstProps): boolean { - return this._focusFirstOrLast(true, props); - } - - focusLast(props: Types.FindFirstProps): boolean { - return this._focusFirstOrLast(false, props); - } - - resetFocus(container: HTMLElement): boolean { - if (!this._tabster.focusable.isVisible(container)) { - return false; - } - - if (!this._tabster.focusable.isFocusable(container, true, true, true)) { - const prevTabIndex = container.getAttribute("tabindex"); - const prevAriaHidden = container.getAttribute("aria-hidden"); - - container.tabIndex = -1; - container.setAttribute("aria-hidden", "true"); - - FocusedElementState._lastResetElement = new WeakHTMLElement( - container - ); - - this.focus(container, true, true); - - this._setOrRemoveAttribute(container, "tabindex", prevTabIndex); - this._setOrRemoveAttribute( - container, - "aria-hidden", - prevAriaHidden - ); - } else { - this.focus(container); - } - - return true; - } - - requestAsyncFocus( - source: Types.AsyncFocusSource, - callback: () => void, - delay: number - ): void { - const win = this._tabster.getWindow(); - const currentAsyncFocus = this._asyncFocus; - - if (currentAsyncFocus) { - if ( - AsyncFocusIntentPriorityBySource[source] > - AsyncFocusIntentPriorityBySource[currentAsyncFocus.source] - ) { - // Previously registered intent has higher priority. - return; - } - - // New intent has higher priority. - win.clearTimeout(currentAsyncFocus.timeout); - } - - this._asyncFocus = { - source, - callback, - timeout: win.setTimeout(() => { - this._asyncFocus = undefined; - callback(); - }, delay), - }; - } - - cancelAsyncFocus(source: Types.AsyncFocusSource): void { - const asyncFocus = this._asyncFocus; - - if (asyncFocus?.source === source) { - this._tabster.getWindow().clearTimeout(asyncFocus.timeout); - this._asyncFocus = undefined; - } - } - - private _setOrRemoveAttribute( - element: HTMLElement, - name: string, - value: string | null - ): void { - if (value === null) { - element.removeAttribute(name); - } else { - element.setAttribute(name, value); - } - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const validateFocusedElement = (element: HTMLElement): void => { + // TODO: Make sure this is not needed anymore and write tests. + }; - private _setFocusedElement( + const setFocusedElement = ( element?: HTMLElement, relatedTarget?: HTMLElement, isFocusedProgrammatically?: boolean - ): void { - if (this._tabster._noop) { + ): void => { + if (tabster._noop) { return; } const detail: Types.FocusedElementDetail = { relatedTarget }; if (element) { - const lastResetElement = - FocusedElementState._lastResetElement?.get(); - FocusedElementState._lastResetElement = undefined; + const lastResetElement = _lastResetElement?.get(); + _lastResetElement = undefined; if (lastResetElement === element || shouldIgnoreFocus(element)) { return; @@ -395,7 +141,7 @@ export class FocusedElementState detail.isFocusedProgrammatically = isFocusedProgrammatically; - const ctx = RootAPI.getTabsterContext(this._tabster, element); + const ctx = RootAPI.getTabsterContext(tabster, element); const modalizerId = ctx?.modalizer?.userId; @@ -404,40 +150,29 @@ export class FocusedElementState } } - const nextVal = (this._nextVal = { + const tracked = (nextVal = { element: element ? new WeakHTMLElement(element) : undefined, detail, }); - if (element && element !== this._val) { - this._validateFocusedElement(element); + if (element && element !== sub.getVal()) { + validateFocusedElement(element); } - // _validateFocusedElement() might cause the refocus which will trigger + // validateFocusedElement() might cause the refocus which will trigger // another call to this function. Making sure that the value is correct. - if (this._nextVal === nextVal) { - this.setVal(element, detail); + if (nextVal === tracked) { + setVal(element, detail); } - this._nextVal = undefined; - } - - protected setVal( - val: HTMLElement | undefined, - detail: Types.FocusedElementDetail - ): void { - super.setVal(val, detail); - - if (val) { - this._lastVal = new WeakHTMLElement(val); - } - } + nextVal = undefined; + }; - private _onFocusIn = (e: KeyborgFocusInEvent): void => { + const onFocusIn = (e: KeyborgFocusInEvent): void => { const target = e.composedPath()[0] as HTMLElement; if (target) { - this._setFocusedElement( + setFocusedElement( target, e.detail.relatedTarget as HTMLElement | undefined, e.detail.isFocusedProgrammatically @@ -445,141 +180,19 @@ export class FocusedElementState } }; - private _onFocusOut = (e: KeyborgFocusOutEvent): void => { - this._setFocusedElement( + const onFocusOut = (e: KeyborgFocusOutEvent): void => { + setFocusedElement( undefined, e.detail?.originalEvent.relatedTarget as HTMLElement | undefined ); }; - static findNextTabbable( - tabster: Types.TabsterCore, - ctx: Types.TabsterContext, - container?: HTMLElement, - currentElement?: HTMLElement, - referenceElement?: HTMLElement, - isBackward?: boolean, - ignoreAccessibility?: boolean - ): Types.NextTabbable | null { - const actualContainer = container || ctx.root.getElement(); - - if (!actualContainer) { - return null; - } - - let next: Types.NextTabbable | null = null; - - const isTabbingTimer = FocusedElementState._isTabbingTimer; - const win = tabster.getWindow(); - - if (isTabbingTimer) { - win.clearTimeout(isTabbingTimer); - } - - FocusedElementState.isTabbing = true; - FocusedElementState._isTabbingTimer = win.setTimeout(() => { - delete FocusedElementState._isTabbingTimer; - FocusedElementState.isTabbing = false; - }, 0); - - const modalizer = ctx.modalizer; - const groupper = ctx.groupper; - const mover = ctx.mover; - - const callFindNext = ( - what: Types.Groupper | Types.Mover | Types.Modalizer - ) => { - next = what.findNextTabbable( - currentElement, - referenceElement, - isBackward, - ignoreAccessibility - ); - - if (currentElement && !next?.element) { - const parentElement = - what !== modalizer && - dom.getParentElement(what.getElement()); - - if (parentElement) { - const parentCtx = RootAPI.getTabsterContext( - tabster, - currentElement, - { referenceElement: parentElement } - ); - - if (parentCtx) { - const currentScopeElement = what.getElement(); - const newCurrent = isBackward - ? currentScopeElement - : (currentScopeElement && - getLastChild(currentScopeElement)) || - currentScopeElement; - - if (newCurrent) { - next = FocusedElementState.findNextTabbable( - tabster, - parentCtx, - container, - newCurrent, - parentElement, - isBackward, - ignoreAccessibility - ); - - if (next) { - next.outOfDOMOrder = true; - } - } - } - } - } - }; - - if (groupper && mover) { - callFindNext(ctx.groupperBeforeMover ? groupper : mover); - } else if (groupper) { - callFindNext(groupper); - } else if (mover) { - callFindNext(mover); - } else if (modalizer) { - callFindNext(modalizer); - } else { - const findProps: Types.FindNextProps = { - container: actualContainer, - currentElement, - referenceElement, - ignoreAccessibility, - useActiveModalizer: true, - }; - - const findPropsOut: Types.FindFocusableOutputProps = {}; - - const nextElement = tabster.focusable[ - isBackward ? "findPrev" : "findNext" - ](findProps, findPropsOut); - - next = { - element: nextElement, - outOfDOMOrder: findPropsOut.outOfDOMOrder, - uncontrolled: findPropsOut.uncontrolled, - }; - } - - return next; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private _validateFocusedElement = (element: HTMLElement): void => { - // TODO: Make sure this is not needed anymore and write tests. - }; - - private _onKeyDown = (event: KeyboardEvent): void => { + const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== Keys.Tab || event.ctrlKey) { return; } - const currentElement = this.getVal(); + const currentElement = sub.getVal(); if ( !currentElement || @@ -589,7 +202,6 @@ export class FocusedElementState return; } - const tabster = this._tabster; const controlTab = tabster.controlTab; const ctx = RootAPI.getTabsterContext(tabster, currentElement); @@ -725,18 +337,18 @@ export class FocusedElementState } }; - _onChanged = ( + const onChanged = ( element: HTMLElement | undefined, detail: Types.FocusedElementDetail ): void => { if (element) { dispatchEvent(element, new TabsterFocusInEvent(detail)); } else { - const last = this._lastVal?.get(); + const last = lastVal?.get(); if (last) { const d = { ...detail }; - const lastCtx = RootAPI.getTabsterContext(this._tabster, last); + const lastCtx = RootAPI.getTabsterContext(tabster, last); const modalizerId = lastCtx?.modalizer?.userId; if (modalizerId) { @@ -747,4 +359,402 @@ export class FocusedElementState } } }; + + const setOrRemoveAttribute = ( + element: HTMLElement, + name: string, + value: string | null + ): void => { + if (value === null) { + element.removeAttribute(name); + } else { + element.setAttribute(name, value); + } + }; + + tabster.queueInit(() => { + const win = getWindow(); + const doc = win.document; + + // Add these event listeners as capture - we want Tabster to run before user event handlers + addListener(doc, KEYBORG_FOCUSIN, onFocusIn as EventListener, true); + addListener(doc, KEYBORG_FOCUSOUT, onFocusOut as EventListener, true); + addListener(win, "keydown", onKeyDown, true); + + const activeElement = dom.getActiveElement(doc); + + if (activeElement && activeElement !== doc.body) { + setFocusedElement(activeElement as HTMLElement); + } + + sub.subscribe(onChanged); + }); + + const api: FocusedElementStateInternal = { + get _nextVal() { + return nextVal; + }, + set _nextVal(value) { + nextVal = value; + }, + get _lastVal() { + return lastVal; + }, + set _lastVal(value) { + lastVal = value; + }, + + subscribe: sub.subscribe, + subscribeFirst: sub.subscribeFirst, + unsubscribe: sub.unsubscribe, + + dispose(): void { + sub.dispose(); + + const win = getWindow(); + const doc = win.document; + + removeListener( + doc, + KEYBORG_FOCUSIN, + onFocusIn as EventListener, + true + ); + removeListener( + doc, + KEYBORG_FOCUSOUT, + onFocusOut as EventListener, + true + ); + removeListener(win, "keydown", onKeyDown, true); + + sub.unsubscribe(onChanged); + + if (asyncFocus) { + win.clearTimeout(asyncFocus.timeout); + asyncFocus = undefined; + } + + _lastResetElement = undefined; + + nextVal = undefined; + lastVal = undefined; + }, + + getFocusedElement(): HTMLElement | undefined { + return sub.getVal(); + }, + + getLastFocusedElement(): HTMLElement | undefined { + let el = lastVal?.get(); + + if (!el || (el && !documentContains(el.ownerDocument, el))) { + lastVal = el = undefined; + } + + return el; + }, + + focus( + element: HTMLElement, + noFocusedProgrammaticallyFlag?: boolean, + noAccessibleCheck?: boolean, + preventScroll?: boolean + ): boolean { + if ( + !tabster.focusable.isFocusable( + element, + noFocusedProgrammaticallyFlag, + false, + noAccessibleCheck + ) + ) { + return false; + } + + element.focus({ preventScroll }); + + return true; + }, + + focusDefault(container: HTMLElement): boolean { + const el = tabster.focusable.findDefault({ container }); + + if (el) { + tabster.focusedElement.focus(el); + + return true; + } + + return false; + }, + + getFirstOrLastTabbable( + isFirst: boolean, + props: Pick< + Types.FindFocusableProps, + "container" | "ignoreAccessibility" + > + ): HTMLElement | undefined { + const { container, ignoreAccessibility } = props; + let toFocus: HTMLElement | null | undefined; + + if (container) { + const ctx = RootAPI.getTabsterContext(tabster, container); + + if (ctx) { + toFocus = FocusedElementState.findNextTabbable( + tabster, + ctx, + container, + undefined, + undefined, + !isFirst, + ignoreAccessibility + )?.element; + } + } + + if (toFocus && !dom.nodeContains(container, toFocus)) { + toFocus = undefined; + } + + return toFocus || undefined; + }, + + focusFirst(props: Types.FindFirstProps): boolean { + const toFocus = api.getFirstOrLastTabbable(true, props); + + if (toFocus) { + api.focus(toFocus, false, true); + + return true; + } + + return false; + }, + + focusLast(props: Types.FindFirstProps): boolean { + const toFocus = api.getFirstOrLastTabbable(false, props); + + if (toFocus) { + api.focus(toFocus, false, true); + + return true; + } + + return false; + }, + + resetFocus(container: HTMLElement): boolean { + if (!tabster.focusable.isVisible(container)) { + return false; + } + + if (!tabster.focusable.isFocusable(container, true, true, true)) { + const prevTabIndex = container.getAttribute("tabindex"); + const prevAriaHidden = container.getAttribute("aria-hidden"); + + container.tabIndex = -1; + container.setAttribute("aria-hidden", "true"); + + _lastResetElement = new WeakHTMLElement(container); + + api.focus(container, true, true); + + setOrRemoveAttribute(container, "tabindex", prevTabIndex); + setOrRemoveAttribute(container, "aria-hidden", prevAriaHidden); + } else { + api.focus(container); + } + + return true; + }, + + requestAsyncFocus( + source: Types.AsyncFocusSource, + callback: () => void, + delay: number + ): void { + const win = tabster.getWindow(); + const currentAsyncFocus = asyncFocus; + + if (currentAsyncFocus) { + if ( + AsyncFocusIntentPriorityBySource[source] > + AsyncFocusIntentPriorityBySource[currentAsyncFocus.source] + ) { + // Previously registered intent has higher priority. + return; + } + + // New intent has higher priority. + win.clearTimeout(currentAsyncFocus.timeout); + } + + asyncFocus = { + source, + callback, + timeout: win.setTimeout(() => { + asyncFocus = undefined; + callback(); + }, delay), + }; + }, + + cancelAsyncFocus(source: Types.AsyncFocusSource): void { + if (asyncFocus?.source === source) { + tabster.getWindow().clearTimeout(asyncFocus.timeout); + asyncFocus = undefined; + } + }, + }; + + return api; } + +let _isTabbingTimer: number | undefined; + +export const FocusedElementState = { + isTabbing: false, + + forgetMemorized( + instance: Types.FocusedElementState, + parent: HTMLElement + ): void { + const internal = instance as FocusedElementStateInternal; + + let wel = _lastResetElement; + let el = wel && wel.get(); + if (el && dom.nodeContains(parent, el)) { + _lastResetElement = undefined; + } + + el = internal._nextVal?.element?.get(); + if (el && dom.nodeContains(parent, el)) { + internal._nextVal = undefined; + } + + wel = internal._lastVal; + el = wel && wel.get(); + if (el && dom.nodeContains(parent, el)) { + internal._lastVal = undefined; + } + }, + + findNextTabbable( + tabster: Types.TabsterCore, + ctx: Types.TabsterContext, + container?: HTMLElement, + currentElement?: HTMLElement, + referenceElement?: HTMLElement, + isBackward?: boolean, + ignoreAccessibility?: boolean + ): Types.NextTabbable | null { + const actualContainer = container || ctx.root.getElement(); + + if (!actualContainer) { + return null; + } + + let next: Types.NextTabbable | null = null; + + const win = tabster.getWindow(); + + if (_isTabbingTimer) { + win.clearTimeout(_isTabbingTimer); + } + + FocusedElementState.isTabbing = true; + _isTabbingTimer = win.setTimeout(() => { + _isTabbingTimer = undefined; + FocusedElementState.isTabbing = false; + }, 0); + + const modalizer = ctx.modalizer; + const groupper = ctx.groupper; + const mover = ctx.mover; + + const callFindNext = ( + what: Types.Groupper | Types.Mover | Types.Modalizer + ) => { + next = what.findNextTabbable( + currentElement, + referenceElement, + isBackward, + ignoreAccessibility + ); + + if (currentElement && !next?.element) { + const parentElement = + what !== modalizer && + dom.getParentElement(what.getElement()); + + if (parentElement) { + const parentCtx = RootAPI.getTabsterContext( + tabster, + currentElement, + { referenceElement: parentElement } + ); + + if (parentCtx) { + const currentScopeElement = what.getElement(); + const newCurrent = isBackward + ? currentScopeElement + : (currentScopeElement && + getLastChild(currentScopeElement)) || + currentScopeElement; + + if (newCurrent) { + next = FocusedElementState.findNextTabbable( + tabster, + parentCtx, + container, + newCurrent, + parentElement, + isBackward, + ignoreAccessibility + ); + + if (next) { + next.outOfDOMOrder = true; + } + } + } + } + } + }; + + if (groupper && mover) { + callFindNext(ctx.groupperBeforeMover ? groupper : mover); + } else if (groupper) { + callFindNext(groupper); + } else if (mover) { + callFindNext(mover); + } else if (modalizer) { + callFindNext(modalizer); + } else { + const findProps: Types.FindNextProps = { + container: actualContainer, + currentElement, + referenceElement, + ignoreAccessibility, + useActiveModalizer: true, + }; + + const findPropsOut: Types.FindFocusableOutputProps = {}; + + const nextElement = tabster.focusable[ + isBackward ? "findPrev" : "findNext" + ](findProps, findPropsOut); + + next = { + element: nextElement, + outOfDOMOrder: findPropsOut.outOfDOMOrder, + uncontrolled: findPropsOut.uncontrolled, + }; + } + + return next; + }, +}; diff --git a/src/State/KeyboardNavigation.ts b/src/State/KeyboardNavigation.ts index 611ab398..e3cc5d44 100644 --- a/src/State/KeyboardNavigation.ts +++ b/src/State/KeyboardNavigation.ts @@ -6,41 +6,41 @@ import { createKeyborg, disposeKeyborg, type Keyborg } from "keyborg"; import type * as Types from "../Types.js"; -import { Subscribable } from "./Subscribable.js"; +import { createSubscribable } from "./Subscribable.js"; -export class KeyboardNavigationState - extends Subscribable - implements Types.KeyboardNavigationState -{ - private _keyborg?: Keyborg; +export function createKeyboardNavigationState( + getWindow: Types.GetWindow +): Types.KeyboardNavigationState { + const sub = createSubscribable(); + let keyborg: Keyborg | undefined = createKeyborg(getWindow()); - constructor(getWindow: Types.GetWindow) { - super(); - this._keyborg = createKeyborg(getWindow()); - this._keyborg.subscribe(this._onChange); - } - - dispose(): void { - super.dispose(); + const onChange = (isNavigatingWithKeyboard: boolean) => { + sub.setVal(isNavigatingWithKeyboard, undefined); + }; - if (this._keyborg) { - this._keyborg.unsubscribe(this._onChange); + keyborg.subscribe(onChange); - disposeKeyborg(this._keyborg); + return { + subscribe: sub.subscribe, + subscribeFirst: sub.subscribeFirst, + unsubscribe: sub.unsubscribe, - delete this._keyborg; - } - } + dispose(): void { + sub.dispose(); - private _onChange = (isNavigatingWithKeyboard: boolean) => { - this.setVal(isNavigatingWithKeyboard, undefined); - }; + if (keyborg) { + keyborg.unsubscribe(onChange); + disposeKeyborg(keyborg); + keyborg = undefined; + } + }, - setNavigatingWithKeyboard(isNavigatingWithKeyboard: boolean): void { - this._keyborg?.setVal(isNavigatingWithKeyboard); - } + setNavigatingWithKeyboard(isNavigatingWithKeyboard: boolean): void { + keyborg?.setVal(isNavigatingWithKeyboard); + }, - isNavigatingWithKeyboard(): boolean { - return !!this._keyborg?.isNavigatingWithKeyboard(); - } + isNavigatingWithKeyboard(): boolean { + return !!keyborg?.isNavigatingWithKeyboard(); + }, + }; } diff --git a/src/State/ObservedElement.ts b/src/State/ObservedElement.ts index 3b7225cb..c07dc993 100644 --- a/src/State/ObservedElement.ts +++ b/src/State/ObservedElement.ts @@ -11,7 +11,7 @@ import { ObservedElementFailureReasons, } from "../Consts.js"; import { documentContains, getElementUId, WeakHTMLElement } from "../Utils.js"; -import { Subscribable } from "./Subscribable.js"; +import { createSubscribable } from "./Subscribable.js"; const _conditionCheckTimeout = 100; @@ -28,58 +28,33 @@ interface ObservedWaiting { reject?: () => void; } -export class ObservedElementAPI - extends Subscribable - implements Types.ObservedElementAPI -{ - private _win: Types.GetWindow; - private _tabster: Types.TabsterCore; - private _waiting: Record = {}; - private _lastRequestFocusId = 0; - private _observedById: { [uid: string]: ObservedElementInfo } = {}; - private _observedByName: { +export function createObservedElementAPI( + tabster: Types.TabsterCore +): Types.ObservedElementAPI { + const sub = createSubscribable(); + const win = tabster.getWindow; + const waiting: Record = {}; + let lastRequestFocusId = 0; + let observedById: { [uid: string]: ObservedElementInfo } = {}; + let observedByName: { [name: string]: { [uid: string]: ObservedElementInfo }; } = {}; - private _currentRequest: + let currentRequest: | Types.ObservedElementAsyncRequest | undefined; - private _currentRequestTimestamp = 0; - onObservedElementChange?: (change: Types.ObservedElementChange) => void; + let currentRequestTimestamp = 0; - constructor(tabster: Types.TabsterCore) { - super(); - this._tabster = tabster; - this._win = tabster.getWindow; - - tabster.queueInit(() => { - this._tabster.focusedElement.subscribe(this._onFocus); - }); - } - - dispose(): void { - this._tabster.focusedElement.unsubscribe(this._onFocus); - - for (const key of Object.keys(this._waiting)) { - this._rejectWaiting(key); - } - - this._observedById = {}; - this._observedByName = {}; - this.onObservedElementChange = undefined; - } - - private _onFocus = (e: HTMLElement | undefined): void => { + const onFocus = (e: HTMLElement | undefined): void => { if (e) { - const current = this._currentRequest; - - if (current) { - const delta = Date.now() - this._currentRequestTimestamp; + if (currentRequest) { + const delta = Date.now() - currentRequestTimestamp; const settleTime = 300; if (delta >= settleTime) { // Giving some time for the focus to settle before // automatically cancelling the current request on focus change. - delete this._currentRequest; + const current = currentRequest; + currentRequest = undefined; // Provide callback to access focused element using WeakRef to avoid memory leaks const elementRef = new WeakRef(e); @@ -93,18 +68,18 @@ export class ObservedElementAPI } }; - private _rejectWaiting(key: string, shouldResolve?: boolean): void { - const w = this._waiting[key]; + const rejectWaiting = (key: string, shouldResolve?: boolean): void => { + const w = waiting[key]; if (w) { - const win = this._win(); + const w2 = win(); if (w.timer) { - win.clearTimeout(w.timer); + w2.clearTimeout(w.timer); } if (w.conditionTimer) { - win.clearTimeout(w.conditionTimer); + w2.clearTimeout(w.conditionTimer); } if (!shouldResolve && w.reject) { @@ -113,17 +88,17 @@ export class ObservedElementAPI w.resolve(null); } - delete this._waiting[key]; + delete waiting[key]; } - } + }; - private _populateTimeoutDiagnostics( + const populateTimeoutDiagnostics = ( request: Types.ObservedElementAsyncRequest, observedName: string, timeout: number, startTime: number - ): void { - const elementInDOM = this.getElement(observedName); + ): void => { + const elementInDOM = api.getElement(observedName); const inDOM = !!elementInDOM; let isAccessible: boolean | undefined; let isFocusable: boolean | undefined; @@ -132,11 +107,8 @@ export class ObservedElementAPI if (!elementInDOM) { reason = ObservedElementFailureReasons.TimeoutElementNotInDOM; } else { - isAccessible = this._tabster.focusable.isAccessible(elementInDOM); - isFocusable = this._tabster.focusable.isFocusable( - elementInDOM, - true - ); + isAccessible = tabster.focusable.isAccessible(elementInDOM); + isFocusable = tabster.focusable.isFocusable(elementInDOM, true); if (!isAccessible) { reason = @@ -156,9 +128,9 @@ export class ObservedElementAPI isAccessible, isFocusable, }; - } + }; - private _isObservedNamesUpdated(cur: string[], prev?: string[]) { + const isObservedNamesUpdated = (cur: string[], prev?: string[]) => { if (!prev || cur.length !== prev.length) { return true; } @@ -168,15 +140,15 @@ export class ObservedElementAPI } } return false; - } + }; - private _notifyObservedElementChange( + const notifyObservedElementChange = ( element: HTMLElement, observedNames: string[], prevNames: string[] | undefined, isNewElement: boolean - ): void { - if (!this.onObservedElementChange) { + ): void => { + if (!api.onObservedElementChange) { return; } @@ -189,7 +161,7 @@ export class ObservedElementAPI if (isNewElement) { // Brand new element added - this.onObservedElementChange({ + api.onObservedElementChange({ element, type: "added", names: observedNames, @@ -197,7 +169,7 @@ export class ObservedElementAPI }); } else if (addedNames.length > 0 || removedNames.length > 0) { // Existing element with names updated - this.onObservedElementChange({ + api.onObservedElementChange({ element, type: "updated", names: observedNames, @@ -206,392 +178,44 @@ export class ObservedElementAPI removedNames.length > 0 ? removedNames : undefined, }); } - } - - /** - * Returns all registered observed names with their respective elements and full names arrays - * - * @returns Map> A map where keys are observed names - * and values are arrays of objects containing the element and its complete names array (in the order they were defined) - */ - getAllObservedElements(): Map< - string, - Array<{ element: HTMLElement; names: string[] }> - > { - const result = new Map< - string, - Array<{ element: HTMLElement; names: string[] }> - >(); - - for (const name of Object.keys(this._observedByName)) { - const elementsWithNames: Array<{ - element: HTMLElement; - names: string[]; - }> = []; - const observed = this._observedByName[name]; - - for (const uid of Object.keys(observed)) { - const el = observed[uid].element.get(); - if (el) { - const info = this._observedById[uid]; - elementsWithNames.push({ - element: el, - names: info?.prevNames || [], - }); - } - } - - if (elementsWithNames.length > 0) { - result.set(name, elementsWithNames); - } - } - - return result; - } - - /** - * Returns existing element by observed name - * - * @param observedName An observed name - * @param accessibility Optionally, return only if the element is accessible or focusable - * @returns HTMLElement | null - */ - getElement( - observedName: string, - accessibility?: Types.ObservedElementAccessibility - ): HTMLElement | null { - const o = this._observedByName[observedName]; - - if (o) { - for (const uid of Object.keys(o)) { - let el = o[uid].element.get() || null; - if (el) { - if ( - (accessibility === - ObservedElementAccessibilities.Accessible && - !this._tabster.focusable.isAccessible(el)) || - (accessibility === - ObservedElementAccessibilities.Focusable && - !this._tabster.focusable.isFocusable(el, true)) - ) { - el = null; - } - } else { - delete o[uid]; - delete this._observedById[uid]; - } - - return el; - } - } - - return null; - } - - /** - * Waits for the element to appear in the DOM and returns it. - * - * @param observedName An observed name - * @param timeout Wait no longer than this timeout - * @param accessibility Optionally, wait for the element to also become accessible or focusable before returning it - * @returns Promise - */ - waitElement( - observedName: string, - timeout: number, - accessibility?: Types.ObservedElementAccessibility - ): Types.ObservedElementAsyncRequest { - const startTime = Date.now(); - const el = this.getElement(observedName, accessibility); - - if (el) { - return { - result: Promise.resolve(el), - cancel: () => { - /**/ - }, - status: ObservedElementRequestStatuses.Succeeded, - diagnostics: { - waitForElementDuration: Date.now() - startTime, - }, - }; - } - - let prefix: string; - - if (accessibility === ObservedElementAccessibilities.Accessible) { - prefix = "a"; - } else if (accessibility === ObservedElementAccessibilities.Focusable) { - prefix = "f"; - } else { - prefix = "_"; - } - - const key = prefix + observedName; - let w = this._waiting[key]; - - if (w && w.request) { - return w.request; - } - - w = this._waiting[key] = { - timer: this._win().setTimeout(() => { - if (w.conditionTimer) { - this._win().clearTimeout(w.conditionTimer); - } - - delete this._waiting[key]; - - if (w.request) { - w.request.status = ObservedElementRequestStatuses.TimedOut; - this._populateTimeoutDiagnostics( - w.request, - observedName, - timeout, - startTime - ); - } - - if (w.resolve) { - w.resolve(null); - } - }, timeout), - }; - - const promise = new Promise((resolve, reject) => { - w.resolve = resolve; - w.reject = reject; - }).catch(() => { - // Ignore the error, it is expected to be rejected when the request is canceled. - return null; - }); - - const request: Types.ObservedElementAsyncRequest = { - result: promise, - cancel: () => { - if (request.status === ObservedElementRequestStatuses.Waiting) { - // cancel() function is callable by user, someone might call it after request is finished, - // we are making sure that status of a finished request is not overriden. - request.status = ObservedElementRequestStatuses.Canceled; - request.diagnostics.waitForElementDuration = - Date.now() - startTime; - } - this._rejectWaiting(key, true); - }, - status: ObservedElementRequestStatuses.Waiting, - diagnostics: {}, - }; - - w.request = request; - - if (accessibility && this.getElement(observedName)) { - // If the observed element is alread in DOM, but not accessible yet, - // we need to run the wait logic. - this._waitConditional(observedName); - } - - return request; - } - - requestFocus( - observedName: string, - timeout: number, - options: Pick = {} - ): Types.ObservedElementAsyncRequest { - const requestId = ++this._lastRequestFocusId; - const currentRequestFocus = this._currentRequest; - - if (currentRequestFocus) { - currentRequestFocus.diagnostics.reason = - ObservedElementFailureReasons.SupersededByNewRequest; - currentRequestFocus.cancel(); - } - - const request = this.waitElement( - observedName, - timeout, - ObservedElementAccessibilities.Focusable - ); - - this._currentRequest = request; - this._currentRequestTimestamp = Date.now(); - - const ret: Types.ObservedElementAsyncRequest = { - result: request.result.then((element) => { - if (this._lastRequestFocusId !== requestId) { - request.diagnostics.reason = - ObservedElementFailureReasons.SupersededByNewRequest; - return false; - } - - if (!element) { - // Element was not found - reason should already be set by timeout or cancellation - // If not set, default to timeout reason - if (request.diagnostics.reason === undefined) { - request.diagnostics.reason = - ObservedElementFailureReasons.TimeoutElementNotInDOM; - } - return false; - } - - const focusResult = this._tabster.focusedElement.focus( - element, - true, - undefined, - options.preventScroll - ); - - if (!focusResult) { - // Focus call failed - request.diagnostics.reason = - ObservedElementFailureReasons.FocusCallFailed; - } - - return focusResult; - }), - cancel: () => { - request.cancel(); - }, - status: request.status, - diagnostics: request.diagnostics, - }; - - request.result.finally(() => { - if (this._currentRequest === request) { - delete this._currentRequest; - } - - ret.status = request.status; - }); - - return ret; - } - - onObservedElementUpdate = (element: HTMLElement): void => { - const observed = getTabsterOnElement(this._tabster, element)?.observed; - const uid = getElementUId(this._win, element); - let info: ObservedElementInfo | undefined = this._observedById[uid]; - - if (observed && documentContains(element.ownerDocument, element)) { - const isNewElement = !info; - - if (!info) { - info = this._observedById[uid] = { - element: new WeakHTMLElement(element), - }; - } - - observed.names.sort(); - const observedNames = observed.names; - const prevNames = info.prevNames; // prevNames are already sorted - - if (this._isObservedNamesUpdated(observedNames, prevNames)) { - if (prevNames) { - prevNames.forEach((prevName) => { - const obn = this._observedByName[prevName]; - - if (obn && obn[uid]) { - if (Object.keys(obn).length > 1) { - delete obn[uid]; - } else { - delete this._observedByName[prevName]; - } - } - }); - } - - info.prevNames = observedNames; - - this._notifyObservedElementChange( - element, - observedNames, - prevNames, - isNewElement - ); - } - - observedNames.forEach((observedName) => { - let obn = this._observedByName[observedName]; - - if (!obn) { - obn = this._observedByName[observedName] = {}; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - obn[uid] = info!; - - this._waitConditional(observedName); - }); - } else if (info) { - const prevNames = info.prevNames; - - if (prevNames) { - prevNames.forEach((prevName) => { - const obn = this._observedByName[prevName]; - - if (obn && obn[uid]) { - if (Object.keys(obn).length > 1) { - delete obn[uid]; - } else { - delete this._observedByName[prevName]; - } - } - }); - - this.onObservedElementChange?.({ - element, - type: "removed", - names: [], - removedNames: prevNames, - }); - } - - delete this._observedById[uid]; - } }; - private _waitConditional(observedName: string): void { + const waitConditional = (observedName: string): void => { const waitingElementKey = "_" + observedName; const waitingAccessibleElementKey = "a" + observedName; const waitingFocusableElementKey = "f" + observedName; - const waitingElement = this._waiting[waitingElementKey]; - const waitingAccessibleElement = - this._waiting[waitingAccessibleElementKey]; - const waitingFocusableElement = - this._waiting[waitingFocusableElementKey]; - const win = this._win(); + const waitingElement = waiting[waitingElementKey]; + const waitingAccessibleElement = waiting[waitingAccessibleElementKey]; + const waitingFocusableElement = waiting[waitingFocusableElementKey]; + const w = win(); const resolve = ( element: HTMLElement, key: string, - waiting: ObservedWaiting, + wait: ObservedWaiting, accessibility: Types.ObservedElementAccessibility ) => { - const observed = getTabsterOnElement( - this._tabster, - element - )?.observed; + const observed = getTabsterOnElement(tabster, element)?.observed; if (!observed || !observed.names.includes(observedName)) { return; } - if (waiting.timer) { - win.clearTimeout(waiting.timer); + if (wait.timer) { + w.clearTimeout(wait.timer); } - delete this._waiting[key]; + delete waiting[key]; - if (waiting.request) { - waiting.request.status = - ObservedElementRequestStatuses.Succeeded; + if (wait.request) { + wait.request.status = ObservedElementRequestStatuses.Succeeded; } - if (waiting.resolve) { - waiting.resolve(element); + if (wait.resolve) { + wait.resolve(element); } - this.trigger(element, { + sub.trigger(element, { names: [observedName], details: observed.details, accessibility, @@ -599,7 +223,7 @@ export class ObservedElementAPI }; if (waitingElement) { - const element = this.getElement(observedName); + const element = api.getElement(observedName); if (element && documentContains(element.ownerDocument, element)) { resolve( @@ -616,12 +240,12 @@ export class ObservedElementAPI !waitingAccessibleElement.conditionTimer ) { const resolveAccessible = () => { - const element = this.getElement(observedName); + const element = api.getElement(observedName); if ( element && documentContains(element.ownerDocument, element) && - this._tabster.focusable.isAccessible(element) + tabster.focusable.isAccessible(element) ) { resolve( element, @@ -630,7 +254,7 @@ export class ObservedElementAPI ObservedElementAccessibilities.Accessible ); } else { - waitingAccessibleElement.conditionTimer = win.setTimeout( + waitingAccessibleElement.conditionTimer = w.setTimeout( resolveAccessible, _conditionCheckTimeout ); @@ -645,12 +269,12 @@ export class ObservedElementAPI !waitingFocusableElement.conditionTimer ) { const resolveFocusable = () => { - const element = this.getElement(observedName); + const element = api.getElement(observedName); if ( element && documentContains(element.ownerDocument, element) && - this._tabster.focusable.isFocusable(element, true) + tabster.focusable.isFocusable(element, true) ) { resolve( element, @@ -659,7 +283,7 @@ export class ObservedElementAPI ObservedElementAccessibilities.Focusable ); } else { - waitingFocusableElement.conditionTimer = win.setTimeout( + waitingFocusableElement.conditionTimer = w.setTimeout( resolveFocusable, _conditionCheckTimeout ); @@ -668,5 +292,372 @@ export class ObservedElementAPI resolveFocusable(); } - } + }; + + tabster.queueInit(() => { + tabster.focusedElement.subscribe(onFocus); + }); + + const api: Types.ObservedElementAPI = { + onObservedElementChange: undefined, + + subscribe: sub.subscribe, + subscribeFirst: sub.subscribeFirst, + unsubscribe: sub.unsubscribe, + + dispose(): void { + tabster.focusedElement.unsubscribe(onFocus); + + for (const key of Object.keys(waiting)) { + rejectWaiting(key); + } + + observedById = {}; + observedByName = {}; + api.onObservedElementChange = undefined; + sub.dispose(); + }, + + /** + * Returns all registered observed names with their respective elements and full names arrays + */ + getAllObservedElements(): Map< + string, + Array<{ element: HTMLElement; names: string[] }> + > { + const result = new Map< + string, + Array<{ element: HTMLElement; names: string[] }> + >(); + + for (const name of Object.keys(observedByName)) { + const elementsWithNames: Array<{ + element: HTMLElement; + names: string[]; + }> = []; + const observed = observedByName[name]; + + for (const uid of Object.keys(observed)) { + const el = observed[uid].element.get(); + if (el) { + const info = observedById[uid]; + elementsWithNames.push({ + element: el, + names: info?.prevNames || [], + }); + } + } + + if (elementsWithNames.length > 0) { + result.set(name, elementsWithNames); + } + } + + return result; + }, + + /** + * Returns existing element by observed name + */ + getElement( + observedName: string, + accessibility?: Types.ObservedElementAccessibility + ): HTMLElement | null { + const o = observedByName[observedName]; + + if (o) { + for (const uid of Object.keys(o)) { + let el = o[uid].element.get() || null; + if (el) { + if ( + (accessibility === + ObservedElementAccessibilities.Accessible && + !tabster.focusable.isAccessible(el)) || + (accessibility === + ObservedElementAccessibilities.Focusable && + !tabster.focusable.isFocusable(el, true)) + ) { + el = null; + } + } else { + delete o[uid]; + delete observedById[uid]; + } + + return el; + } + } + + return null; + }, + + /** + * Waits for the element to appear in the DOM and returns it. + */ + waitElement( + observedName: string, + timeout: number, + accessibility?: Types.ObservedElementAccessibility + ): Types.ObservedElementAsyncRequest { + const startTime = Date.now(); + const el = api.getElement(observedName, accessibility); + + if (el) { + return { + result: Promise.resolve(el), + cancel: () => { + /**/ + }, + status: ObservedElementRequestStatuses.Succeeded, + diagnostics: { + waitForElementDuration: Date.now() - startTime, + }, + }; + } + + let prefix: string; + + if (accessibility === ObservedElementAccessibilities.Accessible) { + prefix = "a"; + } else if ( + accessibility === ObservedElementAccessibilities.Focusable + ) { + prefix = "f"; + } else { + prefix = "_"; + } + + const key = prefix + observedName; + let w = waiting[key]; + + if (w && w.request) { + return w.request; + } + + w = waiting[key] = { + timer: win().setTimeout(() => { + if (w.conditionTimer) { + win().clearTimeout(w.conditionTimer); + } + + delete waiting[key]; + + if (w.request) { + w.request.status = + ObservedElementRequestStatuses.TimedOut; + populateTimeoutDiagnostics( + w.request, + observedName, + timeout, + startTime + ); + } + + if (w.resolve) { + w.resolve(null); + } + }, timeout), + }; + + const promise = new Promise( + (resolve, reject) => { + w.resolve = resolve; + w.reject = reject; + } + ).catch(() => { + // Ignore the error, it is expected to be rejected when the request is canceled. + return null; + }); + + const request: Types.ObservedElementAsyncRequest = + { + result: promise, + cancel: () => { + if ( + request.status === + ObservedElementRequestStatuses.Waiting + ) { + // cancel() function is callable by user, someone might call it after request is finished, + // we are making sure that status of a finished request is not overriden. + request.status = + ObservedElementRequestStatuses.Canceled; + request.diagnostics.waitForElementDuration = + Date.now() - startTime; + } + rejectWaiting(key, true); + }, + status: ObservedElementRequestStatuses.Waiting, + diagnostics: {}, + }; + + w.request = request; + + if (accessibility && api.getElement(observedName)) { + // If the observed element is alread in DOM, but not accessible yet, + // we need to run the wait logic. + waitConditional(observedName); + } + + return request; + }, + + requestFocus( + observedName: string, + timeout: number, + options: Pick = {} + ): Types.ObservedElementAsyncRequest { + const requestId = ++lastRequestFocusId; + const currentRequestFocus = currentRequest; + + if (currentRequestFocus) { + currentRequestFocus.diagnostics.reason = + ObservedElementFailureReasons.SupersededByNewRequest; + currentRequestFocus.cancel(); + } + + const request = api.waitElement( + observedName, + timeout, + ObservedElementAccessibilities.Focusable + ); + + currentRequest = request; + currentRequestTimestamp = Date.now(); + + const ret: Types.ObservedElementAsyncRequest = { + result: request.result.then((element) => { + if (lastRequestFocusId !== requestId) { + request.diagnostics.reason = + ObservedElementFailureReasons.SupersededByNewRequest; + return false; + } + + if (!element) { + // Element was not found - reason should already be set by timeout or cancellation + // If not set, default to timeout reason + if (request.diagnostics.reason === undefined) { + request.diagnostics.reason = + ObservedElementFailureReasons.TimeoutElementNotInDOM; + } + return false; + } + + const focusResult = tabster.focusedElement.focus( + element, + true, + undefined, + options.preventScroll + ); + + if (!focusResult) { + // Focus call failed + request.diagnostics.reason = + ObservedElementFailureReasons.FocusCallFailed; + } + + return focusResult; + }), + cancel: () => { + request.cancel(); + }, + status: request.status, + diagnostics: request.diagnostics, + }; + + request.result.finally(() => { + if (currentRequest === request) { + currentRequest = undefined; + } + + ret.status = request.status; + }); + + return ret; + }, + + onObservedElementUpdate(element: HTMLElement): void { + const observed = getTabsterOnElement(tabster, element)?.observed; + const uid = getElementUId(win, element); + let info: ObservedElementInfo | undefined = observedById[uid]; + + if (observed && documentContains(element.ownerDocument, element)) { + const isNewElement = !info; + + if (!info) { + info = observedById[uid] = { + element: new WeakHTMLElement(element), + }; + } + + observed.names.sort(); + const observedNames = observed.names; + const prevNames = info.prevNames; // prevNames are already sorted + + if (isObservedNamesUpdated(observedNames, prevNames)) { + if (prevNames) { + prevNames.forEach((prevName) => { + const obn = observedByName[prevName]; + + if (obn && obn[uid]) { + if (Object.keys(obn).length > 1) { + delete obn[uid]; + } else { + delete observedByName[prevName]; + } + } + }); + } + + info.prevNames = observedNames; + + notifyObservedElementChange( + element, + observedNames, + prevNames, + isNewElement + ); + } + + observedNames.forEach((observedName) => { + let obn = observedByName[observedName]; + + if (!obn) { + obn = observedByName[observedName] = {}; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + obn[uid] = info!; + + waitConditional(observedName); + }); + } else if (info) { + const prevNames = info.prevNames; + + if (prevNames) { + prevNames.forEach((prevName) => { + const obn = observedByName[prevName]; + + if (obn && obn[uid]) { + if (Object.keys(obn).length > 1) { + delete obn[uid]; + } else { + delete observedByName[prevName]; + } + } + }); + + api.onObservedElementChange?.({ + element, + type: "removed", + names: [], + removedNames: prevNames, + }); + } + + delete observedById[uid]; + } + }, + }; + + return api; } diff --git a/src/State/Subscribable.ts b/src/State/Subscribable.ts index a9ad1995..0c5bdc66 100644 --- a/src/State/Subscribable.ts +++ b/src/State/Subscribable.ts @@ -5,65 +5,62 @@ import type * as Types from "../Types.js"; -export abstract class Subscribable< +/** + * Internal subscribable composed by API factories. The public surface + * (`subscribe`, `subscribeFirst`, `unsubscribe`) matches `Types.Subscribable`; + * `setVal`/`getVal`/`trigger` are exposed for the composing factory only and + * should not be re-exposed on the consumer's external API. + */ +export interface SubscribableCore extends Types.Subscribable< A, - B = undefined, -> implements Types.Subscribable { - protected _val: A | undefined; - private _callbacks: Types.SubscribableCallback[] = []; - - dispose(): void { - this._callbacks = []; - delete this._val; - } - - subscribe(callback: Types.SubscribableCallback): void { - const callbacks = this._callbacks; - const index = callbacks.indexOf(callback); - - if (index < 0) { - callbacks.push(callback); - } - } - - subscribeFirst(callback: Types.SubscribableCallback): void { - const callbacks = this._callbacks; - const index = callbacks.indexOf(callback); - - if (index >= 0) { - callbacks.splice(index, 1); - } - - callbacks.unshift(callback); - } - - unsubscribe(callback: Types.SubscribableCallback): void { - const index = this._callbacks.indexOf(callback); - - if (index >= 0) { - this._callbacks.splice(index, 1); - } - } - - protected setVal(val: A, detail: B): void { - if (this._val === val) { - return; - } - - this._val = val; - - this._callCallbacks(val, detail); - } - - protected getVal(): A | undefined { - return this._val; - } - - protected trigger(val: A, detail: B): void { - this._callCallbacks(val, detail); - } + B +> { + dispose(): void; + setVal(val: A, detail: B): void; + getVal(): A | undefined; + trigger(val: A, detail: B): void; +} - private _callCallbacks(val: A, detail: B): void { - this._callbacks.forEach((callback) => callback(val, detail)); - } +export function createSubscribable(): SubscribableCore { + let val: A | undefined; + let callbacks: Types.SubscribableCallback[] = []; + + const trigger = (v: A, detail: B): void => { + callbacks.forEach((cb) => cb(v, detail)); + }; + + return { + dispose(): void { + callbacks = []; + val = undefined; + }, + subscribe(cb): void { + if (callbacks.indexOf(cb) < 0) { + callbacks.push(cb); + } + }, + subscribeFirst(cb): void { + const i = callbacks.indexOf(cb); + if (i >= 0) { + callbacks.splice(i, 1); + } + callbacks.unshift(cb); + }, + unsubscribe(cb): void { + const i = callbacks.indexOf(cb); + if (i >= 0) { + callbacks.splice(i, 1); + } + }, + setVal(v, detail): void { + if (val !== v) { + val = v; + trigger(v, detail); + } + }, + getVal(): A | undefined { + return val; + }, + trigger, + }; } diff --git a/src/Tabster.ts b/src/Tabster.ts index 6d81a6a5..cb128a99 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -4,9 +4,12 @@ */ import { FocusableAPI } from "./Focusable.js"; -import { FocusedElementState } from "./State/FocusedElement.js"; +import { + FocusedElementState, + createFocusedElementState, +} from "./State/FocusedElement.js"; import { getTabsterOnElement, updateTabsterByAttribute } from "./Instance.js"; -import { KeyboardNavigationState } from "./State/KeyboardNavigation.js"; +import { createKeyboardNavigationState } from "./State/KeyboardNavigation.js"; import { observeMutations } from "./MutationEvent.js"; import { RootAPI, type WindowWithTabsterInstance } from "./Root.js"; import type * as Types from "./Types.js"; @@ -105,8 +108,8 @@ class TabsterCore implements Types.TabsterCore { setDOMAPI({ ...props.DOMAPI }); } - this.keyboardNavigation = new KeyboardNavigationState(getWindow); - this.focusedElement = new FocusedElementState(this, getWindow); + this.keyboardNavigation = createKeyboardNavigationState(getWindow); + this.focusedElement = createFocusedElementState(this, getWindow); this.focusable = new FocusableAPI(this); this.root = new RootAPI(this, props?.autoRoot); this.uncontrolled = new UncontrolledAPI( diff --git a/src/get/getObservedElement.ts b/src/get/getObservedElement.ts index 437bd2da..50988872 100644 --- a/src/get/getObservedElement.ts +++ b/src/get/getObservedElement.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { ObservedElementAPI } from "../State/ObservedElement.js"; +import { createObservedElementAPI } from "../State/ObservedElement.js"; import type * as Types from "../Types.js"; export function getObservedElement( @@ -12,8 +12,9 @@ export function getObservedElement( const tabsterCore = tabster.core; if (!tabsterCore.observedElement) { - tabsterCore.observedElement = new ObservedElementAPI(tabsterCore); + tabsterCore.observedElement = createObservedElementAPI(tabsterCore); } - return tabsterCore.observedElement; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return tabsterCore.observedElement!; } From b125b7078e0ab7a5a6fa64f6f8d8521a13cd00f7 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 11 May 2026 13:16:01 +0200 Subject: [PATCH 06/17] refactor(bundle-size): convert DummyInput family to factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DummyInput was 23.6% of the core bundle (the single largest module). Converting the four classes here in one coordinated pass: - DummyInput → createDummyInput - DummyInputManager → createDummyInputManager (interface + factory), with externally-used statics moveWithPhantomDummy and addPhantomDummyWithTarget kept under a same- named const namespace - DummyInputManagerCore → createDummyInputManagerCore. The class's constructor-return-existing-instance trick (used when an element already has dummy inputs from another subsystem) becomes a natural early-return in a factory. - DummyInputObserver → createDummyInputObserver The four DummyManager subclasses (RootDummyManager, ModalizerDummyManager, MoverDummyManager, GroupperDummyManager) are migrated from `extends DummyInputManager` to composing `createDummyInputManager` and calling `manager.setHandlers(...)` directly. They become free functions (createRootDummyManager etc.) returning the same DummyInputManager interface, so the consuming `dummyManager` field on Root/Modalizer/Mover/ Groupper is now typed as the interface rather than the subclass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/DummyInput.ts | 1258 ++++++++++++++++++++++----------------------- src/Groupper.ts | 112 ++-- src/Modalizer.ts | 86 ++-- src/Mover.ts | 54 +- src/Root.ts | 72 ++- src/Tabster.ts | 4 +- 6 files changed, 789 insertions(+), 797 deletions(-) diff --git a/src/DummyInput.ts b/src/DummyInput.ts index 4f1f9d82..b040c6ec 100644 --- a/src/DummyInput.ts +++ b/src/DummyInput.ts @@ -32,10 +32,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; @@ -50,127 +46,72 @@ 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; - - addListener(input, "focusin", this._focusIn); - addListener(input, "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; - - removeListener(input, "focusin", this._focusIn); - removeListener(input, "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; + let disposeTimer: number | undefined; + let clearDisposeTimeout: (() => void) | undefined; + + 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); @@ -179,38 +120,97 @@ 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 { + if (clearDisposeTimeout) { + clearDisposeTimeout(); + } + + 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) { + disposeTimer = win.setTimeout(() => { + disposeTimer = undefined; + api.dispose(); + }, 0); + + clearDisposeTimeout = () => { + if (disposeTimer) { + win.clearTimeout(disposeTimer); + disposeTimer = undefined; + } + clearDisposeTimeout = undefined; + }; + } + + return api; } export const DummyInputManagerPriorities = { @@ -220,71 +220,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. @@ -301,7 +330,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, }); @@ -426,15 +455,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, { @@ -469,8 +498,8 @@ export class DummyInputManager { dom.insertBefore(dummyParent, input, insertBefore); } } - } -} + }, +}; interface DummyInputWrapper { manager: DummyInputManager; @@ -501,136 +530,64 @@ 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; - } - } - - 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; - } - - private _domChanged = (parent: HTMLElement): void => { - if (this._changedParents.has(parent)) { +type ScrollTopLeftCache = Map< + HTMLElement, + { scrollTop: number; scrollLeft: number } | null +>; + +export function createDummyInputObserver( + getWindow: GetWindow +): DummyInputObserverInterface { + let win: GetWindow | undefined = getWindow; + const updateQueue = new Set<(c: ScrollTopLeftCache) => () => void>(); + let updateTimer: number | undefined; + let lastUpdateQueueTime = 0; + let changedParents: WeakSet = new WeakSet(); + let updateDummyInputsTimer: number | undefined; + 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) { + if (updateDummyInputsTimer) { return; } - this._updateDummyInputsTimer = this._win?.().setTimeout(() => { - delete this._updateDummyInputsTimer; + updateDummyInputsTimer = win?.().setTimeout(() => { + updateDummyInputsTimer = undefined; - for (const ref of this._dummyElements) { + for (const ref of dummyElements) { const dummyElement = ref.get(); if (dummyElement) { - const callback = this._dummyCallbacks.get(dummyElement); + const callback = dummyCallbacks.get(dummyElement); if (callback) { const dummyParent = dom.getParentNode(dummyElement); - if ( - !dummyParent || - this._changedParents.has(dummyParent) - ) { + if (!dummyParent || changedParents.has(dummyParent)) { callback(); } } } } - this._changedParents = new WeakSet(); + 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 => { + if (updateTimer) { return; } - this._updateTimer = this._win?.().setTimeout(() => { - delete this._updateTimer; + updateTimer = win?.().setTimeout(() => { + updateTimer = undefined; // updatePositions() might be called quite a lot during the scrolling. // So, instead of clearing the timeout and scheduling a new one, we @@ -638,23 +595,17 @@ export class DummyInputObserver implements DummyInputObserverInterface { // 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() - ) { + if (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 scrollTopLeftCache: ScrollTopLeftCache = new Map(); const setTopLeftCallbacks: (() => void)[] = []; - for (const compute of this._updateQueue) { + for (const compute of updateQueue) { setTopLeftCallbacks.push(compute(scrollTopLeftCache)); } - this._updateQueue.clear(); + updateQueue.clear(); // We're splitting the computation of offsets and setting them to avoid extra // reflows. @@ -665,324 +616,183 @@ export class DummyInputObserver implements DummyInputObserverInterface { // Explicitly clear to not hold references till the next garbage collection. scrollTopLeftCache.clear(); } else { - this._scheduledUpdatePositions(); + scheduledUpdatePositions(); } }, _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"); - } + }; - this._tabster = tabster; - this._getWindow = tabster.getWindow; - this._callForDefaultAction = callForDefaultAction; + 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; + } + }, - const instance = el.__tabsterDummy; + remove(dummy: HTMLElement): void { + dummyElements = dummyElements.filter((ref) => { + const element = ref.get(); + return element && element !== dummy; + }); - (instance || this)._wrappers.push({ - manager, - priority, - tabbable: true, - }); + dummyCallbacks.delete(dummy); - if (instance) { - if (__DEV__) { - this._firstDummy && - setDummyInputDebugValue( - this._firstDummy, - instance._wrappers - ); - this._lastDummy && - setDummyInputDebugValue( - this._lastDummy, - instance._wrappers - ); + if (dummyElements.length === 0) { + api.domChanged = undefined; } + }, - return instance; - } - - 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 - ); + dispose(): void { + const w = win?.(); - this._lastDummy = new DummyInput( - this._getWindow, - this._isOutside, - { - isFirst: false, - }, - element - ); + if (updateTimer) { + w?.clearTimeout(updateTimer); + updateTimer = undefined; + } - // 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); + if (updateDummyInputsTimer) { + w?.clearTimeout(updateDummyInputsTimer); + updateDummyInputsTimer = undefined; + } - this._firstDummy.onFocusIn = this._onFocusIn; - this._firstDummy.onFocusOut = this._onFocusOut; - this._lastDummy.onFocusIn = this._onFocusIn; - this._lastDummy.onFocusOut = this._onFocusOut; + 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; + } - this._element = element; - this._addDummyInputs(); - } + updateQueue.add(compute); + lastUpdateQueueTime = Date.now(); - dispose(manager: DummyInputManager, force?: boolean): void { - const wrappers = (this._wrappers = this._wrappers.filter( - (w) => w.manager !== manager && !force - )); + scheduledUpdatePositions(); + }, + }; - if (__DEV__) { - this._firstDummy && - setDummyInputDebugValue(this._firstDummy, wrappers); - this._lastDummy && - setDummyInputDebugValue(this._lastDummy, wrappers); - } + return api; +} - if (wrappers.length === 0) { - delete (this._element?.get() as HTMLElementWithDummyInputs) - .__tabsterDummy; +/** + * 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; +} - for (const el of this._transformElements) { - removeListener(el, "scroll", this._addTransformOffsets); - } - this._transformElements.clear(); +interface ElementWithCore extends HTMLElement { + __tabsterDummy?: { + wrappers: DummyInputWrapper[]; + firstDummy: DummyInput | undefined; + lastDummy: DummyInput | undefined; + core: DummyInputManagerCore; + }; +} - const win = this._getWindow(); +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"); + } - if (this._addTimer) { - win.clearTimeout(this._addTimer); - delete this._addTimer; - } + const existing = el.__tabsterDummy; - const dummyElement = this._firstDummy?.input; - dummyElement && this._tabster._dummyObserver.remove(dummyElement); + if (existing) { + existing.wrappers.push({ manager, priority, tabbable: true }); - this._firstDummy?.dispose(); - this._lastDummy?.dispose(); + if (__DEV__) { + existing.firstDummy && + setDummyInputDebugValue(existing.firstDummy, existing.wrappers); + existing.lastDummy && + setDummyInputDebugValue(existing.lastDummy, existing.wrappers); } + + return existing.core; } - private _onFocus( + const getWindow = tabster.getWindow; + let addTimer: number | undefined; + 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); - } - } - } + onFocus(false, dummyInput, isBackward, relatedTarget); }; - /** - * 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 && - dispatchEvent( - element, - new TabsterMoveFocusEvent({ - by: "root", - owner: element, - next: null, - relatedEvent, - }) - ) - ) { - nativeFocus(toFocus); - } - } - } - }; - - 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; } @@ -990,48 +800,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( @@ -1042,17 +827,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 && @@ -1066,55 +856,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)) { - addListener(element, "scroll", this._addTransformOffsets); + if (!transformElements.has(e)) { + addListener(e, "scroll", addTransformOffsets); } scrollTop += scrollTopLeft.scrollTop; @@ -1122,19 +905,230 @@ class DummyInputManagerCore { } } - for (const el of transformElements) { - if (!newTransformElements.has(el)) { - removeListener(el, "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 (addTimer) { + return; + } + + addTimer = getWindow().setTimeout(() => { + addTimer = undefined; + + 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 && + tabster.focusable.isFocusable( + 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(); + + const win = getWindow(); + + if (addTimer) { + win.clearTimeout(addTimer); + addTimer = undefined; + } + + 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/Groupper.ts b/src/Groupper.ts index 0f4487c5..69beec57 100644 --- a/src/Groupper.ts +++ b/src/Groupper.ts @@ -21,8 +21,9 @@ import { } from "./Events.js"; import { FocusedElementState } from "./State/FocusedElement.js"; import { + createDummyInputManager, type DummyInput, - DummyInputManager, + type DummyInputManager, DummyInputManagerPriorities, getDummyInputContainer, } from "./DummyInput.js"; @@ -36,68 +37,65 @@ import { } from "./Utils.js"; import { dom } from "./DOMAPI.js"; -class GroupperDummyManager extends DummyInputManager { - constructor( - element: WeakHTMLElement, - groupper: Groupper, - tabster: Types.TabsterCore, - sys: Types.SysProps | undefined - ) { - super( - tabster, - element, - DummyInputManagerPriorities.Groupper, - sys, - true - ); - - this._setHandlers( - ( - dummyInput: DummyInput, - isBackward: boolean, - relatedTarget: HTMLElement | null - ) => { - const container = element.get(); - const input = dummyInput.input; - - if (container && input) { - const ctx = RootAPI.getTabsterContext(tabster, input); - - if (ctx) { - let next: HTMLElement | null | undefined; - - next = groupper.findNextTabbable( - relatedTarget || undefined, +function createGroupperDummyManager( + element: WeakHTMLElement, + groupper: Groupper, + tabster: Types.TabsterCore, + sys: Types.SysProps | undefined +): DummyInputManager { + const manager = createDummyInputManager( + tabster, + element, + DummyInputManagerPriorities.Groupper, + sys, + true + ); + + manager.setHandlers( + ( + dummyInput: DummyInput, + isBackward: boolean, + relatedTarget: HTMLElement | null + ) => { + const container = element.get(); + const input = dummyInput.input; + + if (container && input) { + const ctx = RootAPI.getTabsterContext(tabster, input); + + if (ctx) { + let next: HTMLElement | null | undefined; + + next = groupper.findNextTabbable( + relatedTarget || undefined, + undefined, + isBackward, + true + )?.element; + + if (!next) { + next = FocusedElementState.findNextTabbable( + tabster, + ctx, + undefined, + dummyInput.isOutside + ? input + : getAdjacentElement(container, !isBackward), undefined, isBackward, true )?.element; + } - if (!next) { - next = FocusedElementState.findNextTabbable( - tabster, - ctx, - undefined, - dummyInput.isOutside - ? input - : getAdjacentElement( - container, - !isBackward - ), - undefined, - isBackward, - true - )?.element; - } - - if (next) { - nativeFocus(next); - } + if (next) { + nativeFocus(next); } } } - ); - } + } + ); + + return manager; } export class Groupper @@ -108,7 +106,7 @@ export class Groupper declare private _first: WeakHTMLElement | undefined; declare private _onDispose: (groupper: Groupper) => void; - declare dummyManager: GroupperDummyManager | undefined; + declare dummyManager: DummyInputManager | undefined; constructor( tabster: Types.TabsterCore, @@ -123,7 +121,7 @@ export class Groupper this._onDispose = onDispose; if (!tabster.controlTab) { - this.dummyManager = new GroupperDummyManager( + this.dummyManager = createGroupperDummyManager( this._element, this, tabster, diff --git a/src/Modalizer.ts b/src/Modalizer.ts index 90c89d97..30ccaa0f 100644 --- a/src/Modalizer.ts +++ b/src/Modalizer.ts @@ -12,8 +12,9 @@ import type * as Types from "./Types.js"; import { ModalizerActiveEvent, ModalizerInactiveEvent } from "./Events.js"; import { type ModalizerEventDetail } from "./EventsTypes.js"; import { + createDummyInputManager, type DummyInput, - DummyInputManager, + type DummyInputManager, DummyInputManagerPriorities, getDummyInputContainer, } from "./DummyInput.js"; @@ -63,48 +64,53 @@ function _setInformativeStyle( } /** - * Manages the dummy inputs for the Modalizer. + * Creates the dummy-input manager for a Modalizer. */ -class ModalizerDummyManager extends DummyInputManager { - constructor( - element: WeakHTMLElement, - tabster: Types.TabsterCore, - sys: Types.SysProps | undefined - ) { - super(tabster, element, DummyInputManagerPriorities.Modalizer, sys); - - this._setHandlers((dummyInput: DummyInput, isBackward: boolean) => { - const el = element.get(); - const container = el && RootAPI.getRoot(tabster, el)?.getElement(); - const input = dummyInput.input; - let toFocus: HTMLElement | null | undefined; - - if (container && input) { - const dummyContainer = getDummyInputContainer(input); +function createModalizerDummyManager( + element: WeakHTMLElement, + tabster: Types.TabsterCore, + sys: Types.SysProps | undefined +): DummyInputManager { + const manager = createDummyInputManager( + tabster, + element, + DummyInputManagerPriorities.Modalizer, + sys + ); + + manager.setHandlers((dummyInput: DummyInput, isBackward: boolean) => { + const el = element.get(); + const container = el && RootAPI.getRoot(tabster, el)?.getElement(); + const input = dummyInput.input; + let toFocus: HTMLElement | null | undefined; + + if (container && input) { + const dummyContainer = getDummyInputContainer(input); + + const ctx = RootAPI.getTabsterContext( + tabster, + dummyContainer || input + ); - const ctx = RootAPI.getTabsterContext( + if (ctx) { + toFocus = FocusedElementState.findNextTabbable( tabster, - dummyContainer || input - ); - - if (ctx) { - toFocus = FocusedElementState.findNextTabbable( - tabster, - ctx, - container, - input, - undefined, - isBackward, - true - )?.element; - } + ctx, + container, + input, + undefined, + isBackward, + true + )?.element; + } - if (toFocus) { - nativeFocus(toFocus); - } + if (toFocus) { + nativeFocus(toFocus); } - }); - } + } + }); + + return manager; } export class Modalizer @@ -118,7 +124,7 @@ export class Modalizer declare private _onDispose: (modalizer: Modalizer) => void; declare private _activeElements: WeakHTMLElement[]; - declare dummyManager: ModalizerDummyManager | undefined; + declare dummyManager: DummyInputManager | undefined; constructor( tabster: Types.TabsterCore, @@ -135,7 +141,7 @@ export class Modalizer this._activeElements = activeElements; if (!tabster.controlTab) { - this.dummyManager = new ModalizerDummyManager( + this.dummyManager = createModalizerDummyManager( this._element, tabster, sys diff --git a/src/Mover.ts b/src/Mover.ts index e57bb598..da6be4ab 100644 --- a/src/Mover.ts +++ b/src/Mover.ts @@ -19,8 +19,9 @@ import { TabsterMoveFocusEvent, } from "./Events.js"; import { + createDummyInputManager, type DummyInput, - DummyInputManager, + type DummyInputManager, DummyInputManagerPriorities, getDummyInputContainer, } from "./DummyInput.js"; @@ -40,36 +41,31 @@ import { dom } from "./DOMAPI.js"; const _inputSelector = ["input", "textarea", "*[contenteditable]"].join(", "); -class MoverDummyManager extends DummyInputManager { - private _tabster: Types.TabsterCore; - private _getMemorized: () => WeakHTMLElement | undefined; - - constructor( - element: WeakHTMLElement, - tabster: Types.TabsterCore, - getMemorized: () => WeakHTMLElement | undefined, - sys: Types.SysProps | undefined - ) { - super(tabster, element, DummyInputManagerPriorities.Mover, sys); - - this._tabster = tabster; - this._getMemorized = getMemorized; - - this._setHandlers(this._onFocusDummyInput); - } - - private _onFocusDummyInput = (dummyInput: DummyInput) => { - const container = this._element.get(); +function createMoverDummyManager( + element: WeakHTMLElement, + tabster: Types.TabsterCore, + getMemorized: () => WeakHTMLElement | undefined, + sys: Types.SysProps | undefined +): DummyInputManager { + const manager = createDummyInputManager( + tabster, + element, + DummyInputManagerPriorities.Mover, + sys + ); + + const onFocusDummyInput = (dummyInput: DummyInput) => { + const container = element.get(); const input = dummyInput.input; if (container && input) { - const ctx = RootAPI.getTabsterContext(this._tabster, container); + const ctx = RootAPI.getTabsterContext(tabster, container); let toFocus: HTMLElement | null | undefined; if (ctx) { toFocus = FocusedElementState.findNextTabbable( - this._tabster, + tabster, ctx, undefined, input, @@ -79,9 +75,9 @@ class MoverDummyManager extends DummyInputManager { )?.element; } - const memorized = this._getMemorized()?.get(); + const memorized = getMemorized()?.get(); - if (memorized && this._tabster.focusable.isFocusable(memorized)) { + if (memorized && tabster.focusable.isFocusable(memorized)) { toFocus = memorized; } @@ -90,6 +86,10 @@ class MoverDummyManager extends DummyInputManager { } } }; + + manager.setHandlers(onFocusDummyInput); + + return manager; } // TypeScript enums produce depressing JavaScript code, so, we're just using @@ -124,7 +124,7 @@ export class Mover private _updateTimer: number | undefined; declare visibilityTolerance: number; - declare dummyManager: MoverDummyManager | undefined; + declare dummyManager: DummyInputManager | undefined; constructor( tabster: Types.TabsterCore, @@ -151,7 +151,7 @@ export class Mover props.memorizeCurrent ? this._current : undefined; if (!tabster.controlTab) { - this.dummyManager = new MoverDummyManager( + this.dummyManager = createMoverDummyManager( this._element, tabster, getMemorized, diff --git a/src/Root.ts b/src/Root.ts index 563731ed..fb43e3ee 100644 --- a/src/Root.ts +++ b/src/Root.ts @@ -8,6 +8,7 @@ import { getTabsterOnElement, updateTabsterByAttribute } from "./Instance.js"; import type * as Types from "./Types.js"; import { RootFocusEvent, RootBlurEvent } from "./Events.js"; import { + createDummyInputManager, type DummyInput, DummyInputManager, DummyInputManagerPriorities, @@ -48,52 +49,41 @@ function _setInformativeStyle( } } -class RootDummyManager extends DummyInputManager { - private _tabster: Types.TabsterCore; - private _setFocused: (focused: boolean) => void; - - constructor( - tabster: Types.TabsterCore, - element: WeakHTMLElement, - setFocused: (focused: boolean) => void, - sys: Types.SysProps | undefined - ) { - super( - tabster, - element, - DummyInputManagerPriorities.Root, - sys, - undefined, - true - ); - - this._setHandlers(this._onDummyInputFocus); - - this._tabster = tabster; - this._setFocused = setFocused; - } - - private _onDummyInputFocus = (dummyInput: DummyInput): void => { +function createRootDummyManager( + tabster: Types.TabsterCore, + element: WeakHTMLElement, + setFocused: (focused: boolean) => void, + sys: Types.SysProps | undefined +): DummyInputManager { + const manager = createDummyInputManager( + tabster, + element, + DummyInputManagerPriorities.Root, + sys, + undefined, + true + ); + + const onDummyInputFocus = (dummyInput: DummyInput): void => { if (dummyInput.useDefaultAction) { // When we've reached the last focusable element, we want to let the browser // to move the focus outside of the page. In order to do that we're synchronously // calling focus() of the dummy input from the Tab key handler and allowing // the default action to move the focus out. - this._setFocused(false); + setFocused(false); } else { // The only way a dummy input gets focused is during the keyboard navigation. - this._tabster.keyboardNavigation.setNavigatingWithKeyboard(true); + tabster.keyboardNavigation.setNavigatingWithKeyboard(true); - const element = this._element.get(); + const el = element.get(); - if (element) { - this._setFocused(true); + if (el) { + setFocused(true); - const toFocus = - this._tabster.focusedElement.getFirstOrLastTabbable( - dummyInput.isFirst, - { container: element, ignoreAccessibility: true } - ); + const toFocus = tabster.focusedElement.getFirstOrLastTabbable( + dummyInput.isFirst, + { container: el, ignoreAccessibility: true } + ); if (toFocus) { nativeFocus(toFocus); @@ -104,6 +94,10 @@ class RootDummyManager extends DummyInputManager { dummyInput.input?.blur(); } }; + + manager.setHandlers(onDummyInputFocus); + + return manager; } export class Root @@ -113,7 +107,7 @@ export class Root // `declare` keeps the type info for TS without emitting a `this.x = void 0` // class-field initializer that the constructor immediately overwrites. declare readonly uid: string; - declare private _dummyManager?: RootDummyManager; + declare private _dummyManager?: DummyInputManager; declare private _sys?: Types.SysProps; private _isFocused = false; declare private _setFocusedTimer: Timer; @@ -151,7 +145,7 @@ export class Root addDummyInputs(): void { if (!this._dummyManager) { - this._dummyManager = new RootDummyManager( + this._dummyManager = createRootDummyManager( this._tabster, this._element, this._setFocused, @@ -184,7 +178,7 @@ export class Root const el = this.getElement(); if (el) { - RootDummyManager.moveWithPhantomDummy( + DummyInputManager.moveWithPhantomDummy( this._tabster, el, true, diff --git a/src/Tabster.ts b/src/Tabster.ts index cb128a99..ed721e24 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -15,7 +15,7 @@ import { RootAPI, type WindowWithTabsterInstance } from "./Root.js"; import type * as Types from "./Types.js"; import { TABSTER_ATTRIBUTE_NAME } from "./Consts.js"; import { UncontrolledAPI } from "./Uncontrolled.js"; -import { DummyInputObserver } from "./DummyInput.js"; +import { createDummyInputObserver } from "./DummyInput.js"; import { clearElementCache, clearTimer, @@ -120,7 +120,7 @@ class TabsterCore implements Types.TabsterCore { this.controlTab = props?.controlTab ?? true; this.rootDummyInputs = !!props?.rootDummyInputs; - this._dummyObserver = new DummyInputObserver(getWindow); + this._dummyObserver = createDummyInputObserver(getWindow); this.getParent = props?.getParent ?? dom.getParentNode; From 3101e2311c87d1c7b9dc54dfbd4a4dc5c6d40bb7 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 4 May 2026 17:54:00 +0200 Subject: [PATCH 07/17] =?UTF-8?q?refactor(bundle-size):=20convert=20Restor?= =?UTF-8?q?erAPI=20class=20=E2=86=92=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class scaffolding (constructor + private field declarations + this-binding on arrow methods) doesn't mangle as well as plain top-level functions and closures. Switching RestorerAPI to a factory shaves ~570 B minified / ~45 B gzipped on getRestorer. Pattern: closures replace private fields; the returned object exposes only the public methods the interface requires. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Restorer.ts | 152 +++++++++++++++++++---------------------- src/get/getRestorer.ts | 4 +- 2 files changed, 72 insertions(+), 84 deletions(-) diff --git a/src/Restorer.ts b/src/Restorer.ts index e9485e4b..39f38ca7 100644 --- a/src/Restorer.ts +++ b/src/Restorer.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. */ +import { + _cancelAsyncFocus, + _requestAsyncFocus, +} from "./State/FocusedElement.js"; import { getTabsterOnElement } from "./Instance.js"; import type { - FocusedElementState, GetWindow, - KeyboardNavigationState, Restorer as RestorerInterface, RestorerAPI as RestorerAPIType, RestorerProps, @@ -136,44 +138,45 @@ class History { } } -export class RestorerAPI implements RestorerAPIType { - private _tabster: TabsterCore; - private _history: History; - private _keyboardNavState: KeyboardNavigationState; - private _focusedElementState: FocusedElementState; - private _getWindow: GetWindow; - - constructor(tabster: TabsterCore) { - this._tabster = tabster; - this._getWindow = tabster.getWindow; - addListener( - this._getWindow(), - RestorerRestoreFocusEventName, - this._onRestoreFocus - ); - this._history = new History(this._getWindow); - - this._keyboardNavState = tabster.keyboardNavigation; - this._focusedElementState = tabster.focusedElement; - - this._focusedElementState.subscribe(this._onFocusIn); - } +export function createRestorerAPI(tabster: TabsterCore): RestorerAPIType { + const getWindow = tabster.getWindow; + const history = new History(getWindow); + const keyboardNavState = tabster.keyboardNavigation; + const focusedElementState = tabster.focusedElement; - dispose(): void { - const win = this._getWindow(); - this._focusedElementState.unsubscribe(this._onFocusIn); + const restoreFocus = (source: HTMLElement, sourceId?: string) => { + // don't restore focus if focus isn't lost to body + const doc = getWindow().document; + if (dom.getActiveElement(doc) !== doc.body) { + return; + } + if ( + // clicking on any empty space focuses body - this is can be a false positive + !keyboardNavState.isNavigatingWithKeyboard() && + // Source no longer exists on DOM - always restore focus + dom.nodeContains(doc.body, source) + ) { + return; + } - this._focusedElementState.cancelAsyncFocus(AsyncFocusSources.Restorer); + const getId = (element: HTMLElement) => { + const restorerProps = getTabsterOnElement( + tabster, + element + )?.restorer?.getProps(); + // We return id or undefined if there is actual restorer on the element, + // and null otherwise. To filter out elements that had restorers in their lifetime + // but don't have them anymore. + return restorerProps ? restorerProps.id : null; + }; - removeListener( - win, - RestorerRestoreFocusEventName, - this._onRestoreFocus - ); - } + // sourceId is undefined or string, if there is no Restorer on the target, the element will + // be filtered out because getId() will return null. + history.pop((target) => sourceId === getId(target))?.focus(); + }; - private _onRestoreFocus = (e: Event) => { - this._focusedElementState.cancelAsyncFocus(AsyncFocusSources.Restorer); + const onRestoreFocus = (e: Event) => { + _cancelAsyncFocus(tabster, AsyncFocusSources.Restorer); // ShadowDOM will have shadowRoot as e.target. const source = e.composedPath()[0] as HTMLElement | undefined; @@ -183,74 +186,59 @@ export class RestorerAPI implements RestorerAPIType { // otherwise it'll be unreachable // (as tabster on element will not be available through getTabsterOnElement) const sourceId = getTabsterOnElement( - this._tabster, + tabster, source )?.restorer?.getProps().id; - this._focusedElementState.requestAsyncFocus( + _requestAsyncFocus( + tabster, AsyncFocusSources.Restorer, - () => this._restoreFocus(source, sourceId), + () => restoreFocus(source, sourceId), 0 ); } }; - private _onFocusIn = (element: HTMLElement | undefined) => { + const onFocusIn = (element: HTMLElement | undefined) => { if (!element) { return; } - const tabsterAttribute = getTabsterOnElement(this._tabster, element); + const tabsterAttribute = getTabsterOnElement(tabster, element); if ( tabsterAttribute?.restorer?.getProps().type !== RestorerTypes.Target ) { return; } - this._history.push(element); + history.push(element); }; - private _restoreFocus = (source: HTMLElement, sourceId?: string) => { - // don't restore focus if focus isn't lost to body - const doc = this._getWindow().document; - if (dom.getActiveElement(doc) !== doc.body) { - return; - } - if ( - // clicking on any empty space focuses body - this is can be a false positive - !this._keyboardNavState.isNavigatingWithKeyboard() && - // Source no longer exists on DOM - always restore focus - dom.nodeContains(doc.body, source) - ) { - return; - } - - const getId = (element: HTMLElement) => { - const restorerProps = getTabsterOnElement( - this._tabster, - element - )?.restorer?.getProps(); - // We return id or undefined if there is actual restorer on the element, - // and null otherwise. To filter out elements that had restorers in their lifetime - // but don't have them anymore. - return restorerProps ? restorerProps.id : null; - }; + addListener(getWindow(), RestorerRestoreFocusEventName, onRestoreFocus); + focusedElementState.subscribe(onFocusIn); - // sourceId is undefined or string, if there is no Restorer on the target, the element will - // be filtered out because getId() will return null. - this._history.pop((target) => sourceId === getId(target))?.focus(); - }; + return { + createRestorer(element: HTMLElement, props: RestorerProps) { + const restorer = new Restorer(tabster, element, props); + // Focus might already be on a restorer target when it gets created so the focusin will not do anything + if ( + props.type === RestorerTypes.Target && + dom.getActiveElement(element.ownerDocument) === element + ) { + history.push(element); + } - public createRestorer(element: HTMLElement, props: RestorerProps) { - const restorer = new Restorer(this._tabster, element, props); - // Focus might already be on a restorer target when it gets created so the focusin will not do anything - if ( - props.type === RestorerTypes.Target && - dom.getActiveElement(element.ownerDocument) === element - ) { - this._history.push(element); - } + return restorer; + }, - return restorer; - } + dispose() { + focusedElementState.unsubscribe(onFocusIn); + _cancelAsyncFocus(tabster, AsyncFocusSources.Restorer); + removeListener( + getWindow(), + RestorerRestoreFocusEventName, + onRestoreFocus + ); + }, + }; } diff --git a/src/get/getRestorer.ts b/src/get/getRestorer.ts index 2f124259..d4c56033 100644 --- a/src/get/getRestorer.ts +++ b/src/get/getRestorer.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. */ -import { RestorerAPI } from "../Restorer.js"; +import { createRestorerAPI } from "../Restorer.js"; import type * as Types from "../Types.js"; export function getRestorer(tabster: Types.Tabster): Types.RestorerAPI { const tabsterCore = tabster.core; if (!tabsterCore.restorer) { - const api = new RestorerAPI(tabsterCore); + const api = createRestorerAPI(tabsterCore); tabsterCore.restorer = api; tabsterCore.attrHandlers.set( "restorer", From fe95178d267cd7f82317369a7a4db32b3aa3ad4f Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 4 May 2026 17:56:11 +0200 Subject: [PATCH 08/17] =?UTF-8?q?refactor(bundle-size):=20convert=20Outlin?= =?UTF-8?q?eAPI=20class=20=E2=86=92=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Outline.ts | 358 +++++++++++++++++++----------------------- src/get/getOutline.ts | 4 +- 2 files changed, 166 insertions(+), 196 deletions(-) diff --git a/src/Outline.ts b/src/Outline.ts index 7531f577..a2db7a4f 100644 --- a/src/Outline.ts +++ b/src/Outline.ts @@ -5,7 +5,14 @@ import { getTabsterOnElement } from "./Instance.js"; import type * as Types from "./Types.js"; -import { addListener, getBoundingRect, removeListener } from "./Utils.js"; +import { + addListener, + clearTimer, + createTimer, + getBoundingRect, + removeListener, + setTimer, +} from "./Utils.js"; interface WindowWithOutlineStyle extends Window { __tabsterOutline?: { @@ -56,100 +63,34 @@ class OutlinePosition { } } -export class OutlineAPI implements Types.OutlineAPI { - private _tabster: Types.TabsterCore; - private _win: Types.GetWindow; - private _updateTimer: number | undefined; - private _outlinedElement: HTMLElement | undefined; - private _curPos: OutlinePosition | undefined; - private _isVisible = false; - private _curOutlineElements: Types.OutlineElements | undefined; - private _allOutlineElements: Types.OutlineElements[] = []; - private _fullScreenElement: HTMLElement | undefined; - - constructor(tabster: Types.TabsterCore) { - this._tabster = tabster; - this._win = tabster.getWindow; - - tabster.queueInit(this._init); - } - - private _init = (): void => { - this._tabster.keyboardNavigation.subscribe( - this._onKeyboardNavigationStateChanged - ); - this._tabster.focusedElement.subscribe(this._onFocus); - - const win = this._win(); - - addListener(win, "scroll", this._onScroll, true); // Capture! - - addListener( - win.document, - "fullscreenchange", - this._onFullScreenChanged - ); - }; - - setup(props?: Partial): void { - _props = { ..._props, ...props }; - - const win = this._win() as WindowWithOutlineStyle; - - if (!win.__tabsterOutline) { - win.__tabsterOutline = {}; - } - - if (!win.__tabsterOutline.style) { - win.__tabsterOutline.style = appendStyles(win.document, _props); - } - - if (!props || !props.areaClass) { - win.document.body.classList.add(defaultProps.areaClass); - } else { - win.document.body.classList.remove(defaultProps.areaClass); - } - } - - dispose(): void { - const win = this._win(); - - if (this._updateTimer) { - win.clearTimeout(this._updateTimer); - this._updateTimer = undefined; - } - - this._tabster.keyboardNavigation.unsubscribe( - this._onKeyboardNavigationStateChanged - ); - this._tabster.focusedElement.unsubscribe(this._onFocus); - - removeListener(win, "scroll", this._onScroll, true); - - removeListener( - win.document, - "fullscreenchange", - this._onFullScreenChanged - ); - - this._allOutlineElements.forEach((outlineElements) => - this._removeDOM(outlineElements.container) - ); - this._allOutlineElements = []; - - delete this._outlinedElement; - delete this._curPos; - delete this._curOutlineElements; - delete this._fullScreenElement; - } +function isParentChild(parent: HTMLElement, child: HTMLElement): boolean { + return ( + child === parent || + // tslint:disable-next-line:no-bitwise + !!( + parent.compareDocumentPosition(child) & + document.DOCUMENT_POSITION_CONTAINED_BY + ) + ); +} - private _onFullScreenChanged = (e: Event): void => { +export function createOutlineAPI(tabster: Types.TabsterCore): Types.OutlineAPI { + const win = tabster.getWindow; + const updateTimer = createTimer(); + let outlinedElement: HTMLElement | undefined; + let curPos: OutlinePosition | undefined; + let isVisible = false; + let curOutlineElements: Types.OutlineElements | undefined; + let allOutlineElements: Types.OutlineElements[] = []; + let fullScreenElement: HTMLElement | undefined; + + const onFullScreenChanged = (e: Event): void => { if (!e.target) { return; } const target = (e.target as Document).body || (e.target as HTMLElement); - const outlineElements = this._getDOM(target); + const outlineElements = getDOM(target); if (target.ownerDocument && outlineElements) { const fsElement = target.ownerDocument @@ -157,22 +98,22 @@ export class OutlineAPI implements Types.OutlineAPI { if (fsElement) { fsElement.appendChild(outlineElements.container); - this._fullScreenElement = fsElement; + fullScreenElement = fsElement; } else { target.ownerDocument.body.appendChild( outlineElements.container ); - this._fullScreenElement = undefined; + fullScreenElement = undefined; } } }; - private _onKeyboardNavigationStateChanged = (): void => { - this._onFocus(this._tabster.focusedElement.getFocusedElement()); + const onKeyboardNavigationStateChanged = (): void => { + onFocus(tabster.focusedElement.getFocusedElement()); }; - private _shouldShowCustomOutline(element: HTMLElement): boolean { - const tabsterOnElement = getTabsterOnElement(this._tabster, element); + const shouldShowCustomOutline = (element: HTMLElement): boolean => { + const tabsterOnElement = getTabsterOnElement(tabster, element); if ( tabsterOnElement && @@ -189,25 +130,22 @@ export class OutlineAPI implements Types.OutlineAPI { } return false; - } + }; - private _onFocus = (e: HTMLElement | undefined): void => { - if (!this._updateElement(e) && this._isVisible) { - this._setVisibility(false); + const onFocus = (e: HTMLElement | undefined): void => { + if (!updateElement(e) && isVisible) { + setVisibility(false); } }; - private _updateElement(e: HTMLElement | undefined): boolean { - this._outlinedElement = undefined; + const updateElement = (e: HTMLElement | undefined): boolean => { + outlinedElement = undefined; - if (this._updateTimer) { - this._win().clearTimeout(this._updateTimer); - this._updateTimer = undefined; - } + clearTimer(updateTimer, win()); - this._curPos = undefined; + curPos = undefined; - if (!this._tabster.keyboardNavigation.isNavigatingWithKeyboard()) { + if (!tabster.keyboardNavigation.isNavigatingWithKeyboard()) { return false; } @@ -239,78 +177,69 @@ export class OutlineAPI implements Types.OutlineAPI { return false; } - if (!this._shouldShowCustomOutline(e)) { + if (!shouldShowCustomOutline(e)) { return false; } - if (this._tabster.keyboardNavigation.isNavigatingWithKeyboard()) { - this._outlinedElement = e; - this._updateOutline(); + if (tabster.keyboardNavigation.isNavigatingWithKeyboard()) { + outlinedElement = e; + updateOutline(); } return true; } return false; - } + }; - private _onScroll = (e: Event): void => { + const onScroll = (e: Event): void => { if ( - !this._outlinedElement || - !OutlineAPI._isParentChild( - e.target as HTMLElement, - this._outlinedElement - ) + !outlinedElement || + !isParentChild(e.target as HTMLElement, outlinedElement) ) { return; } - this._curPos = undefined; + curPos = undefined; - this._setOutlinePosition(); + setOutlinePosition(); }; - private _updateOutline(): void { - this._setOutlinePosition(); + const updateOutline = (): void => { + setOutlinePosition(); - if (this._updateTimer) { - this._win().clearTimeout(this._updateTimer); - this._updateTimer = undefined; - } + clearTimer(updateTimer, win()); - if (!this._outlinedElement) { + if (!outlinedElement) { return; } - this._updateTimer = this._win().setTimeout(() => { - this._updateTimer = undefined; - this._updateOutline(); - }, 30); - } + setTimer(updateTimer, win(), updateOutline, 30); + }; - private _setVisibility(visible: boolean): void { - this._isVisible = visible; + const setVisibility = (visible: boolean): void => { + isVisible = visible; - if (this._curOutlineElements) { + if (curOutlineElements) { if (visible) { - this._curOutlineElements.container.classList.add( + curOutlineElements.container.classList.add( `${_props.outlineClass}_visible` ); } else { - this._curOutlineElements.container.classList.remove( + curOutlineElements.container.classList.remove( `${_props.outlineClass}_visible` ); - this._curPos = undefined; + curPos = undefined; } } - } + }; - private _setOutlinePosition(): void { - if (!this._outlinedElement) { + const setOutlinePosition = (): void => { + if (!outlinedElement) { return; } - let boundingRect = getBoundingRect(this._win, this._outlinedElement); + let boundingRect = getBoundingRect(win, outlinedElement); const position = new OutlinePosition( boundingRect.left, @@ -319,25 +248,25 @@ export class OutlineAPI implements Types.OutlineAPI { boundingRect.bottom ); - if (this._curPos && position.equalsTo(this._curPos)) { + if (curPos && position.equalsTo(curPos)) { return; } - const outlineElements = this._getDOM(this._outlinedElement); - const win = - this._outlinedElement.ownerDocument && - this._outlinedElement.ownerDocument.defaultView; + const outlineElements = getDOM(outlinedElement); + const elWin = + outlinedElement.ownerDocument && + outlinedElement.ownerDocument.defaultView; - if (!outlineElements || !win) { + if (!outlineElements || !elWin) { return; } - if (this._curOutlineElements !== outlineElements) { - this._setVisibility(false); - this._curOutlineElements = outlineElements; + if (curOutlineElements !== outlineElements) { + setVisibility(false); + curOutlineElements = outlineElements; } - this._curPos = position; + curPos = position; const p = position.clone(); let hasAbsolutePositionedParent = false; @@ -354,27 +283,27 @@ export class OutlineAPI implements Types.OutlineAPI { } for ( - let parent = this._outlinedElement.parentElement; + let parent = outlinedElement.parentElement; parent && parent.nodeType === Node.ELEMENT_NODE; parent = parent.parentElement ) { // The element might be partially visible within its scrollable parent, // reduce the bounding rect if this is the case. - if (parent === this._fullScreenElement) { + if (parent === fullScreenElement) { break; } - boundingRect = getBoundingRect(this._win, parent); + boundingRect = getBoundingRect(win, parent); - const win = + const parentWin = parent.ownerDocument && parent.ownerDocument.defaultView; - if (!win) { + if (!parentWin) { return; } - const computedStyle = win.getComputedStyle(parent); + const computedStyle = parentWin.getComputedStyle(parent); const position = computedStyle.position; if (position === "absolute") { @@ -406,7 +335,7 @@ export class OutlineAPI implements Types.OutlineAPI { } } - const allRect = getBoundingRect(this._win, scrollingElement); + const allRect = getBoundingRect(win, scrollingElement); const allWidth = allRect.left + allRect.right; const allHeight = allRect.top + allRect.bottom; const ow = _props.outlineWidth; @@ -425,13 +354,13 @@ export class OutlineAPI implements Types.OutlineAPI { const rightBorderNode = outlineElements.right; const bottomBorderNode = outlineElements.bottom; const sx = - this._fullScreenElement || hasFixedPositionedParent + fullScreenElement || hasFixedPositionedParent ? 0 - : win.pageXOffset; + : elWin.pageXOffset; const sy = - this._fullScreenElement || hasFixedPositionedParent + fullScreenElement || hasFixedPositionedParent ? 0 - : win.pageYOffset; + : elWin.pageYOffset; container.style.position = hasFixedPositionedParent ? "fixed" @@ -463,27 +392,27 @@ export class OutlineAPI implements Types.OutlineAPI { topBorderNode.style.width = bottomBorderNode.style.width = width + "px"; - this._setVisibility(true); + setVisibility(true); } else { - this._setVisibility(false); + setVisibility(false); } - } + }; - private _getDOM( + const getDOM = ( contextElement: HTMLElement - ): Types.OutlineElements | undefined { + ): Types.OutlineElements | undefined => { const doc = contextElement.ownerDocument; - const win = (doc && doc.defaultView) as WindowWithOutlineStyle; + const elWin = (doc && doc.defaultView) as WindowWithOutlineStyle; - if (!doc || !win || !win.__tabsterOutline) { + if (!doc || !elWin || !elWin.__tabsterOutline) { return undefined; } - if (!win.__tabsterOutline.style) { - win.__tabsterOutline.style = appendStyles(doc, _props); + if (!elWin.__tabsterOutline.style) { + elWin.__tabsterOutline.style = appendStyles(doc, _props); } - if (!win.__tabsterOutline.elements) { + if (!elWin.__tabsterOutline.elements) { const outlineElements: Types.OutlineElements = { container: doc.createElement("div"), left: doc.createElement("div"), @@ -505,20 +434,20 @@ export class OutlineAPI implements Types.OutlineAPI { doc.body.appendChild(outlineElements.container); - win.__tabsterOutline.elements = outlineElements; + elWin.__tabsterOutline.elements = outlineElements; // TODO: Make a garbage collector to remove the references // to the outlines which are nowhere in the DOM anymore. - this._allOutlineElements.push(outlineElements); + allOutlineElements.push(outlineElements); } - return win.__tabsterOutline.elements; - } + return elWin.__tabsterOutline.elements; + }; - private _removeDOM(contextElement: HTMLElement): void { - const win = (contextElement.ownerDocument && + const removeDOM = (contextElement: HTMLElement): void => { + const elWin = (contextElement.ownerDocument && contextElement.ownerDocument.defaultView) as WindowWithOutlineStyle; - const outline = win && win.__tabsterOutline; + const outline = elWin && elWin.__tabsterOutline; if (!outline) { return; @@ -541,21 +470,62 @@ export class OutlineAPI implements Types.OutlineAPI { delete outline.elements; } - } + }; - private static _isParentChild( - parent: HTMLElement, - child: HTMLElement - ): boolean { - return ( - child === parent || - // tslint:disable-next-line:no-bitwise - !!( - parent.compareDocumentPosition(child) & - document.DOCUMENT_POSITION_CONTAINED_BY - ) - ); - } + tabster.queueInit(() => { + tabster.keyboardNavigation.subscribe(onKeyboardNavigationStateChanged); + tabster.focusedElement.subscribe(onFocus); + + const w = win(); + + addListener(w, "scroll", onScroll, true); // Capture! + addListener(w.document, "fullscreenchange", onFullScreenChanged); + }); + + return { + setup(props?: Partial): void { + _props = { ..._props, ...props }; + + const w = win() as WindowWithOutlineStyle; + + if (!w.__tabsterOutline) { + w.__tabsterOutline = {}; + } + + if (!w.__tabsterOutline.style) { + w.__tabsterOutline.style = appendStyles(w.document, _props); + } + + if (!props || !props.areaClass) { + w.document.body.classList.add(defaultProps.areaClass); + } else { + w.document.body.classList.remove(defaultProps.areaClass); + } + }, + + dispose(): void { + const w = win(); + clearTimer(updateTimer, w); + + tabster.keyboardNavigation.unsubscribe( + onKeyboardNavigationStateChanged + ); + tabster.focusedElement.unsubscribe(onFocus); + + removeListener(w, "scroll", onScroll, true); + removeListener(w.document, "fullscreenchange", onFullScreenChanged); + + allOutlineElements.forEach((outlineElements) => + removeDOM(outlineElements.container) + ); + allOutlineElements = []; + + outlinedElement = undefined; + curPos = undefined; + curOutlineElements = undefined; + fullScreenElement = undefined; + }, + }; } function appendStyles( diff --git a/src/get/getOutline.ts b/src/get/getOutline.ts index 5e31e341..f1d3977d 100644 --- a/src/get/getOutline.ts +++ b/src/get/getOutline.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. */ -import { OutlineAPI } from "../Outline.js"; +import { createOutlineAPI } from "../Outline.js"; import type * as Types from "../Types.js"; export function getOutline(tabster: Types.Tabster): Types.OutlineAPI { const tabsterCore = tabster.core; if (!tabsterCore.outline) { - tabsterCore.outline = new OutlineAPI(tabsterCore); + tabsterCore.outline = createOutlineAPI(tabsterCore); } return tabsterCore.outline; From 35553c414755712e73104ab1ca316e60b3944341 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 4 May 2026 17:59:38 +0200 Subject: [PATCH 09/17] =?UTF-8?q?refactor(bundle-size):=20convert=20Delose?= =?UTF-8?q?rAPI=20class=20=E2=86=92=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static methods (getDeloser, getHistory, forceRestoreFocus) are kept under a same-named const namespace, so external call sites in CrossOrigin.ts continue to work unchanged. The internal state needed by those statics is exposed via an internal interface with _-prefixed accessors. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Deloser.ts | 449 +++++++++++++++++++++--------------------- src/get/getDeloser.ts | 4 +- 2 files changed, 229 insertions(+), 224 deletions(-) diff --git a/src/Deloser.ts b/src/Deloser.ts index 3d010ada..dd5b3aa1 100644 --- a/src/Deloser.ts +++ b/src/Deloser.ts @@ -701,141 +701,124 @@ 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; - - addListener( - doc, - 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; + let restoreFocusTimer: number | undefined; + 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); - - removeListener( - win.document, - DeloserRestoreFocusEventName, - this._onRestoreFocus - ); - - this._history.dispose(); + const restoreFocus = async () => { + restoreFocusTimer = undefined; + const lastFocused = tabster.focusedElement.getLastFocusedElement(); - 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; - } - - pause(): void { - this._isPaused = true; + isRestoringFocus = 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 { + restoreFocusTimer = win().setTimeout(restoreFocus, 100); } - } + }; - private _onRestoreFocus = (event: DeloserRestoreFocusEvent): void => { + const onRestoreFocus = (event: DeloserRestoreFocusEvent): void => { const target = event.composedPath()[0] as | HTMLElement | null @@ -843,140 +826,169 @@ 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 => { + if (restoreFocusTimer) { + win().clearTimeout(restoreFocusTimer); + restoreFocusTimer = undefined; } 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(); + + if (restoreFocusTimer) { + w.clearTimeout(restoreFocusTimer); + restoreFocusTimer = undefined; + } - const restoreFocus = async () => { - this._restoreFocusTimer = undefined; - const lastFocused = - this._tabster.focusedElement.getLastFocusedElement(); + if (autoDeloserInstance) { + autoDeloserInstance.dispose(); + autoDeloserInstance = undefined; + autoDeloser = undefined; + } + + 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; + return deloser; + }, - if (curDeloser) { - if ( - lastFocused && - curDeloser.customFocusLostHandler(lastFocused) - ) { - return; - } - - 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( - curDeloserElement, - 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; + + if (restoreFocusTimer) { + win().clearTimeout(restoreFocusTimer); + restoreFocusTimer = undefined; } + }, - 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 { @@ -1002,7 +1014,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) { @@ -1018,7 +1031,7 @@ export class DeloserAPI implements Types.DeloserAPI { deloserAPI._autoDeloserInstance = new Deloser( tabster, body, - (tabster.deloser as DeloserAPI)._onDeloserDispose, + deloserAPI._onDeloserDispose, autoDeloserProps ); } @@ -1028,21 +1041,13 @@ export class DeloserAPI implements Types.DeloserAPI { } return undefined; - } + }, - private _onDeloserDispose = (deloser: Deloser) => { - this._history.removeDeloser(deloser); + getHistory(instance: Types.DeloserAPI): DeloserHistory { + return (instance as DeloserAPIInternal)._history; + }, - if (deloser.isActive()) { - this._scheduleRestoreFocus(); - } - }; - - static getHistory(instance: Types.DeloserAPI): DeloserHistory { - return (instance as DeloserAPI)._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/get/getDeloser.ts b/src/get/getDeloser.ts index 07ac5ed7..7dc0a985 100644 --- a/src/get/getDeloser.ts +++ b/src/get/getDeloser.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { DeloserAPI } from "../Deloser.js"; +import { createDeloserAPI } from "../Deloser.js"; import type * as Types from "../Types.js"; /** @@ -18,7 +18,7 @@ export function getDeloser( const tabsterCore = tabster.core; if (!tabsterCore.deloser) { - const api = new DeloserAPI(tabsterCore, props); + const api = createDeloserAPI(tabsterCore, props); tabsterCore.deloser = api; tabsterCore.attrHandlers.set( "deloser", From 127475dd1001257e1a6320f460118593c2f0b28f Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 4 May 2026 18:01:04 +0200 Subject: [PATCH 10/17] =?UTF-8?q?refactor(bundle-size):=20convert=20Groupp?= =?UTF-8?q?erAPI=20class=20=E2=86=92=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Groupper.ts | 362 ++++++++++++++++++++--------------------- src/get/getGroupper.ts | 4 +- 2 files changed, 180 insertions(+), 186 deletions(-) diff --git a/src/Groupper.ts b/src/Groupper.ts index 69beec57..e0d08a8f 100644 --- a/src/Groupper.ts +++ b/src/Groupper.ts @@ -429,143 +429,20 @@ function validateGroupperProps(props: Types.GroupperProps): void { // TODO: Implement validation. } -export class GroupperAPI implements Types.GroupperAPI { - private _tabster: Types.TabsterCore; - private _updateTimer: number | undefined; - private _win: Types.GetWindow; - private _current: Record = {}; - private _grouppers: Record = {}; - - constructor(tabster: Types.TabsterCore, getWindow: Types.GetWindow) { - this._tabster = tabster; - this._win = getWindow; - tabster.queueInit(this._init); - } - - private _init = (): void => { - const win = this._win(); - - // Making sure groupper's onFocus is called before modalizer's onFocus. - this._tabster.focusedElement.subscribeFirst(this._onFocus); - - const doc = win.document; - - const activeElement = dom.getActiveElement(doc); - - if (activeElement) { - this._onFocus(activeElement as HTMLElement); - } - - addListener(doc, "mousedown", this._onMouseDown, true); - addListener(win, "keydown", this._onKeyDown, true); - addListener(win, GroupperMoveFocusEventName, this._onMoveFocus); - }; - - dispose(): void { - const win = this._win(); - - this._tabster.focusedElement.cancelAsyncFocus( - AsyncFocusSources.EscapeGroupper - ); - - this._current = {}; - - if (this._updateTimer) { - win.clearTimeout(this._updateTimer); - delete this._updateTimer; - } - - this._tabster.focusedElement.unsubscribe(this._onFocus); - - removeListener(win.document, "mousedown", this._onMouseDown, true); - removeListener(win, "keydown", this._onKeyDown, true); - removeListener(win, GroupperMoveFocusEventName, this._onMoveFocus); - - Object.keys(this._grouppers).forEach((groupperId) => { - if (this._grouppers[groupperId]) { - this._grouppers[groupperId].dispose(); - delete this._grouppers[groupperId]; - } - }); - } - - createGroupper( - element: HTMLElement, - props: Types.GroupperProps, - sys: Types.SysProps | undefined - ) { - if (__DEV__) { - validateGroupperProps(props); - } - - const tabster = this._tabster; - const newGroupper = new Groupper( - tabster, - element, - this._onGroupperDispose, - props, - sys - ); - - this._grouppers[newGroupper.id] = newGroupper; - - const focusedElement = tabster.focusedElement.getFocusedElement(); - - // Newly created groupper contains currently focused element, update the state on the next tick (to - // make sure all grouppers are processed). - if ( - focusedElement && - dom.nodeContains(element, focusedElement) && - !this._updateTimer - ) { - this._updateTimer = this._win().setTimeout(() => { - delete this._updateTimer; - // Making sure the focused element hasn't changed. - if ( - focusedElement === - tabster.focusedElement.getFocusedElement() - ) { - this._updateCurrent(focusedElement); - } - }, 0); - } - - return newGroupper; - } - - forgetCurrentGrouppers(): void { - this._current = {}; - } - - private _onGroupperDispose = (groupper: Groupper) => { - delete this._grouppers[groupper.id]; - }; - - private _onFocus = (element: HTMLElement | undefined): void => { - if (element) { - this._updateCurrent(element); - } - }; - - private _onMouseDown = (e: MouseEvent): void => { - let target = e.target as HTMLElement | null; - - while (target && !this._tabster.focusable.isFocusable(target)) { - target = this._tabster.getParent(target) as HTMLElement | null; - } - - if (target) { - this._updateCurrent(target); - } - }; +export function createGroupperAPI( + tabster: Types.TabsterCore, + getWindow: Types.GetWindow +): Types.GroupperAPI { + let updateTimer: number | undefined; + let current: Record = {}; + const grouppers: Record = {}; - private _updateCurrent(element: HTMLElement): void { - if (this._updateTimer) { - this._win().clearTimeout(this._updateTimer); - delete this._updateTimer; + const updateCurrent = (element: HTMLElement): void => { + if (updateTimer) { + getWindow().clearTimeout(updateTimer); + updateTimer = undefined; } - const tabster = this._tabster; const newIds: Record = {}; for ( @@ -581,7 +458,7 @@ export class GroupperAPI implements Types.GroupperAPI { if (groupper) { newIds[groupper.id] = true; - this._current[groupper.id] = groupper; + current[groupper.id] = groupper; const isTabbable = groupper.isActive() || (element !== el && @@ -592,54 +469,43 @@ export class GroupperAPI implements Types.GroupperAPI { } } - for (const id of Object.keys(this._current)) { - const groupper = this._current[id]; + for (const id of Object.keys(current)) { + const groupper = current[id]; if (!(groupper.id in newIds)) { groupper.makeTabbable(false); groupper.setFirst(undefined); - delete this._current[id]; + delete current[id]; } } - } - - private _onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== Keys.Enter && event.key !== Keys.Escape) { - return; - } - - // Give a chance to other listeners to handle the event. - if (event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) { - return; - } + }; - const element = this._tabster.focusedElement.getFocusedElement(); + const onGroupperDispose = (groupper: Groupper) => { + delete grouppers[groupper.id]; + }; + const onFocus = (element: HTMLElement | undefined): void => { if (element) { - this.handleKeyPress(element, event); + updateCurrent(element); } }; - private _onMoveFocus = (e: GroupperMoveFocusEvent): void => { - const element = e.composedPath()[0] as HTMLElement | null | undefined; - const action = e.detail?.action; + const onMouseDown = (e: MouseEvent): void => { + let target = e.target as HTMLElement | null; - if (element && action !== undefined && !e.defaultPrevented) { - if (action === GroupperMoveFocusActions.Enter) { - this._enterGroupper(element); - } else { - this._escapeGroupper(element); - } + while (target && !tabster.focusable.isFocusable(target)) { + target = tabster.getParent(target) as HTMLElement | null; + } - e.stopImmediatePropagation(); + if (target) { + updateCurrent(target); } }; - private _enterGroupper( + const enterGroupper = ( element: HTMLElement, relatedEvent?: KeyboardEvent - ): HTMLElement | null { - const tabster = this._tabster; + ): HTMLElement | null => { const ctx = RootAPI.getTabsterContext(tabster, element); const groupper = ctx?.groupper || ctx?.modalizerInGroupper; const groupperElement = groupper?.getElement(); @@ -687,14 +553,13 @@ export class GroupperAPI implements Types.GroupperAPI { } return null; - } + }; - private _escapeGroupper( + const escapeGroupper = ( element: HTMLElement, relatedEvent?: KeyboardEvent, fromModalizer?: boolean - ): HTMLElement | null { - const tabster = this._tabster; + ): HTMLElement | null => { const ctx = RootAPI.getTabsterContext(tabster, element); let groupper = ctx?.groupper || ctx?.modalizerInGroupper; const groupperElement = groupper?.getElement(); @@ -745,23 +610,13 @@ export class GroupperAPI implements Types.GroupperAPI { } return null; - } - - moveFocus( - element: HTMLElement, - action: Types.GroupperMoveFocusAction - ): HTMLElement | null { - return action === GroupperMoveFocusActions.Enter - ? this._enterGroupper(element) - : this._escapeGroupper(element); - } + }; - handleKeyPress( + const handleKeyPress = ( element: HTMLElement, event: KeyboardEvent, fromModalizer?: boolean - ): void { - const tabster = this._tabster; + ): void => { const ctx = RootAPI.getTabsterContext(tabster, element); if (ctx && (ctx?.groupper || ctx?.modalizerInGroupper)) { @@ -774,7 +629,7 @@ export class GroupperAPI implements Types.GroupperAPI { } if (event.key === Keys.Enter) { - this._enterGroupper(element, event); + enterGroupper(element, event); } else if (event.key === Keys.Escape) { // We will handle Esc asynchronously, if something in the application will // move focus during the keypress handling, we will not interfere. @@ -796,13 +651,152 @@ export class GroupperAPI implements Types.GroupperAPI { return; } - this._escapeGroupper(element, event, fromModalizer); + escapeGroupper(element, event, fromModalizer); }, 0 ); } } - } + }; + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== Keys.Enter && event.key !== Keys.Escape) { + return; + } + + // Give a chance to other listeners to handle the event. + if (event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) { + return; + } + + const element = tabster.focusedElement.getFocusedElement(); + + if (element) { + handleKeyPress(element, event); + } + }; + + const onMoveFocus = (e: GroupperMoveFocusEvent): void => { + const element = e.composedPath()[0] as HTMLElement | null | undefined; + const action = e.detail?.action; + + if (element && action !== undefined && !e.defaultPrevented) { + if (action === GroupperMoveFocusActions.Enter) { + enterGroupper(element); + } else { + escapeGroupper(element); + } + + e.stopImmediatePropagation(); + } + }; + + tabster.queueInit(() => { + const win = getWindow(); + + // Making sure groupper's onFocus is called before modalizer's onFocus. + tabster.focusedElement.subscribeFirst(onFocus); + + const doc = win.document; + + const activeElement = dom.getActiveElement(doc); + + if (activeElement) { + onFocus(activeElement as HTMLElement); + } + + doc.addEventListener("mousedown", onMouseDown, true); + win.addEventListener("keydown", onKeyDown, true); + win.addEventListener(GroupperMoveFocusEventName, onMoveFocus); + }); + + return { + dispose(): void { + const win = getWindow(); + + tabster.focusedElement.cancelAsyncFocus( + AsyncFocusSources.EscapeGroupper + ); + + current = {}; + + if (updateTimer) { + win.clearTimeout(updateTimer); + updateTimer = undefined; + } + + tabster.focusedElement.unsubscribe(onFocus); + + win.document.removeEventListener("mousedown", onMouseDown, true); + win.removeEventListener("keydown", onKeyDown, true); + win.removeEventListener(GroupperMoveFocusEventName, onMoveFocus); + + Object.keys(grouppers).forEach((groupperId) => { + if (grouppers[groupperId]) { + grouppers[groupperId].dispose(); + delete grouppers[groupperId]; + } + }); + }, + + createGroupper( + element: HTMLElement, + props: Types.GroupperProps, + sys: Types.SysProps | undefined + ) { + if (__DEV__) { + validateGroupperProps(props); + } + + const newGroupper = new Groupper( + tabster, + element, + onGroupperDispose, + props, + sys + ); + + grouppers[newGroupper.id] = newGroupper; + + const focusedElement = tabster.focusedElement.getFocusedElement(); + + // Newly created groupper contains currently focused element, update the state on the next tick (to + // make sure all grouppers are processed). + if ( + focusedElement && + dom.nodeContains(element, focusedElement) && + !updateTimer + ) { + updateTimer = getWindow().setTimeout(() => { + updateTimer = undefined; + // Making sure the focused element hasn't changed. + if ( + focusedElement === + tabster.focusedElement.getFocusedElement() + ) { + updateCurrent(focusedElement); + } + }, 0); + } + + return newGroupper; + }, + + forgetCurrentGrouppers(): void { + current = {}; + }, + + moveFocus( + element: HTMLElement, + action: Types.GroupperMoveFocusAction + ): HTMLElement | null { + return action === GroupperMoveFocusActions.Enter + ? enterGroupper(element) + : escapeGroupper(element); + }, + + handleKeyPress, + }; } function _setInformativeStyle( diff --git a/src/get/getGroupper.ts b/src/get/getGroupper.ts index 1a5bcb97..744aec4f 100644 --- a/src/get/getGroupper.ts +++ b/src/get/getGroupper.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { GroupperAPI } from "../Groupper.js"; +import { createGroupperAPI } from "../Groupper.js"; import type * as Types from "../Types.js"; /** @@ -14,7 +14,7 @@ export function getGroupper(tabster: Types.Tabster): Types.GroupperAPI { const tabsterCore = tabster.core; if (!tabsterCore.groupper) { - const api = new GroupperAPI(tabsterCore, tabsterCore.getWindow); + const api = createGroupperAPI(tabsterCore, tabsterCore.getWindow); tabsterCore.groupper = api; tabsterCore.attrHandlers.set( "groupper", From 522c515a6b7532117a25865a018f2dda40612ef3 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 4 May 2026 18:03:59 +0200 Subject: [PATCH 11/17] =?UTF-8?q?refactor(bundle-size):=20convert=20MoverA?= =?UTF-8?q?PI=20class=20=E2=86=92=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Mover.ts | 229 +++++++++++++++++++++----------------------- src/get/getMover.ts | 7 +- 2 files changed, 113 insertions(+), 123 deletions(-) diff --git a/src/Mover.ts b/src/Mover.ts index da6be4ab..a2e3784a 100644 --- a/src/Mover.ts +++ b/src/Mover.ts @@ -686,88 +686,19 @@ function getDistance( : Math.sqrt(xDistance * xDistance + yDistance * yDistance); } -export class MoverAPI implements Types.MoverAPI { - private _tabster: Types.TabsterCore; - private _win: Types.GetWindow; - private _movers: Record; - private _ignoredInputTimer: number | undefined; - private _ignoredInputResolve: ((value: boolean) => void) | undefined; - - constructor(tabster: Types.TabsterCore, getWindow: Types.GetWindow) { - this._tabster = tabster; - this._win = getWindow; - this._movers = {}; - - tabster.queueInit(this._init); - } - - private _init = (): void => { - const win = this._win(); - - addListener(win, "keydown", this._onKeyDown, true); - addListener(win, MoverMoveFocusEventName, this._onMoveFocus); - addListener( - win, - MoverMemorizedElementEventName, - this._onMemorizedElement - ); - - this._tabster.focusedElement.subscribe(this._onFocus); - }; - - dispose(): void { - const win = this._win(); - - this._tabster.focusedElement.unsubscribe(this._onFocus); - - this._ignoredInputResolve?.(false); - - if (this._ignoredInputTimer) { - win.clearTimeout(this._ignoredInputTimer); - delete this._ignoredInputTimer; - } - - removeListener(win, "keydown", this._onKeyDown, true); - removeListener(win, MoverMoveFocusEventName, this._onMoveFocus); - removeListener( - win, - MoverMemorizedElementEventName, - this._onMemorizedElement - ); - - Object.keys(this._movers).forEach((moverId) => { - if (this._movers[moverId]) { - this._movers[moverId].dispose(); - delete this._movers[moverId]; - } - }); - } - - createMover( - element: HTMLElement, - props: Types.MoverProps, - sys: Types.SysProps | undefined - ): Types.Mover { - if (__DEV__) { - validateMoverProps(props); - } - - const newMover = new Mover( - this._tabster, - element, - this._onMoverDispose, - props, - sys - ); - this._movers[newMover.id] = newMover; - return newMover; - } - - private _onMoverDispose = (mover: Mover) => { - delete this._movers[mover.id]; +export function createMoverAPI( + tabster: Types.TabsterCore, + getWindow: Types.GetWindow +): Types.MoverAPI { + const movers: Record = {}; + let ignoredInputTimer: number | undefined; + let ignoredInputResolve: ((value: boolean) => void) | undefined; + + const onMoverDispose = (mover: Mover) => { + delete movers[mover.id]; }; - private _onFocus = (element: HTMLElement | undefined): void => { + const onFocus = (element: HTMLElement | undefined): void => { // When something in the app gets focused, we are making sure that // the relevant context Mover is aware of it. // Looking for the relevant context Mover from the currently @@ -786,7 +717,7 @@ export class MoverAPI implements Types.MoverAPI { // We go through all Movers up from the focused element and // set their current element to the deepest focusable of that // Mover. - const mover = getTabsterOnElement(this._tabster, el)?.mover; + const mover = getTabsterOnElement(tabster, el)?.mover; if (mover) { mover.setCurrent(deepestFocusableElement); @@ -795,26 +726,18 @@ export class MoverAPI implements Types.MoverAPI { if ( !currentFocusableElement && - this._tabster.focusable.isFocusable(el) + tabster.focusable.isFocusable(el) ) { currentFocusableElement = deepestFocusableElement = el; } } }; - moveFocus( - fromElement: HTMLElement, - key: Types.MoverKey - ): HTMLElement | null { - return this._moveFocus(fromElement, key); - } - - private _moveFocus( + const moveFocusInternal = ( fromElement: HTMLElement, key: Types.MoverKey, relatedEvent?: KeyboardEvent - ): HTMLElement | null { - const tabster = this._tabster; + ): HTMLElement | null => { const ctx = RootAPI.getTabsterContext(tabster, fromElement, { checkRtl: true, }); @@ -1017,7 +940,7 @@ export class MoverAPI implements Types.MoverAPI { if ( isElementVerticallyVisibleInContainer( - this._win, + getWindow, el, mover.visibilityTolerance ) @@ -1072,7 +995,7 @@ export class MoverAPI implements Types.MoverAPI { if ( isElementVerticallyVisibleInContainer( - this._win, + getWindow, el, mover.visibilityTolerance ) @@ -1213,7 +1136,7 @@ export class MoverAPI implements Types.MoverAPI { ))) ) { if (scrollIntoViewArg !== undefined) { - scrollIntoView(this._win, next, scrollIntoViewArg); + scrollIntoView(getWindow, next, scrollIntoViewArg); } if (relatedEvent) { @@ -1227,15 +1150,15 @@ export class MoverAPI implements Types.MoverAPI { } return null; - } + }; - private _onKeyDown = async (event: KeyboardEvent): Promise => { - if (this._ignoredInputTimer) { - this._win().clearTimeout(this._ignoredInputTimer); - delete this._ignoredInputTimer; + const onKeyDown = async (event: KeyboardEvent): Promise => { + if (ignoredInputTimer) { + getWindow().clearTimeout(ignoredInputTimer); + ignoredInputTimer = undefined; } - this._ignoredInputResolve?.(false); + ignoredInputResolve?.(false); // Give a chance to other listeners to handle the event (for example, // to scroll instead of moving focus). @@ -1268,31 +1191,31 @@ export class MoverAPI implements Types.MoverAPI { return; } - const focused = this._tabster.focusedElement.getFocusedElement(); + const focused = tabster.focusedElement.getFocusedElement(); - if (!focused || (await this._isIgnoredInput(focused, key))) { + if (!focused || (await isIgnoredInput(focused, key))) { return; } - this._moveFocus(focused, moverKey, event); + moveFocusInternal(focused, moverKey, event); }; - private _onMoveFocus = (e: MoverMoveFocusEvent): void => { + const onMoveFocus = (e: MoverMoveFocusEvent): void => { const element = e.composedPath()[0] as HTMLElement | null | undefined; const key = e.detail?.key; if (element && key !== undefined && !e.defaultPrevented) { - this._moveFocus(element, key); + moveFocusInternal(element, key); e.stopImmediatePropagation(); } }; - private _onMemorizedElement = (e: MoverMemorizedElementEvent): void => { + const onMemorizedElement = (e: MoverMemorizedElementEvent): void => { const target = e.composedPath()[0] as HTMLElement | null | undefined; let memorizedElement = e.detail?.memorizedElement; if (target) { - const ctx = RootAPI.getTabsterContext(this._tabster, target); + const ctx = RootAPI.getTabsterContext(tabster, target); const mover = ctx?.mover; if (mover) { @@ -1310,10 +1233,10 @@ export class MoverAPI implements Types.MoverAPI { } }; - private async _isIgnoredInput( + const isIgnoredInput = async ( element: HTMLElement, key: string - ): Promise { + ): Promise => { if ( element.getAttribute("aria-expanded") === "true" && (element.hasAttribute("aria-activedescendant") || @@ -1385,15 +1308,15 @@ export class MoverAPI implements Types.MoverAPI { } } else if (element.contentEditable === "true") { asyncRet = new Promise((resolve) => { - this._ignoredInputResolve = (value: boolean) => { - delete this._ignoredInputResolve; + ignoredInputResolve = (value: boolean) => { + ignoredInputResolve = undefined; resolve(value); }; - const win = this._win(); + const win = getWindow(); - if (this._ignoredInputTimer) { - win.clearTimeout(this._ignoredInputTimer); + if (ignoredInputTimer) { + win.clearTimeout(ignoredInputTimer); } const { @@ -1404,8 +1327,8 @@ export class MoverAPI implements Types.MoverAPI { } = dom.getSelection(element) || {}; // Get selection gives incorrect value if we call it syncronously onKeyDown. - this._ignoredInputTimer = win.setTimeout(() => { - delete this._ignoredInputTimer; + ignoredInputTimer = win.setTimeout(() => { + ignoredInputTimer = undefined; const { anchorNode, @@ -1420,7 +1343,7 @@ export class MoverAPI implements Types.MoverAPI { anchorOffset !== prevAnchorOffset || focusOffset !== prevFocusOffset ) { - this._ignoredInputResolve?.(false); + ignoredInputResolve?.(false); return; } @@ -1481,7 +1404,7 @@ export class MoverAPI implements Types.MoverAPI { } } - this._ignoredInputResolve?.(true); + ignoredInputResolve?.(true); }, 0); }); } @@ -1514,5 +1437,71 @@ export class MoverAPI implements Types.MoverAPI { } return false; - } + }; + + tabster.queueInit(() => { + const win = getWindow(); + + win.addEventListener("keydown", onKeyDown, true); + win.addEventListener(MoverMoveFocusEventName, onMoveFocus); + win.addEventListener(MoverMemorizedElementEventName, onMemorizedElement); + + tabster.focusedElement.subscribe(onFocus); + }); + + return { + dispose(): void { + const win = getWindow(); + + tabster.focusedElement.unsubscribe(onFocus); + + ignoredInputResolve?.(false); + + if (ignoredInputTimer) { + win.clearTimeout(ignoredInputTimer); + ignoredInputTimer = undefined; + } + + win.removeEventListener("keydown", onKeyDown, true); + win.removeEventListener(MoverMoveFocusEventName, onMoveFocus); + win.removeEventListener( + MoverMemorizedElementEventName, + onMemorizedElement + ); + + Object.keys(movers).forEach((moverId) => { + if (movers[moverId]) { + movers[moverId].dispose(); + delete movers[moverId]; + } + }); + }, + + createMover( + element: HTMLElement, + props: Types.MoverProps, + sys: Types.SysProps | undefined + ): Types.Mover { + if (__DEV__) { + validateMoverProps(props); + } + + const newMover = new Mover( + tabster, + element, + onMoverDispose, + props, + sys + ); + movers[newMover.id] = newMover; + return newMover; + }, + + moveFocus( + fromElement: HTMLElement, + key: Types.MoverKey + ): HTMLElement | null { + return moveFocusInternal(fromElement, key); + }, + }; } diff --git a/src/get/getMover.ts b/src/get/getMover.ts index 2db42dce..d5923078 100644 --- a/src/get/getMover.ts +++ b/src/get/getMover.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { MoverAPI } from "../Mover.js"; +import { createMoverAPI } from "../Mover.js"; import type * as Types from "../Types.js"; /** @@ -14,7 +14,7 @@ export function getMover(tabster: Types.Tabster): Types.MoverAPI { const tabsterCore = tabster.core; if (!tabsterCore.mover) { - const api = new MoverAPI(tabsterCore, tabsterCore.getWindow); + const api = createMoverAPI(tabsterCore, tabsterCore.getWindow); tabsterCore.mover = api; tabsterCore.attrHandlers.set( "mover", @@ -28,5 +28,6 @@ export function getMover(tabster: Types.Tabster): Types.MoverAPI { ); } - return tabsterCore.mover; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return tabsterCore.mover!; } From 268431e3cce3052a8bb8cca750d576af7f828e9b Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 4 May 2026 18:07:14 +0200 Subject: [PATCH 12/17] =?UTF-8?q?refactor(bundle-size):=20convert=20Modali?= =?UTF-8?q?zerAPI=20class=20=E2=86=92=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Modalizer.ts | 1000 +++++++++++++++++++-------------------- src/get/getModalizer.ts | 4 +- 2 files changed, 490 insertions(+), 514 deletions(-) diff --git a/src/Modalizer.ts b/src/Modalizer.ts index 30ccaa0f..d67b7815 100644 --- a/src/Modalizer.ts +++ b/src/Modalizer.ts @@ -341,422 +341,28 @@ function validateModalizerProps(props: Types.ModalizerProps): void { // TODO: Implement validation. } -export class ModalizerAPI implements Types.ModalizerAPI { - private _tabster: Types.TabsterCore; - private _win: Types.GetWindow; - private _restoreModalizerFocusTimer: number | undefined; - private _modalizers: Record; - private _parts: Record>; - private _augMap: WeakMap; - private _aug: WeakHTMLElement[]; - private _hiddenUpdateTimer: number | undefined; - private _alwaysAccessibleSelector: string | undefined; - private _accessibleCheck: Types.ModalizerElementAccessibleCheck | undefined; - private _activationHistory: (string | undefined)[]; - - activeId: string | undefined; - currentIsOthersAccessible: boolean | undefined; - activeElements: WeakHTMLElement[]; - - constructor( - tabster: Types.TabsterCore, - // @deprecated use accessibleCheck. - alwaysAccessibleSelector?: string, - accessibleCheck?: Types.ModalizerElementAccessibleCheck - ) { - this._tabster = tabster; - this._win = tabster.getWindow; - this._modalizers = {}; - this._parts = {}; - this._augMap = new WeakMap(); - this._aug = []; - this._alwaysAccessibleSelector = alwaysAccessibleSelector; - this._accessibleCheck = accessibleCheck; - this._activationHistory = []; - this.activeElements = []; - - if (!tabster.controlTab) { - tabster.root.addDummyInputs(); - } - - const win = this._win(); - addListener(win, "keydown", this._onKeyDown, true); - - tabster.queueInit(() => { - this._tabster.focusedElement.subscribe(this._onFocus); - }); - } - - dispose(): void { - const win = this._win(); - - removeListener(win, "keydown", this._onKeyDown, true); - - // Dispose all modalizers managed by the API - Object.keys(this._modalizers).forEach((modalizerId) => { - if (this._modalizers[modalizerId]) { - this._modalizers[modalizerId].dispose(); - delete this._modalizers[modalizerId]; - } - }); - - win.clearTimeout(this._restoreModalizerFocusTimer); - win.clearTimeout(this._hiddenUpdateTimer); - - this._parts = {}; - delete this.activeId; - this.activeElements = []; - - this._augMap = new WeakMap(); - this._aug = []; - - this._tabster.focusedElement.unsubscribe(this._onFocus); - } - - createModalizer( - element: HTMLElement, - props: Types.ModalizerProps, - sys: Types.SysProps | undefined - ): Types.Modalizer { - if (__DEV__) { - validateModalizerProps(props); - } - - const modalizer = new Modalizer( - this._tabster, - element, - this._onModalizerDispose, - props, - sys, - this.activeElements - ); - - const id = modalizer.id; - const userId = props.id; - - this._modalizers[id] = modalizer; - - let part = this._parts[userId]; - if (!part) { - part = this._parts[userId] = {}; - } - part[id] = modalizer; - - const focusedElement = - this._tabster.focusedElement.getFocusedElement() ?? null; - - // Adding a modalizer which is already focused, activate it - if ( - element !== focusedElement && - dom.nodeContains(element, focusedElement) - ) { - if (userId !== this.activeId) { - this.setActive(modalizer); - } else { - modalizer.makeActive(true); - } - } - - return modalizer; - } - - private _onModalizerDispose = (modalizer: Modalizer) => { - const id = modalizer.id; - const userId = modalizer.userId; - const part = this._parts[userId]; - - delete this._modalizers[id]; - - if (part) { - delete part[id]; - - if (Object.keys(part).length === 0) { - delete this._parts[userId]; - - const activationHistory = this._activationHistory; - const cleanActivationHistory: (string | undefined)[] = []; - let prevHistoryItem: string | undefined; - - // The history order is from most recent to oldest. - for (let i = activationHistory.length; i--; ) { - // Remove from activation history, making sure there are no duplicates - // for cases like [modal2, modal1, modal2, modal1]: just removing modal2 - // will result in [modal1, modal1] and we want just [modal1]. Otherwise, - // there is a chance for this array to grow forever in a narrow case of - // a modalizer that stays in DOM forever and is being activated/deactivated - // switching between other modalizers that come and go. - const modalizerUserIdFromHistory = activationHistory[i]; - - if (modalizerUserIdFromHistory === userId) { - continue; - } - - if (modalizerUserIdFromHistory !== prevHistoryItem) { - prevHistoryItem = modalizerUserIdFromHistory; - - if ( - modalizerUserIdFromHistory || - cleanActivationHistory.length > 0 - ) { - cleanActivationHistory.unshift( - modalizerUserIdFromHistory - ); - } - } - } - - this._activationHistory = cleanActivationHistory; - - if (this.activeId === userId) { - const prevActiveId = cleanActivationHistory[0]; - const prevActive = prevActiveId - ? Object.values(this._parts[prevActiveId])[0] - : undefined; - this.setActive(prevActive); - } - } - } - }; - - private _onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== Keys.Escape) { - return; - } - - const tabster = this._tabster; - const element = tabster.focusedElement.getFocusedElement(); - - if (element) { - const ctx = RootAPI.getTabsterContext(tabster, element); - const modalizer = ctx?.modalizer; - - if ( - ctx && - !ctx.groupper && - modalizer?.isActive() && - !ctx.ignoreKeydown(event) - ) { - const activeId = modalizer.userId; - - if (activeId) { - const part = this._parts[activeId]; - - if (part) { - const focusedSince = Object.keys(part) - .map((id) => { - const m = part[id]; - const el = m.getElement(); - let groupper: Types.Groupper | undefined; - - if (el) { - groupper = getTabsterOnElement( - tabster, - el - )?.groupper; - } - - return m && el && groupper - ? { - el, - focusedSince: m.focused(true), - } - : { focusedSince: 0 }; - }) - .filter((f) => f.focusedSince > 0) - .sort((a, b) => - a.focusedSince > b.focusedSince - ? -1 - : a.focusedSince < b.focusedSince - ? 1 - : 0 - ); - - if (focusedSince.length) { - const groupperElement = focusedSince[0].el; - - if (groupperElement) { - tabster.groupper?.handleKeyPress( - groupperElement, - event, - true - ); - } - } - } - } - } - } - }; - - isAugmented(element: HTMLElement): boolean { - return this._augMap.has(element); - } - - hiddenUpdate(): void { - if (this._hiddenUpdateTimer) { - return; - } - - this._hiddenUpdateTimer = this._win().setTimeout(() => { - delete this._hiddenUpdateTimer; - this._hiddenUpdate(); - }, 250); - } - - setActive(modalizer: Types.Modalizer | undefined): void { - const userId = modalizer?.userId; - const activeId = this.activeId; - - if (activeId === userId) { - return; - } - - this.activeId = userId; - - if (activeId) { - const part = this._parts[activeId]; - - if (part) { - for (const id of Object.keys(part)) { - part[id].makeActive(false); - } - } - } - - if (userId) { - const part = this._parts[userId]; - - if (part) { - for (const id of Object.keys(part)) { - part[id].makeActive(true); - } - } - } - - this.currentIsOthersAccessible = - modalizer?.getProps().isOthersAccessible; - - this.hiddenUpdate(); - - const activationHistory = this._activationHistory; - if ( - activationHistory[0] !== userId && - (userId !== undefined || activationHistory.length > 0) - ) { - activationHistory.unshift(userId); - } - } - - focus( - elementFromModalizer: HTMLElement, - noFocusFirst?: boolean, - noFocusDefault?: boolean - ): boolean { - const tabster = this._tabster; - const ctx = RootAPI.getTabsterContext(tabster, elementFromModalizer); - - const modalizer = ctx?.modalizer; - - if (modalizer) { - this.setActive(modalizer); - - const props = modalizer.getProps(); - const modalizerRoot = modalizer.getElement(); - - if (modalizerRoot) { - if (noFocusFirst === undefined) { - noFocusFirst = props.isNoFocusFirst; - } - - if ( - !noFocusFirst && - tabster.keyboardNavigation.isNavigatingWithKeyboard() && - tabster.focusedElement.focusFirst({ - container: modalizerRoot, - }) - ) { - return true; - } - - if (noFocusDefault === undefined) { - noFocusDefault = props.isNoFocusDefault; - } - - if ( - !noFocusDefault && - tabster.focusedElement.focusDefault(modalizerRoot) - ) { - return true; - } - - tabster.focusedElement.resetFocus(modalizerRoot); - } - } else if (__DEV__) { - console.error("Element is not in Modalizer.", elementFromModalizer); - } - - return false; - } - - activate(modalizerElementOrContainer: HTMLElement | undefined): boolean { - const modalizerToActivate: Types.Modalizer | undefined = - modalizerElementOrContainer - ? RootAPI.getTabsterContext( - this._tabster, - modalizerElementOrContainer - )?.modalizer - : undefined; - - if (!modalizerElementOrContainer || modalizerToActivate) { - this.setActive(modalizerToActivate); - return true; - } - - return false; - } - - acceptElement( - element: HTMLElement, - state: Types.FocusableAcceptElementState - ): number | undefined { - const modalizerUserId = state.modalizerUserId; - const currentModalizer = state.currentCtx?.modalizer; - - if (modalizerUserId) { - for (const e of this.activeElements) { - const el = e.get(); - - if (el && (dom.nodeContains(element, el) || el === element)) { - // We have a part of currently active modalizer somewhere deeper in the DOM, - // skipping all other checks. - return NodeFilter.FILTER_SKIP; - } - } - } - - const ret = - modalizerUserId === currentModalizer?.userId || - (!modalizerUserId && - currentModalizer?.getProps().isAlwaysAccessible) - ? undefined - : NodeFilter.FILTER_SKIP; - - if (ret !== undefined) { - state.skippedFocusable = true; - } - - return ret; - } - - private _hiddenUpdate(): void { - const tabster = this._tabster; +export function createModalizerAPI( + tabster: Types.TabsterCore, + // @deprecated use accessibleCheck. + alwaysAccessibleSelector?: string, +): Types.ModalizerAPI { + const win = tabster.getWindow; + let restoreModalizerFocusTimer: number | undefined; + const modalizers: Record = {}; + let parts: Record> = {}; + let augMap: WeakMap = new WeakMap(); + let aug: WeakHTMLElement[] = []; + let hiddenUpdateTimer: number | undefined; + let activationHistory: (string | undefined)[] = []; + + const activeElements: WeakHTMLElement[] = []; + + const hiddenUpdateInternal = (): void => { const body = tabster.getWindow().document.body; - const activeId = this.activeId; + const activeIdLocal = api.activeId; - const parts = this._parts; const visibleElements: HTMLElement[] = []; const hiddenElements: HTMLElement[] = []; - const alwaysAccessibleSelector = this._alwaysAccessibleSelector; const alwaysAccessibleElements: HTMLElement[] = alwaysAccessibleSelector ? Array.from( dom.querySelectorAll( @@ -777,10 +383,10 @@ export class ModalizerAPI implements Types.ModalizerAPI { const isAlwaysAccessible = props.isAlwaysAccessible; if (el) { - if (userId === activeId) { + if (userId === activeIdLocal) { activeModalizerElements.push(el); - if (!this.currentIsOthersAccessible) { + if (!api.currentIsOthersAccessible) { visibleElements.push(el); } } else if (isAlwaysAccessible) { @@ -792,7 +398,7 @@ export class ModalizerAPI implements Types.ModalizerAPI { } } - const augmentedMap = this._augMap; + const augmentedMap = augMap; const allVisibleElements: HTMLElement[] | undefined = visibleElements.length > 0 ? [...visibleElements, ...alwaysAccessibleElements] @@ -819,7 +425,7 @@ export class ModalizerAPI implements Types.ModalizerAPI { } } else if ( hide && - !this._accessibleCheck?.(element, activeModalizerElements) && + !accessibleCheck?.(element, activeModalizerElements) && augmentAttribute(tabster, element, _ariaHidden, "true") ) { augmentedMap.set(element, true); @@ -891,44 +497,109 @@ export class ModalizerAPI implements Types.ModalizerAPI { walk(body); } - this._aug - ?.map((e) => e.get()) - .forEach((e) => { - if (e && !newAugmentedMap.get(e)) { - toggle(e, false); - } - }); + aug?.map((e) => e.get()).forEach((e) => { + if (e && !newAugmentedMap.get(e)) { + toggle(e, false); + } + }); - this._aug = newAugmented; - this._augMap = newAugmentedMap; - } + aug = newAugmented; + augMap = newAugmentedMap; + }; - /** - * Subscribes to the focus state and handles modalizer related focus events - * @param focusedElement - Element that is focused - * @param detail - Additional data about the focus event - */ - private _onFocus = ( - focusedElement: HTMLElement | undefined, - detail: Types.FocusedElementDetail + const restoreModalizerFocus = ( + outsideElement: HTMLElement | undefined ): void => { - const tabster = this._tabster; - const ctx = - focusedElement && - RootAPI.getTabsterContext(tabster, focusedElement); + const ownerDocument = outsideElement?.ownerDocument; - // Modalizer behaviour is opt in, only apply to elements that have a tabster context - if (!ctx || !focusedElement) { + if (!outsideElement || !ownerDocument) { return; } - const augmentedMap = this._augMap; + const focusedElement = tabster.focusedElement.getFocusedElement(); + const focusedElementModalizer = + focusedElement && + RootAPI.getTabsterContext(tabster, focusedElement)?.modalizer; - for ( - let e: HTMLElement | null = focusedElement; - e; - e = dom.getParentElement(e) - ) { + if ( + !focusedElement || + (focusedElement && + focusedElementModalizer?.userId === api.activeId) + ) { + // If there is no currently focused element, or the currently focused element + // is in the active modalizer, we don't need to do anything. + return; + } + + const ctx = RootAPI.getTabsterContext(tabster, outsideElement); + const modalizer = ctx?.modalizer; + const activeIdLocal = api.activeId; + + if ( + (!modalizer && !activeIdLocal) || + (modalizer && activeIdLocal === modalizer.userId) + ) { + return; + } + + const container = ctx?.root.getElement(); + + if (container) { + let toFocus = tabster.focusable.findFirst({ + container, + useActiveModalizer: true, + }); + + if (toFocus) { + if ( + outsideElement.compareDocumentPosition(toFocus) & + document.DOCUMENT_POSITION_PRECEDING + ) { + toFocus = tabster.focusable.findLast({ + container, + useActiveModalizer: true, + }); + + if (!toFocus) { + // This only might mean that findFirst/findLast are buggy and inconsistent. + throw new Error("Something went wrong."); + } + } + + tabster.focusedElement.focus(toFocus); + + return; + } + } + + // Current Modalizer doesn't seem to have focusable elements. + // Blurring the currently focused element which is outside of the current Modalizer. + outsideElement.blur(); + }; + + /** + * Subscribes to the focus state and handles modalizer related focus events + */ + const onFocus = ( + focusedElement: HTMLElement | undefined, + detail: Types.FocusedElementDetail + ): void => { + const ctx = + focusedElement && + RootAPI.getTabsterContext(tabster, focusedElement); + + // Modalizer behaviour is opt in, only apply to elements that have a tabster context + if (!ctx || !focusedElement) { + return; + } + + const augmentedMap = augMap; + + for ( + let e: HTMLElement | null = focusedElement; + e; + e = dom.getParentElement(e) + ) { // If the newly focused element is inside some of the hidden containers, // remove aria-hidden from those synchronously for the screen readers // to be able to read the element. The rest of aria-hiddens, will be removed @@ -952,7 +623,7 @@ export class ModalizerAPI implements Types.ModalizerAPI { modalizerOnFocusedElement.focused(); if ( - modalizerOnFocusedElement.userId === this.activeId && + modalizerOnFocusedElement.userId === api.activeId && tabsterOnFocusedElement.groupper ) { const parentElement = tabster.getParent(focusedElement); @@ -964,7 +635,7 @@ export class ModalizerAPI implements Types.ModalizerAPI { if (parentModalizer) { modalizer = parentModalizer; } else { - this.setActive(undefined); + api.setActive(undefined); return; } } @@ -974,8 +645,8 @@ export class ModalizerAPI implements Types.ModalizerAPI { // in the context, yet we still want to track that the modalizer's container was focused. modalizer?.focused(); - if (modalizer?.userId === this.activeId) { - this.currentIsOthersAccessible = + if (modalizer?.userId === api.activeId) { + api.currentIsOthersAccessible = modalizer?.getProps().isOthersAccessible; return; @@ -984,96 +655,401 @@ export class ModalizerAPI implements Types.ModalizerAPI { // Developers calling `element.focus()` should change/deactivate active modalizer if ( detail.isFocusedProgrammatically || - this.currentIsOthersAccessible || + api.currentIsOthersAccessible || modalizer?.getProps().isAlwaysAccessible ) { - this.setActive(modalizer); + api.setActive(modalizer); } else { // Focused outside of the active modalizer, try pull focus back to current modalizer - const win = this._win(); - win.clearTimeout(this._restoreModalizerFocusTimer); + const w = win(); + w.clearTimeout(restoreModalizerFocusTimer); // TODO some rendering frameworks (i.e. React) might async rerender the DOM so we need to wait for a duration // Figure out a better way of doing this rather than a 100ms timeout - this._restoreModalizerFocusTimer = win.setTimeout( - () => this._restoreModalizerFocus(focusedElement), + restoreModalizerFocusTimer = w.setTimeout( + () => restoreModalizerFocus(focusedElement), 100 ); } }; - /** - * Called when an element is focused outside of an active modalizer. - * Attempts to pull focus back into the active modalizer - * @param outsideElement - An element being focused outside of the modalizer - */ - private _restoreModalizerFocus( - outsideElement: HTMLElement | undefined - ): void { - const ownerDocument = outsideElement?.ownerDocument; + const onModalizerDispose = (modalizer: Modalizer) => { + const id = modalizer.id; + const userId = modalizer.userId; + const part = parts[userId]; - if (!outsideElement || !ownerDocument) { - return; - } + delete modalizers[id]; - const focusedElement = this._tabster.focusedElement.getFocusedElement(); - const focusedElementModalizer = - focusedElement && - RootAPI.getTabsterContext(this._tabster, focusedElement)?.modalizer; + if (part) { + delete part[id]; - if ( - !focusedElement || - (focusedElement && - focusedElementModalizer?.userId === this.activeId) - ) { - // If there is no currently focused element, or the currently focused element - // is in the active modalizer, we don't need to do anything. + if (Object.keys(part).length === 0) { + delete parts[userId]; + + const cleanActivationHistory: (string | undefined)[] = []; + let prevHistoryItem: string | undefined; + + // The history order is from most recent to oldest. + for (let i = activationHistory.length; i--; ) { + // Remove from activation history, making sure there are no duplicates + // for cases like [modal2, modal1, modal2, modal1]: just removing modal2 + // will result in [modal1, modal1] and we want just [modal1]. Otherwise, + // there is a chance for this array to grow forever in a narrow case of + // a modalizer that stays in DOM forever and is being activated/deactivated + // switching between other modalizers that come and go. + const modalizerUserIdFromHistory = activationHistory[i]; + + if (modalizerUserIdFromHistory === userId) { + continue; + } + + if (modalizerUserIdFromHistory !== prevHistoryItem) { + prevHistoryItem = modalizerUserIdFromHistory; + + if ( + modalizerUserIdFromHistory || + cleanActivationHistory.length > 0 + ) { + cleanActivationHistory.unshift( + modalizerUserIdFromHistory + ); + } + } + } + + activationHistory = cleanActivationHistory; + + if (api.activeId === userId) { + const prevActiveId = cleanActivationHistory[0]; + const prevActive = prevActiveId + ? Object.values(parts[prevActiveId])[0] + : undefined; + api.setActive(prevActive); + } + } + } + }; + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== Keys.Escape) { return; } - const tabster = this._tabster; - const ctx = RootAPI.getTabsterContext(tabster, outsideElement); - const modalizer = ctx?.modalizer; - const activeId = this.activeId; + const element = tabster.focusedElement.getFocusedElement(); - if ( - (!modalizer && !activeId) || - (modalizer && activeId === modalizer.userId) - ) { - return; + if (element) { + const ctx = RootAPI.getTabsterContext(tabster, element); + const modalizer = ctx?.modalizer; + + if ( + ctx && + !ctx.groupper && + modalizer?.isActive() && + !ctx.ignoreKeydown(event) + ) { + const activeIdLocal = modalizer.userId; + + if (activeIdLocal) { + const part = parts[activeIdLocal]; + + if (part) { + const focusedSince = Object.keys(part) + .map((id) => { + const m = part[id]; + const el = m.getElement(); + let groupper: Types.Groupper | undefined; + + if (el) { + groupper = getTabsterOnElement( + tabster, + el + )?.groupper; + } + + return m && el && groupper + ? { + el, + focusedSince: m.focused(true), + } + : { focusedSince: 0 }; + }) + .filter((f) => f.focusedSince > 0) + .sort((a, b) => + a.focusedSince > b.focusedSince + ? -1 + : a.focusedSince < b.focusedSince + ? 1 + : 0 + ); + + if (focusedSince.length) { + const groupperElement = focusedSince[0].el; + + if (groupperElement) { + tabster.groupper?.handleKeyPress( + groupperElement, + event, + true + ); + } + } + } + } + } } + }; - const container = ctx?.root.getElement(); + if (!tabster.controlTab) { + tabster.root.addDummyInputs(); + } - if (container) { - let toFocus = tabster.focusable.findFirst({ - container, - useActiveModalizer: true, + win().addEventListener("keydown", onKeyDown, true); + + tabster.queueInit(() => { + tabster.focusedElement.subscribe(onFocus); + }); + + const api: Types.ModalizerAPI = { + activeId: undefined, + currentIsOthersAccessible: undefined, + activeElements, + + dispose(): void { + const w = win(); + + w.removeEventListener("keydown", onKeyDown, true); + + // Dispose all modalizers managed by the API + Object.keys(modalizers).forEach((modalizerId) => { + if (modalizers[modalizerId]) { + modalizers[modalizerId].dispose(); + delete modalizers[modalizerId]; + } }); - if (toFocus) { - if ( - outsideElement.compareDocumentPosition(toFocus) & - document.DOCUMENT_POSITION_PRECEDING - ) { - toFocus = tabster.focusable.findLast({ - container, - useActiveModalizer: true, - }); + w.clearTimeout(restoreModalizerFocusTimer); + w.clearTimeout(hiddenUpdateTimer); - if (!toFocus) { - // This only might mean that findFirst/findLast are buggy and inconsistent. - throw new Error("Something went wrong."); - } + parts = {}; + api.activeId = undefined; + activeElements.length = 0; + + augMap = new WeakMap(); + aug = []; + + tabster.focusedElement.unsubscribe(onFocus); + }, + + createModalizer( + element: HTMLElement, + props: Types.ModalizerProps, + sys: Types.SysProps | undefined + ): Types.Modalizer { + if (__DEV__) { + validateModalizerProps(props); + } + + const modalizer = new Modalizer( + tabster, + element, + onModalizerDispose, + props, + sys, + activeElements + ); + + const id = modalizer.id; + const userId = props.id; + + modalizers[id] = modalizer; + + let part = parts[userId]; + if (!part) { + part = parts[userId] = {}; + } + part[id] = modalizer; + + const focusedElement = + tabster.focusedElement.getFocusedElement() ?? null; + + // Adding a modalizer which is already focused, activate it + if ( + element !== focusedElement && + dom.nodeContains(element, focusedElement) + ) { + if (userId !== api.activeId) { + api.setActive(modalizer); + } else { + modalizer.makeActive(true); } + } - tabster.focusedElement.focus(toFocus); + return modalizer; + }, + isAugmented(element: HTMLElement): boolean { + return augMap.has(element); + }, + + hiddenUpdate(): void { + if (hiddenUpdateTimer) { return; } - } - // Current Modalizer doesn't seem to have focusable elements. - // Blurring the currently focused element which is outside of the current Modalizer. - outsideElement.blur(); - } + hiddenUpdateTimer = win().setTimeout(() => { + hiddenUpdateTimer = undefined; + hiddenUpdateInternal(); + }, 250); + }, + + setActive(modalizer: Types.Modalizer | undefined): void { + const userId = modalizer?.userId; + const previousActiveId = api.activeId; + + if (previousActiveId === userId) { + return; + } + + api.activeId = userId; + + if (previousActiveId) { + const part = parts[previousActiveId]; + + if (part) { + for (const id of Object.keys(part)) { + part[id].makeActive(false); + } + } + } + + if (userId) { + const part = parts[userId]; + + if (part) { + for (const id of Object.keys(part)) { + part[id].makeActive(true); + } + } + } + + api.currentIsOthersAccessible = + modalizer?.getProps().isOthersAccessible; + + api.hiddenUpdate(); + + if ( + activationHistory[0] !== userId && + (userId !== undefined || activationHistory.length > 0) + ) { + activationHistory.unshift(userId); + } + }, + + focus( + elementFromModalizer: HTMLElement, + noFocusFirst?: boolean, + noFocusDefault?: boolean + ): boolean { + const ctx = RootAPI.getTabsterContext(tabster, elementFromModalizer); + + const modalizer = ctx?.modalizer; + + if (modalizer) { + api.setActive(modalizer); + + const props = modalizer.getProps(); + const modalizerRoot = modalizer.getElement(); + + if (modalizerRoot) { + if (noFocusFirst === undefined) { + noFocusFirst = props.isNoFocusFirst; + } + + if ( + !noFocusFirst && + tabster.keyboardNavigation.isNavigatingWithKeyboard() && + tabster.focusedElement.focusFirst({ + container: modalizerRoot, + }) + ) { + return true; + } + + if (noFocusDefault === undefined) { + noFocusDefault = props.isNoFocusDefault; + } + + if ( + !noFocusDefault && + tabster.focusedElement.focusDefault(modalizerRoot) + ) { + return true; + } + + tabster.focusedElement.resetFocus(modalizerRoot); + } + } else if (__DEV__) { + console.error( + "Element is not in Modalizer.", + elementFromModalizer + ); + } + + return false; + }, + + activate(modalizerElementOrContainer: HTMLElement | undefined): boolean { + const modalizerToActivate: Types.Modalizer | undefined = + modalizerElementOrContainer + ? RootAPI.getTabsterContext( + tabster, + modalizerElementOrContainer + )?.modalizer + : undefined; + + if (!modalizerElementOrContainer || modalizerToActivate) { + api.setActive(modalizerToActivate); + return true; + } + + return false; + }, + + acceptElement( + element: HTMLElement, + state: Types.FocusableAcceptElementState + ): number | undefined { + const modalizerUserId = state.modalizerUserId; + const currentModalizer = state.currentCtx?.modalizer; + + if (modalizerUserId) { + for (const e of activeElements) { + const el = e.get(); + + if ( + el && + (dom.nodeContains(element, el) || el === element) + ) { + // We have a part of currently active modalizer somewhere deeper in the DOM, + // skipping all other checks. + return NodeFilter.FILTER_SKIP; + } + } + } + + const ret = + modalizerUserId === currentModalizer?.userId || + (!modalizerUserId && + currentModalizer?.getProps().isAlwaysAccessible) + ? undefined + : NodeFilter.FILTER_SKIP; + + if (ret !== undefined) { + state.skippedFocusable = true; + } + + return ret; + }, + }; + + return api; } diff --git a/src/get/getModalizer.ts b/src/get/getModalizer.ts index 4d649c44..dacbbc77 100644 --- a/src/get/getModalizer.ts +++ b/src/get/getModalizer.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { ModalizerAPI } from "../Modalizer.js"; +import { createModalizerAPI } from "../Modalizer.js"; import type * as Types from "../Types.js"; /** @@ -24,7 +24,7 @@ export function getModalizer( const tabsterCore = tabster.core; if (!tabsterCore.modalizer) { - const api = new ModalizerAPI( + const api = createModalizerAPI( tabsterCore, alwaysAccessibleSelector, accessibleCheck From 8606f2589c96a75d939898a775fd92c43754e7db Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 4 May 2026 18:08:20 +0200 Subject: [PATCH 13/17] =?UTF-8?q?refactor(bundle-size):=20convert=20CrossO?= =?UTF-8?q?riginAPI=20class=20=E2=86=92=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/CrossOrigin.ts | 245 +++++++++++++++++--------------------- src/get/getCrossOrigin.ts | 4 +- 2 files changed, 113 insertions(+), 136 deletions(-) diff --git a/src/CrossOrigin.ts b/src/CrossOrigin.ts index 87370f0f..3963de1d 100644 --- a/src/CrossOrigin.ts +++ b/src/CrossOrigin.ts @@ -1683,155 +1683,62 @@ export const CrossOriginObservedElementState = { }, }; -export class CrossOriginAPI implements Types.CrossOriginAPI { - private _tabster: Types.TabsterCore; - private _win: Types.GetWindow; - private _transactions: CrossOriginTransactions; - private _blurTimer: Timer; - private _ctx: CrossOriginInstanceContext; - - focusedElement: Types.CrossOriginFocusedElementState; - observedElement: Types.CrossOriginObservedElementState; - - constructor(tabster: Types.TabsterCore) { - this._tabster = tabster; - this._win = tabster.getWindow; - this._blurTimer = createTimer(); - this._ctx = { - ignoreKeyboardNavigationStateUpdate: false, - deloserByUId: {}, - }; - - this._transactions = new CrossOriginTransactions( - tabster, - this._win, - this._ctx - ); - this.focusedElement = createCrossOriginFocusedElementState( - this._transactions - ); - this.observedElement = createCrossOriginObservedElementState( - 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 - ); - 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); + const transactions = new CrossOriginTransactions(tabster, win, ctx); + const focusedElement = createCrossOriginFocusedElementState(transactions); + const observedElement = createCrossOriginObservedElementState( + tabster, + transactions + ); - this._transactions.dispose(); - this.focusedElement.dispose(); - this.observedElement.dispose(); - - 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); - clearTimer(this._blurTimer, win); + 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 { setTimer( - this._blurTimer, - win, + blurTimer, + win(), () => { - if ( - this._ctx.focusOwner && - this._ctx.focusOwner === ownerUId - ) { - this._transactions + if (ctx.focusOwner && ctx.focusOwner === ownerUId) { + transactions .beginTransaction(GetElementTransaction, undefined) .then((value) => { - if ( - !value && - this._ctx.focusOwner === ownerUId - ) { - this._transactions.beginTransaction( + if (!value && ctx.focusOwner === ownerUId) { + transactions.beginTransaction( StateTransaction, { ownerUId, @@ -1848,32 +1755,102 @@ export class CrossOriginAPI implements Types.CrossOriginAPI { } }; - 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/get/getCrossOrigin.ts b/src/get/getCrossOrigin.ts index 3e1c97ea..caf3d46d 100644 --- a/src/get/getCrossOrigin.ts +++ b/src/get/getCrossOrigin.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { CrossOriginAPI } from "../CrossOrigin.js"; +import { createCrossOriginAPI } from "../CrossOrigin.js"; import type * as Types from "../Types.js"; import { getDeloser } from "./getDeloser.js"; import { getModalizer } from "./getModalizer.js"; @@ -21,7 +21,7 @@ export function getCrossOrigin(tabster: Types.Tabster): Types.CrossOriginAPI { getGroupper(tabster); getOutline(tabster); getObservedElement(tabster); - tabsterCore.crossOrigin = new CrossOriginAPI(tabsterCore); + tabsterCore.crossOrigin = createCrossOriginAPI(tabsterCore); } return tabsterCore.crossOrigin; From d3ab201d0a6effe189c586fe440ae1b9f6c45cf1 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 4 May 2026 18:10:18 +0200 Subject: [PATCH 14/17] =?UTF-8?q?refactor(bundle-size):=20convert=20Uncont?= =?UTF-8?q?rolledAPI=20class=20=E2=86=92=20factory=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Tabster.ts | 4 ++-- src/Uncontrolled.ts | 38 +++++++++++++------------------------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/src/Tabster.ts b/src/Tabster.ts index ed721e24..e6c7011f 100644 --- a/src/Tabster.ts +++ b/src/Tabster.ts @@ -14,7 +14,7 @@ import { observeMutations } from "./MutationEvent.js"; import { RootAPI, type WindowWithTabsterInstance } from "./Root.js"; import type * as Types from "./Types.js"; import { TABSTER_ATTRIBUTE_NAME } from "./Consts.js"; -import { UncontrolledAPI } from "./Uncontrolled.js"; +import { createUncontrolledAPI } from "./Uncontrolled.js"; import { createDummyInputObserver } from "./DummyInput.js"; import { clearElementCache, @@ -112,7 +112,7 @@ class TabsterCore implements Types.TabsterCore { this.focusedElement = createFocusedElementState(this, getWindow); this.focusable = new FocusableAPI(this); this.root = new RootAPI(this, props?.autoRoot); - this.uncontrolled = new UncontrolledAPI( + this.uncontrolled = createUncontrolledAPI( // TODO: Remove checkUncontrolledTrappingFocus in the next major version. props?.checkUncontrolledCompletely || props?.checkUncontrolledTrappingFocus diff --git a/src/Uncontrolled.ts b/src/Uncontrolled.ts index d33a03ff..e478a38f 100644 --- a/src/Uncontrolled.ts +++ b/src/Uncontrolled.ts @@ -9,33 +9,21 @@ import type * as Types from "./Types.js"; * Allows default or user focus behaviour on the DOM subtree * i.e. Tabster will not control focus events within an uncontrolled area */ -export class UncontrolledAPI implements Types.UncontrolledAPI { - private _isUncontrolledCompletely?: ( +export function createUncontrolledAPI( + isUncontrolledCompletely?: ( element: HTMLElement, completely: boolean - ) => boolean | undefined; - - constructor( - isUncontrolledCompletely?: ( + ) => boolean | undefined +): Types.UncontrolledAPI { + return { + isUncontrolledCompletely( element: HTMLElement, completely: boolean - ) => boolean | undefined - ) { - this._isUncontrolledCompletely = isUncontrolledCompletely; - } - - isUncontrolledCompletely( - element: HTMLElement, - completely: boolean - ): boolean { - const isUncontrolledCompletely = this._isUncontrolledCompletely?.( - element, - completely - ); - // If isUncontrolledCompletely callback is not defined or returns undefined, then the default - // behaviour is to return the uncontrolled.completely value from the element. - return isUncontrolledCompletely === undefined - ? completely - : isUncontrolledCompletely; - } + ): boolean { + const result = isUncontrolledCompletely?.(element, completely); + // If isUncontrolledCompletely callback is not defined or returns undefined, then the default + // behaviour is to return the uncontrolled.completely value from the element. + return result === undefined ? completely : result; + }, + }; } From bb1f119ce42adc9282754235b5c98fa8259cb102 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 11 May 2026 13:45:13 +0200 Subject: [PATCH 15/17] fix: resolve cherry-pick conflicts for PR4/PR5/PR6 compatibility in factory conversions Apply post-cherry-pick fixes across all 8 converted API factories: - Use addListener/removeListener helpers (PR4) instead of direct addEventListener - Use focusedElementState method calls instead of missing free-function exports - Expose _grouppers/_modalizers as internal getters for test compatibility - Add _grouppers and _modalizers to GroupperAPIInternal/ModalizerAPIInternal types - Exclude generated bundle-size/.readable/ and .claude/ from prettier checks Co-Authored-By: Claude Opus 4.7 (1M context) --- .prettierignore | 2 ++ src/Groupper.ts | 17 +++++++++++------ src/Modalizer.ts | 22 ++++++++++++++++------ src/Mover.ts | 18 ++++++++---------- src/Restorer.ts | 11 +++-------- src/Types.ts | 4 ++++ 6 files changed, 44 insertions(+), 30 deletions(-) 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/src/Groupper.ts b/src/Groupper.ts index e0d08a8f..6da34eae 100644 --- a/src/Groupper.ts +++ b/src/Groupper.ts @@ -705,9 +705,9 @@ export function createGroupperAPI( onFocus(activeElement as HTMLElement); } - doc.addEventListener("mousedown", onMouseDown, true); - win.addEventListener("keydown", onKeyDown, true); - win.addEventListener(GroupperMoveFocusEventName, onMoveFocus); + addListener(doc, "mousedown", onMouseDown, true); + addListener(win, "keydown", onKeyDown, true); + addListener(win, GroupperMoveFocusEventName, onMoveFocus); }); return { @@ -727,9 +727,9 @@ export function createGroupperAPI( tabster.focusedElement.unsubscribe(onFocus); - win.document.removeEventListener("mousedown", onMouseDown, true); - win.removeEventListener("keydown", onKeyDown, true); - win.removeEventListener(GroupperMoveFocusEventName, onMoveFocus); + removeListener(win.document, "mousedown", onMouseDown, true); + removeListener(win, "keydown", onKeyDown, true); + removeListener(win, GroupperMoveFocusEventName, onMoveFocus); Object.keys(grouppers).forEach((groupperId) => { if (grouppers[groupperId]) { @@ -796,6 +796,11 @@ export function createGroupperAPI( }, handleKeyPress, + + /** @internal - exposed for tests only */ + get _grouppers() { + return grouppers; + }, }; } diff --git a/src/Modalizer.ts b/src/Modalizer.ts index d67b7815..4e9e26d3 100644 --- a/src/Modalizer.ts +++ b/src/Modalizer.ts @@ -345,6 +345,7 @@ export function createModalizerAPI( tabster: Types.TabsterCore, // @deprecated use accessibleCheck. alwaysAccessibleSelector?: string, + accessibleCheck?: Types.ModalizerElementAccessibleCheck ): Types.ModalizerAPI { const win = tabster.getWindow; let restoreModalizerFocusTimer: number | undefined; @@ -523,8 +524,7 @@ export function createModalizerAPI( if ( !focusedElement || - (focusedElement && - focusedElementModalizer?.userId === api.activeId) + (focusedElement && focusedElementModalizer?.userId === api.activeId) ) { // If there is no currently focused element, or the currently focused element // is in the active modalizer, we don't need to do anything. @@ -802,7 +802,7 @@ export function createModalizerAPI( tabster.root.addDummyInputs(); } - win().addEventListener("keydown", onKeyDown, true); + addListener(win(), "keydown", onKeyDown, true); tabster.queueInit(() => { tabster.focusedElement.subscribe(onFocus); @@ -816,7 +816,7 @@ export function createModalizerAPI( dispose(): void { const w = win(); - w.removeEventListener("keydown", onKeyDown, true); + removeListener(w, "keydown", onKeyDown, true); // Dispose all modalizers managed by the API Object.keys(modalizers).forEach((modalizerId) => { @@ -949,7 +949,10 @@ export function createModalizerAPI( noFocusFirst?: boolean, noFocusDefault?: boolean ): boolean { - const ctx = RootAPI.getTabsterContext(tabster, elementFromModalizer); + const ctx = RootAPI.getTabsterContext( + tabster, + elementFromModalizer + ); const modalizer = ctx?.modalizer; @@ -997,7 +1000,9 @@ export function createModalizerAPI( return false; }, - activate(modalizerElementOrContainer: HTMLElement | undefined): boolean { + activate( + modalizerElementOrContainer: HTMLElement | undefined + ): boolean { const modalizerToActivate: Types.Modalizer | undefined = modalizerElementOrContainer ? RootAPI.getTabsterContext( @@ -1049,6 +1054,11 @@ export function createModalizerAPI( return ret; }, + + /** @internal - exposed for tests only */ + get _modalizers() { + return modalizers; + }, }; return api; diff --git a/src/Mover.ts b/src/Mover.ts index a2e3784a..ab75e127 100644 --- a/src/Mover.ts +++ b/src/Mover.ts @@ -724,10 +724,7 @@ export function createMoverAPI( currentFocusableElement = undefined; } - if ( - !currentFocusableElement && - tabster.focusable.isFocusable(el) - ) { + if (!currentFocusableElement && tabster.focusable.isFocusable(el)) { currentFocusableElement = deepestFocusableElement = el; } } @@ -1442,9 +1439,9 @@ export function createMoverAPI( tabster.queueInit(() => { const win = getWindow(); - win.addEventListener("keydown", onKeyDown, true); - win.addEventListener(MoverMoveFocusEventName, onMoveFocus); - win.addEventListener(MoverMemorizedElementEventName, onMemorizedElement); + addListener(win, "keydown", onKeyDown, true); + addListener(win, MoverMoveFocusEventName, onMoveFocus); + addListener(win, MoverMemorizedElementEventName, onMemorizedElement); tabster.focusedElement.subscribe(onFocus); }); @@ -1462,9 +1459,10 @@ export function createMoverAPI( ignoredInputTimer = undefined; } - win.removeEventListener("keydown", onKeyDown, true); - win.removeEventListener(MoverMoveFocusEventName, onMoveFocus); - win.removeEventListener( + removeListener(win, "keydown", onKeyDown, true); + removeListener(win, MoverMoveFocusEventName, onMoveFocus); + removeListener( + win, MoverMemorizedElementEventName, onMemorizedElement ); diff --git a/src/Restorer.ts b/src/Restorer.ts index 39f38ca7..35e799be 100644 --- a/src/Restorer.ts +++ b/src/Restorer.ts @@ -3,10 +3,6 @@ * Licensed under the MIT License. */ -import { - _cancelAsyncFocus, - _requestAsyncFocus, -} from "./State/FocusedElement.js"; import { getTabsterOnElement } from "./Instance.js"; import type { GetWindow, @@ -176,7 +172,7 @@ export function createRestorerAPI(tabster: TabsterCore): RestorerAPIType { }; const onRestoreFocus = (e: Event) => { - _cancelAsyncFocus(tabster, AsyncFocusSources.Restorer); + focusedElementState.cancelAsyncFocus(AsyncFocusSources.Restorer); // ShadowDOM will have shadowRoot as e.target. const source = e.composedPath()[0] as HTMLElement | undefined; @@ -190,8 +186,7 @@ export function createRestorerAPI(tabster: TabsterCore): RestorerAPIType { source )?.restorer?.getProps().id; - _requestAsyncFocus( - tabster, + focusedElementState.requestAsyncFocus( AsyncFocusSources.Restorer, () => restoreFocus(source, sourceId), 0 @@ -233,7 +228,7 @@ export function createRestorerAPI(tabster: TabsterCore): RestorerAPIType { dispose() { focusedElementState.unsubscribe(onFocusIn); - _cancelAsyncFocus(tabster, AsyncFocusSources.Restorer); + focusedElementState.cancelAsyncFocus(AsyncFocusSources.Restorer); removeListener( getWindow(), RestorerRestoreFocusEventName, diff --git a/src/Types.ts b/src/Types.ts index 34255665..76bb5a95 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -941,6 +941,8 @@ export interface GroupperAPI extends GroupperAPIInternal, Disposable { export interface GroupperAPIInternal { forgetCurrentGrouppers(): void; + /** @internal - exposed for tests only */ + readonly _grouppers: Record; } export interface ModalizerProps { @@ -1103,6 +1105,8 @@ interface ModalizerAPIInternal extends TabsterPartWithAcceptElement { hiddenUpdate(): void; /** @internal */ isAugmented(element: HTMLElement): boolean; + /** @internal - exposed for tests only */ + readonly _modalizers: Record; } export interface ModalizerAPI extends ModalizerAPIInternal, Disposable { From 1acb7bea1164ad40c75e3d24c7d73cb1ebd22525 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 11 May 2026 14:07:24 +0200 Subject: [PATCH 16/17] refactor(bundle-size): extract context resolvers + slim TabsterCore surface Co-Authored-By: Claude Opus 4.7 (1M context) --- bundle-size/focusableFindAll.fixture.js | 8 +- bundle-size/focusableFindLast.fixture.js | 8 +- bundle-size/focusableFindNext.fixture.js | 8 +- bundle-size/focusableFindPrev.fixture.js | 8 +- src/Context.ts | 203 ++++++ src/CrossOrigin.ts | 9 +- src/Deloser.ts | 49 +- src/DummyInput.ts | 194 +++-- src/Focusable.ts | 864 ++++++++++++----------- src/Groupper.ts | 79 ++- src/Instance.ts | 182 ++--- src/Modalizer.ts | 78 +- src/Mover.ts | 350 ++++----- src/Root.ts | 226 +----- src/State/FocusedElement.ts | 240 ++++--- src/State/ObservedElement.ts | 77 +- src/Tabster.ts | 43 +- src/Types.ts | 82 +-- src/get/getCrossOrigin.ts | 4 +- src/get/getDeloser.ts | 1 + src/get/getGroupper.ts | 31 + src/get/getModalizer.ts | 33 + src/get/getMover.ts | 31 + src/get/getObservedElement.ts | 4 +- src/get/getOutline.ts | 5 +- src/get/getRestorer.ts | 1 + src/index.ts | 14 + stories/Modalizer/ModalDialog.ts | 14 +- tests/Focusable.test.tsx | 134 ++-- tests/FocusedElement.test.tsx | 8 +- tests/Tabster.test.tsx | 8 +- tests/iframeFocus.test.tsx | 18 +- tests/test-setup.js | 25 + tests/utils/BroTest.ts | 24 + 34 files changed, 1653 insertions(+), 1410 deletions(-) create mode 100644 src/Context.ts diff --git a/bundle-size/focusableFindAll.fixture.js b/bundle-size/focusableFindAll.fixture.js index 33f90019..bfea024b 100644 --- a/bundle-size/focusableFindAll.fixture.js +++ b/bundle-size/focusableFindAll.fixture.js @@ -1,23 +1,21 @@ import { createTabster, disposeTabster, + findAllFocusable, getTabsterAttribute, setTabsterAttribute, Types, } from "tabster"; -/** @param {ReturnType} tabster */ -const useFocusable = (tabster) => tabster.focusable.findAll; - console.log( createTabster, disposeTabster, getTabsterAttribute, setTabsterAttribute, - useFocusable, + findAllFocusable, Types ); export default { - name: "focusable.findAll", + name: "findAllFocusable", }; diff --git a/bundle-size/focusableFindLast.fixture.js b/bundle-size/focusableFindLast.fixture.js index c49fe4c6..1d9fb787 100644 --- a/bundle-size/focusableFindLast.fixture.js +++ b/bundle-size/focusableFindLast.fixture.js @@ -1,23 +1,21 @@ import { createTabster, disposeTabster, + findLastFocusable, getTabsterAttribute, setTabsterAttribute, Types, } from "tabster"; -/** @param {ReturnType} tabster */ -const useFocusable = (tabster) => tabster.focusable.findLast; - console.log( createTabster, disposeTabster, getTabsterAttribute, setTabsterAttribute, - useFocusable, + findLastFocusable, Types ); export default { - name: "focusable.findLast", + name: "findLastFocusable", }; diff --git a/bundle-size/focusableFindNext.fixture.js b/bundle-size/focusableFindNext.fixture.js index f591c764..de34230c 100644 --- a/bundle-size/focusableFindNext.fixture.js +++ b/bundle-size/focusableFindNext.fixture.js @@ -1,23 +1,21 @@ import { createTabster, disposeTabster, + findNextFocusable, getTabsterAttribute, setTabsterAttribute, Types, } from "tabster"; -/** @param {ReturnType} tabster */ -const useFocusable = (tabster) => tabster.focusable.findNext; - console.log( createTabster, disposeTabster, getTabsterAttribute, setTabsterAttribute, - useFocusable, + findNextFocusable, Types ); export default { - name: "focusable.findNext", + name: "findNextFocusable", }; diff --git a/bundle-size/focusableFindPrev.fixture.js b/bundle-size/focusableFindPrev.fixture.js index fc1459d5..0b88044d 100644 --- a/bundle-size/focusableFindPrev.fixture.js +++ b/bundle-size/focusableFindPrev.fixture.js @@ -1,23 +1,21 @@ import { createTabster, disposeTabster, + findPrevFocusable, getTabsterAttribute, setTabsterAttribute, Types, } from "tabster"; -/** @param {ReturnType} tabster */ -const useFocusable = (tabster) => tabster.focusable.findPrev; - console.log( createTabster, disposeTabster, getTabsterAttribute, setTabsterAttribute, - useFocusable, + findPrevFocusable, Types ); export default { - name: "focusable.findPrev", + name: "findPrevFocusable", }; 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 3963de1d..c33aa99e 100644 --- a/src/CrossOrigin.ts +++ b/src/CrossOrigin.ts @@ -8,8 +8,9 @@ import { DeloserHistoryByRootBase, DeloserItemBase, } from "./Deloser.js"; +import { _isFocusable } from "./Focusable.js"; import { getTabsterOnElement } from "./Instance.js"; -import { RootAPI } from "./Root.js"; +import { getTabsterContext } from "./Context.js"; import { createSubscribable } from "./State/Subscribable.js"; import type * as Types from "./Types.js"; import { ObservedElementAccessibilities } from "./Consts.js"; @@ -438,7 +439,7 @@ class FocusElementTransaction extends CrossOriginTransaction< getOwner, data.beginData ); - return !el || !tabster.focusable.isFocusable(el); + return !el || !_isFocusable(tabster, el); } static async makeResponse( @@ -768,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; @@ -797,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; diff --git a/src/Deloser.ts b/src/Deloser.ts index dd5b3aa1..0d3ee4aa 100644 --- a/src/Deloser.ts +++ b/src/Deloser.ts @@ -3,8 +3,14 @@ * Licensed under the MIT License. */ +import { + _findDefaultFocusable, + _findFocusable, + _isElementVisible, + _isFocusable, +} from "./Focusable.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 { @@ -15,12 +21,16 @@ import { } 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"; @@ -203,7 +213,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); @@ -527,14 +537,14 @@ export class Deloser 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; @@ -552,7 +562,7 @@ export class Deloser } if (restoreFocusOrder === RestoreFocusOrders.RootDefault) { - available = this._tabster.focusable.findDefault({ + available = _findDefaultFocusable(this._tabster, { container: rootElement, }); } @@ -574,7 +584,7 @@ export class Deloser return availableInHistory; } - const availableDefault = this._tabster.focusable.findDefault({ + const availableDefault = _findDefaultFocusable(this._tabster, { container: element, }); @@ -632,7 +642,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) { @@ -663,7 +673,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; } } @@ -676,7 +686,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, }); @@ -717,7 +727,7 @@ export function createDeloserAPI( const history = new DeloserHistory(tabster); let inDeloser = false; let curDeloser: Types.Deloser | undefined; - let restoreFocusTimer: number | undefined; + const restoreFocusTimer: Timer = createTimer(); let isRestoringFocus = false; let isPaused = false; let autoDeloser: Types.DeloserProps | undefined = props?.autoDeloser; @@ -750,7 +760,6 @@ export function createDeloserAPI( } const restoreFocus = async () => { - restoreFocusTimer = undefined; const lastFocused = tabster.focusedElement.getLastFocusedElement(); if ( @@ -814,7 +823,7 @@ export function createDeloserAPI( if (force) { restoreFocus(); } else { - restoreFocusTimer = win().setTimeout(restoreFocus, 100); + setTimer(restoreFocusTimer, win(), restoreFocus, 100); } }; @@ -839,10 +848,7 @@ export function createDeloserAPI( }; const onFocus = (e: HTMLElement | undefined): void => { - if (restoreFocusTimer) { - win().clearTimeout(restoreFocusTimer); - restoreFocusTimer = undefined; - } + clearTimer(restoreFocusTimer, win()); if (!e) { scheduleRestoreFocus(); @@ -897,10 +903,7 @@ export function createDeloserAPI( dispose(): void { const w = win(); - if (restoreFocusTimer) { - w.clearTimeout(restoreFocusTimer); - restoreFocusTimer = undefined; - } + clearTimer(restoreFocusTimer, w); if (autoDeloserInstance) { autoDeloserInstance.dispose(); @@ -968,11 +971,7 @@ export function createDeloserAPI( pause(): void { isPaused = true; - - if (restoreFocusTimer) { - win().clearTimeout(restoreFocusTimer); - restoreFocusTimer = undefined; - } + clearTimer(restoreFocusTimer, win()); }, resume(restore?: boolean): void { diff --git a/src/DummyInput.ts b/src/DummyInput.ts index b040c6ec..65fea5a6 100644 --- a/src/DummyInput.ts +++ b/src/DummyInput.ts @@ -16,13 +16,18 @@ 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 { addListener, + clearTimer, + createTimer, dispatchEvent, hasSubFocusable, + isTimerActive, makeFocusIgnored, removeListener, + setTimer, WeakHTMLElement, } from "./Utils.js"; @@ -92,8 +97,7 @@ export function createDummyInput( (input as HTMLElementWithDummyContainer).__tabsterDummyContainer = element; const isPhantom = props.isPhantom ?? false; - let disposeTimer: number | undefined; - let clearDisposeTimeout: (() => void) | undefined; + const disposeTimer = createTimer(); const isBackward = ( isIn: boolean, @@ -170,9 +174,7 @@ export function createDummyInput( }, dispose(): void { - if (clearDisposeTimeout) { - clearDisposeTimeout(); - } + clearTimer(disposeTimer, win); const currentInput = api.input; @@ -196,18 +198,7 @@ export function createDummyInput( }; if (isPhantom) { - disposeTimer = win.setTimeout(() => { - disposeTimer = undefined; - api.dispose(); - }, 0); - - clearDisposeTimeout = () => { - if (disposeTimer) { - win.clearTimeout(disposeTimer); - disposeTimer = undefined; - } - clearDisposeTimeout = undefined; - }; + setTimer(disposeTimer, win, api.dispose, 0); } return api; @@ -386,12 +377,7 @@ export const DummyInputManager = { moveOutOfElement && (!isBackward || (isBackward && - !tabster.focusable.isFocusable( - element, - false, - true, - true - ))) + !_isFocusable(tabster, element, false, true, true))) ) { parent = element; insertBefore = isBackward @@ -540,10 +526,10 @@ export function createDummyInputObserver( ): DummyInputObserverInterface { let win: GetWindow | undefined = getWindow; const updateQueue = new Set<(c: ScrollTopLeftCache) => () => void>(); - let updateTimer: number | undefined; + const updateTimer = createTimer(); let lastUpdateQueueTime = 0; let changedParents: WeakSet = new WeakSet(); - let updateDummyInputsTimer: number | undefined; + const updateDummyInputsTimer = createTimer(); let dummyElements: WeakHTMLElement[] = []; let dummyCallbacks: WeakMap void> = new WeakMap(); @@ -554,71 +540,85 @@ export function createDummyInputObserver( changedParents.add(parent); - if (updateDummyInputsTimer) { + const w = win?.(); + if (!w || isTimerActive(updateDummyInputsTimer)) { return; } - updateDummyInputsTimer = win?.().setTimeout(() => { - updateDummyInputsTimer = undefined; - - 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(); + 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(); + } } } } - } - changedParents = new WeakSet(); - }, _updateDummyInputsTimeout); + changedParents = new WeakSet(); + }, + _updateDummyInputsTimeout + ); }; const scheduledUpdatePositions = (): void => { - if (updateTimer) { + const w = win?.(); + if (!w || isTimerActive(updateTimer)) { return; } - updateTimer = win?.().setTimeout(() => { - updateTimer = undefined; + 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 (lastUpdateQueueTime + _updateDummyInputsTimeout <= Date.now()) { - // A cache for current bulk of updates to reduce getComputedStyle() calls. - const scrollTopLeftCache: ScrollTopLeftCache = new Map(); + const setTopLeftCallbacks: (() => void)[] = []; - const setTopLeftCallbacks: (() => void)[] = []; + for (const compute of updateQueue) { + setTopLeftCallbacks.push(compute(scrollTopLeftCache)); + } - for (const compute of updateQueue) { - setTopLeftCallbacks.push(compute(scrollTopLeftCache)); - } + updateQueue.clear(); - 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(); } - - // Explicitly clear to not hold references till the next garbage collection. - scrollTopLeftCache.clear(); - } else { - scheduledUpdatePositions(); - } - }, _updateDummyInputsTimeout); + }, + _updateDummyInputsTimeout + ); }; const api: DummyInputObserverInterface = { @@ -646,14 +646,9 @@ export function createDummyInputObserver( dispose(): void { const w = win?.(); - if (updateTimer) { - w?.clearTimeout(updateTimer); - updateTimer = undefined; - } - - if (updateDummyInputsTimer) { - w?.clearTimeout(updateDummyInputsTimer); - updateDummyInputsTimer = undefined; + if (w) { + clearTimer(updateTimer, w); + clearTimer(updateDummyInputsTimer, w); } changedParents = new WeakSet(); @@ -740,7 +735,7 @@ function createDummyInputManagerCore( } const getWindow = tabster.getWindow; - let addTimer: number | undefined; + const addTimer = createTimer(); let transformElements: Set = new Set(); const wrappers: DummyInputWrapper[] = [ { manager, priority, tabbable: true }, @@ -928,22 +923,25 @@ function createDummyInputManagerCore( * Called each time the children under the element is mutated */ const addDummyInputs = () => { - if (addTimer) { + if (isTimerActive(addTimer)) { return; } - addTimer = getWindow().setTimeout(() => { - addTimer = undefined; - - ensurePosition(); + setTimer( + addTimer, + getWindow(), + () => { + ensurePosition(); - if (__DEV__) { - firstDummy && setDummyInputDebugValue(firstDummy, wrappers); - lastDummy && setDummyInputDebugValue(lastDummy, wrappers); - } + if (__DEV__) { + firstDummy && setDummyInputDebugValue(firstDummy, wrappers); + lastDummy && setDummyInputDebugValue(lastDummy, wrappers); + } - addTransformOffsets(); - }, 0); + addTransformOffsets(); + }, + 0 + ); }; const firstDummy: DummyInput = createDummyInput( @@ -1019,12 +1017,7 @@ function createDummyInputManagerCore( if (backwards) { if ( !firstDummy.isOutside && - tabster.focusable.isFocusable( - currentElement, - true, - true, - true - ) + _isFocusable(tabster, currentElement, true, true, true) ) { toFocus = currentElement; } else { @@ -1108,12 +1101,7 @@ function createDummyInputManagerCore( } transformElements.clear(); - const win = getWindow(); - - if (addTimer) { - win.clearTimeout(addTimer); - addTimer = undefined; - } + clearTimer(addTimer, getWindow()); const input = firstDummy.input; input && tabster._dummyObserver.remove(input); diff --git a/src/Focusable.ts b/src/Focusable.ts index af2d7d3b..4ed14ad8 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,582 @@ 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