From a478b6c660a45a892d3d4b6a1ae34acb854366bc Mon Sep 17 00:00:00 2001 From: barryking Date: Sat, 7 Mar 2026 12:29:26 +0000 Subject: [PATCH 1/2] chore: release 0.3.0 --- .gitignore | 1 + README.md | 27 +- package.json | 3 +- pnpm-lock.yaml | 471 ++++++++++++++++++++++++++++ src/core/tour-service.ts | 220 +++++++++++++ src/core/types.ts | 38 +++ src/dom/deep-query.ts | 20 ++ src/dom/positioning.ts | 145 +++++++++ src/dom/scroll-manager.ts | 67 ++++ src/dom/target-resolver.ts | 65 ++++ src/index.ts | 10 +- src/overlay/focus-manager.ts | 49 +++ src/overlay/step-runner.ts | 112 +++++++ src/overlay/types.ts | 8 + src/tour-overlay.ts | 460 ++++++--------------------- src/tour-service.ts | 272 +--------------- src/types.ts | 148 +-------- src/utils/deep-query.ts | 39 +-- test/declaration-surface.test.ts | 44 +++ test/deep-query.test.ts | 64 ++-- test/positioning.test.ts | 77 +++++ test/step-runner.test.ts | 160 ++++++++++ test/tour-overlay.test.ts | 453 +++++++++------------------ test/tour-service.test.ts | 518 ++++++++----------------------- vitest.config.ts | 6 + 25 files changed, 1936 insertions(+), 1541 deletions(-) create mode 100644 src/core/tour-service.ts create mode 100644 src/core/types.ts create mode 100644 src/dom/deep-query.ts create mode 100644 src/dom/positioning.ts create mode 100644 src/dom/scroll-manager.ts create mode 100644 src/dom/target-resolver.ts create mode 100644 src/overlay/focus-manager.ts create mode 100644 src/overlay/step-runner.ts create mode 100644 src/overlay/types.ts create mode 100644 test/declaration-surface.test.ts create mode 100644 test/positioning.test.ts create mode 100644 test/step-runner.test.ts diff --git a/.gitignore b/.gitignore index 94531b8..4ce8e15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +coverage/ *.tsbuildinfo .vite/ .DS_Store diff --git a/README.md b/README.md index baac444..d910ace 100644 --- a/README.md +++ b/README.md @@ -107,14 +107,14 @@ That's it. The spotlight finds elements even inside shadow roots. // Full library (service + overlay + types + deepQuery) import { createTourService, TorchlitOverlay, deepQuery } from 'torchlit'; -// Headless service only — zero Lit dependency +// Headless service only — no DOM access and no Lit types in its declarations import { createTourService } from 'torchlit/service'; // Overlay component only import { TorchlitOverlay } from 'torchlit/overlay'; ``` -The `torchlit/service` entry point has **zero dependencies** and can be used with any rendering layer. +The `torchlit/service` entry point is **DOM-free** and can be used with any rendering layer. ## Features @@ -139,15 +139,26 @@ The `torchlit/service` entry point has **zero dependencies** and can be used wit torchlit/ src/ index.ts # Public API barrel export - types.ts # All TypeScript interfaces - tour-service.ts # Framework-agnostic state engine - tour-overlay.ts # Lit web component (rendering) - utils/ + types.ts # Overlay-facing TypeScript interfaces + tour-service.ts # Stable headless service entrypoint + tour-overlay.ts # Lit overlay composition layer + core/ + tour-service.ts # Pure state engine + types.ts # Service-facing core types + dom/ deep-query.ts # Shadow DOM traversal utility + positioning.ts # Placement, tooltip, clamp, arrow helpers + scroll-manager.ts # Scroll-into-view and restore helpers + target-resolver.ts # Target lookup and lazy target waiting + overlay/ + focus-manager.ts # Focus trap and restore helpers + step-runner.ts # Step preparation orchestration test/ - tour-service.test.ts # Service unit tests (Vitest) - tour-overlay.test.ts # Overlay positioning & feature tests + tour-service.test.ts # Pure service unit tests (node env) + tour-overlay.test.ts # Overlay integration regressions deep-query.test.ts # Deep query unit tests + positioning.test.ts # Positioning unit tests + step-runner.test.ts # Step orchestration unit tests site/ index.html # Docs site source (builds to docs/) examples/ diff --git a/package.json b/package.json index 4fbe08f..d34bd2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "torchlit", - "version": "0.2.3", + "version": "0.3.0", "description": "Lightweight guided-tour & onboarding library built on Lit web components. Shadow DOM aware, framework-agnostic, tiny footprint.", "type": "module", "main": "dist/index.js", @@ -70,6 +70,7 @@ "node": ">=18" }, "devDependencies": { + "@vitest/coverage-v8": "^3.2.4", "jsdom": "^28.0.0", "lit": "^3.2.1", "typescript": "^5.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c588a0d..54ad08e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(jsdom@28.0.0)) jsdom: specifier: ^28.0.0 version: 28.0.0 @@ -29,6 +32,10 @@ packages: '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@4.1.2': resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} @@ -38,6 +45,27 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@csstools/color-helpers@6.0.1': resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} engines: {node: '>=20.19.0'} @@ -234,15 +262,37 @@ packages: '@noble/hashes': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lit-labs/ssr-dom-shim@1.5.1': resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} '@lit/reactive-element@2.1.2': resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -393,6 +443,15 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -426,13 +485,38 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -445,6 +529,17 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -473,6 +568,15 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -501,15 +605,31 @@ packages: picomatch: optional: true + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -518,9 +638,38 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -545,6 +694,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -552,9 +704,24 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -563,9 +730,20 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -601,9 +779,26 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -614,12 +809,36 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -755,11 +974,24 @@ packages: resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -771,6 +1003,11 @@ snapshots: '@acemir/cssom@0.9.31': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@4.1.2': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -789,6 +1026,21 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@csstools/color-helpers@6.0.1': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -891,14 +1143,40 @@ snapshots: '@exodus/bytes@1.14.1': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@lit-labs/ssr-dom-shim@1.5.1': {} '@lit/reactive-element@2.1.2': dependencies: '@lit-labs/ssr-dom-shim': 1.5.1 + '@pkgjs/parseargs@0.11.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -985,6 +1263,25 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(jsdom@28.0.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.11 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(jsdom@28.0.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1029,12 +1326,34 @@ snapshots: agent-base@7.1.4: {} + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + balanced-match@1.0.2: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + cac@6.7.14: {} chai@5.3.3: @@ -1047,6 +1366,18 @@ snapshots: check-error@2.1.3: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-tree@3.1.0: dependencies: mdn-data: 2.12.2 @@ -1074,6 +1405,12 @@ snapshots: deep-eql@5.0.2: {} + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + entities@6.0.1: {} es-module-lexer@1.7.0: {} @@ -1117,15 +1454,33 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + has-flag@4.0.0: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.14.1 transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -1140,8 +1495,41 @@ snapshots: transitivePeerDependencies: - supports-color + is-fullwidth-code-point@3.0.0: {} + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-tokens@10.0.0: {} + js-tokens@9.0.1: {} jsdom@28.0.0: @@ -1188,22 +1576,49 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.2.6: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + mdn-data@2.12.2: {} + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + ms@2.1.3: {} nanoid@3.3.11: {} + package-json-from-dist@1.0.1: {} + parse5@8.0.0: dependencies: entities: 6.0.1 + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -1257,20 +1672,60 @@ snapshots: dependencies: xmlchars: 2.2.0 + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + symbol-tree@3.2.4: {} + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 9.0.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1393,11 +1848,27 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/src/core/tour-service.ts b/src/core/tour-service.ts new file mode 100644 index 0000000..a7255eb --- /dev/null +++ b/src/core/tour-service.ts @@ -0,0 +1,220 @@ +import type { + TourConfig, + TourDefinition, + TourListener, + TourSnapshot, + TourState, + StorageAdapter, +} from './types.js'; + +const DEFAULT_STORAGE_KEY = 'torchlit-state'; +const DEFAULT_TARGET_ATTR = 'data-tour-id'; +const DEFAULT_SPOTLIGHT_PADDING = 10; + +const noopStorage: StorageAdapter = { + getItem: () => null, + setItem: () => {}, +}; + +function defaultStorage(): StorageAdapter { + try { + const test = '__torchlit_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return localStorage; + } catch { + return noopStorage; + } +} + +export class TourService { + private tours: Map> = new Map(); + private persistedState: TourState; + private activeTourId: string | null = null; + private currentStepIndex = 0; + private listeners: Set> = new Set(); + + private readonly storageKey: string; + private readonly storage: StorageAdapter; + readonly targetAttribute: string; + readonly spotlightPadding: number; + + constructor(config: TourConfig = {}) { + this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY; + this.storage = config.storage ?? defaultStorage(); + this.targetAttribute = config.targetAttribute ?? DEFAULT_TARGET_ATTR; + this.spotlightPadding = config.spotlightPadding ?? DEFAULT_SPOTLIGHT_PADDING; + this.persistedState = this.loadState(); + } + + private loadState(): TourState { + try { + const stored = this.storage.getItem(this.storageKey); + if (stored) { + const parsed = JSON.parse(stored); + return { + completed: Array.isArray(parsed.completed) ? parsed.completed : [], + dismissed: Array.isArray(parsed.dismissed) ? parsed.dismissed : [], + }; + } + } catch (error) { + console.error('[torchlit] Failed to load state:', error); + } + return { completed: [], dismissed: [] }; + } + + private saveState(): void { + try { + this.storage.setItem(this.storageKey, JSON.stringify(this.persistedState)); + } catch (error) { + console.error('[torchlit] Failed to save state:', error); + } + } + + register(tours: TourDefinition[]): void; + register(tour: TourDefinition): void; + register(input: TourDefinition | TourDefinition[]): void { + if (Array.isArray(input)) { + input.forEach(tour => this.tours.set(tour.id, tour)); + return; + } + + this.tours.set(input.id, input); + } + + getTour(id: string): TourDefinition | undefined { + return this.tours.get(id); + } + + getAvailableTours(): TourDefinition[] { + return Array.from(this.tours.values()); + } + + shouldAutoStart(tourId: string): boolean { + const tour = this.tours.get(tourId); + if (!tour || tour.trigger !== 'first-visit') return false; + + return ( + !this.persistedState.completed.includes(tourId) && + !this.persistedState.dismissed.includes(tourId) + ); + } + + isActive(): boolean { + return this.activeTourId !== null; + } + + start(tourId: string): void { + const tour = this.tours.get(tourId); + if (!tour || tour.steps.length === 0) return; + + this.activeTourId = tourId; + this.currentStepIndex = 0; + this.notify(); + } + + nextStep(): void { + if (!this.activeTourId) return; + + const tour = this.tours.get(this.activeTourId); + if (!tour) return; + + if (this.currentStepIndex < tour.steps.length - 1) { + this.currentStepIndex += 1; + this.notify(); + return; + } + + if (tour.loop) { + this.currentStepIndex = 0; + this.notify(); + return; + } + + this.completeTour(); + } + + prevStep(): void { + if (!this.activeTourId || this.currentStepIndex === 0) return; + + this.currentStepIndex -= 1; + this.notify(); + } + + skipTour(): void { + if (!this.activeTourId) return; + + const id = this.activeTourId; + const tour = this.tours.get(id); + + if (!this.persistedState.dismissed.includes(id)) { + this.persistedState.dismissed.push(id); + this.saveState(); + } + + this.activeTourId = null; + this.currentStepIndex = 0; + this.notify(); + tour?.onSkip?.(); + } + + private completeTour(): void { + if (!this.activeTourId) return; + + const id = this.activeTourId; + const tour = this.tours.get(id); + + if (!this.persistedState.completed.includes(id)) { + this.persistedState.completed.push(id); + this.saveState(); + } + + this.activeTourId = null; + this.currentStepIndex = 0; + this.notify(); + tour?.onComplete?.(); + } + + getSnapshot(): TourSnapshot | null { + if (!this.activeTourId) return null; + + const tour = this.tours.get(this.activeTourId); + if (!tour) return null; + + const step = tour.steps[this.currentStepIndex]; + if (step === undefined) return null; + + return { + tourId: this.activeTourId, + tourName: tour.name, + step, + stepIndex: this.currentStepIndex, + totalSteps: tour.steps.length, + }; + } + + subscribe(listener: TourListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + resetAll(): void { + this.persistedState = { completed: [], dismissed: [] }; + this.activeTourId = null; + this.currentStepIndex = 0; + this.tours.clear(); + this.saveState(); + this.notify(); + } + + private notify(): void { + const snapshot = this.getSnapshot(); + this.listeners.forEach(listener => listener(snapshot)); + } +} + +export function createTourService( + config?: TourConfig, +): TourService { + return new TourService(config); +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..c57154b --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,38 @@ +export interface TourState { + completed: string[]; + dismissed: string[]; +} + +export interface StorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export interface TourConfig { + storageKey?: string; + storage?: StorageAdapter; + targetAttribute?: string; + spotlightPadding?: number; +} + +export interface TourDefinition { + id: string; + name: string; + trigger: 'first-visit' | 'manual'; + steps: TStep[]; + loop?: boolean; + onComplete?: () => void; + onSkip?: () => void; +} + +export interface TourSnapshot { + tourId: string; + tourName: string; + step: TStep; + stepIndex: number; + totalSteps: number; +} + +export type TourListener = ( + snapshot: TourSnapshot | null, +) => void; diff --git a/src/dom/deep-query.ts b/src/dom/deep-query.ts new file mode 100644 index 0000000..d6011d2 --- /dev/null +++ b/src/dom/deep-query.ts @@ -0,0 +1,20 @@ +export function deepQuery( + selector: string, + root: Element | Document = document.body, +): Element | null { + const found = root.querySelector(selector); + if (found) return found; + + const children = root.querySelectorAll('*'); + for (const element of children) { + if (element.shadowRoot) { + const shadowResult = deepQuery( + selector, + element.shadowRoot as unknown as Document, + ); + if (shadowResult) return shadowResult; + } + } + + return null; +} diff --git a/src/dom/positioning.ts b/src/dom/positioning.ts new file mode 100644 index 0000000..19fe1c1 --- /dev/null +++ b/src/dom/positioning.ts @@ -0,0 +1,145 @@ +import type { TourPlacement } from '../types.js'; + +export const TOOLTIP_W = 320; +export const TOOLTIP_H_MAX = 270; +export const GAP = 16; +export const VIEWPORT_MARGIN = 24; + +export function fitsInViewport( + rect: DOMRect, + viewportHeight = window.innerHeight, +): boolean { + return rect.height + TOOLTIP_H_MAX + GAP * 2 < viewportHeight; +} + +export function bestPlacement( + rect: DOMRect, + preferred: TourPlacement, + spotlightPadding: number, + viewport = { width: window.innerWidth, height: window.innerHeight }, +): TourPlacement { + const fits = (placement: TourPlacement): boolean => { + switch (placement) { + case 'bottom': + return rect.bottom + spotlightPadding + GAP + TOOLTIP_H_MAX < viewport.height; + case 'top': + return rect.top - spotlightPadding - GAP - TOOLTIP_H_MAX > 0; + case 'right': + return rect.right + spotlightPadding + GAP + TOOLTIP_W < viewport.width; + case 'left': + return rect.left - spotlightPadding - GAP - TOOLTIP_W > 0; + } + }; + + const opposite: Record = { + top: 'bottom', + bottom: 'top', + left: 'right', + right: 'left', + }; + + const perpendicular: Record = { + top: ['left', 'right'], + bottom: ['left', 'right'], + left: ['top', 'bottom'], + right: ['top', 'bottom'], + }; + + if (fits(preferred)) return preferred; + if (fits(opposite[preferred])) return opposite[preferred]; + + for (const placement of perpendicular[preferred]) { + if (fits(placement)) return placement; + } + + return preferred; +} + +export function getTooltipPosition( + rect: DOMRect, + placement: TourPlacement, + spotlightPadding: number, + viewportHeight = window.innerHeight, +): { top: number; left: number } { + const visibleTop = Math.max(0, rect.top); + const visibleBottom = Math.min(viewportHeight, rect.bottom); + const visibleCenterY = (visibleTop + visibleBottom) / 2; + + switch (placement) { + case 'right': + return { + top: visibleCenterY - 80, + left: rect.right + spotlightPadding + GAP, + }; + case 'left': + return { + top: visibleCenterY - 80, + left: rect.left - spotlightPadding - GAP - TOOLTIP_W, + }; + case 'bottom': + return { + top: rect.bottom + spotlightPadding + GAP, + left: rect.left + rect.width / 2 - TOOLTIP_W / 2, + }; + case 'top': + return { + top: rect.top - spotlightPadding - GAP, + left: rect.left + rect.width / 2 - TOOLTIP_W / 2, + }; + } +} + +export function clampToViewport( + pos: { top: number; left: number }, + viewport = { width: window.innerWidth, height: window.innerHeight }, +): { top: number; left: number } { + return { + top: Math.max( + VIEWPORT_MARGIN, + Math.min(pos.top, viewport.height - TOOLTIP_H_MAX - VIEWPORT_MARGIN), + ), + left: Math.max( + VIEWPORT_MARGIN, + Math.min(pos.left, viewport.width - TOOLTIP_W - VIEWPORT_MARGIN), + ), + }; +} + +export function getArrowClass(placement: TourPlacement): string { + switch (placement) { + case 'right': + return 'arrow-right'; + case 'left': + return 'arrow-left'; + case 'bottom': + return 'arrow-bottom'; + case 'top': + return 'arrow-top'; + default: + return 'arrow-bottom'; + } +} + +export function getArrowOffset( + targetRect: DOMRect, + tooltipPos: { top: number; left: number }, + placement: TourPlacement, + viewportHeight = window.innerHeight, +): string { + const arrowSize = 12; + const minOffset = arrowSize + 8; + + if (placement === 'top' || placement === 'bottom') { + const targetCenterX = targetRect.left + targetRect.width / 2; + const offset = targetCenterX - tooltipPos.left; + const clamped = Math.max(minOffset, Math.min(offset, TOOLTIP_W - minOffset)); + return `${clamped}px`; + } + + const visibleTop = Math.max(0, targetRect.top); + const visibleBottom = Math.min(viewportHeight, targetRect.bottom); + const targetCenterY = (visibleTop + visibleBottom) / 2; + const offset = targetCenterY - tooltipPos.top; + const clamped = Math.max(minOffset, Math.min(offset, TOOLTIP_H_MAX - minOffset)); + return `${clamped}px`; +} diff --git a/src/dom/scroll-manager.ts b/src/dom/scroll-manager.ts new file mode 100644 index 0000000..bd0eb41 --- /dev/null +++ b/src/dom/scroll-manager.ts @@ -0,0 +1,67 @@ +import type { TourPlacement } from '../types.js'; +import { GAP, TOOLTIP_H_MAX, fitsInViewport } from './positioning.js'; + +export function restoreScrollPosition( + mode: 'restore' | 'top' | 'none', + savedScrollY: number, +): void { + if (mode === 'restore') { + window.scrollTo({ top: savedScrollY, behavior: 'smooth' }); + } else if (mode === 'top') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } +} + +export function scrollAndSettle( + element: Element, + placement: TourPlacement, + spotlightPadding: number, +): Promise { + const rect = element.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + + if (fitsInViewport(rect, viewportHeight)) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + } else { + const desiredTop = + placement === 'top' + ? TOOLTIP_H_MAX + GAP + spotlightPadding + : viewportHeight * 0.15; + const scrollTarget = window.scrollY + rect.top - desiredTop; + window.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'smooth' }); + } + + return new Promise(resolve => { + let lastTop = element.getBoundingClientRect().top; + let stableFrames = 0; + let rafId = 0; + const maxWait = setTimeout(() => { + cancelAnimationFrame(rafId); + resolve(); + }, 1500); + + const poll = () => { + const top = element.getBoundingClientRect().top; + if (Math.abs(top - lastTop) < 1) { + stableFrames += 1; + } else { + stableFrames = 0; + } + lastTop = top; + + if (stableFrames >= 3) { + clearTimeout(maxWait); + resolve(); + return; + } + + rafId = requestAnimationFrame(poll); + }; + + rafId = requestAnimationFrame(poll); + }); +} diff --git a/src/dom/target-resolver.ts b/src/dom/target-resolver.ts new file mode 100644 index 0000000..fee9f4e --- /dev/null +++ b/src/dom/target-resolver.ts @@ -0,0 +1,65 @@ +import type { TourStep } from '../types.js'; +import { deepQuery } from './deep-query.js'; + +const DEFAULT_TARGET_ATTR = 'data-tour-id'; +const DEFAULT_TIMEOUT = 3000; + +export function resolveTargetSelector( + targetId: string, + targetAttribute = DEFAULT_TARGET_ATTR, +): string { + return `[${targetAttribute}="${targetId}"]`; +} + +export function resolveTarget( + targetId: string, + targetAttribute = DEFAULT_TARGET_ATTR, + root: Element | Document = document.body, +): Element | null { + if (!targetId || targetId === '_none_') return null; + return deepQuery(resolveTargetSelector(targetId, targetAttribute), root); +} + +export async function waitForTarget( + targetId: string, + targetAttribute = DEFAULT_TARGET_ATTR, + timeout = DEFAULT_TIMEOUT, +): Promise { + const existing = resolveTarget(targetId, targetAttribute); + if (existing) return existing; + + return new Promise(resolve => { + let resolved = false; + const observer = new MutationObserver(() => { + const element = resolveTarget(targetId, targetAttribute); + if (!element) return; + + resolved = true; + observer.disconnect(); + resolve(element); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + setTimeout(() => { + if (resolved) return; + + observer.disconnect(); + resolve(resolveTarget(targetId, targetAttribute)); + }, timeout); + }); +} + +export function resolveStepTarget( + step: Pick, + targetAttribute = DEFAULT_TARGET_ATTR, +): { targetElement: Element | null; targetRect: DOMRect | null } { + const targetElement = resolveTarget(step.target, targetAttribute); + return { + targetElement, + targetRect: targetElement?.getBoundingClientRect() ?? null, + }; +} diff --git a/src/index.ts b/src/index.ts index 5aa13b3..5d139d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -// ── Types ──────────────────────────────────────────────────────────────────── - export type { TourPlacement, TourStep, @@ -11,14 +9,8 @@ export type { TourListener, } from './types.js'; -// ── Service ────────────────────────────────────────────────────────────────── - export { TourService, createTourService } from './tour-service.js'; -// ── Overlay (web component) ────────────────────────────────────────────────── - export { TorchlitOverlay } from './tour-overlay.js'; -// ── Utilities ──────────────────────────────────────────────────────────────── - -export { deepQuery } from './utils/deep-query.js'; +export { deepQuery } from './dom/deep-query.js'; diff --git a/src/overlay/focus-manager.ts b/src/overlay/focus-manager.ts new file mode 100644 index 0000000..1d47076 --- /dev/null +++ b/src/overlay/focus-manager.ts @@ -0,0 +1,49 @@ +export class FocusManager { + private previouslyFocused: HTMLElement | null = null; + + capture(): void { + if (document.activeElement instanceof HTMLElement) { + this.previouslyFocused = document.activeElement; + } + } + + restore(): void { + this.previouslyFocused?.focus(); + this.previouslyFocused = null; + } + + focusDialog(root: ShadowRoot | null): void { + root + ?.querySelector('.tour-tooltip, .tour-center-card') + ?.focus(); + } + + trapFocus(event: KeyboardEvent, root: ShadowRoot | null): void { + const container = root?.querySelector( + '.tour-tooltip, .tour-center-card', + ); + if (!container) return; + + const focusable = container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeElement = root?.activeElement; + + if (event.shiftKey) { + if (activeElement === first) { + event.preventDefault(); + last.focus(); + } + return; + } + + if (activeElement === last) { + event.preventDefault(); + first.focus(); + } + } +} diff --git a/src/overlay/step-runner.ts b/src/overlay/step-runner.ts new file mode 100644 index 0000000..f69d0f5 --- /dev/null +++ b/src/overlay/step-runner.ts @@ -0,0 +1,112 @@ +import type { TourSnapshot as CoreTourSnapshot } from '../core/types.js'; +import { scrollAndSettle } from '../dom/scroll-manager.js'; +import { resolveStepTarget, waitForTarget } from '../dom/target-resolver.js'; +import type { TourDefinition, TourStep } from '../types.js'; +import type { ResolvedTourSnapshot } from './types.js'; + +export interface StepRunnerOptions { + getCurrentSnapshot: () => CoreTourSnapshot | null; + getTour: (tourId: string) => TourDefinition | undefined; + nextStep: () => void; + spotlightPadding: number; + targetAttribute: string; + dispatchRouteChange: (route: string) => void; +} + +export class StepRunner { + private autoAdvanceTimer: ReturnType | null = null; + + constructor(private readonly options: StepRunnerOptions) {} + + clearAutoAdvance(): void { + if (this.autoAdvanceTimer !== null) { + clearTimeout(this.autoAdvanceTimer); + this.autoAdvanceTimer = null; + } + } + + startAutoAdvance(ms: number): void { + this.clearAutoAdvance(); + this.autoAdvanceTimer = setTimeout(() => { + this.autoAdvanceTimer = null; + this.options.nextStep(); + }, ms); + } + + async prepareStep( + snapshot: CoreTourSnapshot, + ): Promise { + if (snapshot.step.beforeShow) { + try { + await snapshot.step.beforeShow(); + } catch (error) { + console.error('[torchlit] beforeShow hook failed:', error); + } + } + + if (snapshot.step.route) { + this.options.dispatchRouteChange(snapshot.step.route); + } + + if (snapshot.step.target && snapshot.step.target !== '_none_') { + await waitForTarget(snapshot.step.target, this.options.targetAttribute); + } + + const currentSnapshot = this.options.getCurrentSnapshot() ?? snapshot; + const tour = this.options.getTour(currentSnapshot.tourId); + if (!tour) return null; + + let resolved = this.resolveSnapshot(currentSnapshot, tour); + + if (resolved.targetElement && this.shouldScrollIntoView(resolved)) { + await scrollAndSettle( + resolved.targetElement, + resolved.step.placement, + this.options.spotlightPadding, + ); + resolved = this.resolveSnapshot( + this.options.getCurrentSnapshot() ?? currentSnapshot, + tour, + ); + } + + return resolved; + } + + private resolveSnapshot( + snapshot: CoreTourSnapshot, + tour: TourDefinition, + ): ResolvedTourSnapshot { + const { targetElement, targetRect } = resolveStepTarget( + snapshot.step, + this.options.targetAttribute, + ); + + return { + ...snapshot, + tour, + targetElement, + targetRect, + }; + } + + private shouldScrollIntoView(snapshot: ResolvedTourSnapshot): boolean { + const rect = snapshot.targetRect; + if (!rect) return false; + + const viewportHeight = window.innerHeight; + const fits = + rect.height + 270 + 32 < viewportHeight; + const inView = fits + ? rect.top >= 0 && + rect.bottom <= viewportHeight && + rect.left >= 0 && + rect.right <= window.innerWidth + : snapshot.step.placement === 'top' + ? rect.top >= 270 + 16 + this.options.spotlightPadding && + rect.top < viewportHeight + : rect.top >= 0 && rect.top < viewportHeight; + + return !inView; + } +} diff --git a/src/overlay/types.ts b/src/overlay/types.ts new file mode 100644 index 0000000..f95f80f --- /dev/null +++ b/src/overlay/types.ts @@ -0,0 +1,8 @@ +import type { TourSnapshot as CoreTourSnapshot } from '../core/types.js'; +import type { TourDefinition, TourStep } from '../types.js'; + +export interface ResolvedTourSnapshot extends CoreTourSnapshot { + tour: TourDefinition; + targetElement: Element | null; + targetRect: DOMRect | null; +} diff --git a/src/tour-overlay.ts b/src/tour-overlay.ts index 500656f..7962cb6 100644 --- a/src/tour-overlay.ts +++ b/src/tour-overlay.ts @@ -1,17 +1,22 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { keyed } from 'lit/directives/keyed.js'; -import { deepQuery } from './utils/deep-query.js'; +import type { TourSnapshot as CoreTourSnapshot } from './core/types.js'; +import { + GAP, + VIEWPORT_MARGIN, + bestPlacement, + clampToViewport, + getArrowClass, + getArrowOffset, + getTooltipPosition, +} from './dom/positioning.js'; +import { restoreScrollPosition } from './dom/scroll-manager.js'; +import { FocusManager } from './overlay/focus-manager.js'; +import { StepRunner } from './overlay/step-runner.js'; +import type { ResolvedTourSnapshot } from './overlay/types.js'; import type { TourService } from './tour-service.js'; -import type { TourStep, TourSnapshot, TourPlacement } from './types.js'; - -// ── Constants ──────────────────────────────────────────────────────────────── - -const TOOLTIP_W = 320; -const TOOLTIP_H_MAX = 270; // conservative max height for clamp & flip checks -const GAP = 16; -const VIEWPORT_MARGIN = 24; -const MUTATION_TIMEOUT = 3000; +import type { TourDefinition, TourPlacement, TourStep } from './types.js'; /** * `` — Full-screen overlay that renders a spotlight cutout @@ -370,18 +375,21 @@ export class TorchlitOverlay extends LitElement { * Must be set before the overlay will render anything. */ @property({ attribute: false }) - service!: TourService; + service!: TourService; - @state() private snapshot: TourSnapshot | null = null; + @state() private snapshot: ResolvedTourSnapshot | null = null; @state() private visible = false; private unsubscribe?: () => void; - private previouslyFocused: HTMLElement | null = null; - private autoAdvanceTimer: ReturnType | null = null; + private teardownTimer: ReturnType | null = null; + private readonly focusManager = new FocusManager(); + private stepRunner: StepRunner | null = null; private lastResolvedPlacement: TourPlacement = 'bottom'; private scrollRafId = 0; private savedScrollY = 0; - private activeTourId: string | null = null; + private activeTour: TourDefinition | null = null; + private resolvedTargetElement: Element | null = null; + private changeToken = 0; /* ── Lifecycle ──────────────────────────────────── */ @@ -400,6 +408,7 @@ export class TorchlitOverlay extends LitElement { this.unsubscribe?.(); this.clearAutoAdvance(); if (this.scrollRafId) cancelAnimationFrame(this.scrollRafId); + if (this.teardownTimer) clearTimeout(this.teardownTimer); window.removeEventListener('resize', this.handleResize); window.removeEventListener('scroll', this.handleScroll, true); window.removeEventListener('keydown', this.handleKeydown); @@ -412,15 +421,9 @@ export class TorchlitOverlay extends LitElement { } if (this.visible && this.snapshot) { - // Measure the actual tooltip and correct position for 'top' placement this.adjustTooltipPosition(); - - // Focus the dialog container this.updateComplete.then(() => { - const dialog = this.shadowRoot?.querySelector( - '.tour-tooltip, .tour-center-card', - ); - dialog?.focus(); + this.focusManager.focusDialog(this.shadowRoot); }); } } @@ -446,250 +449,114 @@ export class TorchlitOverlay extends LitElement { } private attachService() { - this.unsubscribe = this.service.subscribe(snap => this.handleTourChange(snap)); - } + this.stepRunner = new StepRunner({ + getCurrentSnapshot: () => this.service.getSnapshot() as CoreTourSnapshot | null, + getTour: tourId => this.getTourDefinition(tourId), + nextStep: () => this.service.nextStep(), + spotlightPadding: this.service.spotlightPadding, + targetAttribute: this.service.targetAttribute, + dispatchRouteChange: route => this.dispatchRouteChange(route), + }); - /* ── Auto-advance ───────────────────────────────── */ + this.unsubscribe = this.service.subscribe(snapshot => { + void this.handleTourChange(snapshot as CoreTourSnapshot | null); + }); + } private clearAutoAdvance() { - if (this.autoAdvanceTimer !== null) { - clearTimeout(this.autoAdvanceTimer); - this.autoAdvanceTimer = null; - } + this.stepRunner?.clearAutoAdvance(); } private startAutoAdvance(ms: number) { - this.clearAutoAdvance(); - this.autoAdvanceTimer = setTimeout(() => { - this.autoAdvanceTimer = null; - this.service?.nextStep(); - }, ms); - } - - /* ── MutationObserver target resolution ─────────── */ - - /** - * Wait for a target element to appear in the DOM using a MutationObserver. - * Resolves as soon as `deepQuery` finds the target, or after `timeout` ms. - */ - private waitForTarget( - targetId: string, - timeout = MUTATION_TIMEOUT, - ): Promise { - const attr = this.service?.targetAttribute ?? 'data-tour-id'; - const selector = `[${attr}="${targetId}"]`; - - // Fast path — already in the DOM - const existing = deepQuery(selector, document.body); - if (existing) return Promise.resolve(existing); - - return new Promise(resolve => { - let resolved = false; - const observer = new MutationObserver(() => { - const el = deepQuery(selector, document.body); - if (el) { - resolved = true; - observer.disconnect(); - resolve(el); - } - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - }); - - setTimeout(() => { - if (!resolved) { - observer.disconnect(); - resolve(deepQuery(selector, document.body)); - } - }, timeout); - }); - } - - /* ── Scroll helpers ─────────────────────────────── */ - - /** - * Whether the target element (plus its tooltip) fits comfortably inside the - * viewport. When it doesn't, we only need the top of the target visible — - * the tooltip tracks scroll, so the user can explore the rest naturally. - */ - private fitsInViewport(el: Element): boolean { - return el.getBoundingClientRect().height + TOOLTIP_H_MAX + GAP * 2 < window.innerHeight; + this.stepRunner?.startAutoAdvance(ms); } - /* ── Tour state handler ─────────────────────────── */ - - private async handleTourChange(snapshot: TourSnapshot | null) { + private async handleTourChange(snapshot: CoreTourSnapshot | null) { + const token = ++this.changeToken; this.clearAutoAdvance(); + if (this.teardownTimer) { + clearTimeout(this.teardownTimer); + this.teardownTimer = null; + } if (!snapshot) { - // Tour ended — fade out, restore focus, and restore scroll - const endingTourId = this.activeTourId; + const endingTour = this.activeTour; this.visible = false; - this.activeTourId = null; - setTimeout(() => { + this.activeTour = null; + this.resolvedTargetElement = null; + this.teardownTimer = setTimeout(() => { + if (token !== this.changeToken) return; + this.snapshot = null; - if (this.previouslyFocused) { - this.previouslyFocused.focus(); - this.previouslyFocused = null; - } - // Scroll restore - const tour = endingTourId ? this.service?.getTour(endingTourId) : null; - const scrollMode = tour?.onEndScroll ?? 'restore'; - if (scrollMode === 'restore') { - window.scrollTo({ top: this.savedScrollY, behavior: 'smooth' }); - } else if (scrollMode === 'top') { - window.scrollTo({ top: 0, behavior: 'smooth' }); - } + this.focusManager.restore(); + restoreScrollPosition(endingTour?.onEndScroll ?? 'restore', this.savedScrollY); }, 300); return; } - // Save the element that had focus and scroll position before the tour started - if (!this.snapshot) { - if (document.activeElement instanceof HTMLElement) { - this.previouslyFocused = document.activeElement; - } - this.savedScrollY = window.scrollY; - this.activeTourId = snapshot.tourId; - } - - // Run beforeShow hook if present - if (snapshot.step.beforeShow) { - try { - await snapshot.step.beforeShow(); - } catch (err) { - console.error('[torchlit] beforeShow hook failed:', err); - } + const isNewTour = snapshot.tourId !== this.activeTour?.id; + if (!this.activeTour) { + this.focusManager.capture(); } - - // Emit route-change event if the step has a route - if (snapshot.step.route) { - this.dispatchEvent(new CustomEvent('tour-route-change', { - detail: { route: snapshot.step.route }, - bubbles: true, - composed: true, - })); + if (isNewTour) { + this.savedScrollY = window.scrollY; } - // Wait for the target element to appear (handles lazy rendering / route transitions) - if (snapshot.step.target && snapshot.step.target !== '_none_') { - await this.waitForTarget(snapshot.step.target); - this.snapshot = this.service.getSnapshot(); - } else { - this.snapshot = snapshot; - } + const resolved = await this.stepRunner?.prepareStep(snapshot); + if (!resolved || token !== this.changeToken) return; - // Scroll into view if needed, then show - if (this.snapshot?.targetElement) { - const rect = this.snapshot.targetElement.getBoundingClientRect(); - const vh = window.innerHeight; - const fits = this.fitsInViewport(this.snapshot.targetElement); - const placement = this.snapshot.step.placement; - const PADDING = this.service?.spotlightPadding ?? 10; - - // Small targets that fit with their tooltip: require the whole element visible. - // Large targets: placement-aware — for 'top' placement, ensure there is - // enough room above the target for the tooltip; for other placements, - // just require the top to be somewhere on screen. - const inView = fits - ? rect.top >= 0 && rect.bottom <= vh && rect.left >= 0 && rect.right <= window.innerWidth - : placement === 'top' - ? rect.top >= TOOLTIP_H_MAX + GAP + PADDING && rect.top < vh - : rect.top >= 0 && rect.top < vh; - - if (!inView) { - await this.scrollAndSettle(this.snapshot.targetElement, placement); - // Recalculate rect at the post-scroll position - this.snapshot = this.service.getSnapshot(); - } - } + this.activeTour = resolved.tour; + this.snapshot = resolved; + this.resolvedTargetElement = resolved.targetElement; requestAnimationFrame(() => { + if (token !== this.changeToken) return; + this.visible = true; - // Start auto-advance timer if configured - if (this.snapshot?.step.autoAdvance) { - this.startAutoAdvance(this.snapshot.step.autoAdvance); + if (resolved.step.autoAdvance) { + this.startAutoAdvance(resolved.step.autoAdvance); } }); } - /** - * Scroll an element into view and wait for the scroll to finish. - * - * Small elements that fit (with their tooltip) are centered in the viewport. - * Large elements are scrolled with a **placement-aware** offset so there is - * room for the tooltip on the preferred side. When `placement` is `'top'`, - * we reserve enough space above the target for the tooltip; for other - * placements the tooltip goes below or beside, so a smaller offset suffices. - */ - private scrollAndSettle(el: Element, placement: TourPlacement): Promise { - const vh = window.innerHeight; - const rect = el.getBoundingClientRect(); - - if (this.fitsInViewport(el)) { - // Small targets — center them for a balanced feel - el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); - } else { - const PADDING = this.service?.spotlightPadding ?? 10; - - // Reserve space above the target based on preferred tooltip placement. - // 'top': the tooltip sits above the target, so leave room for it. - // Others: tooltip goes below or beside, so a small offset suffices. - const desiredTop = placement === 'top' - ? TOOLTIP_H_MAX + GAP + PADDING // ~296px — room for the tooltip - : vh * 0.15; // ~15% — comfortable context - - const scrollTarget = window.scrollY + rect.top - desiredTop; - window.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'smooth' }); - } + private dispatchRouteChange(route: string) { + this.dispatchEvent( + new CustomEvent('tour-route-change', { + detail: { route }, + bubbles: true, + composed: true, + }), + ); + } - return new Promise(resolve => { - let lastTop = el.getBoundingClientRect().top; - let stableFrames = 0; - let rafId = 0; - const maxWait = setTimeout(() => { cancelAnimationFrame(rafId); resolve(); }, 1500); - - const poll = () => { - const top = el.getBoundingClientRect().top; - if (Math.abs(top - lastTop) < 1) { - stableFrames++; - } else { - stableFrames = 0; - } - lastTop = top; - - // Consider settled after 3 consecutive stable frames (~50ms) - if (stableFrames >= 3) { - clearTimeout(maxWait); - resolve(); - } else { - rafId = requestAnimationFrame(poll); - } - }; - - rafId = requestAnimationFrame(poll); - }); + private getTourDefinition(tourId: string): TourDefinition | undefined { + return this.service?.getTour(tourId) as TourDefinition | undefined; } - /* ── Event handlers ─────────────────────────────── */ + private refreshSnapshotFromTarget() { + if (!this.snapshot) return; + + const targetElement = this.resolvedTargetElement?.isConnected + ? this.resolvedTargetElement + : null; + + this.snapshot = { + ...this.snapshot, + targetElement, + targetRect: targetElement?.getBoundingClientRect() ?? null, + }; + } private handleResize = () => { - if (this.snapshot && this.service) { - this.snapshot = this.service.getSnapshot(); - } + this.refreshSnapshotFromTarget(); }; - /** Throttled scroll handler — refreshes the snapshot once per frame. */ private handleScroll = () => { - if (!this.snapshot || !this.service || this.scrollRafId) return; + if (!this.snapshot || this.scrollRafId) return; + this.scrollRafId = requestAnimationFrame(() => { this.scrollRafId = 0; - if (this.snapshot && this.service) { - this.snapshot = this.service.getSnapshot(); - } + this.refreshSnapshotFromTarget(); }); }; @@ -709,8 +576,7 @@ export class TorchlitOverlay extends LitElement { this.clearAutoAdvance(); this.service.prevStep(); } else if (e.key === 'Tab') { - // Focus trap — keep Tab within the tooltip - this.trapFocus(e); + this.focusManager.trapFocus(e, this.shadowRoot); } }; @@ -719,131 +585,24 @@ export class TorchlitOverlay extends LitElement { this.service?.skipTour(); }; - /* ── Focus trap ─────────────────────────────────── */ - - private trapFocus(e: KeyboardEvent) { - const container = this.shadowRoot?.querySelector( - '.tour-tooltip, .tour-center-card', - ); - if (!container) return; - - const focusable = container.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - if (focusable.length === 0) return; - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - if (e.shiftKey) { - if (this.shadowRoot?.activeElement === first) { - e.preventDefault(); - last.focus(); - } - } else { - if (this.shadowRoot?.activeElement === last) { - e.preventDefault(); - first.focus(); - } - } - } - - /* ── Smart auto-positioning ─────────────────────── */ - /** * Determine the best placement for the tooltip, flipping when the preferred * placement would clip the viewport. Tries: preferred → opposite → perpendicular. */ private bestPlacement(rect: DOMRect, preferred: TourPlacement): TourPlacement { - const PADDING = this.service?.spotlightPadding ?? 10; - const vw = window.innerWidth; - const vh = window.innerHeight; - - const fits = (p: TourPlacement): boolean => { - switch (p) { - case 'bottom': - return rect.bottom + PADDING + GAP + TOOLTIP_H_MAX < vh; - case 'top': - return rect.top - PADDING - GAP - TOOLTIP_H_MAX > 0; - case 'right': - return rect.right + PADDING + GAP + TOOLTIP_W < vw; - case 'left': - return rect.left - PADDING - GAP - TOOLTIP_W > 0; - } - }; - - const opposite: Record = { - top: 'bottom', bottom: 'top', left: 'right', right: 'left', - }; - - const perpendicular: Record = { - top: ['left', 'right'], bottom: ['left', 'right'], - left: ['top', 'bottom'], right: ['top', 'bottom'], - }; - - if (fits(preferred)) return preferred; - if (fits(opposite[preferred])) return opposite[preferred]; - for (const p of perpendicular[preferred]) { - if (fits(p)) return p; - } - // Nothing fits perfectly — keep preferred, clampToViewport will save us - return preferred; + return bestPlacement(rect, preferred, this.service?.spotlightPadding ?? 10); } - /* ── Tooltip positioning ────────────────────────── */ - private getTooltipPosition(rect: DOMRect, placement: TourPlacement): { top: number; left: number } { - const PADDING = this.service?.spotlightPadding ?? 10; - const vh = window.innerHeight; - - // For tall targets, use the visible center rather than the absolute center. - // This keeps the tooltip near the portion of the target the user can actually see. - const visibleTop = Math.max(0, rect.top); - const visibleBottom = Math.min(vh, rect.bottom); - const visibleCenterY = (visibleTop + visibleBottom) / 2; - - switch (placement) { - case 'right': - return { - top: visibleCenterY - 80, - left: rect.right + PADDING + GAP, - }; - case 'left': - return { - top: visibleCenterY - 80, - left: rect.left - PADDING - GAP - TOOLTIP_W, - }; - case 'bottom': - return { - top: rect.bottom + PADDING + GAP, - left: rect.left + rect.width / 2 - TOOLTIP_W / 2, - }; - case 'top': - // Initial estimate — corrected after render in adjustTooltipPosition() - return { - top: rect.top - PADDING - GAP, - left: rect.left + rect.width / 2 - TOOLTIP_W / 2, - }; - default: - return { top: rect.bottom + GAP, left: rect.left }; - } + return getTooltipPosition(rect, placement, this.service?.spotlightPadding ?? 10); } private clampToViewport(pos: { top: number; left: number }): { top: number; left: number } { - return { - top: Math.max(VIEWPORT_MARGIN, Math.min(pos.top, window.innerHeight - TOOLTIP_H_MAX - VIEWPORT_MARGIN)), - left: Math.max(VIEWPORT_MARGIN, Math.min(pos.left, window.innerWidth - TOOLTIP_W - VIEWPORT_MARGIN)), - }; + return clampToViewport(pos); } private getArrowClass(placement: TourPlacement): string { - switch (placement) { - case 'right': return 'arrow-right'; - case 'left': return 'arrow-left'; - case 'bottom': return 'arrow-bottom'; - case 'top': return 'arrow-top'; - default: return 'arrow-bottom'; - } + return getArrowClass(placement); } /** @@ -855,24 +614,7 @@ export class TorchlitOverlay extends LitElement { tooltipPos: { top: number; left: number }, placement: TourPlacement, ): string { - const ARROW_SIZE = 12; - const MIN = ARROW_SIZE + 8; - - if (placement === 'top' || placement === 'bottom') { - // Horizontal offset - const targetCenterX = targetRect.left + targetRect.width / 2; - const offset = targetCenterX - tooltipPos.left; - const clamped = Math.max(MIN, Math.min(offset, TOOLTIP_W - MIN)); - return `${clamped}px`; - } - - // Vertical offset (left / right placement) — use visible center for tall targets - const visibleTop = Math.max(0, targetRect.top); - const visibleBottom = Math.min(window.innerHeight, targetRect.bottom); - const targetCenterY = (visibleTop + visibleBottom) / 2; - const offset = targetCenterY - tooltipPos.top; - const clamped = Math.max(MIN, Math.min(offset, TOOLTIP_H_MAX - MIN)); - return `${clamped}px`; + return getArrowOffset(targetRect, tooltipPos, placement); } /* ── Render ─────────────────────────────────────── */ diff --git a/src/tour-service.ts b/src/tour-service.ts index 2cafe35..af82a6f 100644 --- a/src/tour-service.ts +++ b/src/tour-service.ts @@ -1,274 +1,10 @@ -import { deepQuery } from './utils/deep-query.js'; -import type { +export { TourService, createTourService } from './core/tour-service.js'; + +export type { TourConfig, TourDefinition, TourListener, TourSnapshot, TourState, StorageAdapter, -} from './types.js'; - -// ── Defaults ───────────────────────────────────────────────────────────────── - -const DEFAULT_STORAGE_KEY = 'torchlit-state'; -const DEFAULT_TARGET_ATTR = 'data-tour-id'; -const DEFAULT_SPOTLIGHT_PADDING = 10; - -/** A no-op storage adapter for SSR / environments without localStorage. */ -const noopStorage: StorageAdapter = { - getItem: () => null, - setItem: () => {}, -}; - -/** Safely wrap localStorage — falls back to noop if unavailable. */ -function defaultStorage(): StorageAdapter { - try { - // Guard against SSR or restricted environments - const test = '__torchlit_test__'; - localStorage.setItem(test, test); - localStorage.removeItem(test); - return localStorage; - } catch { - return noopStorage; - } -} - -// ── TourService ────────────────────────────────────────────────────────────── - -export class TourService { - private tours: Map = new Map(); - private persistedState: TourState; - private activeTourId: string | null = null; - private currentStepIndex = 0; - private listeners: Set = new Set(); - - // Resolved config - private readonly storageKey: string; - private readonly storage: StorageAdapter; - readonly targetAttribute: string; - readonly spotlightPadding: number; - - constructor(config: TourConfig = {}) { - this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY; - this.storage = config.storage ?? defaultStorage(); - this.targetAttribute = config.targetAttribute ?? DEFAULT_TARGET_ATTR; - this.spotlightPadding = config.spotlightPadding ?? DEFAULT_SPOTLIGHT_PADDING; - this.persistedState = this.loadState(); - } - - /* ── Persistence ──────────────────────────────── */ - - private loadState(): TourState { - try { - const stored = this.storage.getItem(this.storageKey); - if (stored) { - const parsed = JSON.parse(stored); - return { - completed: Array.isArray(parsed.completed) ? parsed.completed : [], - dismissed: Array.isArray(parsed.dismissed) ? parsed.dismissed : [], - }; - } - } catch (error) { - console.error('[torchlit] Failed to load state:', error); - } - return { completed: [], dismissed: [] }; - } - - private saveState(): void { - try { - this.storage.setItem(this.storageKey, JSON.stringify(this.persistedState)); - } catch (error) { - console.error('[torchlit] Failed to save state:', error); - } - } - - /* ── Registration ─────────────────────────────── */ - - /** Register a single tour definition. */ - register(tours: TourDefinition[]): void; - register(tour: TourDefinition): void; - register(input: TourDefinition | TourDefinition[]): void { - if (Array.isArray(input)) { - input.forEach(t => this.tours.set(t.id, t)); - } else { - this.tours.set(input.id, input); - } - } - - /* ── Queries ──────────────────────────────────── */ - - /** Return a registered tour by ID. */ - getTour(id: string): TourDefinition | undefined { - return this.tours.get(id); - } - - /** Return all registered tours. */ - getAvailableTours(): TourDefinition[] { - return Array.from(this.tours.values()); - } - - /** - * Whether a `first-visit` tour should auto-start. - * Returns `false` if the tour is manual, already completed, or dismissed. - */ - shouldAutoStart(tourId: string): boolean { - const tour = this.tours.get(tourId); - if (!tour || tour.trigger !== 'first-visit') return false; - return ( - !this.persistedState.completed.includes(tourId) && - !this.persistedState.dismissed.includes(tourId) - ); - } - - /** Whether any tour is currently active. */ - isActive(): boolean { - return this.activeTourId !== null; - } - - /* ── Tour control ─────────────────────────────── */ - - /** Start a tour by ID. No-op if the tour doesn't exist or has no steps. */ - start(tourId: string): void { - const tour = this.tours.get(tourId); - if (!tour || tour.steps.length === 0) return; - - this.activeTourId = tourId; - this.currentStepIndex = 0; - this.notify(); - } - - /** Advance to the next step, loop if enabled, or complete the tour. */ - nextStep(): void { - if (!this.activeTourId) return; - const tour = this.tours.get(this.activeTourId)!; - - if (this.currentStepIndex < tour.steps.length - 1) { - this.currentStepIndex++; - this.notify(); - } else if (tour.loop) { - this.currentStepIndex = 0; - this.notify(); - } else { - this.completeTour(); - } - } - - /** Go back to the previous step. No-op if already on step 0. */ - prevStep(): void { - if (!this.activeTourId) return; - if (this.currentStepIndex > 0) { - this.currentStepIndex--; - this.notify(); - } - } - - /** Skip / dismiss the current tour. Persists "dismissed" state. */ - skipTour(): void { - if (!this.activeTourId) return; - const id = this.activeTourId; - const tour = this.tours.get(id); - - if (!this.persistedState.dismissed.includes(id)) { - this.persistedState.dismissed.push(id); - this.saveState(); - } - - this.activeTourId = null; - this.currentStepIndex = 0; - this.notify(); - - tour?.onSkip?.(); - } - - private completeTour(): void { - if (!this.activeTourId) return; - const id = this.activeTourId; - const tour = this.tours.get(id); - - if (!this.persistedState.completed.includes(id)) { - this.persistedState.completed.push(id); - this.saveState(); - } - - this.activeTourId = null; - this.currentStepIndex = 0; - this.notify(); - - tour?.onComplete?.(); - } - - /* ── Snapshot (current state for overlay) ─────── */ - - /** Return a snapshot of the current tour state, or `null` if inactive. */ - getSnapshot(): TourSnapshot | null { - if (!this.activeTourId) return null; - const tour = this.tours.get(this.activeTourId); - if (!tour) return null; - - const step = tour.steps[this.currentStepIndex]; - if (!step) return null; - - const targetElement = this.findTarget(step.target); - const targetRect = targetElement?.getBoundingClientRect() ?? null; - - return { - tourId: this.activeTourId, - tourName: tour.name, - step, - stepIndex: this.currentStepIndex, - totalSteps: tour.steps.length, - targetRect, - targetElement, - }; - } - - /* ── Shadow DOM target resolution ─────────────── */ - - /** Find a DOM element by its `data-tour-id` (or custom attribute). */ - findTarget(targetId: string): Element | null { - return deepQuery(`[${this.targetAttribute}="${targetId}"]`, document.body); - } - - /* ── Observer pattern ─────────────────────────── */ - - /** Subscribe to snapshot changes. Returns an unsubscribe function. */ - subscribe(listener: TourListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notify(): void { - const snapshot = this.getSnapshot(); - this.listeners.forEach(listener => listener(snapshot)); - } - - /* ── Reset (for testing & demos) ──────────────── */ - - /** Clear all persisted state and stop any active tour. */ - resetAll(): void { - this.persistedState = { completed: [], dismissed: [] }; - this.activeTourId = null; - this.currentStepIndex = 0; - this.tours.clear(); - this.saveState(); - this.notify(); - } -} - -// ── Factory ────────────────────────────────────────────────────────────────── - -/** - * Create a new `TourService` instance. - * - * @example - * ```ts - * import { createTourService } from 'torchlit'; - * - * const tours = createTourService({ storageKey: 'my-app-tours' }); - * tours.register([...]); - * tours.start('onboarding'); - * ``` - */ -export function createTourService(config?: TourConfig): TourService { - return new TourService(config); -} +} from './core/types.js'; diff --git a/src/types.ts b/src/types.ts index 37d2d59..8f060cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,167 +1,33 @@ import type { TemplateResult } from 'lit'; -// ── Placement ──────────────────────────────────────────────────────────────── +export type { + TourConfig, + TourListener, + TourSnapshot, + TourState, + StorageAdapter, +} from './core/types.js'; -/** Where to position the tooltip relative to the spotlight target. */ export type TourPlacement = 'top' | 'bottom' | 'left' | 'right'; -// ── Tour Step ──────────────────────────────────────────────────────────────── - export interface TourStep { - /** - * Matches `[data-tour-id="..."]` on the target element. - * Deep shadow DOM traversal is used automatically. - * Use `'_none_'` for a centered "welcome" card with no spotlight. - */ target: string; - - /** Bold title shown in the tooltip. */ title: string; - - /** - * Descriptive message shown below the title. - * - * Accepts a plain string **or** a Lit `TemplateResult` for rich HTML content: - * ```ts - * message: html`Click here to continue.` - * ``` - */ message: string | TemplateResult; - - /** Where to position the tooltip relative to the target. */ placement: TourPlacement; - - /** - * Override the spotlight border-radius for this step. - * Use `'50%'` for a circle, `'9999px'` for a pill, `'0'` for sharp corners. - * Falls back to the `--tour-spotlight-radius` CSS custom property. - */ spotlightBorderRadius?: string; - - /** - * Automatically advance to the next step after this many milliseconds. - * Useful for demo / kiosk modes. A progress bar is rendered at the bottom - * of the tooltip. Manual interaction (Next / Back / Skip) cancels the timer. - */ autoAdvance?: number; - - /** - * Arbitrary route / view hint. - * When set, a `tour-route-change` event is dispatched with `{ route }` detail - * so the host application can switch views before the step renders. - */ route?: string; - - /** - * Optional async hook that runs **before** the step is shown. - * Use this for route navigation, data loading, or any async prep work. - */ beforeShow?: () => void | Promise; } -// ── Tour Definition ────────────────────────────────────────────────────────── - export interface TourDefinition { - /** Unique tour identifier. */ id: string; - - /** Human-readable tour name. */ name: string; - - /** - * `'first-visit'` — auto-triggers on first page load (unless completed/dismissed). - * `'manual'` — only starts when explicitly called via `service.start(id)`. - */ trigger: 'first-visit' | 'manual'; - - /** Ordered list of tour steps. */ steps: TourStep[]; - - /** - * When `true`, advancing past the last step restarts at step 0 - * instead of completing. Combine with `autoAdvance` for kiosk / demo modes. - * The user can still exit via Skip or Escape. - */ loop?: boolean; - - /** - * Scroll behaviour when the tour ends (completes or is skipped). - * - * - `'restore'` — scroll back to where the user was before the tour started (default) - * - `'top'` — scroll to the top of the page - * - `'none'` — leave the scroll position as-is - * - * @default `'restore'` - */ onEndScroll?: 'restore' | 'top' | 'none'; - - /** Called when the user completes every step in the tour. */ onComplete?: () => void; - - /** Called when the user skips / dismisses the tour. */ onSkip?: () => void; } - -// ── Persisted State ────────────────────────────────────────────────────────── - -export interface TourState { - /** Tour IDs that have been completed (user went through all steps). */ - completed: string[]; - - /** Tour IDs that have been dismissed (user skipped). */ - dismissed: string[]; -} - -// ── Snapshot (current state exposed to the overlay) ────────────────────────── - -export interface TourSnapshot { - tourId: string; - tourName: string; - step: TourStep; - stepIndex: number; - totalSteps: number; - targetRect: DOMRect | null; - targetElement: Element | null; -} - -// ── Configuration ──────────────────────────────────────────────────────────── - -/** - * Minimal storage interface. - * Defaults to `localStorage` when not provided. - */ -export interface StorageAdapter { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} - -export interface TourConfig { - /** - * Key used for persisting tour state. - * @default `'torchlit-state'` - */ - storageKey?: string; - - /** - * Custom storage adapter. Useful for SSR, sessionStorage, - * or API-backed persistence. - * @default localStorage - */ - storage?: StorageAdapter; - - /** - * The `data-*` attribute used to locate tour targets. - * @default `'data-tour-id'` - */ - targetAttribute?: string; - - /** - * Padding (in px) around the spotlight cutout. - * @default 10 - */ - spotlightPadding?: number; -} - -// ── Listener ───────────────────────────────────────────────────────────────── - -export type TourListener = (snapshot: TourSnapshot | null) => void; diff --git a/src/utils/deep-query.ts b/src/utils/deep-query.ts index fe940b7..eeb9a25 100644 --- a/src/utils/deep-query.ts +++ b/src/utils/deep-query.ts @@ -1,38 +1 @@ -/** - * Recursively search the DOM — including shadow roots — for an element - * matching the given CSS selector. - * - * This is the key differentiator vs. libraries like Shepherd.js or Intro.js - * which cannot pierce shadow DOM boundaries. - * - * @param selector A valid CSS selector string. - * @param root The root element (or Document) to start searching from. - * Defaults to `document.body`. - * @returns The first matching `Element`, or `null`. - * - * @example - * ```ts - * import { deepQuery } from 'torchlit'; - * - * const el = deepQuery('[data-tour-id="sidebar-nav"]'); - * ``` - */ -export function deepQuery( - selector: string, - root: Element | Document = document.body, -): Element | null { - // Try light DOM first (fast path) - const found = root.querySelector(selector); - if (found) return found; - - // Walk children that expose a shadowRoot - const children = root.querySelectorAll('*'); - for (const el of children) { - if (el.shadowRoot) { - const shadowResult = deepQuery(selector, el.shadowRoot as unknown as Document); - if (shadowResult) return shadowResult; - } - } - - return null; -} +export { deepQuery } from '../dom/deep-query.js'; diff --git a/test/declaration-surface.test.ts b/test/declaration-surface.test.ts new file mode 100644 index 0000000..53ee8f1 --- /dev/null +++ b/test/declaration-surface.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment node + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +describe('tour-service declarations', () => { + it('do not reference lit', () => { + const configPath = resolve(process.cwd(), 'tsconfig.json'); + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + process.cwd(), + ); + const outDir = mkdtempSync(join(tmpdir(), 'torchlit-dts-')); + + try { + const program = ts.createProgram({ + rootNames: [resolve(process.cwd(), 'src/tour-service.ts')], + options: { + ...parsed.options, + declaration: true, + emitDeclarationOnly: true, + noEmit: false, + outDir, + }, + }); + const emitResult = program.emit(); + const diagnostics = ts + .getPreEmitDiagnostics(program) + .concat(emitResult.diagnostics); + + expect(diagnostics).toHaveLength(0); + + const declaration = readFileSync(join(outDir, 'tour-service.d.ts'), 'utf8'); + expect(declaration).not.toMatch(/from ['"]lit(?:\/|['"])/); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/deep-query.test.ts b/test/deep-query.test.ts index 2d93415..c02b210 100644 --- a/test/deep-query.test.ts +++ b/test/deep-query.test.ts @@ -1,51 +1,57 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { deepQuery } from '../src/utils/deep-query'; +import { afterEach, describe, expect, it } from 'vitest'; +import { deepQuery } from '../src/dom/deep-query'; describe('deepQuery', () => { const cleanup: Element[] = []; afterEach(() => { - cleanup.forEach(el => el.remove()); + cleanup.forEach(element => element.remove()); cleanup.length = 0; }); - function addToBody(el: Element) { - document.body.appendChild(el); - cleanup.push(el); - return el; + function addToBody(element: Element) { + document.body.appendChild(element); + cleanup.push(element); + return element; } it('finds an element in light DOM', () => { - const el = addToBody(document.createElement('div')); - el.setAttribute('data-tour-id', 'light-target'); + const element = addToBody(document.createElement('div')); + element.setAttribute('data-tour-id', 'light-target'); - const found = deepQuery('[data-tour-id="light-target"]'); - expect(found).toBe(el); + expect(deepQuery('[data-tour-id="light-target"]')).toBe(element); }); - it('returns null when no match exists', () => { - const found = deepQuery('[data-tour-id="does-not-exist"]'); - expect(found).toBeNull(); + it('searches through nested open shadow roots', () => { + const host = addToBody(document.createElement('div')); + const outerShadow = host.attachShadow({ mode: 'open' }); + const nestedHost = document.createElement('div'); + outerShadow.appendChild(nestedHost); + const innerShadow = nestedHost.attachShadow({ mode: 'open' }); + const target = document.createElement('button'); + target.setAttribute('data-tour-id', 'deep-shadow'); + innerShadow.appendChild(target); + + expect(deepQuery('[data-tour-id="deep-shadow"]')).toBe(target); }); - it('searches from a custom root', () => { - const container = addToBody(document.createElement('div')); - const child = document.createElement('span'); - child.setAttribute('data-x', 'inner'); - container.appendChild(child); + it('prefers a light DOM match over a later shadow DOM match', () => { + const light = addToBody(document.createElement('div')); + light.className = 'shared'; - // Searching from container should find it - const found = deepQuery('[data-x="inner"]', container); - expect(found).toBe(child); + const host = addToBody(document.createElement('div')); + const shadow = host.attachShadow({ mode: 'open' }); + const shadowMatch = document.createElement('div'); + shadowMatch.className = 'shared'; + shadow.appendChild(shadowMatch); + + expect(deepQuery('.shared')).toBe(light); }); - it('returns the first match found', () => { - const first = addToBody(document.createElement('div')); - first.classList.add('dup'); - const second = addToBody(document.createElement('div')); - second.classList.add('dup'); + it('returns null for closed shadow roots', () => { + const host = addToBody(document.createElement('div')); + host.attachShadow({ mode: 'closed' }); - const found = deepQuery('.dup'); - expect(found).toBe(first); + expect(deepQuery('[data-tour-id="closed-target"]')).toBeNull(); }); }); diff --git a/test/positioning.test.ts b/test/positioning.test.ts new file mode 100644 index 0000000..e5857f5 --- /dev/null +++ b/test/positioning.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + bestPlacement, + clampToViewport, + getArrowClass, + getArrowOffset, + getTooltipPosition, +} from '../src/dom/positioning'; +import type { TourPlacement } from '../src/types'; + +function mockRect(x: number, y: number, width: number, height: number): DOMRect { + return { + x, + y, + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + toJSON() { + return this; + }, + }; +} + +describe('positioning helpers', () => { + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + value: 1024, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'innerHeight', { + value: 768, + writable: true, + configurable: true, + }); + }); + + it('keeps the preferred placement when it fits', () => { + expect(bestPlacement(mockRect(100, 100, 120, 40), 'bottom', 10)).toBe('bottom'); + }); + + it('flips to the opposite side when the preferred side clips', () => { + expect(bestPlacement(mockRect(100, 700, 120, 40), 'bottom', 10)).toBe('top'); + }); + + it('falls back to a perpendicular placement when needed', () => { + Object.defineProperty(window, 'innerHeight', { + value: 300, + writable: true, + configurable: true, + }); + + expect(['left', 'right']).toContain( + bestPlacement(mockRect(400, 130, 200, 40), 'bottom', 10), + ); + }); + + it('computes tooltip positions and clamps them to the viewport', () => { + const position = getTooltipPosition(mockRect(200, 100, 120, 40), 'bottom', 10); + const clamped = clampToViewport({ top: -50, left: 900 }); + + expect(position).toEqual({ top: 166, left: 100 }); + expect(clamped).toEqual({ top: 24, left: 680 }); + }); + + it('returns stable arrow classes and offsets', () => { + const placement: TourPlacement = 'right'; + const targetRect = mockRect(100, 300, 80, 40); + const tooltipPos = { top: 250, left: 206 }; + + expect(getArrowClass(placement)).toBe('arrow-right'); + expect(getArrowOffset(targetRect, tooltipPos, placement)).toBe('70px'); + }); +}); diff --git a/test/step-runner.test.ts b/test/step-runner.test.ts new file mode 100644 index 0000000..51814a1 --- /dev/null +++ b/test/step-runner.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TourSnapshot } from '../src/core/types'; +import { StepRunner } from '../src/overlay/step-runner'; +import type { TourDefinition, TourStep } from '../src/types'; + +function makeSnapshot(step: TourStep): TourSnapshot { + return { + tourId: 'tour', + tourName: 'Tour', + step, + stepIndex: 0, + totalSteps: 1, + }; +} + +function makeTour(step: TourStep, overrides: Partial = {}): TourDefinition { + return { + id: 'tour', + name: 'Tour', + trigger: 'manual', + steps: [step], + ...overrides, + }; +} + +function mockRect(x: number, y: number, width: number, height: number): DOMRect { + return { + x, + y, + width, + height, + top: y, + left: x, + right: x + width, + bottom: y + height, + toJSON() { + return this; + }, + }; +} + +describe('StepRunner', () => { + const cleanup: Element[] = []; + + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + value: 1280, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'innerHeight', { + value: 900, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + cleanup.forEach(element => element.remove()); + cleanup.length = 0; + vi.restoreAllMocks(); + }); + + it('runs beforeShow and dispatches route changes before resolving the step', async () => { + const beforeShow = vi.fn(); + const dispatchRouteChange = vi.fn(); + const step: TourStep = { + target: '_none_', + title: 'Welcome', + message: 'Hello', + placement: 'bottom', + route: '/settings', + beforeShow, + }; + + const runner = new StepRunner({ + getCurrentSnapshot: () => makeSnapshot(step), + getTour: () => makeTour(step), + nextStep: vi.fn(), + spotlightPadding: 10, + targetAttribute: 'data-tour-id', + dispatchRouteChange, + }); + + const resolved = await runner.prepareStep(makeSnapshot(step)); + + expect(beforeShow).toHaveBeenCalledOnce(); + expect(dispatchRouteChange).toHaveBeenCalledWith('/settings'); + expect(resolved?.targetElement).toBeNull(); + }); + + it('waits for lazy targets and resolves their rect', async () => { + const step: TourStep = { + target: 'lazy-target', + title: 'Lazy', + message: 'Wait for it', + placement: 'bottom', + }; + const snapshot = makeSnapshot(step); + const tour = makeTour(step); + + const runner = new StepRunner({ + getCurrentSnapshot: () => snapshot, + getTour: () => tour, + nextStep: vi.fn(), + spotlightPadding: 10, + targetAttribute: 'data-tour-id', + dispatchRouteChange: vi.fn(), + }); + + setTimeout(() => { + const element = document.createElement('div'); + element.setAttribute('data-tour-id', 'lazy-target'); + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue( + mockRect(120, 160, 180, 40), + ); + document.body.appendChild(element); + cleanup.push(element); + }, 20); + + const resolved = await runner.prepareStep(snapshot); + + expect(resolved?.targetElement).not.toBeNull(); + expect(resolved?.targetRect?.top).toBe(160); + }); + + it('starts and clears auto-advance timers', () => { + vi.useFakeTimers(); + const nextStep = vi.fn(); + const step: TourStep = { + target: '_none_', + title: 'Auto', + message: 'Advance', + placement: 'bottom', + }; + + const runner = new StepRunner({ + getCurrentSnapshot: () => makeSnapshot(step), + getTour: () => makeTour(step), + nextStep, + spotlightPadding: 10, + targetAttribute: 'data-tour-id', + dispatchRouteChange: vi.fn(), + }); + + runner.startAutoAdvance(3000); + vi.advanceTimersByTime(2000); + expect(nextStep).not.toHaveBeenCalled(); + + runner.clearAutoAdvance(); + vi.advanceTimersByTime(2000); + expect(nextStep).not.toHaveBeenCalled(); + + runner.startAutoAdvance(1000); + vi.advanceTimersByTime(1000); + expect(nextStep).toHaveBeenCalledOnce(); + + vi.useRealTimers(); + }); +}); diff --git a/test/tour-overlay.test.ts b/test/tour-overlay.test.ts index dc4f23c..2c38ba9 100644 --- a/test/tour-overlay.test.ts +++ b/test/tour-overlay.test.ts @@ -1,347 +1,190 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TorchlitOverlay } from '../src/tour-overlay'; -import type { TourPlacement } from '../src/types'; +import type { TourSnapshot } from '../src/core/types'; +import type { TourDefinition, TourStep } from '../src/types'; +import * as deepQueryModule from '../src/dom/deep-query'; -/* ── Helpers ─────────────────────────────────────────── */ +function priv(overlay: TorchlitOverlay): T { + return overlay as unknown as T; +} + +function makeSnapshot( + tourId: string, + step: TourStep, + stepIndex = 0, + totalSteps = 1, +): TourSnapshot { + return { + tourId, + tourName: tourId, + step, + stepIndex, + totalSteps, + }; +} -/** Create a minimal mock DOMRect */ -function mockRect(x: number, y: number, w: number, h: number): DOMRect { +function makeTour(id: string, step: TourStep, overrides: Partial = {}): TourDefinition { + return { + id, + name: id, + trigger: 'manual', + steps: [step], + ...overrides, + }; +} + +function mockRect(x: number, y: number, width: number, height: number): DOMRect { return { x, y, - width: w, - height: h, + width, + height, top: y, left: x, - right: x + w, - bottom: y + h, - toJSON() { return this; }, + right: x + width, + bottom: y + height, + toJSON() { + return this; + }, }; } -/** Shortcut to access private methods on the overlay instance */ -function priv(overlay: TorchlitOverlay): Record unknown> { - return overlay as unknown as Record unknown>; -} - -describe('TorchlitOverlay — positioning utilities', () => { - let overlay: TorchlitOverlay; +describe('TorchlitOverlay', () => { + const cleanup: Element[] = []; beforeEach(() => { - overlay = new TorchlitOverlay(); - // Provide a minimal mock service with spotlightPadding - (overlay as unknown as { service: { spotlightPadding: number; targetAttribute: string; nextStep: () => void } }).service = { - spotlightPadding: 10, - targetAttribute: 'data-tour-id', - nextStep: vi.fn(), - }; - }); - - /* ── bestPlacement ──────────────────────────────── */ - - describe('bestPlacement', () => { - it('returns preferred placement when it fits', () => { - // Target near top-left — plenty of room below and to the right - Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true }); - Object.defineProperty(window, 'innerHeight', { value: 768, writable: true, configurable: true }); - - const rect = mockRect(100, 100, 200, 40); - const result = priv(overlay).bestPlacement(rect, 'bottom'); - expect(result).toBe('bottom'); - }); - - it('flips to opposite when preferred clips', () => { - Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true }); - Object.defineProperty(window, 'innerHeight', { value: 768, writable: true, configurable: true }); - - // Target near bottom of viewport — no room for tooltip below - const rect = mockRect(100, 700, 200, 40); - const result = priv(overlay).bestPlacement(rect, 'bottom'); - expect(result).toBe('top'); - }); - - it('flips to perpendicular when both preferred and opposite clip', () => { - Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true }); - // Very short viewport — no room for top or bottom - Object.defineProperty(window, 'innerHeight', { value: 300, writable: true, configurable: true }); - - const rect = mockRect(400, 130, 200, 40); - const result = priv(overlay).bestPlacement(rect, 'bottom'); - // Should try left or right - expect(['left', 'right']).toContain(result); - }); - - it('falls back to preferred when nothing fits', () => { - // Tiny viewport — nothing fits - Object.defineProperty(window, 'innerWidth', { value: 200, writable: true, configurable: true }); - Object.defineProperty(window, 'innerHeight', { value: 200, writable: true, configurable: true }); - - const rect = mockRect(50, 50, 100, 100); - const result = priv(overlay).bestPlacement(rect, 'right'); - expect(result).toBe('right'); - }); - - it('flips left to right when no room on the left', () => { - Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true }); - Object.defineProperty(window, 'innerHeight', { value: 768, writable: true, configurable: true }); - - // Target at left edge — no room for tooltip on the left - const rect = mockRect(10, 300, 50, 40); - const result = priv(overlay).bestPlacement(rect, 'left'); - expect(result).toBe('right'); - }); - }); - - /* ── getTooltipPosition ─────────────────────────── */ - - describe('getTooltipPosition', () => { - it('positions below the target for bottom placement', () => { - const rect = mockRect(200, 100, 120, 40); - const pos = priv(overlay).getTooltipPosition(rect, 'bottom') as { top: number; left: number }; - // Should be below target + padding + gap - expect(pos.top).toBe(100 + 40 + 10 + 16); // rect.bottom + PADDING + GAP - expect(pos.left).toBe(200 + 60 - 160); // centered horizontally - }); - - it('positions above the target for top placement', () => { - const rect = mockRect(200, 400, 120, 40); - const pos = priv(overlay).getTooltipPosition(rect, 'top') as { top: number; left: number }; - // Initial estimate — above target - expect(pos.top).toBe(400 - 10 - 16); // rect.top - PADDING - GAP - }); - - it('positions to the right for right placement', () => { - const rect = mockRect(100, 200, 80, 40); - const pos = priv(overlay).getTooltipPosition(rect, 'right') as { top: number; left: number }; - expect(pos.left).toBe(100 + 80 + 10 + 16); // rect.right + PADDING + GAP - }); - - it('positions to the left for left placement', () => { - const rect = mockRect(500, 200, 80, 40); - const pos = priv(overlay).getTooltipPosition(rect, 'left') as { top: number; left: number }; - expect(pos.left).toBe(500 - 10 - 16 - 320); // rect.left - PADDING - GAP - TOOLTIP_W - }); - }); - - /* ── clampToViewport ────────────────────────────── */ - - describe('clampToViewport', () => { - beforeEach(() => { - Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true, configurable: true }); - Object.defineProperty(window, 'innerHeight', { value: 768, writable: true, configurable: true }); - }); - - it('clamps negative top to VIEWPORT_MARGIN', () => { - const pos = priv(overlay).clampToViewport({ top: -50, left: 100 }) as { top: number; left: number }; - expect(pos.top).toBe(24); // VIEWPORT_MARGIN - }); - - it('clamps negative left to VIEWPORT_MARGIN', () => { - const pos = priv(overlay).clampToViewport({ top: 100, left: -30 }) as { top: number; left: number }; - expect(pos.left).toBe(24); // VIEWPORT_MARGIN - }); - - it('clamps right overflow', () => { - const pos = priv(overlay).clampToViewport({ top: 100, left: 900 }) as { top: number; left: number }; - // max left = 1024 - 320 - 24 = 680 - expect(pos.left).toBe(680); - }); - - it('passes through when already in bounds', () => { - const pos = priv(overlay).clampToViewport({ top: 200, left: 300 }) as { top: number; left: number }; - expect(pos.top).toBe(200); - expect(pos.left).toBe(300); - }); - }); - - /* ── getArrowOffset ─────────────────────────────── */ - - describe('getArrowOffset', () => { - it('centers arrow horizontally for bottom placement', () => { - const targetRect = mockRect(300, 100, 120, 40); - const tooltipPos = { top: 166, left: 200 }; - - const offset = priv(overlay).getArrowOffset(targetRect, tooltipPos, 'bottom') as string; - // target center X = 300 + 60 = 360, tooltip left = 200, offset = 160px - expect(offset).toBe('160px'); - }); - - it('clamps arrow to min edge for far-off targets', () => { - const targetRect = mockRect(10, 100, 30, 40); - const tooltipPos = { top: 166, left: 200 }; - - const offset = priv(overlay).getArrowOffset(targetRect, tooltipPos, 'bottom') as string; - // target center X = 10 + 15 = 25, tooltip left = 200, raw offset = -175 → clamped to MIN (20) - expect(offset).toBe('20px'); + Object.defineProperty(window, 'innerWidth', { + value: 1280, + writable: true, + configurable: true, }); - - it('computes vertical offset for left/right placement', () => { - const targetRect = mockRect(100, 300, 80, 40); - const tooltipPos = { top: 250, left: 206 }; - - const offset = priv(overlay).getArrowOffset(targetRect, tooltipPos, 'right') as string; - // target center Y = 300 + 20 = 320, tooltip top = 250, offset = 70px - expect(offset).toBe('70px'); + Object.defineProperty(window, 'innerHeight', { + value: 900, + writable: true, + configurable: true, }); }); -}); - -/* ── waitForTarget ─────────────────────────────────── */ - -describe('TorchlitOverlay — waitForTarget (MutationObserver)', () => { - let overlay: TorchlitOverlay; - const cleanup: Element[] = []; - - beforeEach(() => { - overlay = new TorchlitOverlay(); - (overlay as unknown as { service: { spotlightPadding: number; targetAttribute: string } }).service = { - spotlightPadding: 10, - targetAttribute: 'data-tour-id', - }; - }); afterEach(() => { - cleanup.forEach(el => el.remove()); + cleanup.forEach(element => element.remove()); cleanup.length = 0; + vi.restoreAllMocks(); }); - it('resolves immediately when the target already exists', async () => { - const el = document.createElement('div'); - el.setAttribute('data-tour-id', 'existing-target'); - document.body.appendChild(el); - cleanup.push(el); - - const found = await priv(overlay).waitForTarget('existing-target', 500); - expect(found).toBe(el); - }); - - it('resolves when the target is added to the DOM after a delay', async () => { - const promise = priv(overlay).waitForTarget('lazy-target', 2000) as Promise; - - // Add the element asynchronously - setTimeout(() => { - const el = document.createElement('div'); - el.setAttribute('data-tour-id', 'lazy-target'); - document.body.appendChild(el); - cleanup.push(el); - }, 50); - - const found = await promise; - expect(found).not.toBeNull(); - expect(found?.getAttribute('data-tour-id')).toBe('lazy-target'); - }); - - it('resolves with null when the target never appears (timeout)', async () => { - const found = await priv(overlay).waitForTarget('never-exists', 100); - expect(found).toBeNull(); - }); -}); - -/* ── Auto-advance ──────────────────────────────────── */ - -describe('TorchlitOverlay — auto-advance', () => { - let overlay: TorchlitOverlay; - - beforeEach(() => { + it('uses the latest active tour when restoring scroll after a replacement', async () => { vi.useFakeTimers(); - overlay = new TorchlitOverlay(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - it('starts a timer that calls service.nextStep', () => { - const nextStep = vi.fn(); - (overlay as unknown as { service: { nextStep: () => void; spotlightPadding: number } }).service = { - nextStep, - spotlightPadding: 10, + const overlay = new TorchlitOverlay(); + const stepA: TourStep = { + target: '_none_', + title: 'A', + message: 'First', + placement: 'bottom', }; - - priv(overlay).startAutoAdvance(3000); - - expect(nextStep).not.toHaveBeenCalled(); - vi.advanceTimersByTime(3000); - expect(nextStep).toHaveBeenCalledOnce(); - }); - - it('clears the timer so it does not fire', () => { - const nextStep = vi.fn(); - (overlay as unknown as { service: { nextStep: () => void; spotlightPadding: number } }).service = { - nextStep, - spotlightPadding: 10, + const stepB: TourStep = { + target: '_none_', + title: 'B', + message: 'Second', + placement: 'bottom', }; - priv(overlay).startAutoAdvance(3000); - priv(overlay).clearAutoAdvance(); + const tours = new Map([ + ['tour-a', makeTour('tour-a', stepA, { onEndScroll: 'top' })], + ['tour-b', makeTour('tour-b', stepB, { onEndScroll: 'restore' })], + ]); - vi.advanceTimersByTime(5000); - expect(nextStep).not.toHaveBeenCalled(); - }); - - it('replaces previous timer when called again', () => { - const nextStep = vi.fn(); - (overlay as unknown as { service: { nextStep: () => void; spotlightPadding: number } }).service = { - nextStep, + const service = { + subscribe: vi.fn(() => () => {}), + getSnapshot: vi.fn(() => null), + getTour: vi.fn((tourId: string) => tours.get(tourId)), + nextStep: vi.fn(), + prevStep: vi.fn(), + skipTour: vi.fn(), spotlightPadding: 10, + targetAttribute: 'data-tour-id', }; - priv(overlay).startAutoAdvance(2000); - vi.advanceTimersByTime(1000); - - // Restart with a fresh 3000ms - priv(overlay).startAutoAdvance(3000); - vi.advanceTimersByTime(2000); - expect(nextStep).not.toHaveBeenCalled(); // 2000 < 3000 - - vi.advanceTimersByTime(1000); - expect(nextStep).toHaveBeenCalledOnce(); - }); -}); - -describe('TorchlitOverlay — scroll restore', () => { - let overlay: TorchlitOverlay; + priv<{ service: typeof service; attachService: () => void }>(overlay).service = service; + priv<{ attachService: () => void }>(overlay).attachService(); - beforeEach(() => { - overlay = new TorchlitOverlay(); - vi.useFakeTimers(); - }); + const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); - afterEach(() => { - vi.useRealTimers(); - }); + Object.defineProperty(window, 'scrollY', { value: 120, configurable: true }); + await priv<{ handleTourChange: (snapshot: TourSnapshot | null) => Promise }>(overlay) + .handleTourChange(makeSnapshot('tour-a', stepA)); - it('saves scrollY on first snapshot and stores activeTourId', () => { - // Simulate window.scrollY - Object.defineProperty(window, 'scrollY', { value: 350, configurable: true }); + Object.defineProperty(window, 'scrollY', { value: 480, configurable: true }); + await priv<{ handleTourChange: (snapshot: TourSnapshot | null) => Promise }>(overlay) + .handleTourChange(makeSnapshot('tour-b', stepB)); - const privOverlay = overlay as unknown as Record; - expect(privOverlay.savedScrollY).toBe(0); - expect(privOverlay.activeTourId).toBeNull(); + await priv<{ handleTourChange: (snapshot: TourSnapshot | null) => Promise }>(overlay) + .handleTourChange(null); - // Simulate first snapshot arriving (snapshot was null, now has data) - privOverlay.snapshot = null; - // Manually trigger what handleTourChange does on first snapshot - privOverlay.savedScrollY = window.scrollY; - privOverlay.activeTourId = 'test-tour'; + vi.advanceTimersByTime(300); - expect(privOverlay.savedScrollY).toBe(350); - expect(privOverlay.activeTourId).toBe('test-tour'); - }); + expect(scrollSpy).toHaveBeenCalledWith({ top: 480, behavior: 'smooth' }); + expect(scrollSpy).not.toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); - it('has default onEndScroll of restore in TourDefinition', async () => { - // Import the type to verify the interface allows undefined (defaults to restore) - const def = { id: 't', name: 'T', trigger: 'manual' as const, steps: [] }; - expect(def.onEndScroll).toBeUndefined(); // undefined means default = 'restore' + vi.useRealTimers(); }); - it('accepts onEndScroll values', () => { - const tourRestore = { id: 'a', name: 'A', trigger: 'manual' as const, steps: [], onEndScroll: 'restore' as const }; - const tourTop = { id: 'b', name: 'B', trigger: 'manual' as const, steps: [], onEndScroll: 'top' as const }; - const tourNone = { id: 'c', name: 'C', trigger: 'manual' as const, steps: [], onEndScroll: 'none' as const }; + it('reuses the cached target element on resize and scroll without re-querying the DOM', async () => { + const overlay = new TorchlitOverlay(); + const step: TourStep = { + target: 'cached-target', + title: 'Cached', + message: 'Reuse rects', + placement: 'bottom', + }; + const snapshot = makeSnapshot('tour', step); + const target = document.createElement('button'); + target.setAttribute('data-tour-id', 'cached-target'); + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue( + mockRect(200, 240, 160, 48), + ); + document.body.appendChild(target); + cleanup.push(target); + + const tour = makeTour('tour', step); + const service = { + subscribe: vi.fn(() => () => {}), + getSnapshot: vi.fn(() => snapshot), + getTour: vi.fn(() => tour), + nextStep: vi.fn(), + prevStep: vi.fn(), + skipTour: vi.fn(), + spotlightPadding: 10, + targetAttribute: 'data-tour-id', + }; - expect(tourRestore.onEndScroll).toBe('restore'); - expect(tourTop.onEndScroll).toBe('top'); - expect(tourNone.onEndScroll).toBe('none'); + priv<{ service: typeof service; attachService: () => void }>(overlay).service = service; + priv<{ attachService: () => void }>(overlay).attachService(); + + const deepQuerySpy = vi.spyOn(deepQueryModule, 'deepQuery'); + await priv<{ handleTourChange: (snapshot: TourSnapshot | null) => Promise }>(overlay) + .handleTourChange(snapshot); + + const initialCalls = deepQuerySpy.mock.calls.length; + const rafSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation(callback => { + callback(0); + return 1; + }); + + priv<{ handleResize: () => void }>(overlay).handleResize(); + priv<{ handleScroll: () => void }>(overlay).handleScroll(); + + expect(deepQuerySpy.mock.calls.length).toBe(initialCalls); + expect( + priv<{ snapshot: { targetElement: Element | null; targetRect: DOMRect | null } | null }>(overlay) + .snapshot?.targetElement, + ).toBe(target); + expect( + priv<{ snapshot: { targetElement: Element | null; targetRect: DOMRect | null } | null }>(overlay) + .snapshot?.targetRect?.top, + ).toBe(240); + + rafSpy.mockRestore(); }); }); diff --git a/test/tour-service.test.ts b/test/tour-service.test.ts index 67e151a..3a1a308 100644 --- a/test/tour-service.test.ts +++ b/test/tour-service.test.ts @@ -1,425 +1,177 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createTourService, TourService } from '../src/tour-service'; -import type { TourDefinition } from '../src/types'; +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + TourService, + createTourService, + type StorageAdapter, + type TourDefinition, +} from '../src/tour-service'; const STORAGE_KEY = 'test-tour-state'; -function makeTour(overrides: Partial = {}): TourDefinition { +interface Step { + id: string; +} + +function createMemoryStorage(seed?: { completed?: string[]; dismissed?: string[] }): StorageAdapter { + const store = new Map(); + + if (seed) { + store.set(STORAGE_KEY, JSON.stringify(seed)); + } + + return { + getItem: key => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + +function makeTour(overrides: Partial> = {}): TourDefinition { return { id: 'test-tour', name: 'Test Tour', trigger: 'manual', - steps: [ - { target: 'btn-a', title: 'Step A', message: 'Message A', placement: 'right' }, - ], + steps: [{ id: 'step-a' }], ...overrides, }; } describe('TourService', () => { - let service: TourService; + let storage: StorageAdapter; + let service: TourService; beforeEach(() => { - localStorage.clear(); - service = createTourService({ storageKey: STORAGE_KEY }); + storage = createMemoryStorage(); + service = createTourService({ storageKey: STORAGE_KEY, storage }); }); - /* ── Registration ─────────────────────────────── */ - - describe('register', () => { - it('should register a tour and retrieve it', () => { - service.register(makeTour()); - - expect(service.getTour('test-tour')).toBeDefined(); - expect(service.getTour('test-tour')!.name).toBe('Test Tour'); - }); - - it('should register multiple tours via array', () => { - service.register([ - makeTour({ id: 'tour-1', name: 'Tour 1' }), - makeTour({ id: 'tour-2', name: 'Tour 2', trigger: 'first-visit' }), - ]); - - expect(service.getAvailableTours()).toHaveLength(2); - expect(service.getTour('tour-1')).toBeDefined(); - expect(service.getTour('tour-2')).toBeDefined(); + it('registers tours and returns the current snapshot', () => { + service.register( + makeTour({ + id: 'multi', + steps: [{ id: 'step-a' }, { id: 'step-b' }], + }), + ); + + service.start('multi'); + + expect(service.getTour('multi')?.name).toBe('Test Tour'); + expect(service.getAvailableTours()).toHaveLength(1); + expect(service.getSnapshot()).toEqual({ + tourId: 'multi', + tourName: 'Test Tour', + step: { id: 'step-a' }, + stepIndex: 0, + totalSteps: 2, }); }); - /* ── shouldAutoStart ──────────────────────────── */ - - describe('shouldAutoStart', () => { - it('returns true for a first-visit tour not yet seen', () => { - service.register(makeTour({ id: 'onboarding', trigger: 'first-visit' })); - expect(service.shouldAutoStart('onboarding')).toBe(true); - }); - - it('returns false for a manual tour', () => { - service.register(makeTour({ id: 'help', trigger: 'manual' })); - expect(service.shouldAutoStart('help')).toBe(false); - }); - - it('returns false after the tour has been completed', () => { - service.register(makeTour({ id: 'onboarding', trigger: 'first-visit' })); - service.start('onboarding'); - service.nextStep(); // single step → completes + it('supports subscribe, nextStep, and prevStep without DOM state', () => { + const listener = vi.fn(); - expect(service.shouldAutoStart('onboarding')).toBe(false); - }); - - it('returns false after the tour has been dismissed', () => { - service.register(makeTour({ id: 'onboarding', trigger: 'first-visit' })); - service.start('onboarding'); - service.skipTour(); + service.register( + makeTour({ + id: 'multi', + steps: [{ id: 'step-a' }, { id: 'step-b' }], + }), + ); + service.subscribe(listener); - expect(service.shouldAutoStart('onboarding')).toBe(false); - }); + service.start('multi'); + service.nextStep(); + service.prevStep(); - it('returns false for unregistered tour', () => { - expect(service.shouldAutoStart('nonexistent')).toBe(false); - }); + expect(listener).toHaveBeenCalledTimes(3); + expect(service.getSnapshot()?.step).toEqual({ id: 'step-a' }); }); - /* ── Tour lifecycle ───────────────────────────── */ + it('marks first-visit tours as auto-startable until they are completed or dismissed', () => { + service.register(makeTour({ id: 'welcome', trigger: 'first-visit' })); + expect(service.shouldAutoStart('welcome')).toBe(true); - describe('start / nextStep / prevStep / skipTour', () => { - const multiStepTour: TourDefinition = { - id: 'multi', - name: 'Multi Step', - trigger: 'manual', - steps: [ - { target: 'a', title: 'A', message: 'Msg A', placement: 'right' }, - { target: 'b', title: 'B', message: 'Msg B', placement: 'bottom' }, - { target: 'c', title: 'C', message: 'Msg C', placement: 'left' }, - ], - }; + service.start('welcome'); + service.skipTour(); + expect(service.shouldAutoStart('welcome')).toBe(false); - beforeEach(() => { - service.register(multiStepTour); - }); - - it('should start a tour and report as active', () => { - service.start('multi'); - expect(service.isActive()).toBe(true); - }); - - it('should provide a snapshot of the current step', () => { - service.start('multi'); - const snap = service.getSnapshot(); - expect(snap).not.toBeNull(); - expect(snap!.stepIndex).toBe(0); - expect(snap!.step.title).toBe('A'); - expect(snap!.totalSteps).toBe(3); - }); - - it('should advance to the next step', () => { - service.start('multi'); - service.nextStep(); - const snap = service.getSnapshot(); - expect(snap!.stepIndex).toBe(1); - expect(snap!.step.title).toBe('B'); - }); - - it('should go back to the previous step', () => { - service.start('multi'); - service.nextStep(); - service.prevStep(); - const snap = service.getSnapshot(); - expect(snap!.stepIndex).toBe(0); - expect(snap!.step.title).toBe('A'); - }); - - it('should not go before step 0', () => { - service.start('multi'); - service.prevStep(); - const snap = service.getSnapshot(); - expect(snap!.stepIndex).toBe(0); - }); - - it('should complete tour when advancing past last step', () => { - service.start('multi'); - service.nextStep(); - service.nextStep(); - service.nextStep(); // completes - - expect(service.isActive()).toBe(false); - expect(service.getSnapshot()).toBeNull(); - }); - - it('should skip tour and mark as dismissed', () => { - service.start('multi'); - service.skipTour(); - - expect(service.isActive()).toBe(false); - }); - - it('should not start a tour with no steps', () => { - service.register(makeTour({ id: 'empty', steps: [] })); - service.start('empty'); - expect(service.isActive()).toBe(false); - }); - - it('should not start an unregistered tour', () => { - service.start('nonexistent'); - expect(service.isActive()).toBe(false); - }); + service.resetAll(); + service.register(makeTour({ id: 'welcome', trigger: 'first-visit' })); + service.start('welcome'); + service.nextStep(); + expect(service.shouldAutoStart('welcome')).toBe(false); }); - /* ── Loop ───────────────────────────────────────── */ - - describe('loop', () => { - const loopTour: TourDefinition = { - id: 'loop-tour', - name: 'Loop Tour', - trigger: 'manual', - loop: true, - steps: [ - { target: 'a', title: 'A', message: 'Msg A', placement: 'right' }, - { target: 'b', title: 'B', message: 'Msg B', placement: 'bottom' }, - ], - }; - - it('wraps back to step 0 when advancing past the last step', () => { - service.register(loopTour); - service.start('loop-tour'); - - service.nextStep(); // step 1 - service.nextStep(); // should wrap to step 0 - - expect(service.isActive()).toBe(true); - const snap = service.getSnapshot(); - expect(snap!.stepIndex).toBe(0); - expect(snap!.step.title).toBe('A'); - }); - - it('does not mark the tour as completed', () => { - service.register(loopTour); - service.start('loop-tour'); - service.nextStep(); - service.nextStep(); // wraps - - const raw = localStorage.getItem(STORAGE_KEY); - const stored = raw ? JSON.parse(raw) : { completed: [] }; - expect(stored.completed).not.toContain('loop-tour'); - }); - - it('can still be skipped normally', () => { - service.register(loopTour); - service.start('loop-tour'); - service.skipTour(); - - expect(service.isActive()).toBe(false); - }); - - it('does not call onComplete on wrap', () => { - const onComplete = vi.fn(); - service.register({ ...loopTour, id: 'loop-hooks', onComplete }); - service.start('loop-hooks'); - service.nextStep(); - service.nextStep(); // wraps - - expect(onComplete).not.toHaveBeenCalled(); - }); - - it('non-loop tour still completes normally', () => { - const onComplete = vi.fn(); - service.register(makeTour({ id: 'no-loop', onComplete })); - service.start('no-loop'); - service.nextStep(); // single step → completes - - expect(service.isActive()).toBe(false); - expect(onComplete).toHaveBeenCalledOnce(); - }); - }); - - /* ── Lifecycle hooks ─────────────────────────── */ - - describe('onComplete / onSkip hooks', () => { - it('calls onComplete when tour finishes', () => { - const onComplete = vi.fn(); - service.register(makeTour({ id: 'hooks', onComplete })); - - service.start('hooks'); - service.nextStep(); // single step → completes - - expect(onComplete).toHaveBeenCalledOnce(); - }); - - it('calls onSkip when tour is skipped', () => { - const onSkip = vi.fn(); - service.register(makeTour({ id: 'hooks', onSkip })); - - service.start('hooks'); - service.skipTour(); - - expect(onSkip).toHaveBeenCalledOnce(); - }); - - it('does not call onComplete on skip', () => { - const onComplete = vi.fn(); - service.register(makeTour({ id: 'hooks', onComplete })); - - service.start('hooks'); - service.skipTour(); - - expect(onComplete).not.toHaveBeenCalled(); - }); - }); - - /* ── Subscription ─────────────────────────────── */ - - describe('subscribe', () => { - it('should notify listeners on step changes', () => { - const listener = vi.fn(); - service.subscribe(listener); - - service.register({ - id: 'sub-test', - name: 'Sub Test', - trigger: 'manual', - steps: [ - { target: 'a', title: 'A', message: 'A', placement: 'right' }, - { target: 'b', title: 'B', message: 'B', placement: 'right' }, - ], - }); - - service.start('sub-test'); - expect(listener).toHaveBeenCalledTimes(1); - expect(listener.mock.calls[0][0]?.stepIndex).toBe(0); - - service.nextStep(); - expect(listener).toHaveBeenCalledTimes(2); - expect(listener.mock.calls[1][0]?.stepIndex).toBe(1); - }); - - it('should notify with null when tour ends', () => { - const listener = vi.fn(); - service.subscribe(listener); - - service.register(makeTour({ id: 'end-test' })); - - service.start('end-test'); - service.nextStep(); // completes - - const lastCall = listener.mock.calls[listener.mock.calls.length - 1]; - expect(lastCall[0]).toBeNull(); - }); - - it('should allow unsubscribing', () => { - const listener = vi.fn(); - const unsub = service.subscribe(listener); - - service.register(makeTour({ id: 'unsub-test' })); - unsub(); - - service.start('unsub-test'); - expect(listener).not.toHaveBeenCalled(); - }); + it('loops instead of completing when loop is enabled', () => { + service.register( + makeTour({ + id: 'loop-tour', + loop: true, + steps: [{ id: 'step-a' }, { id: 'step-b' }], + }), + ); + + service.start('loop-tour'); + service.nextStep(); + service.nextStep(); + + expect(service.isActive()).toBe(true); + expect(service.getSnapshot()?.stepIndex).toBe(0); }); - /* ── Persistence ──────────────────────────────── */ - - describe('persistence', () => { - it('should persist completed status to storage', () => { - service.register(makeTour({ id: 'persist', trigger: 'first-visit' })); - - service.start('persist'); - service.nextStep(); // completes - - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - expect(stored.completed).toContain('persist'); - }); - - it('should persist dismissed status to storage', () => { - service.register(makeTour({ id: 'dismiss', trigger: 'first-visit' })); - - service.start('dismiss'); - service.skipTour(); - - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - expect(stored.dismissed).toContain('dismiss'); - }); - - it('should clear state on resetAll', () => { - service.register(makeTour({ id: 'reset', trigger: 'first-visit' })); - service.start('reset'); - service.nextStep(); // completes - - service.resetAll(); - - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - expect(stored.completed).toHaveLength(0); - expect(stored.dismissed).toHaveLength(0); - }); - - it('should use custom storage adapter', () => { - const fakeStorage: Record = {}; - const adapter = { - getItem: (key: string) => fakeStorage[key] ?? null, - setItem: (key: string, value: string) => { fakeStorage[key] = value; }, - }; - - const customService = createTourService({ - storageKey: 'custom-key', - storage: adapter, - }); - - customService.register(makeTour({ id: 'adapter-test', trigger: 'first-visit' })); - customService.start('adapter-test'); - customService.nextStep(); // completes - - const stored = JSON.parse(fakeStorage['custom-key']); - expect(stored.completed).toContain('adapter-test'); + it('allows a second tour to replace an active tour cleanly', () => { + service.register([ + makeTour({ + id: 'tour-a', + name: 'Tour A', + steps: [{ id: 'a-1' }, { id: 'a-2' }], + }), + makeTour({ + id: 'tour-b', + name: 'Tour B', + steps: [{ id: 'b-1' }], + }), + ]); + + service.start('tour-a'); + service.nextStep(); + service.start('tour-b'); + + expect(service.getSnapshot()).toEqual({ + tourId: 'tour-b', + tourName: 'Tour B', + step: { id: 'b-1' }, + stepIndex: 0, + totalSteps: 1, }); }); - /* ── Target resolution ────────────────────────── */ - - describe('findTarget', () => { - it('should find an element with data-tour-id in the DOM', () => { - const el = document.createElement('div'); - el.setAttribute('data-tour-id', 'test-target'); - document.body.appendChild(el); - - const found = service.findTarget('test-target'); - expect(found).toBe(el); - - document.body.removeChild(el); + it('loads persisted state from the provided storage adapter', () => { + service = createTourService({ + storageKey: STORAGE_KEY, + storage: createMemoryStorage({ completed: ['welcome'] }), }); + service.register(makeTour({ id: 'welcome', trigger: 'first-visit' })); - it('should return null for missing targets', () => { - const found = service.findTarget('nonexistent-target'); - expect(found).toBeNull(); - }); - - it('should use custom target attribute', () => { - const customService = createTourService({ - storageKey: 'attr-test', - targetAttribute: 'data-spotlight', - }); - - const el = document.createElement('div'); - el.setAttribute('data-spotlight', 'my-el'); - document.body.appendChild(el); - - const found = customService.findTarget('my-el'); - expect(found).toBe(el); - - document.body.removeChild(el); - }); + expect(service.shouldAutoStart('welcome')).toBe(false); }); - /* ── Factory ──────────────────────────────────── */ + it('fires completion and skip hooks at the right time', () => { + const onComplete = vi.fn(); + const onSkip = vi.fn(); - describe('createTourService', () => { - it('creates independent instances', () => { - const a = createTourService({ storageKey: 'a' }); - const b = createTourService({ storageKey: 'b' }); + service.register([ + makeTour({ id: 'complete-me', onComplete }), + makeTour({ id: 'skip-me', onSkip }), + ]); - a.register(makeTour({ id: 'only-a' })); + service.start('complete-me'); + service.nextStep(); + service.start('skip-me'); + service.skipTour(); - expect(a.getTour('only-a')).toBeDefined(); - expect(b.getTour('only-a')).toBeUndefined(); - }); + expect(onComplete).toHaveBeenCalledOnce(); + expect(onSkip).toHaveBeenCalledOnce(); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index fa2023e..2242ac7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,11 @@ export default defineConfig({ include: ['test/**/*.test.ts'], environment: 'jsdom', restoreMocks: true, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/index.ts', 'src/types.ts'], + reporter: ['text', 'lcov'], + }, }, }); From a043ffd06e013d56558254ccd215488a3fe791b7 Mon Sep 17 00:00:00 2001 From: barryking Date: Sat, 7 Mar 2026 12:56:11 +0000 Subject: [PATCH 2/2] chore: prepare 0.3.0 release --- SECURITY.md | 4 ++-- package.json | 2 +- pnpm-lock.yaml | 2 +- src/dom/deep-query.ts | 7 ++----- src/dom/positioning.ts | 5 +++-- src/dom/scroll-manager.ts | 7 ++++--- src/overlay/step-runner.ts | 8 +++++--- test/positioning.test.ts | 27 +++++++++++++++++++++++---- 8 files changed, 41 insertions(+), 21 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 575c076..61bc35a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 0.2.x | :white_check_mark: | -| < 0.2 | :x: | +| 0.3.x | :white_check_mark: | +| < 0.3 | :x: | ## Reporting a Vulnerability diff --git a/package.json b/package.json index d34bd2c..341613b 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,6 @@ "lit": "^3.2.1", "typescript": "^5.7.0", "vite": "^6.1.0", - "vitest": "^3.0.0" + "vitest": "^3.2.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54ad08e..c1b465b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: specifier: ^6.1.0 version: 6.4.1 vitest: - specifier: ^3.0.0 + specifier: ^3.2.4 version: 3.2.4(jsdom@28.0.0) packages: diff --git a/src/dom/deep-query.ts b/src/dom/deep-query.ts index d6011d2..5515074 100644 --- a/src/dom/deep-query.ts +++ b/src/dom/deep-query.ts @@ -1,6 +1,6 @@ export function deepQuery( selector: string, - root: Element | Document = document.body, + root: Element | Document | ShadowRoot = document.body, ): Element | null { const found = root.querySelector(selector); if (found) return found; @@ -8,10 +8,7 @@ export function deepQuery( const children = root.querySelectorAll('*'); for (const element of children) { if (element.shadowRoot) { - const shadowResult = deepQuery( - selector, - element.shadowRoot as unknown as Document, - ); + const shadowResult = deepQuery(selector, element.shadowRoot); if (shadowResult) return shadowResult; } } diff --git a/src/dom/positioning.ts b/src/dom/positioning.ts index 19fe1c1..8cad757 100644 --- a/src/dom/positioning.ts +++ b/src/dom/positioning.ts @@ -4,6 +4,7 @@ export const TOOLTIP_W = 320; export const TOOLTIP_H_MAX = 270; export const GAP = 16; export const VIEWPORT_MARGIN = 24; +export const TOOLTIP_VERTICAL_OFFSET = 80; export function fitsInViewport( rect: DOMRect, @@ -68,12 +69,12 @@ export function getTooltipPosition( switch (placement) { case 'right': return { - top: visibleCenterY - 80, + top: visibleCenterY - TOOLTIP_VERTICAL_OFFSET, left: rect.right + spotlightPadding + GAP, }; case 'left': return { - top: visibleCenterY - 80, + top: visibleCenterY - TOOLTIP_VERTICAL_OFFSET, left: rect.left - spotlightPadding - GAP - TOOLTIP_W, }; case 'bottom': diff --git a/src/dom/scroll-manager.ts b/src/dom/scroll-manager.ts index bd0eb41..ef8b45f 100644 --- a/src/dom/scroll-manager.ts +++ b/src/dom/scroll-manager.ts @@ -17,10 +17,11 @@ export function scrollAndSettle( placement: TourPlacement, spotlightPadding: number, ): Promise { - const rect = element.getBoundingClientRect(); + const initialRect = element.getBoundingClientRect(); const viewportHeight = window.innerHeight; - if (fitsInViewport(rect, viewportHeight)) { + // Use the pre-scroll measurement only to decide fit and target offset. + if (fitsInViewport(initialRect, viewportHeight)) { element.scrollIntoView({ behavior: 'smooth', block: 'center', @@ -31,7 +32,7 @@ export function scrollAndSettle( placement === 'top' ? TOOLTIP_H_MAX + GAP + spotlightPadding : viewportHeight * 0.15; - const scrollTarget = window.scrollY + rect.top - desiredTop; + const scrollTarget = window.scrollY + initialRect.top - desiredTop; window.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'smooth' }); } diff --git a/src/overlay/step-runner.ts b/src/overlay/step-runner.ts index f69d0f5..085991c 100644 --- a/src/overlay/step-runner.ts +++ b/src/overlay/step-runner.ts @@ -1,9 +1,12 @@ import type { TourSnapshot as CoreTourSnapshot } from '../core/types.js'; +import { GAP, TOOLTIP_H_MAX } from '../dom/positioning.js'; import { scrollAndSettle } from '../dom/scroll-manager.js'; import { resolveStepTarget, waitForTarget } from '../dom/target-resolver.js'; import type { TourDefinition, TourStep } from '../types.js'; import type { ResolvedTourSnapshot } from './types.js'; +const TARGET_CONTEXT_MARGIN = 32; + export interface StepRunnerOptions { getCurrentSnapshot: () => CoreTourSnapshot | null; getTour: (tourId: string) => TourDefinition | undefined; @@ -95,15 +98,14 @@ export class StepRunner { if (!rect) return false; const viewportHeight = window.innerHeight; - const fits = - rect.height + 270 + 32 < viewportHeight; + const fits = rect.height + TOOLTIP_H_MAX + TARGET_CONTEXT_MARGIN < viewportHeight; const inView = fits ? rect.top >= 0 && rect.bottom <= viewportHeight && rect.left >= 0 && rect.right <= window.innerWidth : snapshot.step.placement === 'top' - ? rect.top >= 270 + 16 + this.options.spotlightPadding && + ? rect.top >= TOOLTIP_H_MAX + GAP + this.options.spotlightPadding && rect.top < viewportHeight : rect.top >= 0 && rect.top < viewportHeight; diff --git a/test/positioning.test.ts b/test/positioning.test.ts index e5857f5..c34c62a 100644 --- a/test/positioning.test.ts +++ b/test/positioning.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { + GAP, + TOOLTIP_H_MAX, + TOOLTIP_W, + VIEWPORT_MARGIN, bestPlacement, clampToViewport, getArrowClass, @@ -59,19 +63,34 @@ describe('positioning helpers', () => { }); it('computes tooltip positions and clamps them to the viewport', () => { - const position = getTooltipPosition(mockRect(200, 100, 120, 40), 'bottom', 10); + const rect = mockRect(200, 100, 120, 40); + const padding = 10; + const position = getTooltipPosition(rect, 'bottom', padding); const clamped = clampToViewport({ top: -50, left: 900 }); + const expectedPosition = { + top: rect.bottom + padding + GAP, + left: rect.left + rect.width / 2 - TOOLTIP_W / 2, + }; + const expectedClamped = { + top: VIEWPORT_MARGIN, + left: window.innerWidth - TOOLTIP_W - VIEWPORT_MARGIN, + }; - expect(position).toEqual({ top: 166, left: 100 }); - expect(clamped).toEqual({ top: 24, left: 680 }); + expect(position).toEqual(expectedPosition); + expect(clamped).toEqual(expectedClamped); }); it('returns stable arrow classes and offsets', () => { const placement: TourPlacement = 'right'; const targetRect = mockRect(100, 300, 80, 40); const tooltipPos = { top: 250, left: 206 }; + const visibleTop = Math.max(0, targetRect.top); + const visibleBottom = Math.min(window.innerHeight, targetRect.bottom); + const targetCenterY = (visibleTop + visibleBottom) / 2; expect(getArrowClass(placement)).toBe('arrow-right'); - expect(getArrowOffset(targetRect, tooltipPos, placement)).toBe('70px'); + expect(getArrowOffset(targetRect, tooltipPos, placement)).toBe( + `${Math.max(20, Math.min(targetCenterY - tooltipPos.top, TOOLTIP_H_MAX - 20))}px`, + ); }); });