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