From 9112d7afe7df470cf6826e5c069a717890cc86a6 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Mon, 11 May 2026 11:35:07 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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.