diff --git a/.browserslistrc b/.browserslistrc index c82a77678..3dc607122 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -4,4 +4,3 @@ last 2 Chrome versions last 2 Firefox versions last 2 Safari versions last 2 Edge versions -Edge 18 diff --git a/.claude/skills/verify-tour-lifecycle/SKILL.md b/.claude/skills/verify-tour-lifecycle/SKILL.md index 97c0d6216..4c9989ca7 100644 --- a/.claude/skills/verify-tour-lifecycle/SKILL.md +++ b/.claude/skills/verify-tour-lifecycle/SKILL.md @@ -24,12 +24,12 @@ Run the check if you changed any of these. The check was last confirmed to descr | `shepherd.js/src/utils/general.ts` | `waitForAttachToElement`, `resolveAttachToElement` | | `shepherd.js/src/components/shepherd-modal.ts` | `hide`, `show`, `destroy`, `setupForStep` | -**Sync commit:** `cba285a53dd85d87c185ee6b04070f87b834b14b` +**Sync commit:** `e281838b3795be56639894861cd21a4f507424b9` To see what changed since then, run this from the repo root: ```sh -git log --oneline cba285a53dd85d87c185ee6b04070f87b834b14b..HEAD -- shepherd.js/src/tour.ts shepherd.js/src/step.ts shepherd.js/src/utils/general.ts shepherd.js/src/components/shepherd-modal.ts +git log --oneline e281838b3795be56639894861cd21a4f507424b9..HEAD -- shepherd.js/src/tour.ts shepherd.js/src/step.ts shepherd.js/src/utils/general.ts shepherd.js/src/components/shepherd-modal.ts ``` Move the sync commit forward only after you confirm the scenarios and this table still describe the code at the new commit. A passing run alone does not confirm that. diff --git a/README.md b/README.md index 126ba189f..612d12f1a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,30 @@ See Shepherd Live on our website by clicking on the image: # Using Shepherd +```bash +npm install shepherd.js +``` + +```js +import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; + +const tour = new Shepherd.Tour({ useModalOverlay: true }); + +tour.addStep({ + title: 'Welcome', + text: 'This step points at the save button.', + attachTo: { element: '#save', on: 'bottom' }, + buttons: [{ text: 'Done', action: 'complete' }] +}); + +tour.start(); +``` + +Steps position themselves with a small built-in engine. Tune the gap, edge distance and flipping with `positionOptions: { offset, padding, strategy, flip }`. Upgrading from Shepherd 15, which took Floating UI middleware in `floatingUIOptions`? See [the upgrade notes](https://docs.shepherdjs.dev/guides/install/#upgrading-from-shepherd-15). + +Coding agents: read [`shepherd.js/AGENTS.md`](shepherd.js/AGENTS.md), which is also in the npm package. + ### Shepherd Open Source Library The Shepherd Standalone Library has been open source since the very beginning. Check out our tutorials here: diff --git a/docs-src/api-readme.md b/docs-src/api-readme.md new file mode 100644 index 000000000..575ef5fc1 --- /dev/null +++ b/docs-src/api-readme.md @@ -0,0 +1,11 @@ +Generated from the TSDoc in `shepherd.js/src`. Start with +[`Tour`](/api/tour/classes/tour/) and +[`StepOptions`](/api/step/interfaces/stepoptions/). + +## Modules + +- [evented](/api/evented/readme/) +- [motion](/api/motion/readme/) +- [shepherd](/api/shepherd/readme/) +- [step](/api/step/readme/) +- [tour](/api/tour/readme/) diff --git a/docs-src/astro.config.mjs b/docs-src/astro.config.mjs index 2c0a8364e..4228ce5ca 100644 --- a/docs-src/astro.config.mjs +++ b/docs-src/astro.config.mjs @@ -36,7 +36,10 @@ export default defineConfig({ tsconfig: './node_modules/shepherd.js/tsconfig.json', typeDoc: { entryPointStrategy: 'expand', - includeVersion: true + includeVersion: true, + // Without a readme, starlight-typedoc drops each module's index page + // while the API index still links to it. + readme: './api-readme.md' } }) ], diff --git a/docs-src/src/content/docs/api/README.md b/docs-src/src/content/docs/api/README.md index 874df4b70..c25532de0 100644 --- a/docs-src/src/content/docs/api/README.md +++ b/docs-src/src/content/docs/api/README.md @@ -5,9 +5,14 @@ prev: false title: "shepherd.js" --- +Generated from the TSDoc in `shepherd.js/src`. Start with +[`Tour`](/api/tour/classes/tour/) and +[`StepOptions`](/api/step/interfaces/stepoptions/). + ## Modules - [evented](/api/evented/readme/) +- [motion](/api/motion/readme/) - [shepherd](/api/shepherd/readme/) - [step](/api/step/readme/) - [tour](/api/tour/readme/) diff --git a/docs-src/src/content/docs/guides/install.mdx b/docs-src/src/content/docs/guides/install.mdx index 17365fb1e..edbb9e7ef 100644 --- a/docs-src/src/content/docs/guides/install.mdx +++ b/docs-src/src/content/docs/guides/install.mdx @@ -48,18 +48,19 @@ to the release in GitHub. You can find those assets [here](https://github.com/sh ### jsDelivr CDN -You can use jsDelivr to pull down any release from npm. For example, you could include v10.0.1 with styles in your app -with: +You can use jsDelivr to pull down any release from npm. `shepherd.mjs` is an ES module and defines no global, so +import it: ```html - + ``` ## JS Framework Wrappers @@ -70,3 +71,27 @@ We strive to make it easy to use Shepherd in all the major frameworks, and have - [ember-shepherd](https://github.com/shipshapecode/ember-shepherd) - [react-shepherd](https://github.com/shipshapecode/shepherd/tree/main/packages/react) - [vue-shepherd](https://github.com/shipshapecode/vue-shepherd) + +## Upgrading from Shepherd 15 + +Shepherd positions steps with its own engine and no longer runs Floating UI +middleware or platforms. `floatingUIOptions` is replaced by `positionOptions`, +which takes four keys. A step that still passes `floatingUIOptions` logs a +warning and is positioned as if it had none. + +| Shepherd 15 | Now | +| ------------------------------------------------------------ | ----------------------------------------------- | +| `floatingUIOptions: { strategy: 'fixed' }` | `positionOptions: { strategy: 'fixed' }` | +| `floatingUIOptions: { middleware: [offset(12)] }` | `positionOptions: { offset: 12 }` | +| `offset({ mainAxis: 4, crossAxis: 8 })` | `positionOptions: { offset: { mainAxis: 4, crossAxis: 8 } }` | +| `shift({ padding: 24 })` or `flip({ padding: 24 })` | `positionOptions: { padding: 24 }`, for both | +| Removing `flip()` to keep a side | `positionOptions: { flip: false }` | +| `placement` in `floatingUIOptions` | `attachTo.on` | +| `hide()`, `size()`, `inline()`, custom middleware, `platform` | No replacement | + +Shepherd 15 ran a consumer's `offset()` after its own `flip()` and `shift()`. +`offset` now runs first, as Floating UI recommends, so flipping and shifting +account for the gap. Remove `@floating-ui/dom` from your +dependencies if nothing else uses it. See +[`StepOptionsPosition`](https://docs.shepherdjs.dev/api/step/interfaces/stepoptionsposition/) +for each key. diff --git a/docs-src/src/content/docs/guides/styling.md b/docs-src/src/content/docs/guides/styling.md index 7cb540147..c7f0984f6 100644 --- a/docs-src/src/content/docs/guides/styling.md +++ b/docs-src/src/content/docs/guides/styling.md @@ -7,6 +7,39 @@ title: Styling Shepherd ships with some default styles, but we try to keep it minimal, so you do not have a ton to override. You can also opt out of styles entirely, by not including the `shepherd.css` file. +### Optional theme + +`shepherd.js/dist/css/theme.css` is a second stylesheet, with dark mode and entry motion. Import it after `shepherd.css`. +Every color and dimension in it is a custom property, so you restyle it by overriding `--shepherd-accent` and its +neighbors rather than selectors. + +```js +import 'shepherd.js/dist/css/shepherd.css'; +import 'shepherd.js/dist/css/theme.css'; +``` + +It pairs with `shepherd.js/motion`, which ships separately and only costs bytes if you import it. `glideSteps(tour)` moves +the dialog between steps, and `stepProgress(tour)` feeds the theme's step counter and progress bar. Each returns a function +that stops it. + +```js +import { glideSteps, stepProgress } from 'shepherd.js/motion'; + +glideSteps(tour); +stepProgress(tour); +``` + +### Overlay motion + +With `useModalOverlay`, the opening glides from one target to the next. Its length comes from `transition-duration` on the +overlay's path, 0.42s by default, and it cuts straight to the target under `prefers-reduced-motion: reduce`. To turn it off: + +```css +.shepherd-modal-overlay-container path { + transition-duration: 0s; +} +``` + ### Custom Classes If you'd like to change styles within your own CSS, you can pass custom class names to the tour instance — diff --git a/docs-src/src/content/docs/guides/usage.md b/docs-src/src/content/docs/guides/usage.md index 90ac891f1..305531c75 100644 --- a/docs-src/src/content/docs/guides/usage.md +++ b/docs-src/src/content/docs/guides/usage.md @@ -11,8 +11,13 @@ i.e. ```js import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; ``` +`Tour` and `Step` are also named exports, so +`import { Tour } from 'shepherd.js'` works too. The stylesheet is a separate +import, and Shepherd logs a warning if it is missing. + First create a new `Tour` instance for your tour: ```javascript @@ -42,7 +47,7 @@ tour.addStep({ buttons: [ { text: 'Next', - action: tour.next + action: 'next' } ] }); @@ -67,10 +72,12 @@ tour.removeStep('example-step'); #### Global Shepherd Object -Shepherd exposes a single object onto the window, `Shepherd`. +The package's default export is a single object, `Shepherd`. It does not define a +`window.Shepherd` global: `shepherd.mjs` is an ES module, so import it, including +from a ` + + \`\`\` ## Quick example \`\`\`js import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; const tour = new Shepherd.Tour({ useModalOverlay: true, @@ -61,8 +64,8 @@ tour.addStep({ text: 'Create a Tour instance and add as many steps as you want.', attachTo: { element: '.example', on: 'bottom' }, buttons: [ - { action() { return this.back(); }, secondary: true, text: 'Back' }, - { action() { return this.next(); }, text: 'Next' } + { action: 'back', secondary: true, text: 'Back' }, + { action: 'next', text: 'Next' } ] }); diff --git a/landing/src/pages/about.astro b/landing/src/pages/about.astro index 144bf1f6e..fa61e9248 100644 --- a/landing/src/pages/about.astro +++ b/landing/src/pages/about.astro @@ -12,9 +12,8 @@ import ContentPage from '@layouts/ContentPage.astro'; app. It lets you build product tours, user onboarding flows, trainings, and feature announcements as a sequence of steps — dialogs that attach to elements in your interface, highlight them with a modal overlay, and walk - users through what to do next. Steps are positioned by - Floating UI, so they never end up off - screen or cropped by an overflow. + users through what to do next. Steps are positioned by a small built-in + engine, so they never end up off screen or cropped by an overflow.

diff --git a/landing/src/pages/index.astro b/landing/src/pages/index.astro index 9117855e0..54b33a6a8 100644 --- a/landing/src/pages/index.astro +++ b/landing/src/pages/index.astro @@ -143,7 +143,7 @@ Astro.response.headers.set( function setupShepherd() { const element = document.createElement('p'); element.innerText = - 'Including Shepherd is easy! Just include shepherd.js. The styles are bundled with the JS.'; + 'Including Shepherd is easy! Just import shepherd.js and its stylesheet.'; const shepherd = new Shepherd.Tour({ id: 'ts4udfrnm7o95ny7e9qbjo3t', @@ -160,7 +160,7 @@ Astro.response.headers.set( // This should add the first tour step steps: [ { - text: '\n

\n Shepherd is a JavaScript library for guiding users through your app.\n It uses Floating UI,\n another open source library, to render dialogs for each tour "step".\n

\n \n

\n Among many things, Floating UI makes sure your steps never end up off screen or cropped by an overflow.\n (Try resizing your browser to see what we mean.)\n

\n', + text: '\n

\n Shepherd is a JavaScript library for guiding users through your app.\n It renders a dialog for each tour "step", positioned\n by its own small engine, with no dependencies.\n

\n \n

\n Among many things, it makes sure your steps never end up off screen or cropped by an overflow.\n (Try resizing your browser to see what we mean.)\n

\n', attachTo: { element: '.hero-welcome', on: 'bottom' diff --git a/landing/src/styles/shepherd.css b/landing/src/styles/shepherd.css deleted file mode 100644 index 45a72c867..000000000 --- a/landing/src/styles/shepherd.css +++ /dev/null @@ -1,9 +0,0 @@ -.shepherd-button{background:#3288e6;border:0;border-radius:3px;color:hsla(0,0%,100%,.75);cursor:pointer;margin-right:.5rem;padding:.5rem 1.5rem;transition:all .5s ease}.shepherd-button:not(:disabled):hover{background:#196fcc;color:hsla(0,0%,100%,.75)}.shepherd-button.shepherd-button-secondary{background:#f1f2f3;color:rgba(0,0,0,.75)}.shepherd-button.shepherd-button-secondary:not(:disabled):hover{background:#d6d9db;color:rgba(0,0,0,.75)}.shepherd-button:disabled{cursor:not-allowed} -.shepherd-footer{border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:flex;justify-content:flex-end;padding:0 .75rem .75rem}.shepherd-footer .shepherd-button:last-child{margin-right:0} -.shepherd-cancel-icon{background:transparent;border:none;color:hsla(0,0%,50%,.75);cursor:pointer;font-size:2em;font-weight:400;margin:0;padding:0;transition:color .5s ease}.shepherd-cancel-icon:hover{color:rgba(0,0,0,.75)}.shepherd-has-title .shepherd-content .shepherd-cancel-icon{color:hsla(0,0%,50%,.75)}.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover{color:rgba(0,0,0,.75)} -.shepherd-title{color:rgba(0,0,0,.75);display:flex;flex:1 0 auto;font-size:1rem;font-weight:400;margin:0;padding:0} -.shepherd-header{align-items:center;border-top-left-radius:5px;border-top-right-radius:5px;display:flex;justify-content:flex-end;line-height:2em;padding:.75rem .75rem 0}.shepherd-has-title .shepherd-content .shepherd-header{background:#e6e6e6;padding:1em} -.shepherd-text{color:rgba(0,0,0,.75);font-size:1rem;line-height:1.3em;padding:.75em}.shepherd-text p{margin-top:0}.shepherd-text p:last-child{margin-bottom:0} -.shepherd-content{border-radius:5px;outline:none;padding:0} -.shepherd-element{background:#fff;border-radius:5px;box-shadow:0 1px 4px rgba(0,0,0,.2);margin: 0;max-width:400px;opacity:0;outline:none;transition:opacity .3s,visibility .3s;visibility:hidden;width:100%;z-index:9999}.shepherd-enabled.shepherd-element{opacity:1;visibility:visible}.shepherd-element[data-popper-reference-hidden]:not(.shepherd-centered){opacity:0;pointer-events:none;visibility:hidden}.shepherd-element,.shepherd-element *,.shepherd-element :after,.shepherd-element :before{box-sizing:border-box}.shepherd-arrow,.shepherd-arrow:before{height:16px;position:absolute;width:16px;z-index:-1}.shepherd-arrow:before{background:#fff;content:"";transform:rotate(45deg)}.shepherd-element[data-popper-placement^=top]>.shepherd-arrow{bottom:-8px}.shepherd-element[data-popper-placement^=bottom]>.shepherd-arrow{top:-8px}.shepherd-element[data-popper-placement^=left]>.shepherd-arrow{right:-8px}.shepherd-element[data-popper-placement^=right]>.shepherd-arrow{left:-8px}.shepherd-element.shepherd-centered>.shepherd-arrow{opacity:0}.shepherd-element.shepherd-has-title[data-popper-placement^=bottom]>.shepherd-arrow:before{background-color:#e6e6e6}.shepherd-target-click-disabled.shepherd-target-click-disabled.shepherd-target-click-disabled,.shepherd-target-click-disabled.shepherd-target-click-disabled.shepherd-target-click-disabled *{pointer-events:none} -.shepherd-modal-overlay-container{height:0;left:0;opacity:0;overflow:hidden;pointer-events:none;position:fixed;top:0;transition:all .3s ease-out,height 0ms .3s,opacity .3s 0ms;width:100vw;z-index:9997}.shepherd-modal-overlay-container.shepherd-modal-is-visible{height:100vh;opacity:.5;transform:translateZ(0);transition:all .3s ease-out,height 0s 0s,opacity .3s 0s}.shepherd-modal-overlay-container.shepherd-modal-is-visible path{pointer-events:all} \ No newline at end of file diff --git a/packages/react/README.md b/packages/react/README.md index ff946a871..e37f57e4a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -20,9 +20,9 @@ npm install --save react-shepherd ### Via Provider/Context ```tsx -import { Component, useContext } from 'react'; +import { useEffect, useMemo } from 'react'; import { ShepherdJourneyProvider, useShepherd } from 'react-shepherd'; -import newSteps from './steps'; +import 'shepherd.js/dist/css/shepherd.css'; const tourOptions = { defaultStepOptions: { @@ -30,15 +30,27 @@ const tourOptions = { enabled: true } }, - useModalOverlay: true + useModalOverlay: true, + steps: [ + { + title: 'Welcome', + text: 'This is the only step.', + buttons: [{ text: 'Done', action: 'complete' as const }] + } + ] }; function Button() { const Shepherd = useShepherd(); - const tour = new Shepherd.Tour({ - ...tourOptions, - steps: newSteps - }); + const tour = useMemo(() => new Shepherd.Tour(tourOptions), [Shepherd]); + + // Ends the tour if this component unmounts while it is open. + useEffect( + () => () => { + if (tour.isActive()) tour.complete(); + }, + [tour] + ); return ( ; +} +``` + +Wrap the app in ``, and import the stylesheet. The cleanup +fires `complete`, so keep anything like "mark as seen" out of a `complete` handler +that the cleanup can trigger, for instance by setting it in the "Done" button's +`action` function. + +Server rendering is safe: on the server, a tour's methods do nothing. + +## No bundler + +`shepherd.mjs` is an ES module. Load it with `type="module"` and import it: + +```html + + +``` + +## Tours as data + +A tour with named button actions is plain JSON: `new Shepherd.Tour(json).start()`. +`shepherd.js/tour.schema.json` is the JSON Schema for it, usable for validation or as +the output schema when a model generates a tour. Functions, such as +`beforeShowPromise`, `when` and `showOn`, are not part of the schema. + +## Names from older versions and other libraries + +| Do not write | Write | +| ---------------------------------------------- | -------------------------------------------- | +| `showCancelLink: true` | `cancelIcon: { enabled: true }` | +| `defaults: {...}` | `defaultStepOptions: {...}` | +| `modal: true`, `overlay: true` | `useModalOverlay: true` | +| `attachTo: '#a bottom'` | `attachTo: { element: '#a', on: 'bottom' }` | +| `position: 'bottom'`, `on: 'below'` | `on: 'bottom'` | +| `on: 'bottom-left'` | `on: 'bottom-start'` | +| `content`, `body`, `description` | `text` | +| `tippyOptions`, `popperOptions` | `positionOptions` | +| `floatingUIOptions: { middleware: [offset(12)] }` | `positionOptions: { offset: 12 }` | +| Button `type: 'next'` | Button `action: 'next'` | +| `shepherd-theme-*.css` | `dist/css/shepherd.css` | +| `import { ShepherdTour } from 'react-shepherd'` | `useShepherd`, `ShepherdJourneyProvider` | + +## Motion and theming + +The overlay's opening glides between steps by default, and cuts straight there under +`prefers-reduced-motion: reduce`. Set `transition-duration: 0s` on +`.shepherd-modal-overlay-container path` to turn it off. + +Two extras ship separately, and cost nothing unless imported: + +```js +import 'shepherd.js/dist/css/theme.css'; // After shepherd.css. Restyle it through --shepherd-* properties. +import { glideSteps, stepProgress } from 'shepherd.js/motion'; + +glideSteps(tour); // Moves the dialog with the opening. +stepProgress(tour); // Feeds the theme's step counter and progress bar. +``` + +Each of those functions returns a function that stops it. + +## Licensing + +Free under AGPL-3.0 for open source and non-commercial use. Commercial products need a +commercial license: https://shepherdjs.dev/pricing. Tell the user if it applies. diff --git a/shepherd.js/babel.config.cjs b/shepherd.js/babel.config.cjs index 73e48ec38..2ef9b394e 100644 --- a/shepherd.js/babel.config.cjs +++ b/shepherd.js/babel.config.cjs @@ -5,18 +5,6 @@ module.exports = function (api) { presets: ['@babel/preset-typescript'], plugins: [ ['@babel/plugin-transform-typescript', { allowDeclareFields: true }] - ], - env: { - development: { - presets: [ - [ - '@babel/preset-env', - { - loose: true - } - ] - ] - } - } + ] }; }; diff --git a/shepherd.js/dev/index.html b/shepherd.js/dev/index.html index 4eff10ec9..db17f89dc 100644 --- a/shepherd.js/dev/index.html +++ b/shepherd.js/dev/index.html @@ -133,12 +133,12 @@

Example

text: `

Shepherd is a JavaScript library for guiding users through your app. - It uses Floating UI, - another open source library, to render dialogs for each tour "step". + It renders a dialog for each tour "step", positioned + by its own small engine, with no dependencies.

- Among many things, Floating UI makes sure your steps never end up off screen or cropped by an overflow. + Among many things, it makes sure your steps never end up off screen or cropped by an overflow. (Try resizing your browser to see what we mean.)

`, attachTo: { @@ -167,7 +167,7 @@

Example

}, { title: 'Including', - text: 'Including Shepherd is easy! Just include shepherd.js. The styles are bundled with the JS.', + text: 'Including Shepherd is easy! Just import shepherd.js and its stylesheet.', attachTo: { element: '.hero-including', on: 'bottom' diff --git a/shepherd.js/dummy/index.html b/shepherd.js/dummy/index.html index ce3ac30a0..2cf23714f 100644 --- a/shepherd.js/dummy/index.html +++ b/shepherd.js/dummy/index.html @@ -140,12 +140,12 @@

Example

text: `

Shepherd is a JavaScript library for guiding users through your app. - It uses Floating UI, - another open source library, to render dialogs for each tour "step". + It renders a dialog for each tour "step", positioned + by its own small engine, with no dependencies.

- Among many things, Floating UI makes sure your steps never end up off screen or cropped by an overflow. + Among many things, it makes sure your steps never end up off screen or cropped by an overflow. (Try resizing your browser to see what we mean.)

`, attachTo: { @@ -169,7 +169,7 @@

Example

}, { title: 'Including', - text: 'Including Shepherd is easy! Just include shepherd.js. The styles are bundled with the JS.', + text: 'Including Shepherd is easy! Just import shepherd.js and its stylesheet.', attachTo: { element: '.hero-including', on: 'bottom' diff --git a/shepherd.js/dummy/motion.html b/shepherd.js/dummy/motion.html new file mode 100644 index 000000000..7308d01c8 --- /dev/null +++ b/shepherd.js/dummy/motion.html @@ -0,0 +1,340 @@ + + + + + + Shepherd motion demo + + + + + +
+ +
+
+

North pasture

+
+ + + + +
+
+
+ +
+ 41%Grazed this week +
+
+ 18 mmRain since Monday +
+
+ + + + + + + + + + + + + + + + + + + + +
Next moveWhenHead
River flatThursday84
Top paddockSaturday128
+
+

South gate is closed. Nothing has passed it since Tuesday.

+ +
+
+
+ + + + diff --git a/shepherd.js/eslint.config.js b/shepherd.js/eslint.config.js index dc9ee5ac6..92a567a8e 100644 --- a/shepherd.js/eslint.config.js +++ b/shepherd.js/eslint.config.js @@ -118,6 +118,7 @@ export default [ 'dummy/', 'tmp/', 'test/cypress/dummy/js/prism.js', + 'test/llm-eval/reference/', 'node_modules/', 'coverage/', 'test/coverage/', diff --git a/shepherd.js/llms.txt b/shepherd.js/llms.txt new file mode 100644 index 000000000..0c2acacd9 --- /dev/null +++ b/shepherd.js/llms.txt @@ -0,0 +1,41 @@ +# Shepherd.js + +> Guided product tours for the web. Steps are accessible dialogs that attach to DOM +> elements. Runs in the browser, with no backend. AGPL-3.0, or a commercial license +> for commercial products. + +Read `AGENTS.md` in this package before writing code. It has a working example and the +mistakes to avoid. Option docs are in `dist/js/shepherd.d.mts`. + +```js +import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; // Required. Not included by the JS import. + +const tour = new Shepherd.Tour({ useModalOverlay: true }); +tour.addStep({ + title: 'Save', + text: 'Saves your work.', + attachTo: { element: '#save', on: 'bottom' }, // top, bottom, left, right, auto; optional -start or -end + buttons: [{ text: 'Done', action: 'complete' }] // 'next', 'back', 'cancel', 'complete', or a function +}); +tour.start(); +``` + +Gaps and edge distance go in `positionOptions: { offset, padding, strategy, flip }`. +Shepherd runs no Floating UI middleware, so there is no `floatingUIOptions`. + +## Files in this package + +- `AGENTS.md`: how to use the library correctly +- `tour.schema.json`: JSON Schema for a tour written as plain data +- `dist/js/shepherd.d.mts`: types and option docs +- `dist/js/shepherd.mjs`: ES module with no global. Import it. +- `dist/css/shepherd.css`: the stylesheet +- `dist/css/theme.css`: an optional theme, loaded after the stylesheet +- `shepherd.js/motion`: optional `glideSteps` and `stepProgress` + +## Links + +- [Documentation](https://docs.shepherdjs.dev/) +- [react-shepherd](https://github.com/shipshapecode/shepherd/tree/main/packages/react) +- [Pricing and licensing](https://www.shepherdjs.dev/pricing) diff --git a/shepherd.js/package.json b/shepherd.js/package.json index f502f6c34..f373e9c12 100644 --- a/shepherd.js/package.json +++ b/shepherd.js/package.json @@ -20,8 +20,11 @@ ], "files": [ "dist", + "AGENTS.md", "LICENSE.md", - "README.md" + "README.md", + "llms.txt", + "tour.schema.json" ], "main": "./dist/cjs/shepherd.cjs", "module": "./dist/js/shepherd.mjs", @@ -36,9 +39,23 @@ "default": "./dist/cjs/shepherd.cjs" } }, - "./dist/*": "./dist/*" + "./motion": { + "import": { + "types": "./dist/js/motion.d.mts", + "default": "./dist/js/motion.mjs" + }, + "require": { + "types": "./dist/cjs/motion.d.cts", + "default": "./dist/cjs/motion.cjs" + } + }, + "./dist/*": "./dist/*", + "./tour.schema.json": "./tour.schema.json" }, "type": "module", + "sideEffects": [ + "**/*.css" + ], "scripts": { "build": "pnpm clean && rollup -c", "clean": "rimraf ./dist ./tmp", @@ -57,6 +74,7 @@ "test": "vitest", "test:all": "pnpm test:unit:ci && pnpm test:cy:ci", "test:ci": "pnpm test:unit:ci && pnpm test:cy:ci", + "test:position": "node test/position-differential/run.mjs", "test:cy:ci": "start-server-and-test start-test-server http://127.0.0.1:9002 cy:run:chrome", "test:cy:watch": "start-server-and-test start-test-server http://127.0.0.1:9002 cy:open", "test:unit": "vitest", @@ -68,16 +86,13 @@ "view-coverage": "http-server -p 9003 ./test/coverage/lcov-report -o", "watch": "pnpm clean && rollup -c --environment DEVELOPMENT --watch" }, - "dependencies": { - "@floating-ui/dom": "^1.8.0", - "deepmerge-ts": "^8.0.2" - }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@babel/core": "^7.29.7", "@babel/plugin-transform-typescript": "^8.0.6", "@babel/preset-env": "^7.29.7", "@babel/preset-typescript": "^7.29.7", + "@floating-ui/dom": "^1.8.0", "@rollup/plugin-babel": "^7.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.3", @@ -85,13 +100,12 @@ "@testing-library/jest-dom": "^7.0.1", "@vitest/coverage-v8": "^4.1.10", "@vitest/expect": "^4.1.11", - "autoprefixer": "^10.5.4", "chai": "^6.2.2", - "cssnano": "^8.0.7", "cypress": "^15.21.0", "cypress-plugin-tab": "^1.0.5", "del": "^8.0.1", "dts-bundle-generator": "^9.5.1", + "esbuild": "^0.28.2", "eslint": "^10.11.0", "eslint-plugin-cypress": "^5.3.0", "eslint-plugin-vitest": "^0.5.4", @@ -99,7 +113,9 @@ "globals": "^17.9.0", "happy-dom": "^21.0.4", "http-server": "^14.1.1", + "jsdom": "^27.4.0", "lodash": "^4.18.1", + "playwright-core": "^1.64.1", "postcss": "^8.5.26", "prettier": "^3.9.10", "replace": "^1.2.2", diff --git a/shepherd.js/rollup.config.mjs b/shepherd.js/rollup.config.mjs index 5d420b51f..ad22ac635 100644 --- a/shepherd.js/rollup.config.mjs +++ b/shepherd.js/rollup.config.mjs @@ -1,7 +1,5 @@ -import autoprefixer from 'autoprefixer'; import { execaCommand } from 'execa'; import fs from 'fs'; -import cssnanoPlugin from 'cssnano'; import { babel } from '@rollup/plugin-babel'; import serve from 'rollup-plugin-serve'; import livereload from 'rollup-plugin-livereload'; @@ -33,10 +31,10 @@ const plugins = [ extensions: ['.cjs', '.js', '.ts', '.mjs'], babelHelpers: 'bundled', presets: ['@babel/preset-typescript'], - exclude: 'node_modules/**' + exclude: '**/node_modules/**' }), postcss({ - plugins: isDev ? [autoprefixer] : [autoprefixer, cssnanoPlugin], + minimize: !isDev, extract: 'css/shepherd.css' }), license({ @@ -56,12 +54,15 @@ if (process.env.DEVELOPMENT) { plugins.push( terser({ compress: { - drop_console: true, drop_debugger: true, pure_funcs: ['console.log', 'console.debug'] }, mangle: { - properties: false + properties: { + regex: /^_[a-zA-Z]/, + // Called by test/cypress/integration/test.acceptance.cy.js + reserved: ['_getResolvedAttachToOptions'] + } } }) ); @@ -156,7 +157,6 @@ export default [ dir: 'tmp/cjs', entryFileNames: '[name].cjs', format: 'cjs', - sourcemap: true, exports: 'named' }, plugins: [ @@ -171,7 +171,7 @@ export default [ console.log('Move shepherd.cjs from tmp to dist'); await execaCommand( - `mv ./tmp/cjs/shepherd.cjs ./tmp/cjs/shepherd.cjs.map ./dist/cjs/`, + `mv ./tmp/cjs/shepherd.cjs ./dist/cjs/`, { stdio: 'inherit' } @@ -188,5 +188,50 @@ export default [ } } ] + }, + // Opt-in extras: neither ships unless a consumer imports it + { + input: 'src/motion.ts', + output: [ + { file: 'dist/js/motion.mjs', format: 'es' }, + { file: 'dist/cjs/motion.cjs', format: 'cjs', exports: 'named' } + ], + plugins: [ + nodeResolve({ extensions: ['.ts'] }), + babel({ + extensions: ['.ts'], + babelHelpers: 'bundled', + presets: ['@babel/preset-typescript'] + }), + ...(isDev ? [] : [terser()]), + { + name: 'Motion declarations', + closeBundle: () => { + const types = fs + .readFileSync('tmp/js/motion.d.ts', 'utf8') + .replace(/\/\/# sourceMappingURL.*/, ''); + fs.writeFileSync( + 'dist/js/motion.d.mts', + types.replace('./shepherd.ts', './shepherd.mjs') + ); + fs.writeFileSync( + 'dist/cjs/motion.d.cts', + types.replace('./shepherd.ts', './shepherd.cjs') + ); + } + } + ] + }, + { + input: 'src/theme.css', + output: { dir: 'tmp/theme' }, + plugins: [ + postcss({ minimize: !isDev, extract: 'theme.css' }), + { + name: 'Copy the theme to dist', + closeBundle: () => + fs.copyFileSync('tmp/theme/theme.css', 'dist/css/theme.css') + } + ] } ]; diff --git a/shepherd.js/scripts/scorecard/golden/api.json b/shepherd.js/scripts/scorecard/golden/api.json new file mode 100644 index 000000000..856a98d12 --- /dev/null +++ b/shepherd.js/scripts/scorecard/golden/api.json @@ -0,0 +1,149 @@ +[ + "AnyHandler", + "Bindings", + "EventOptions", + "EventOptions.previous", + "EventOptions.step", + "EventOptions.tour", + "Evented", + "Evented.bindings", + "Evented.off", + "Evented.on", + "Evented.once", + "Evented.trigger", + "ModalRadiusType", + "PopperPlacement", + "ShepherdBase", + "ShepherdBase.Step", + "ShepherdBase.Tour", + "ShepherdBase.activeTour", + "ShepherdElementResult", + "ShepherdElementResult.cleanup", + "ShepherdElementResult.element", + "ShepherdModalAPI", + "ShepherdModalAPI.closeModalOpening", + "ShepherdModalAPI.destroy", + "ShepherdModalAPI.getElement", + "ShepherdModalAPI.hide", + "ShepherdModalAPI.positionModal", + "ShepherdModalAPI.setupForStep", + "ShepherdModalAPI.show", + "Step", + "Step$1", + "Step.cancel", + "Step.classPrefix", + "Step.cleanup", + "Step.complete", + "Step.destroy", + "Step.el", + "Step.getElement", + "Step.getTarget", + "Step.getTour", + "Step.hide", + "Step.id", + "Step.isOpen", + "Step.options", + "Step.shepherdElementComponent", + "Step.show", + "Step.target", + "Step.tour", + "Step.updateStepOptions", + "StepButtonAction", + "StepOptions", + "StepOptions.advanceOn", + "StepOptions.arrow", + "StepOptions.attachTo", + "StepOptions.beforeShowPromise", + "StepOptions.buttons", + "StepOptions.canClickTarget", + "StepOptions.cancelIcon", + "StepOptions.classes", + "StepOptions.data", + "StepOptions.extraHighlights", + "StepOptions.highlightClass", + "StepOptions.id", + "StepOptions.label", + "StepOptions.modalOverlayOpeningPadding", + "StepOptions.modalOverlayOpeningRadius", + "StepOptions.modalOverlayOpeningXOffset", + "StepOptions.modalOverlayOpeningYOffset", + "StepOptions.positionOptions", + "StepOptions.scrollTo", + "StepOptions.scrollToHandler", + "StepOptions.showOn", + "StepOptions.skipMissingElement", + "StepOptions.text", + "StepOptions.title", + "StepOptions.waitForElement", + "StepOptions.when", + "StepOptionsAdvanceOn", + "StepOptionsAdvanceOn.event", + "StepOptionsAdvanceOn.selector", + "StepOptionsArrow", + "StepOptionsArrow.padding", + "StepOptionsAttachTo", + "StepOptionsAttachTo.element", + "StepOptionsAttachTo.on", + "StepOptionsButton", + "StepOptionsButton.action", + "StepOptionsButton.attrs", + "StepOptionsButton.classes", + "StepOptionsButton.disabled", + "StepOptionsButton.label", + "StepOptionsButton.secondary", + "StepOptionsButton.text", + "StepOptionsButtonEvent", + "StepOptionsCancelIcon", + "StepOptionsCancelIcon.attrs", + "StepOptionsCancelIcon.enabled", + "StepOptionsCancelIcon.label", + "StepOptionsPosition", + "StepOptionsPosition.flip", + "StepOptionsPosition.offset", + "StepOptionsPosition.padding", + "StepOptionsPosition.strategy", + "StepOptionsWhen", + "StepText", + "StringOrStringFunction", + "Tour", + "Tour$1", + "Tour.addStep", + "Tour.addSteps", + "Tour.back", + "Tour.cancel", + "Tour.classPrefix", + "Tour.complete", + "Tour.currentStep", + "Tour.focusedElBeforeOpen", + "Tour.getById", + "Tour.getCurrentStep", + "Tour.hide", + "Tour.id", + "Tour.isActive", + "Tour.modal", + "Tour.next", + "Tour.options", + "Tour.removeStep", + "Tour.setupModal", + "Tour.show", + "Tour.start", + "Tour.steps", + "Tour.trackedEvents", + "TourConfirmCancel", + "TourOptions", + "TourOptions.classPrefix", + "TourOptions.confirmCancel", + "TourOptions.confirmCancelMessage", + "TourOptions.defaultStepOptions", + "TourOptions.exitOnEsc", + "TourOptions.id", + "TourOptions.keyboardNavigation", + "TourOptions.modalContainer", + "TourOptions.steps", + "TourOptions.stepsContainer", + "TourOptions.tourName", + "TourOptions.useModalOverlay", + "export:Step", + "export:Tour", + "export:default" +] diff --git a/shepherd.js/scripts/scorecard/golden/baseline-scorecard.json b/shepherd.js/scripts/scorecard/golden/baseline-scorecard.json new file mode 100644 index 000000000..651d4b20a --- /dev/null +++ b/shepherd.js/scripts/scorecard/golden/baseline-scorecard.json @@ -0,0 +1,124 @@ +{ + "commit": "f2170c9c", + "size": { + "attachedTour": { + "eager": { + "min": 46735, + "gz": 16449, + "br": 14895 + }, + "total": { + "min": 46735, + "gz": 16449, + "br": 14895 + }, + "chunks": 1 + }, + "centeredTour": { + "eager": { + "min": 46668, + "gz": 16426, + "br": 14858 + }, + "total": { + "min": 46668, + "gz": 16426, + "br": 14858 + }, + "chunks": 1 + } + }, + "dist": { + "./dist/js/shepherd.mjs": { + "min": 47548, + "gz": 16003, + "br": 14443 + }, + "dist/css/shepherd.css": { + "min": 3504, + "gz": 993, + "br": 833 + } + }, + "api": { + "removed": [], + "added": 0, + "tsdocCoveragePct": 59, + "dtsApproxTokens": 6319 + }, + "pack": { + "tarballKb": 221, + "unpackedKb": 812, + "prodDependencies": 2, + "files": 10, + "agentDocsInPackage": [] + }, + "diagnostics": { + "missingElementMessageShips": false, + "consoleCalls": 0 + }, + "parity": [ + { + "browser": "chromium", + "cases": 197, + "goldenCases": 197, + "errors": [], + "mismatchCount": 0, + "byContext": {}, + "inViewportLost": 0, + "overlapGained": 0, + "sample": [] + }, + { + "browser": "firefox", + "cases": 197, + "goldenCases": 197, + "errors": [], + "mismatchCount": 0, + "byContext": {}, + "inViewportLost": 0, + "overlapGained": 0, + "sample": [] + }, + { + "browser": "webkit", + "cases": 197, + "goldenCases": 197, + "errors": [], + "mismatchCount": 0, + "byContext": {}, + "inViewportLost": 0, + "overlapGained": 0, + "sample": [] + } + ], + "gates": [ + { + "name": "unit", + "pass": true, + "seconds": 3, + "testsPassed": 338 + }, + { + "name": "lifecycle", + "pass": true, + "seconds": 15, + "testsPassed": 5 + }, + { + "name": "types", + "pass": true, + "seconds": 0 + }, + { + "name": "attw", + "pass": true, + "seconds": 14 + }, + { + "name": "lint", + "pass": true, + "seconds": 1 + } + ] +} diff --git a/shepherd.js/scripts/scorecard/golden/parity.chromium.json b/shepherd.js/scripts/scorecard/golden/parity.chromium.json new file mode 100644 index 000000000..ec6f0c870 --- /dev/null +++ b/shepherd.js/scripts/scorecard/golden/parity.chromium.json @@ -0,0 +1,2366 @@ +{ + "window/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left-start": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left-start": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left-start": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left-start": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left-end": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left-end": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left-end": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right-start": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right-start": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right-start": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right-start": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right-end": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right-end": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right-end": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 112, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 147.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 292, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 440, + "y": 247.09375, + "w": 400, + "h": 152.90625, + "arrowX": 632, + "arrowY": 392, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 340, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 332, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 580, + "y": 440, + "w": 400, + "h": 152.90625, + "arrowX": 632, + "arrowY": 432, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 243.546875, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 312.046875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 180, + "y": 343.546875, + "w": 400, + "h": 152.90625, + "arrowX": 572, + "arrowY": 412.046875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 187.09375, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 312.03125, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 180, + "y": 287.09375, + "w": 400, + "h": 152.90625, + "arrowX": 572, + "arrowY": 412.03125, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 340, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 332, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 440, + "y": 247.09375, + "w": 400, + "h": 152.90625, + "arrowX": 632, + "arrowY": 392, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 792, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 600, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-start", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/left": { + "positioned": true, + "placement": "left", + "x": 600, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 992, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 600, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 992, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": true + }, + "rtl/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 600, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 592, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 600, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 992, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": true + }, + "rtl/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 792, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 165.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 310, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 495.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 382, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 374, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 495.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/left": { + "positioned": true, + "placement": "left", + "x": 28, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 420, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 468, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 860, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 572, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 564, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 468, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 860, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 382, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 374, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 495.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 576, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 576, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 523.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 660.09375, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 217.09375, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.03125, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 523.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 656.09375, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 576, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 177.09375, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 672.03125, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 221.046875, + "w": 400, + "h": 152.90625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 547.09375, + "w": 400, + "h": 152.90625, + "arrowX": 872, + "arrowY": 668.09375, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 152.90625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 507.09375, + "w": 400, + "h": 152.90625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "centered/noAttachTo": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "centered/noOn": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 273.546875, + "w": 400, + "h": 152.90625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "mobileViewport/center/top": { + "positioned": true, + "placement": "top", + "x": 65, + "y": 177.09375, + "w": 375, + "h": 152.90625, + "arrowX": 424, + "arrowY": 322, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 505, + "y": 507.09375, + "w": 375, + "h": 152.90625, + "arrowX": 864, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/bottom-start": { + "positioned": true, + "placement": "bottom-end", + "x": 65, + "y": 370, + "w": 375, + "h": 152.90625, + "arrowX": 420, + "arrowY": 362, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 505, + "y": 507.09375, + "w": 375, + "h": 152.90625, + "arrowX": 860, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/left": { + "positioned": true, + "placement": "left", + "x": 65, + "y": 273.546875, + "w": 375, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 505, + "y": 514.09375, + "w": 375, + "h": 152.90625, + "arrowX": 872, + "arrowY": 651.09375, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/right-end": { + "positioned": true, + "placement": "left-end", + "x": 65, + "y": 217.09375, + "w": 375, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.03125, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 505, + "y": 514.09375, + "w": 375, + "h": 152.90625, + "arrowX": 872, + "arrowY": 647.09375, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/auto": { + "positioned": true, + "placement": "left", + "x": 65, + "y": 273.546875, + "w": 375, + "h": 152.90625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 505, + "y": 507.09375, + "w": 375, + "h": 152.90625, + "arrowX": 864, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + } +} diff --git a/shepherd.js/scripts/scorecard/golden/parity.firefox.json b/shepherd.js/scripts/scorecard/golden/parity.firefox.json new file mode 100644 index 000000000..8479afa3e --- /dev/null +++ b/shepherd.js/scripts/scorecard/golden/parity.firefox.json @@ -0,0 +1,2366 @@ +{ + "window/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left-start": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left-start": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left-start": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left-start": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left-end": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left-end": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left-end": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right-start": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right-start": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right-start": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right-start": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right-end": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right-end": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 12.050003051757812, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right-end": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 112, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 146.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 292, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 440, + "y": 246.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 632, + "arrowY": 392, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 340, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 332, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 580, + "y": 440, + "w": 400, + "h": 153.89999389648438, + "arrowX": 632, + "arrowY": 432, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 243.0500030517578, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 312.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 180, + "y": 343.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 572, + "arrowY": 412.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 186.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 312.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 180, + "y": 286.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 572, + "arrowY": 412.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 340, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 332, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 440, + "y": 246.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 632, + "arrowY": 392, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 792, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 600, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-start", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/left": { + "positioned": true, + "placement": "left", + "x": 600, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 992, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 600, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 992, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": true + }, + "rtl/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 600, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 592, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 600, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 992, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": true + }, + "rtl/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 792, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 164.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 310, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 494.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 382, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 374, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 494.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/left": { + "positioned": true, + "placement": "left", + "x": 28, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 420, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 468, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 860, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 572, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 564, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 468, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 860, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 382, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 374, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 494.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 576, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 576, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 522.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 660.0999755859375, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 216.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 522.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 656.0999755859375, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 576, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 176.10000610351562, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 672.0499877929688, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 220.0500030517578, + "w": 400, + "h": 153.89999389648438, + "arrowX": 552, + "arrowY": 342.04998779296875, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 546.0999755859375, + "w": 400, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 668.0999755859375, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 153.89999389648438, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 506.1000061035156, + "w": 400, + "h": 153.89999389648438, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "centered/noAttachTo": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "centered/noOn": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 273.04998779296875, + "w": 400, + "h": 153.89999389648438, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "mobileViewport/center/top": { + "positioned": true, + "placement": "top", + "x": 65, + "y": 176.10000610351562, + "w": 375, + "h": 153.89999389648438, + "arrowX": 424, + "arrowY": 322, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 505, + "y": 506.1000061035156, + "w": 375, + "h": 153.89999389648438, + "arrowX": 864, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/bottom-start": { + "positioned": true, + "placement": "bottom-end", + "x": 65, + "y": 370, + "w": 375, + "h": 153.89999389648438, + "arrowX": 420, + "arrowY": 362, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 505, + "y": 506.1000061035156, + "w": 375, + "h": 153.89999389648438, + "arrowX": 860, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/left": { + "positioned": true, + "placement": "left", + "x": 65, + "y": 273.04998779296875, + "w": 375, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 505, + "y": 513.0999755859375, + "w": 375, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 651.0999755859375, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/right-end": { + "positioned": true, + "placement": "left-end", + "x": 65, + "y": 216.10000610351562, + "w": 375, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 505, + "y": 513.0999755859375, + "w": 375, + "h": 153.89999389648438, + "arrowX": 872, + "arrowY": 647.0999755859375, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/auto": { + "positioned": true, + "placement": "left", + "x": 65, + "y": 273.04998779296875, + "w": 375, + "h": 153.89999389648438, + "arrowX": 432, + "arrowY": 342.04998779296875, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 505, + "y": 506.1000061035156, + "w": 375, + "h": 153.89999389648438, + "arrowX": 864, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + } +} diff --git a/shepherd.js/scripts/scorecard/golden/parity.webkit.json b/shepherd.js/scripts/scorecard/golden/parity.webkit.json new file mode 100644 index 000000000..836b19430 --- /dev/null +++ b/shepherd.js/scripts/scorecard/golden/parity.webkit.json @@ -0,0 +1,2366 @@ +{ + "window/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/top-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/top-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/top-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/top-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/top-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/bottom-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/bottom-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/bottom-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/bottom-end": { + "positioned": true, + "placement": "bottom-end", + "x": 160, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left-start": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left-start": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left-start": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left-start": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left-start": { + "positioned": true, + "placement": "left-start", + "x": 40, + "y": 330, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/left-end": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/left-end": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/left-end": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/left-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/left-end": { + "positioned": true, + "placement": "left-end", + "x": 40, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right": { + "positioned": true, + "placement": "right", + "x": 120, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right": { + "positioned": true, + "placement": "right", + "x": 560, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right-start": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right-start": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right-start": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right-start": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right-start": { + "positioned": true, + "placement": "right-start", + "x": 560, + "y": 330, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/right-end": { + "positioned": true, + "placement": "right-start", + "x": 120, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/right-end": { + "positioned": true, + "placement": "left-start", + "x": 480, + "y": 0, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 12.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/right-end": { + "positioned": true, + "placement": "right-end", + "x": 120, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 112, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto": { + "positioned": true, + "placement": "bottom", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto": { + "positioned": true, + "placement": "bottom", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto": { + "positioned": true, + "placement": "top", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto-start": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto-start": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto-start": { + "positioned": true, + "placement": "top-start", + "x": 440, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "window/center/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "window/topLeft/auto-end": { + "positioned": true, + "placement": "bottom-start", + "x": 0, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/topRight/auto-end": { + "positioned": true, + "placement": "bottom-end", + "x": 600, + "y": 40, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 32, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomLeft/auto-end": { + "positioned": true, + "placement": "top-start", + "x": 0, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 52, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "window/bottomRight/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "scrolled/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "noArrow/auto-end": { + "positioned": true, + "placement": "top-end", + "x": 160, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 149.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 292, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 440, + "y": 249.109375, + "w": 400, + "h": 150.890625, + "arrowX": 632, + "arrowY": 392, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 340, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 332, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 580, + "y": 440, + "w": 400, + "h": 150.890625, + "arrowX": 632, + "arrowY": 432, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 244.546875, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 312.046875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 180, + "y": 344.546875, + "w": 400, + "h": 150.890625, + "arrowX": 572, + "arrowY": 412.046875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 189.109375, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 312.046875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 180, + "y": 289.109375, + "w": 400, + "h": 150.890625, + "arrowX": 572, + "arrowY": 412.046875, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/center/auto": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 340, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 332, + "inViewport": true, + "overlapsTarget": false + }, + "overflowParent/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 440, + "y": 249.109375, + "w": 400, + "h": 150.890625, + "arrowX": 632, + "arrowY": 392, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/center/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "transformedContainer/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/center/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "fixedTarget/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/center/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "strategyFixed/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 792, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 600, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-start", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/center/left": { + "positioned": true, + "placement": "left", + "x": 600, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 992, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 600, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 992, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": true + }, + "rtl/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 600, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 592, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 600, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 992, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": true + }, + "rtl/center/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 792, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "rtl/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 167.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 310, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 497.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 382, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 374, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 497.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/left": { + "positioned": true, + "placement": "left", + "x": 28, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 420, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 468, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 860, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 572, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 564, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 468, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 860, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/center/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 167.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 310, + "inViewport": true, + "overlapsTarget": false + }, + "userOffset/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 497.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 640, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 576, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 576, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 525.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 660.109375, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 219.109375, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 525.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 656.109375, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/center/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "userShift/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 576, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/top": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/bottom-start": { + "positioned": true, + "placement": "bottom-start", + "x": 440, + "y": 370, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 362, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/left": { + "positioned": true, + "placement": "left", + "x": 40, + "y": 274.546875, + "w": 400, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 672.046875, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/right-end": { + "positioned": true, + "placement": "right-end", + "x": 560, + "y": 223.046875, + "w": 400, + "h": 150.890625, + "arrowX": 552, + "arrowY": 342.046875, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 480, + "y": 549.109375, + "w": 400, + "h": 150.890625, + "arrowX": 872, + "arrowY": 668.109375, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/center/auto": { + "positioned": true, + "placement": "top", + "x": 300, + "y": 179.109375, + "w": 400, + "h": 150.890625, + "arrowX": 492, + "arrowY": 322, + "inViewport": true, + "overlapsTarget": false + }, + "arrowPadding/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 600, + "y": 509.109375, + "w": 400, + "h": 150.890625, + "arrowX": 932, + "arrowY": 652, + "inViewport": true, + "overlapsTarget": false + }, + "centered/noAttachTo": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 274.5, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "centered/noOn": { + "positioned": true, + "placement": "bottom", + "x": 300, + "y": 274.5, + "w": 400, + "h": 150.890625, + "arrowX": null, + "arrowY": null, + "inViewport": true, + "overlapsTarget": false + }, + "mobileViewport/center/top": { + "positioned": true, + "placement": "top", + "x": 65, + "y": 179.109375, + "w": 375, + "h": 150.890625, + "arrowX": 424, + "arrowY": 322, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/top": { + "positioned": true, + "placement": "top", + "x": 505, + "y": 509.109375, + "w": 375, + "h": 150.890625, + "arrowX": 864, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/bottom-start": { + "positioned": true, + "placement": "bottom-end", + "x": 65, + "y": 370, + "w": 375, + "h": 150.890625, + "arrowX": 420, + "arrowY": 362, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/bottom-start": { + "positioned": true, + "placement": "top-end", + "x": 505, + "y": 509.109375, + "w": 375, + "h": 150.890625, + "arrowX": 860, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/left": { + "positioned": true, + "placement": "left", + "x": 65, + "y": 274.546875, + "w": 375, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/left": { + "positioned": true, + "placement": "left", + "x": 505, + "y": 516.109375, + "w": 375, + "h": 150.890625, + "arrowX": 872, + "arrowY": 651.109375, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/right-end": { + "positioned": true, + "placement": "left-end", + "x": 65, + "y": 219.109375, + "w": 375, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/right-end": { + "positioned": true, + "placement": "left-end", + "x": 505, + "y": 516.109375, + "w": 375, + "h": 150.890625, + "arrowX": 872, + "arrowY": 647.109375, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/center/auto": { + "positioned": true, + "placement": "left", + "x": 65, + "y": 274.546875, + "w": 375, + "h": 150.890625, + "arrowX": 432, + "arrowY": 342.046875, + "inViewport": false, + "overlapsTarget": false + }, + "mobileViewport/bottomRight/auto": { + "positioned": true, + "placement": "top", + "x": 505, + "y": 509.109375, + "w": 375, + "h": 150.890625, + "arrowX": 864, + "arrowY": 652, + "inViewport": false, + "overlapsTarget": false + } +} diff --git a/shepherd.js/scripts/scorecard/lib.mjs b/shepherd.js/scripts/scorecard/lib.mjs new file mode 100644 index 000000000..19cedd596 --- /dev/null +++ b/shepherd.js/scripts/scorecard/lib.mjs @@ -0,0 +1,68 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const scorecardDir = fileURLToPath(new URL('.', import.meta.url)); +export const packageDir = resolve(scorecardDir, '../..'); + +const mime = { + '.css': 'text/css', + '.html': 'text/html', + '.js': 'text/javascript', + '.mjs': 'text/javascript' +}; + +const pageHtml = ` + +
`; + +export function serve(pkgDir) { + const server = createServer(async (req, res) => { + const path = normalize(decodeURIComponent(req.url.split('?')[0])); + if (path === '/page.html') { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(pageHtml); + return; + } + const file = path.startsWith('/dist/') + ? join(pkgDir, path) + : path.startsWith('/vendor/') + ? join(scorecardDir, path) + : null; + try { + if (!file) throw new Error('not served'); + const body = await readFile(file); + res.writeHead(200, { + 'content-type': mime[extname(file)] ?? 'application/octet-stream', + 'cache-control': 'no-store' + }); + res.end(body); + } catch { + res.writeHead(404); + res.end(); + } + }); + return new Promise((done) => { + server.listen(0, '127.0.0.1', () => + done({ + url: `http://127.0.0.1:${server.address().port}`, + close: () => new Promise((closed) => server.close(closed)) + }) + ); + }); +} + +export function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +export function round(value, digits = 2) { + return Number(value.toFixed(digits)); +} diff --git a/shepherd.js/scripts/scorecard/parity.mjs b/shepherd.js/scripts/scorecard/parity.mjs new file mode 100644 index 000000000..ee02373c5 --- /dev/null +++ b/shepherd.js/scripts/scorecard/parity.mjs @@ -0,0 +1,321 @@ +// Records where steps land in a real browser and compares against goldens +// captured from the Floating UI build, so a positioning change has to prove +// it lands in the same place. +// +// node scripts/scorecard/parity.mjs [--browser chromium|firefox|webkit|all] +// node scripts/scorecard/parity.mjs --update (only on the baseline build) +import process from 'node:process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import * as playwright from 'playwright-core'; +import { packageDir, scorecardDir, serve } from './lib.mjs'; + +const PLACEMENTS = [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'right', + 'right-start', + 'right-end', + 'auto', + 'auto-start', + 'auto-end' +]; +const SPOTS = { + center: { left: 440, top: 330 }, + topLeft: { left: 0, top: 0 }, + topRight: { left: 880, top: 0 }, + bottomLeft: { left: 0, top: 660 }, + bottomRight: { left: 880, top: 660 } +}; +const CORE = ['top', 'bottom-start', 'left', 'right-end', 'auto']; + +function buildCases() { + const cases = []; + for (const on of PLACEMENTS) { + for (const spot of Object.keys(SPOTS)) { + cases.push({ id: `window/${spot}/${on}`, context: 'window', spot, on }); + } + cases.push({ + id: `scrolled/${on}`, + context: 'scrolled', + spot: 'center', + on + }); + cases.push({ id: `noArrow/${on}`, context: 'noArrow', spot: 'center', on }); + } + for (const context of [ + 'overflowParent', + 'transformedContainer', + 'fixedTarget', + 'strategyFixed', + 'rtl', + 'userOffset', + 'userShift', + 'arrowPadding', + 'mobileViewport' + ]) { + for (const on of CORE) { + for (const spot of ['center', 'bottomRight']) { + cases.push({ id: `${context}/${spot}/${on}`, context, spot, on }); + } + } + } + cases.push({ id: 'centered/noAttachTo', context: 'centered' }); + cases.push({ id: 'centered/noOn', context: 'centeredNoOn', spot: 'center' }); + return cases; +} + +async function runCase(cfg) { + const { default: Shepherd } = await import('/dist/js/shepherd.mjs'); + const page = document.getElementById('page'); + page.innerHTML = ''; + page.removeAttribute('style'); + document.documentElement.removeAttribute('dir'); + window.scrollTo(0, 0); + + const spot = { + center: { left: 440, top: 330 }, + topLeft: { left: 0, top: 0 }, + topRight: { left: 880, top: 0 }, + bottomLeft: { left: 0, top: 660 }, + bottomRight: { left: 880, top: 660 } + }[cfg.spot ?? 'center']; + + const target = document.createElement('div'); + target.className = 'target'; + target.style.left = `${spot.left}px`; + target.style.top = `${spot.top}px`; + const stepOptions = { + arrow: true, + text: 'A step with a sentence of text that wraps onto a second line here.', + title: 'Parity', + buttons: [{ text: 'Next' }] + }; + const tourOptions = { useModalOverlay: true }; + + if (cfg.context === 'scrolled') { + target.style.top = `${spot.top + 500}px`; + page.append(target); + window.scrollTo(0, 500); + } else if (cfg.context === 'overflowParent') { + const host = document.createElement('div'); + Object.assign(host.style, { + position: 'absolute', + left: '300px', + top: '200px', + width: '400px', + height: '300px', + overflow: 'auto' + }); + const inner = document.createElement('div'); + inner.style.cssText = 'position:relative;height:1200px;width:900px'; + target.style.left = cfg.spot === 'center' ? '140px' : '280px'; + target.style.top = cfg.spot === 'center' ? '300px' : '500px'; + inner.append(target); + host.append(inner); + page.append(host); + host.scrollTop = cfg.spot === 'center' ? 200 : 300; + } else if (cfg.context === 'transformedContainer') { + const container = document.createElement('div'); + Object.assign(container.style, { + position: 'absolute', + left: '50px', + top: '80px', + width: '800px', + height: '500px', + transform: 'translate(10px, 10px)' + }); + page.append(target, container); + tourOptions.stepsContainer = container; + } else if (cfg.context === 'fixedTarget') { + target.style.position = 'fixed'; + page.append(target); + window.scrollTo(0, 250); + } else if (cfg.context === 'rtl') { + document.documentElement.setAttribute('dir', 'rtl'); + page.append(target); + } else if (cfg.context !== 'centered') { + page.append(target); + } + + if (cfg.context === 'noArrow') stepOptions.arrow = false; + if (cfg.context === 'arrowPadding') stepOptions.arrow = { padding: 16 }; + if (cfg.context === 'strategyFixed') { + stepOptions.positionOptions = { strategy: 'fixed' }; + } + if (cfg.context === 'userOffset') { + stepOptions.positionOptions = { offset: 12 }; + } + if (cfg.context === 'userShift') { + stepOptions.positionOptions = { padding: 24 }; + } + if (cfg.context === 'centeredNoOn') + stepOptions.attachTo = { element: target }; + else if (cfg.context !== 'centered') { + stepOptions.attachTo = { element: target, on: cfg.on }; + } + + const tour = new Shepherd.Tour(tourOptions); + tour.addStep(stepOptions); + await tour.start(); + + const el = tour.getCurrentStep().el; + const deadline = performance.now() + 3000; + while (!el.dataset.popperPlacement && performance.now() < deadline) { + await new Promise(requestAnimationFrame); + } + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + const rect = el.getBoundingClientRect(); + const arrow = el.querySelector('.shepherd-arrow')?.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const result = { + positioned: Boolean(el.dataset.popperPlacement), + placement: el.dataset.popperPlacement ?? null, + x: rect.x, + y: rect.y, + w: rect.width, + h: rect.height, + arrowX: arrow?.x ?? null, + arrowY: arrow?.y ?? null, + inViewport: + rect.left >= -1 && + rect.top >= -1 && + rect.right <= innerWidth + 1 && + rect.bottom <= innerHeight + 1, + overlapsTarget: + cfg.context !== 'centered' && + cfg.context !== 'centeredNoOn' && + rect.left < targetRect.right - 1 && + rect.right > targetRect.left + 1 && + rect.top < targetRect.bottom - 1 && + rect.bottom > targetRect.top + 1 + }; + tour.complete(); + return result; +} + +const TOLERANCE = 1; +const COORDS = ['x', 'y', 'w', 'h', 'arrowX', 'arrowY']; + +function diff(golden, actual) { + const out = []; + if (golden.placement !== actual.placement) { + out.push(`placement ${golden.placement} -> ${actual.placement}`); + } + for (const key of COORDS) { + const a = golden[key]; + const b = actual[key]; + if ((a === null) !== (b === null)) out.push(`${key} ${a} -> ${b}`); + else if (a !== null && Math.abs(a - b) > TOLERANCE) { + out.push(`${key} ${a.toFixed(1)} -> ${b.toFixed(1)}`); + } + } + return out; +} + +export async function runParity({ + browser: browserName, + update = false, + pkgDir = packageDir +}) { + const server = await serve(pkgDir); + const browser = await playwright[browserName].launch(); + const results = {}; + const errors = []; + try { + const cases = buildCases(); + for (const viewport of [ + { width: 1000, height: 700 }, + { width: 375, height: 667 } + ]) { + const mobile = viewport.width === 375; + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(`${server.url}/page.html`); + for (const cfg of cases) { + if (mobile !== (cfg.context === 'mobileViewport')) continue; + try { + results[cfg.id] = await page.evaluate(runCase, cfg); + } catch (error) { + errors.push(`${cfg.id}: ${error.message.split('\n')[0]}`); + } + } + await context.close(); + } + } finally { + await browser.close(); + await server.close(); + } + + const goldenPath = join(scorecardDir, 'golden', `parity.${browserName}.json`); + if (update) { + if (errors.length) throw new Error(`errors: ${errors.join('; ')}`); + writeFileSync(goldenPath, `${JSON.stringify(results, null, 1)}\n`); + } + if (!existsSync(goldenPath)) { + throw new Error(`no golden at ${goldenPath}, run with --update`); + } + const golden = JSON.parse(readFileSync(goldenPath, 'utf8')); + const mismatches = []; + const byContext = {}; + for (const id of Object.keys(results).filter((id) => !(id in golden))) { + mismatches.push({ id, changes: ['no golden'] }); + const context = id.split('/')[0]; + byContext[context] = (byContext[context] ?? 0) + 1; + } + let inViewportLost = 0; + let overlapGained = 0; + for (const [id, expected] of Object.entries(golden)) { + const actual = results[id]; + const changes = actual ? diff(expected, actual) : ['missing']; + const context = id.split('/')[0]; + if (changes.length) { + mismatches.push({ id, changes }); + byContext[context] = (byContext[context] ?? 0) + 1; + } + if (actual && expected.inViewport && !actual.inViewport) inViewportLost++; + if (actual && !expected.overlapsTarget && actual.overlapsTarget) { + overlapGained++; + } + } + return { + browser: browserName, + cases: Object.keys(results).length, + goldenCases: Object.keys(golden).length, + errors, + mismatchCount: mismatches.length, + byContext, + inViewportLost, + overlapGained, + sample: mismatches.slice(0, 12) + }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + const browserArg = args.includes('--browser') + ? args[args.indexOf('--browser') + 1] + : 'chromium'; + const names = + browserArg === 'all' ? ['chromium', 'firefox', 'webkit'] : [browserArg]; + let failed = false; + for (const browser of names) { + const report = await runParity({ + browser, + update: args.includes('--update') + }); + console.log(JSON.stringify(report, null, 1)); + failed ||= report.mismatchCount > 0 || report.errors.length > 0; + } + process.exit(failed ? 1 : 0); +} diff --git a/shepherd.js/scripts/scorecard/perf.mjs b/shepherd.js/scripts/scorecard/perf.mjs new file mode 100644 index 000000000..83ab3dacb --- /dev/null +++ b/shepherd.js/scripts/scorecard/perf.mjs @@ -0,0 +1,191 @@ +// Interleaved browser benchmark of built packages. Run it on an otherwise idle +// machine; samples alternate between sides so drift affects both equally. +// +// node scripts/scorecard/perf.mjs [ ...] [--samples 15] +import process from 'node:process'; +import { resolve } from 'node:path'; +import { chromium } from 'playwright-core'; +import { median, round, serve } from './lib.mjs'; + +const CPU_THROTTLE = 4; +const STEPS = 10; + +async function metrics(cdp) { + const { metrics: list } = await cdp.send('Performance.getMetrics'); + return Object.fromEntries(list.map(({ name, value }) => [name, value])); +} + +async function journey(steps) { + const page = document.getElementById('page'); + for (let i = 0; i < steps; i++) { + const target = document.createElement('div'); + target.className = 'target'; + target.id = `t${i}`; + target.style.left = `${100 + ((i * 97) % 700)}px`; + target.style.top = `${100 + ((i * 131) % 500)}px`; + page.append(target); + } + + const importStart = performance.now(); + const { default: Shepherd } = await import('/dist/js/shepherd.mjs'); + const importMs = performance.now() - importStart; + + const positioned = (el) => + new Promise((done) => { + if (el.dataset.popperPlacement) return done(); + const observer = new MutationObserver(() => { + if (el.dataset.popperPlacement) { + observer.disconnect(); + done(); + } + }); + observer.observe(el, { attributes: true }); + }); + + const tour = new Shepherd.Tour({ useModalOverlay: true }); + let shown = 0; + tour.on('show', () => shown++); + for (let i = 0; i < steps; i++) { + tour.addStep({ + id: `s${i}`, + arrow: true, + title: `Step ${i}`, + text: 'Some explanatory text for this step of the tour.', + attachTo: { element: `#t${i}`, on: 'bottom' }, + buttons: [{ text: 'Next' }] + }); + } + + const startStart = performance.now(); + await tour.start(); + await positioned(tour.getCurrentStep().el); + const startMs = performance.now() - startStart; + + const nextTimes = []; + for (let i = 1; i < steps; i++) { + const nextStart = performance.now(); + await tour.next(); + await positioned(tour.getCurrentStep().el); + nextTimes.push(performance.now() - nextStart); + } + nextTimes.sort((a, b) => a - b); + window.__tour = tour; + return { + importMs, + startMs, + nextMs: nextTimes[nextTimes.length >> 1], + shown + }; +} + +async function scrollFrames(frames) { + for (let i = 0; i < frames; i++) { + window.scrollBy(0, i % 2 ? -8 : 8); + await new Promise(requestAnimationFrame); + } +} + +async function cycles(count) { + const { default: Shepherd } = await import('/dist/js/shepherd.mjs'); + for (let i = 0; i < count; i++) { + const tour = new Shepherd.Tour({ useModalOverlay: true }); + tour.addStep({ + arrow: true, + text: 'x', + attachTo: { element: '#t0', on: 'bottom' } + }); + await tour.start(); + await new Promise(requestAnimationFrame); + tour.complete(); + } + await new Promise((done) => setTimeout(done, 500)); +} + +async function sample(browser, url) { + const context = await browser.newContext({ + viewport: { width: 1000, height: 700 } + }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(`${url}/page.html`); + const cdp = await context.newCDPSession(page); + await cdp.send('Performance.enable'); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: CPU_THROTTLE }); + + const start = await metrics(cdp); + await page.evaluate(scrollFrames, 60); + const control = await metrics(cdp); + const before = control; + const timing = await page.evaluate(journey, STEPS); + const afterJourney = await metrics(cdp); + await page.evaluate(scrollFrames, 60); + const afterScroll = await metrics(cdp); + await page.evaluate(() => window.__tour.complete()); + + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }); + await page.evaluate(cycles, 5); + await cdp.send('HeapProfiler.collectGarbage'); + const warm = await metrics(cdp); + await page.evaluate(cycles, 30); + await cdp.send('HeapProfiler.collectGarbage'); + const cold = await metrics(cdp); + await context.close(); + + return { + importMs: timing.importMs, + startMs: timing.startMs, + nextMs: timing.nextMs, + journeyLayouts: afterJourney.LayoutCount - before.LayoutCount, + journeyStyleRecalcs: + afterJourney.RecalcStyleCount - before.RecalcStyleCount, + journeyTaskMs: (afterJourney.TaskDuration - before.TaskDuration) * 1e3, + scrollLayouts: afterScroll.LayoutCount - afterJourney.LayoutCount, + scrollTaskMs: (afterScroll.TaskDuration - afterJourney.TaskDuration) * 1e3, + controlScrollLayouts: control.LayoutCount - start.LayoutCount, + controlScrollTaskMs: (control.TaskDuration - start.TaskDuration) * 1e3, + leakedNodesPerCycle: (cold.Nodes - warm.Nodes) / 30, + leakedListenersPerCycle: + (cold.JSEventListeners - warm.JSEventListeners) / 30, + leakedHeapKbPerCycle: + (cold.JSHeapUsedSize - warm.JSHeapUsedSize) / 30 / 1024, + stepsShown: timing.shown, + errors: errors.length + }; +} + +const args = process.argv.slice(2); +const flagAt = args.indexOf('--samples'); +const samples = flagAt >= 0 ? Number(args[flagAt + 1]) : 15; +const dirs = args + .filter((_, i) => flagAt < 0 || (i !== flagAt && i !== flagAt + 1)) + .map((dir) => resolve(dir)); +if (!dirs.length) { + console.error('usage: perf.mjs [ ...] [--samples N]'); + process.exit(2); +} + +const browser = await chromium.launch(); +const servers = await Promise.all(dirs.map(serve)); +const runs = dirs.map(() => []); +for (let i = 0; i < samples; i++) { + for (let d = 0; d < dirs.length; d++) { + runs[d].push(await sample(browser, servers[d].url)); + } +} +await browser.close(); +await Promise.all(servers.map((server) => server.close())); + +const report = dirs.map((dir, d) => { + const summary = { dir, samples, cpuThrottle: CPU_THROTTLE }; + for (const key of Object.keys(runs[d][0])) { + const values = runs[d].map((run) => run[key]); + summary[key] = { + median: round(median(values)), + min: round(Math.min(...values)), + max: round(Math.max(...values)) + }; + } + return summary; +}); +console.log(JSON.stringify(report, null, 1)); diff --git a/shepherd.js/scripts/scorecard/scorecard.mjs b/shepherd.js/scripts/scorecard/scorecard.mjs new file mode 100644 index 000000000..426946c26 --- /dev/null +++ b/shepherd.js/scripts/scorecard/scorecard.mjs @@ -0,0 +1,266 @@ +// One command to score a built package against the shared criteria. +// +// pnpm build && node scripts/scorecard/scorecard.mjs [--gates] [--all-browsers] +// [--json out.json] [--update-api] +// +// Size is what a consumer actually ships: the package is bundled from its +// published entry through esbuild, and "total" counts every chunk, so moving +// code behind a dynamic import lowers "eager" but never "total". +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { brotliCompressSync, constants, gzipSync } from 'node:zlib'; +import { build } from 'esbuild'; +import ts from 'typescript'; +import { packageDir, scorecardDir } from './lib.mjs'; +import { runParity } from './parity.mjs'; + +const args = process.argv.slice(2); +const has = (flag) => args.includes(flag); +const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')); + +const gz = (data) => gzipSync(data, { level: 9 }).length; +const br = (data) => + brotliCompressSync(data, { + params: { [constants.BROTLI_PARAM_QUALITY]: 11 } + }).length; + +const SCENARIOS = { + attachedTour: ` + import Shepherd from 'shepherd.js'; + const tour = new Shepherd.Tour({ useModalOverlay: true }); + tour.addStep({ text: 'hi', arrow: true, attachTo: { element: '#a', on: 'bottom' } }); + tour.start();`, + centeredTour: ` + import Shepherd from 'shepherd.js'; + const tour = new Shepherd.Tour(); + tour.addStep({ text: 'hi' }); + tour.start();` +}; + +async function consumerSize(source) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'shepherd-scorecard-'))); + try { + mkdirSync(join(dir, 'node_modules')); + symlinkSync(packageDir, join(dir, 'node_modules', 'shepherd.js')); + writeFileSync(join(dir, 'entry.mjs'), source); + const result = await build({ + absWorkingDir: dir, + entryPoints: ['entry.mjs'], + bundle: true, + splitting: true, + minify: true, + format: 'esm', + target: 'es2020', + outdir: 'out', + write: false, + metafile: true, + logLevel: 'silent' + }); + const files = new Map( + result.outputFiles.map((file) => [ + file.path.replace(`${dir}/`, ''), + file.contents + ]) + ); + const eager = new Set(); + const visit = (path) => { + if (eager.has(path)) return; + eager.add(path); + for (const imported of result.metafile.outputs[path].imports) { + if (imported.kind === 'import-statement') visit(imported.path); + } + }; + visit('out/entry.js'); + const size = (paths) => { + const list = [...paths].filter((path) => path.endsWith('.js')); + return { + min: list.reduce((sum, path) => sum + files.get(path).length, 0), + gz: list.reduce((sum, path) => sum + gz(files.get(path)), 0), + br: list.reduce((sum, path) => sum + br(files.get(path)), 0) + }; + }; + return { + eager: size(eager), + total: size(files.keys()), + chunks: [...files.keys()].filter((path) => path.endsWith('.js')).length + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function publicApi() { + const dts = join(packageDir, 'dist/js/shepherd.d.mts'); + const source = ts.createSourceFile( + dts, + readFileSync(dts, 'utf8'), + ts.ScriptTarget.Latest, + true + ); + const names = new Set(); + let documented = 0; + let declared = 0; + const hasDoc = (node) => (node.jsDoc?.length ?? 0) > 0; + const isPrivate = (node) => + node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.PrivateKeyword); + const add = (name, node) => { + names.add(name); + declared++; + if (hasDoc(node)) documented++; + }; + source.forEachChild((node) => { + if (ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) { + const owner = node.name.text; + add(owner, node); + for (const member of node.members) { + const name = member.name?.getText(source); + if (!name || isPrivate(member) || name.startsWith('_')) continue; + add(`${owner}.${name}`, member); + } + } else if (ts.isTypeAliasDeclaration(node)) { + add(node.name.text, node); + } else if (ts.isExportDeclaration(node) && node.exportClause) { + for (const element of node.exportClause.elements) { + names.add(`export:${element.name.text}`); + } + } else if (ts.isExportAssignment(node)) { + names.add('export:default'); + } + }); + const goldenPath = join(scorecardDir, 'golden', 'api.json'); + if (has('--update-api')) { + writeFileSync( + goldenPath, + `${JSON.stringify([...names].sort(), null, 1)}\n` + ); + } + const golden = existsSync(goldenPath) + ? JSON.parse(readFileSync(goldenPath, 'utf8')) + : []; + return { + removed: golden.filter((name) => !names.has(name)), + added: [...names].filter((name) => !golden.includes(name)).length, + tsdocCoveragePct: Math.round((documented / declared) * 100), + dtsApproxTokens: Math.round(source.getFullText().length / 4) + }; +} + +function packFootprint() { + const run = spawnSync( + 'npm', + ['pack', '--dry-run', '--json', '--ignore-scripts', '--silent'], + { cwd: packageDir, encoding: 'utf8' } + ); + const [info] = JSON.parse(run.stdout); + const paths = info.files.map((file) => file.path); + return { + tarballKb: Math.round(info.size / 1024), + unpackedKb: Math.round(info.unpackedSize / 1024), + prodDependencies: Object.keys(pkg.dependencies ?? {}).length, + files: paths.length, + agentDocsInPackage: paths.filter((path) => + /(^|\/)(llms[^/]*\.txt|AGENTS\.md|SKILL\.md)$/i.test(path) + ) + }; +} + +function diagnosticsRetained() { + const bundle = readFileSync(join(packageDir, pkg.module), 'utf8'); + return { + missingElementMessageShips: /not found/i.test(bundle), + consoleCalls: (bundle.match(/console\.(error|warn)/g) ?? []).length + }; +} + +function runGate(name, command, cwd = packageDir) { + const started = Date.now(); + const run = spawnSync('sh', ['-c', command], { + cwd, + encoding: 'utf8', + maxBuffer: 1 << 27, + env: { ...process.env, CI: '1', NO_COLOR: '1' } + }); + const output = `${run.stdout}\n${run.stderr}`; + const tests = output.match(/Tests\s+(?:(\d+) failed \| )?(\d+) passed/); + return { + name, + pass: run.status === 0, + seconds: Math.round((Date.now() - started) / 1000), + testsPassed: tests ? Number(tests[2]) : undefined, + testsFailed: tests?.[1] ? Number(tests[1]) : undefined, + tail: run.status === 0 ? undefined : output.slice(-1500) + }; +} + +const report = { + commit: spawnSync('git', ['rev-parse', '--short', 'HEAD'], { + encoding: 'utf8', + cwd: packageDir + }).stdout.trim(), + size: {}, + dist: {} +}; + +for (const [name, source] of Object.entries(SCENARIOS)) { + report.size[name] = await consumerSize(source); +} +for (const file of [pkg.module, 'dist/css/shepherd.css']) { + const data = readFileSync(join(packageDir, file)); + report.dist[file] = { min: data.length, gz: gz(data), br: br(data) }; +} +report.api = publicApi(); +report.pack = packFootprint(); +report.diagnostics = diagnosticsRetained(); + +const browsers = has('--all-browsers') + ? ['chromium', 'firefox', 'webkit'] + : ['chromium']; +report.parity = []; +for (const browser of browsers) { + report.parity.push(await runParity({ browser })); +} + +if (has('--gates')) { + const bin = './node_modules/.bin'; + report.gates = [ + runGate('unit', `${bin}/vitest run`), + runGate( + 'lifecycle', + `${bin}/vitest run --config test/verify/vitest.config.mjs` + ), + runGate( + 'types', + `${bin}/tsc --module esnext --moduleResolution bundler --noEmit true --emitDeclarationOnly false` + ), + runGate( + 'attw', + `${bin}/attw --pack . --exclude-entrypoints ./dist/css/shepherd.css` + ), + runGate('lint', `${bin}/eslint . && ${bin}/prettier --check '**/*.{js,ts}'`) + ]; +} + +const jsonAt = args.indexOf('--json'); +if (jsonAt >= 0) { + mkdirSync(dirname(args[jsonAt + 1]), { recursive: true }); + writeFileSync(args[jsonAt + 1], `${JSON.stringify(report, null, 1)}\n`); +} +console.log(JSON.stringify(report, null, 1)); + +const failed = + report.api.removed.length > 0 || + report.parity.some((run) => run.mismatchCount || run.errors.length) || + report.gates?.some((gate) => !gate.pass); +process.exit(failed ? 1 : 0); diff --git a/shepherd.js/scripts/scorecard/vendor/floating-ui-user-middleware.mjs b/shepherd.js/scripts/scorecard/vendor/floating-ui-user-middleware.mjs new file mode 100644 index 000000000..e4819928b --- /dev/null +++ b/shepherd.js/scripts/scorecard/vendor/floating-ui-user-middleware.mjs @@ -0,0 +1 @@ +var A=Math.min,O=Math.max;function R(t,e,n){return O(t,A(e,n))}function v(t,e){return typeof t=="function"?t(e):t}function C(t){return t.split("-")[0]}function P(t){return t.split("-")[1]}function S(t){return t==="x"?"y":"x"}function y(t){let e=t[0];return e==="t"||e==="b"?"y":"x"}var F=new Set(["left","top"]);async function M(t,e){let{placement:n,platform:i,elements:u}=t,d=await(i.isRTL==null?void 0:i.isRTL(u.floating)),f=C(n),o=P(n),s=y(n)==="y",h=F.has(f)?-1:1,m=d&&s?-1:1,r=v(e,t),{mainAxis:c,crossAxis:l,alignmentAxis:a}=typeof r=="number"?{mainAxis:r,crossAxis:0,alignmentAxis:null}:{mainAxis:r.mainAxis||0,crossAxis:r.crossAxis||0,alignmentAxis:r.alignmentAxis};return o&&typeof a=="number"&&(l=o==="end"?a*-1:a),s?{x:l*m,y:c*h}:{x:c*h,y:l*m}}var L=function(t){return t===void 0&&(t=0),{name:"offset",options:t,async fn(e){var n,i;let{x:u,y:d,placement:f,middlewareData:o}=e,s=await M(e,t);return f===((n=o.offset)==null?void 0:n.placement)&&(i=o.arrow)!=null&&i.alignmentOffset?{}:{x:u+s.x,y:d+s.y,data:{...s,placement:f}}}}},T=function(t){return t===void 0&&(t={}),{name:"shift",options:t,async fn(e){let{x:n,y:i,placement:u,platform:d}=e,{mainAxis:f=!0,crossAxis:o=!1,limiter:s={fn:p=>{let{x:g,y:E}=p;return{x:g,y:E}}},...h}=v(t,e),m={x:n,y:i},r=await d.detectOverflow(e,h),c=y(u),l=S(c),a=m[l],x=m[c],b=(p,g)=>R(g+r[p==="y"?"top":"left"],g,g-r[p==="y"?"bottom":"right"]);f&&(a=b(l,a)),o&&(x=b(c,x));let w=s.fn({...e,[l]:a,[c]:x});return{...w,data:{x:w.x-n,y:w.y-i,enabled:{[l]:f,[c]:o}}}}}};var W=L;var $=T;export{W as offset,$ as shift}; diff --git a/shepherd.js/src/components/shepherd-button.ts b/shepherd.js/src/components/shepherd-button.ts index 662c0b53b..bd6e2b731 100644 --- a/shepherd.js/src/components/shepherd-button.ts +++ b/shepherd.js/src/components/shepherd-button.ts @@ -1,40 +1,40 @@ import { h } from '../utils/dom.ts'; -import { isFunction } from '../utils/type-check.ts'; +import { isFunction, isString } from '../utils/type-check.ts'; import type { Step, StepOptionsButton } from '../step.ts'; import './shepherd-button.css'; -function getConfigOption(option: unknown, step: Step): unknown { - if (isFunction(option)) { - return option.call(step); - } - return option; -} - export function createShepherdButton( config: StepOptionsButton, step: Step ): HTMLButtonElement { - const action = config.action ? config.action.bind(step.tour) : null; - const disabled = config.disabled - ? getConfigOption(config.disabled, step) - : false; - const label = config.label ? getConfigOption(config.label, step) : null; - const text = config.text ? getConfigOption(config.text, step) : null; + const resolve = (option: unknown) => + isFunction(option) ? option.call(step) : option; + const text = resolve(config.text); + // Only the names `StepButtonAction` and the schema allow, so JSON cannot call + // any other tour method. + const found = isString(config.action) + ? /^(back|cancel|complete|next)$/.test(config.action) && + step.tour[config.action] + : config.action; + const action = isFunction(found) ? found : undefined; + if (!action) { + console.warn(`Shepherd: button "${text}" has no action.`); + } const btn = h('button', { - ...(config.attrs || {}), - 'aria-label': label || null, + ...config.attrs, + 'aria-label': resolve(config.label) || null, class: `${config.classes || ''} shepherd-button ${ config.secondary ? 'shepherd-button-secondary' : '' }`, - disabled: disabled || null, - onclick: action, + disabled: resolve(config.disabled) || null, + onclick: action ? action.bind(step.tour) : null, tabindex: '0', type: 'button' }) as HTMLButtonElement; if (text) { - btn.innerHTML = text as string; + btn.innerHTML = text; } return btn; diff --git a/shepherd.js/src/components/shepherd-cancel-icon.ts b/shepherd.js/src/components/shepherd-cancel-icon.ts deleted file mode 100644 index 1974cd25e..000000000 --- a/shepherd.js/src/components/shepherd-cancel-icon.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { h } from '../utils/dom.ts'; -import type { Step, StepOptionsCancelIcon } from '../step.ts'; -import './shepherd-cancel-icon.css'; - -export function createShepherdCancelIcon( - cancelIcon: StepOptionsCancelIcon, - step: Step -): HTMLButtonElement { - const handleCancelClick = (e: Event) => { - e.preventDefault(); - step.cancel(); - }; - - const btn = h( - 'button', - { - ...(cancelIcon.attrs || {}), - 'aria-label': cancelIcon.label ? cancelIcon.label : 'Close Tour', - class: 'shepherd-cancel-icon', - onclick: handleCancelClick, - type: 'button' - }, - h('span', { 'aria-hidden': 'true' }, '\u00D7') - ) as HTMLButtonElement; - - return btn; -} diff --git a/shepherd.js/src/components/shepherd-content.ts b/shepherd.js/src/components/shepherd-content.ts index 2e315d252..efb87773f 100644 --- a/shepherd.js/src/components/shepherd-content.ts +++ b/shepherd.js/src/components/shepherd-content.ts @@ -1,32 +1,67 @@ import { h } from '../utils/dom.ts'; -import { createShepherdFooter } from './shepherd-footer.ts'; -import { createShepherdHeader } from './shepherd-header.ts'; -import { createShepherdText } from './shepherd-text.ts'; -import { isUndefined } from '../utils/type-check.ts'; +import { createShepherdButton } from './shepherd-button.ts'; +import { isHTMLElement, isFunction, isUndefined } from '../utils/type-check.ts'; import type { Step } from '../step.ts'; +import './shepherd-footer.css'; +import './shepherd-cancel-icon.css'; +import './shepherd-title.css'; +import './shepherd-header.css'; +import './shepherd-text.css'; import './shepherd-content.css'; export function createShepherdContent( descriptionId: string, labelId: string, step: Step -): HTMLDivElement { - const content = h('div', { class: 'shepherd-content' }) as HTMLDivElement; +): HTMLElement { + const { title, cancelIcon, text, buttons } = step.options; - if ( - !isUndefined(step.options.title) || - (step.options.cancelIcon && step.options.cancelIcon.enabled) - ) { - content.append(createShepherdHeader(labelId, step)); - } + const fill = (el: HTMLElement, content: unknown) => { + const resolved = isFunction(content) ? content.call(step) : content; - if (!isUndefined(step.options.text)) { - content.append(createShepherdText(descriptionId, step)); - } + if (isHTMLElement(resolved)) { + el.append(resolved); + } else { + el.innerHTML = Array.isArray(resolved) + ? resolved.join('') + : (resolved as string); + } - if (Array.isArray(step.options.buttons) && step.options.buttons.length) { - content.append(createShepherdFooter(step)); - } + return el; + }; - return content; + return h( + 'div', + { class: 'shepherd-content' }, + (!isUndefined(title) || cancelIcon?.enabled) && + h( + 'header', + { class: 'shepherd-header' }, + title && fill(h('h3', { id: labelId, class: 'shepherd-title' }), title), + cancelIcon?.enabled && + h( + 'button', + { + ...cancelIcon.attrs, + 'aria-label': cancelIcon.label || 'Close Tour', + class: 'shepherd-cancel-icon', + onclick: (e: Event) => { + e.preventDefault(); + step.cancel(); + }, + type: 'button' + }, + h('span', { 'aria-hidden': 'true' }, '×') + ) + ), + !isUndefined(text) && + fill(h('div', { class: 'shepherd-text', id: descriptionId }), text), + Array.isArray(buttons) && + buttons.length > 0 && + h( + 'footer', + { class: 'shepherd-footer' }, + ...buttons.map((config) => createShepherdButton(config, step)) + ) + ); } diff --git a/shepherd.js/src/components/shepherd-element.css b/shepherd.js/src/components/shepherd-element.css index adf053f98..c4d93f3b3 100644 --- a/shepherd.js/src/components/shepherd-element.css +++ b/shepherd.js/src/components/shepherd-element.css @@ -8,9 +8,7 @@ opacity: 0; outline: none; padding: 0; - transition: - opacity 0.3s, - visibility 0.3s; + transition: opacity 0.3s; visibility: hidden; width: 100%; z-index: 9999; @@ -21,12 +19,6 @@ visibility: visible; } -.shepherd-element[data-popper-reference-hidden]:not(.shepherd-centered) { - opacity: 0; - pointer-events: none; - visibility: hidden; -} - .shepherd-element, .shepherd-element *, .shepherd-element *:after, @@ -80,7 +72,7 @@ * Blocks clicks on the target of a step with `canClickTarget: false`. * * Keyed only on `shepherd-target-click-disabled`, which -* `Step#_styleTargetElementForStep` adds unprefixed, and only when +* `Step#_show` adds unprefixed, and only when * `canClickTarget === false`. The sibling * `shepherd-enabled`/`shepherd-target` classes cannot be part of this selector: * `classPrefix` prefixes them at runtime and a static stylesheet cannot know diff --git a/shepherd.js/src/components/shepherd-element.ts b/shepherd.js/src/components/shepherd-element.ts index 1ee06803a..18441a185 100644 --- a/shepherd.js/src/components/shepherd-element.ts +++ b/shepherd.js/src/components/shepherd-element.ts @@ -16,8 +16,17 @@ export interface ShepherdElementOptions { step: Step; } +/** + * A step's rendered element, with its teardown. + */ export interface ShepherdElementResult { + /** + * The step's `` element. + */ element: HTMLDialogElement; + /** + * Removes the listeners added to the element. + */ cleanup: () => void; } @@ -26,20 +35,8 @@ export function createShepherdElement( ): ShepherdElementResult { const { classPrefix, descriptionId, labelId, step } = options; - let attachToElement: HTMLElement | null | undefined; - - // Focusable attachTo elements - let focusableAttachToElements: Element[] | undefined; - let firstFocusableAttachToElement: Element | undefined; - let lastFocusableAttachToElement: Element | undefined; - - // Focusable dialog elements - let firstFocusableDialogElement: Element | undefined; - let focusableDialogElements: Element[] | undefined; - let lastFocusableDialogElement: Element | undefined; - - const hasCancelIcon = step.options?.cancelIcon?.enabled ?? false; - const hasTitle = step.options?.title ?? false; + const { attachTo, arrow, cancelIcon, title, text, label, classes } = + step.options; // A step with no title has no naming attribute at all, so its dialog has no // accessible name. `label` supplies one via `aria-label`. @@ -49,150 +46,117 @@ export function createShepherdElement( // comes first so a function-valued `label` is never invoked when it would // be discarded — a throwing or side-effecting one must not break a titled // step. - const resolvedLabel = hasTitle + const resolvedLabel = title ? undefined - : isFunction(step.options.label) - ? (step.options.label.call(step) as string) - : step.options.label; + : isFunction(label) + ? (label.call(step) as string) + : label; // Gate on the trimmed value so `''`, `() => ''` and whitespace-only names // omit the attribute instead of emitting an accessible name that assistive // technology treats as empty. The untrimmed value is what gets written. const ariaLabel = isString(resolvedLabel) && resolvedLabel.trim() ? resolvedLabel : null; + let focusableDialogElements: Element[] = []; + let focusableAttachToElements: Element[] = []; + /** - * Setup keydown events to allow closing the modal with ESC + * Keeps tab focus cycling through the dialog and its target * * Borrowed from this great post! https://bitsofco.de/accessible-modal-dialog/ */ + const trapTab = (e: KeyboardEvent) => { + const firstDialog = focusableDialogElements[0]; + const lastDialog = + focusableDialogElements[focusableDialogElements.length - 1]; + const firstAttachTo = focusableAttachToElements[0]; + const lastAttachTo = + focusableAttachToElements[focusableAttachToElements.length - 1]; + const active = document.activeElement; + const moveFocus = (el?: Element) => { + e.preventDefault(); + (el as HTMLElement | undefined)?.focus(); + }; + + // Nothing to cycle through, so Tab stays put. + if (!firstAttachTo && firstDialog === element) { + moveFocus(); + } else if (e.shiftKey) { + if ( + active === firstDialog || + active?.classList.contains('shepherd-element') + ) { + moveFocus(lastAttachTo ?? lastDialog); + } else if (active === firstAttachTo) { + moveFocus(lastDialog); + } + } else if (active === lastDialog) { + moveFocus(firstAttachTo ?? firstDialog); + } else if (active === lastAttachTo) { + moveFocus(firstDialog); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { const { tour } = step; - switch (e.keyCode) { - case KEY_TAB: - if ( - (!focusableAttachToElements || - focusableAttachToElements.length === 0) && - focusableDialogElements && - focusableDialogElements.length === 0 - ) { - e.preventDefault(); - break; - } - // Backward tab - if (e.shiftKey) { - if ( - document.activeElement === firstFocusableDialogElement || - document.activeElement?.classList.contains('shepherd-element') - ) { - e.preventDefault(); - ( - (lastFocusableAttachToElement ?? - lastFocusableDialogElement) as HTMLElement - )?.focus(); - } else if (document.activeElement === firstFocusableAttachToElement) { - e.preventDefault(); - (lastFocusableDialogElement as HTMLElement)?.focus(); - } - } else { - if (document.activeElement === lastFocusableDialogElement) { - e.preventDefault(); - ( - (firstFocusableAttachToElement ?? - firstFocusableDialogElement) as HTMLElement - )?.focus(); - } else if (document.activeElement === lastFocusableAttachToElement) { - e.preventDefault(); - (firstFocusableDialogElement as HTMLElement)?.focus(); - } - } - break; - case KEY_ESC: - if (tour.options.exitOnEsc) { - e.preventDefault(); - e.stopPropagation(); - step.cancel(); - } - break; - case LEFT_ARROW: - if (tour.options.keyboardNavigation) { - e.preventDefault(); - e.stopPropagation(); - tour.back(); - } - break; - case RIGHT_ARROW: - if (tour.options.keyboardNavigation) { - e.preventDefault(); - e.stopPropagation(); - tour.next(); - } - break; - default: - break; + const { exitOnEsc, keyboardNavigation } = tour.options; + const action = { + [KEY_ESC]: exitOnEsc && (() => step.cancel()), + [LEFT_ARROW]: keyboardNavigation && (() => tour.back()), + [RIGHT_ARROW]: keyboardNavigation && (() => tour.next()) + }[e.keyCode]; + + if (action) { + e.preventDefault(); + e.stopPropagation(); + action(); + } else if (e.keyCode === KEY_TAB) { + trapTab(e); } }; - // Build the dialog element - const element = h('dialog', { - 'aria-describedby': !isUndefined(step.options.text) ? descriptionId : null, - 'aria-label': ariaLabel, - 'aria-labelledby': step.options.title ? labelId : null, - class: [ - 'shepherd-element', - hasCancelIcon ? 'shepherd-has-cancel-icon' : '', - hasTitle ? 'shepherd-has-title' : '' - ] - .filter(Boolean) - .join(' '), - [`data-${classPrefix}shepherd-step-id`]: step.id, - onkeydown: handleKeyDown, - open: 'true' - }) as HTMLDialogElement; - - // Add arrow if needed - if ( - step.options.arrow && - step.options.attachTo && - step.options.attachTo.element && - step.options.attachTo.on - ) { - element.append( - h('div', { class: 'shepherd-arrow', 'data-popper-arrow': '' }) - ); - } + const element = h( + 'dialog', + { + 'aria-describedby': !isUndefined(text) ? descriptionId : null, + 'aria-label': ariaLabel, + 'aria-labelledby': title ? labelId : null, + class: [ + 'shepherd-element', + cancelIcon?.enabled && 'shepherd-has-cancel-icon', + title && 'shepherd-has-title', + isString(classes) && classes + ] + .filter(Boolean) + .join(' '), + [`data-${classPrefix}shepherd-step-id`]: step.id, + onkeydown: handleKeyDown, + open: 'true' + }, + arrow && + attachTo?.element && + attachTo.on && + h('div', { class: 'shepherd-arrow', 'data-popper-arrow': '' }), + createShepherdContent(descriptionId, labelId, step) + ) as HTMLDialogElement; - // Add content - element.append(createShepherdContent(descriptionId, labelId, step)); - - // Dynamic class management - if (isString(step.options.classes)) { - const classes = step.options.classes.split(' ').filter((c) => !!c.length); - if (classes.length) { - element.classList.add(...classes); - } - } - - // Setup focusable element tracking (equivalent of onMount) const focusableSelector = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]'; - focusableDialogElements = [...element.querySelectorAll(focusableSelector)]; - firstFocusableDialogElement = focusableDialogElements[0]; - lastFocusableDialogElement = - focusableDialogElements[focusableDialogElements.length - 1]; + const dialogControls = [...element.querySelectorAll(focusableSelector)]; + // With nothing to focus inside, the dialog itself is the stop in the cycle. + focusableDialogElements = dialogControls.length ? dialogControls : [element]; - const attachTo = step._getResolvedAttachToOptions(); - if (attachTo?.element) { - attachToElement = attachTo.element as HTMLElement; + const attachToElement = step._getResolvedAttachToOptions().element as + HTMLElement | null | undefined; + + if (attachToElement) { step._storeOriginalTabIndex(attachToElement); attachToElement.tabIndex = 0; focusableAttachToElements = [ attachToElement, ...attachToElement.querySelectorAll(focusableSelector) ]; - firstFocusableAttachToElement = focusableAttachToElements[0]; - lastFocusableAttachToElement = - focusableAttachToElements[focusableAttachToElements.length - 1]; attachToElement.addEventListener('keydown', handleKeyDown); } diff --git a/shepherd.js/src/components/shepherd-footer.ts b/shepherd.js/src/components/shepherd-footer.ts deleted file mode 100644 index b730173b9..000000000 --- a/shepherd.js/src/components/shepherd-footer.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { h } from '../utils/dom.ts'; -import { createShepherdButton } from './shepherd-button.ts'; -import type { Step } from '../step.ts'; -import './shepherd-footer.css'; - -export function createShepherdFooter(step: Step): HTMLElement { - const footer = h('footer', { class: 'shepherd-footer' }); - - if (step.options.buttons) { - for (const config of step.options.buttons) { - footer.append(createShepherdButton(config, step)); - } - } - - return footer; -} diff --git a/shepherd.js/src/components/shepherd-header.ts b/shepherd.js/src/components/shepherd-header.ts deleted file mode 100644 index d9e0cb69e..000000000 --- a/shepherd.js/src/components/shepherd-header.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { h } from '../utils/dom.ts'; -import { createShepherdCancelIcon } from './shepherd-cancel-icon.ts'; -import { createShepherdTitle } from './shepherd-title.ts'; -import type { Step } from '../step.ts'; -import './shepherd-header.css'; - -export function createShepherdHeader(labelId: string, step: Step): HTMLElement { - const header = h('header', { class: 'shepherd-header' }); - - if (step.options.title) { - header.append(createShepherdTitle(labelId, step.options.title)); - } - - if (step.options.cancelIcon && step.options.cancelIcon.enabled) { - header.append(createShepherdCancelIcon(step.options.cancelIcon, step)); - } - - return header; -} diff --git a/shepherd.js/src/components/shepherd-modal.css b/shepherd.js/src/components/shepherd-modal.css index 56f625da2..09357cde0 100644 --- a/shepherd.js/src/components/shepherd-modal.css +++ b/shepherd.js/src/components/shepherd-modal.css @@ -27,3 +27,10 @@ .shepherd-modal-overlay-container.shepherd-modal-is-visible path { pointer-events: all; } + +/* Read by the modal to glide the opening between steps; nothing transitions. */ +@media (prefers-reduced-motion: no-preference) { + .shepherd-modal-overlay-container path { + transition: none 0.42s; + } +} diff --git a/shepherd.js/src/components/shepherd-modal.ts b/shepherd.js/src/components/shepherd-modal.ts index dafddff88..85ac64b86 100644 --- a/shepherd.js/src/components/shepherd-modal.ts +++ b/shepherd.js/src/components/shepherd-modal.ts @@ -1,8 +1,9 @@ -import { svgEl } from '../utils/dom.ts'; import { makeOverlayPath } from '../utils/overlay-path.ts'; import type { Step } from '../step.ts'; import './shepherd-modal.css'; +const SVG_NS = 'http://www.w3.org/2000/svg'; + interface OpeningProperty { width: number; height: number; @@ -18,6 +19,9 @@ interface OpeningProperty { }; } +/** + * The corner radius of the overlay opening: one number, or one per corner. + */ type ModalRadiusType = | number | { @@ -27,10 +31,25 @@ type ModalRadiusType = topRight?: number; }; +/** + * Controls the overlay behind a tour. Reached through `tour.modal`. + */ export interface ShepherdModalAPI { + /** + * Removes the opening, so the overlay covers the whole page. + */ closeModalOpening: () => void; + /** + * Removes the overlay from the DOM. + */ destroy: () => void; + /** + * Hides the overlay. + */ hide: () => void; + /** + * Moves the opening to the given target and extra highlights. + */ positionModal: ( modalOverlayOpeningPadding?: number, modalOverlayOpeningRadius?: ModalRadiusType, @@ -40,42 +59,79 @@ export interface ShepherdModalAPI { targetElement?: HTMLElement | null, extraHighlights?: HTMLElement[] ) => void; + /** + * Shows the overlay and keeps its opening on the step's target, or hides the + * overlay when the tour does not use `useModalOverlay`. + */ setupForStep: (step: Step) => void; + /** + * Shows the overlay. + */ show: () => void; + /** + * Returns the overlay's `` element. + */ getElement: () => SVGElement; } +const PATH_NUMBER = /-?[\d.]+(e-\d+)?/g; + export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { let rafId: number | undefined; + let currentPath = ''; // Memoizes the chain of scroll parents of a highlighted element for the // lifetime of a single step. Reset in `_cleanupStepEventListeners`. let _stepScrollParents = new WeakMap(); - let openingProperties: OpeningProperty[] = [ - { width: 0, height: 0, x: 0, y: 0, r: 0 } - ]; + let isClosed: boolean; + let glideFrom: string[]; + let glideEnd: number; + let glideMs: number; // Build SVG elements - const pathEl = svgEl('path'); - const element = svgEl( - 'svg', - { class: 'shepherd-modal-overlay-container' }, - pathEl - ); + const pathEl = document.createElementNS(SVG_NS, 'path'); + const element = document.createElementNS(SVG_NS, 'svg'); + element.setAttribute('class', 'shepherd-modal-overlay-container'); + element.append(pathEl); element.addEventListener('touchmove', _preventModalOverlayTouch); - // Initial render - _updatePath(); + closeModalOpening(); container.append(element); - function _updatePath() { - pathEl.setAttribute('d', makeOverlayPath(openingProperties)); + function _updatePath(openings: OpeningProperty[]) { + const to = makeOverlayPath(openings); + const left = (glideEnd - Date.now()) / glideMs; + let i = 0; + + // Consecutive paths share a shape, so tweening their numbers morphs the + // opening. `makeOverlayPath` puts a comma between every pair of numbers, so + // a sign that changes mid-glide cannot run two of them together. + // A different number of openings has no shape to morph from, so it cuts + // and ends the glide, or a later frame could morph back toward the old one. + if (left > 0 && to.match(PATH_NUMBER)!.length !== glideFrom.length) { + glideEnd = 0; + } + const d = + glideEnd > Date.now() + ? to.replace( + PATH_NUMBER, + (n) => `${+n + (+(glideFrom[i++] ?? n) - +n) * left ** 3}` + ) + : to; + + // Writing `d` invalidates layout even when the value is unchanged, and this + // runs every frame. + if (d !== currentPath) { + currentPath = d; + pathEl.setAttribute('d', d); + } } function closeModalOpening() { - openingProperties = [{ width: 0, height: 0, x: 0, y: 0, r: 0 }]; - _updatePath(); + isClosed = true; + glideEnd = 0; + _updatePath([{ width: 0, height: 0, x: 0, y: 0, r: 0 }]); } function hide() { @@ -119,8 +175,6 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { el === targetElement ? targetScrollParents : _cachedScrollParents(el); for (const el of elementsToHighlight) { - if (!el) continue; - // Skip duplicate elements if ( elementsToHighlight.indexOf(el) !== @@ -130,7 +184,7 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { } const { y, height } = _getVisibleHeight(el, scrollParentsFor(el)); - const { x, width, left } = el.getBoundingClientRect(); + const { x, width } = el.getBoundingClientRect(); // Check if the element is contained by another element. // Use _getVisibleHeight for otherElement too so both sides compare @@ -156,28 +210,27 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { newOpenings.push({ width: width + modalOverlayOpeningPadding * 2, height: height + modalOverlayOpeningPadding * 2, - x: - (x || left) + - modalOverlayOpeningXOffset - - modalOverlayOpeningPadding, + x: x + modalOverlayOpeningXOffset - modalOverlayOpeningPadding, y: y + modalOverlayOpeningYOffset - modalOverlayOpeningPadding, r: modalOverlayOpeningRadius as OpeningProperty['r'] }); } - openingProperties = newOpenings; + isClosed = false; + _updatePath(newOpenings); } else { closeModalOpening(); - return; } - - _updatePath(); } function setupForStep(step: Step) { _cleanupStepEventListeners(); if (step.tour.options.useModalOverlay) { + glideFrom = currentPath.match(PATH_NUMBER)!; + glideMs = parseFloat(getComputedStyle(pathEl).transitionDuration) * 1e3; + // A closed opening sits at the origin, with nowhere to glide from. + glideEnd = isClosed ? 0 : Date.now() + glideMs; _styleForStep(step); show(); } else { @@ -205,24 +258,15 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { e.preventDefault(); }; - function _addStepEventListeners() { - window.addEventListener('touchmove', _preventModalBodyTouch, { - passive: false - }); - } - function _cleanupStepEventListeners() { if (rafId) { cancelAnimationFrame(rafId); - rafId = undefined; } // Scroll parents are only memoized for the duration of a single step. _stepScrollParents = new WeakMap(); - window.removeEventListener('touchmove', _preventModalBodyTouch, { - passive: false - } as EventListenerOptions); + window.removeEventListener('touchmove', _preventModalBodyTouch); } function _styleForStep(step: Step) { @@ -234,10 +278,9 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { } = step.options; const iframeOffset = _getIframeOffset(step.target); - const targetScrollParent = _getScrollParent(step.target); + const targetScrollParent = _cachedScrollParents(step.target)[0] ?? null; const rafLoop = () => { - rafId = undefined; positionModal( modalOverlayOpeningPadding, modalOverlayOpeningRadius, @@ -251,7 +294,9 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { }; rafLoop(); - _addStepEventListeners(); + window.addEventListener('touchmove', _preventModalBodyTouch, { + passive: false + }); } function _isContained(style: CSSStyleDeclaration) { @@ -325,9 +370,7 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { * rather than from `offsetParent`, which happy-dom does not implement and the * unit tests therefore cannot exercise. */ - function _getScrollParents(el?: HTMLElement | null): HTMLElement[] { - if (!(el instanceof HTMLElement)) return []; - + function _getScrollParents(el: HTMLElement): HTMLElement[] { const { position } = window.getComputedStyle(el); // Laid out against the viewport, so no ancestor crops it. @@ -357,10 +400,6 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { return scrollParents; } - function _getScrollParent(el?: HTMLElement | null): HTMLElement | null { - return _getScrollParents(el)[0] ?? null; - } - /** * Memoized `_getScrollParents`, scoped to the current step. * @@ -372,7 +411,8 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { * target already has in `_styleForStep`. */ function _cachedScrollParents(el?: HTMLElement | null): HTMLElement[] { - if (!el) return []; + // `nodeType`, not `instanceof`, so a target in a same-origin iframe counts. + if (el?.nodeType !== Node.ELEMENT_NODE) return []; const cached = _stepScrollParents.get(el); if (cached) return cached; @@ -402,7 +442,7 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { // nested under the modal's window at all. targetWindow !== targetWindow.parent ) { - const targetIframe = targetWindow?.frameElement; + const targetIframe = targetWindow.frameElement; if (targetIframe) { const rect = targetIframe.getBoundingClientRect(); @@ -420,21 +460,18 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI { } function _getVisibleHeight(el: HTMLElement, scrollParents: HTMLElement[]) { - const elementRect = el.getBoundingClientRect(); - let top = elementRect.y || elementRect.top; - let bottom = elementRect.bottom || top + elementRect.height; + const { y, height } = el.getBoundingClientRect(); + let top = y; + let bottom = y + height; for (const scrollParent of scrollParents) { const scrollRect = scrollParent.getBoundingClientRect(); - const scrollTop = scrollRect.y || scrollRect.top; - const scrollBottom = scrollRect.bottom || scrollTop + scrollRect.height; - top = Math.max(top, scrollTop); - bottom = Math.min(bottom, scrollBottom); + top = Math.max(top, scrollRect.y); + bottom = Math.min(bottom, scrollRect.y + scrollRect.height); } - const height = Math.max(bottom - top, 0); - return { y: top, height }; + return { y: top, height: Math.max(bottom - top, 0) }; } return { diff --git a/shepherd.js/src/components/shepherd-text.ts b/shepherd.js/src/components/shepherd-text.ts deleted file mode 100644 index ecf275ba5..000000000 --- a/shepherd.js/src/components/shepherd-text.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { h } from '../utils/dom.ts'; -import { isHTMLElement, isFunction } from '../utils/type-check.ts'; -import type { Step } from '../step.ts'; -import './shepherd-text.css'; - -export function createShepherdText( - descriptionId: string, - step: Step -): HTMLDivElement { - const el = h('div', { - class: 'shepherd-text', - id: descriptionId - }) as HTMLDivElement; - - let text = step.options.text; - - if (isFunction(text)) { - text = text.call(step); - } - - if (isHTMLElement(text)) { - el.appendChild(text); - } else { - el.innerHTML = text as string; - } - - return el; -} diff --git a/shepherd.js/src/components/shepherd-title.ts b/shepherd.js/src/components/shepherd-title.ts deleted file mode 100644 index 54123df3c..000000000 --- a/shepherd.js/src/components/shepherd-title.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { h } from '../utils/dom.ts'; -import { isFunction } from '../utils/type-check.ts'; -import type { StringOrStringFunction } from '../step.ts'; -import './shepherd-title.css'; - -export function createShepherdTitle( - labelId: string, - title: StringOrStringFunction -): HTMLHeadingElement { - const el = h('h3', { - id: labelId, - class: 'shepherd-title' - }) as HTMLHeadingElement; - - const resolvedTitle = isFunction(title) ? title() : title; - el.innerHTML = resolvedTitle; - - return el; -} diff --git a/shepherd.js/src/evented.ts b/shepherd.js/src/evented.ts index ae30d73be..175e9960c 100644 --- a/shepherd.js/src/evented.ts +++ b/shepherd.js/src/evented.ts @@ -1,13 +1,25 @@ import { isUndefined } from './utils/type-check.ts'; +/** + * Registered event handlers, by event name. + */ export type Bindings = { [key: string]: Array<{ handler: () => void; ctx?: unknown; once?: boolean }>; }; +/** + * An event handler. Its arguments depend on the event. + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyHandler = (...args: any[]) => void; +/** + * The event methods shared by `Shepherd`, `Tour` and `Step`. + */ export class Evented { + /** + * The handlers registered through `on` and `once`. + */ declare bindings: Bindings; /** @@ -20,13 +32,12 @@ export class Evented { * @returns */ on(event: string, handler: AnyHandler, ctx?: unknown, once = false) { - if (isUndefined(this.bindings)) { - this.bindings = {}; - } - if (isUndefined(this.bindings[event])) { - this.bindings[event] = []; - } - this.bindings[event]?.push({ handler, ctx, once }); + this.bindings = this.bindings || {}; + (this.bindings[event] = this.bindings[event] || []).push({ + handler, + ctx, + once + }); return this; } @@ -53,16 +64,14 @@ export class Evented { off(event: string, handler?: AnyHandler) { const bindings = this.bindings?.[event]; - if (isUndefined(bindings)) { - return this; - } - - if (isUndefined(handler)) { - delete this.bindings[event]; - } else { - this.bindings[event] = bindings.filter( - (binding) => binding.handler !== handler - ); + if (bindings) { + if (isUndefined(handler)) { + delete this.bindings[event]; + } else { + this.bindings[event] = bindings.filter( + (binding) => binding.handler !== handler + ); + } } return this; @@ -76,28 +85,17 @@ export class Evented { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any trigger(event: string, ...args: any[]) { - const bindings = this.bindings?.[event]; - - if (isUndefined(bindings)) { - return this; - } - // Iterate over a copy, since handlers may add or remove bindings while we // are dispatching. - for (const binding of bindings.slice()) { - const { ctx, handler, once } = binding; - - const context = ctx || this; - - handler.apply(context, args as []); + for (const binding of this.bindings?.[event]?.slice() || []) { + binding.handler.apply(binding.ctx || this, args as []); - if (once) { - // Look the binding up by identity rather than by loop index, since - // indexes shift as bindings are removed. - const index = this.bindings[event]?.indexOf(binding) ?? -1; + if (binding.once) { + // Match by identity, since the same handler may also be bound normally. + const remaining = this.bindings[event]?.filter((b) => b !== binding); - if (index !== -1) { - this.bindings[event]?.splice(index, 1); + if (remaining) { + this.bindings[event] = remaining; } } } diff --git a/shepherd.js/src/motion.ts b/shepherd.js/src/motion.ts new file mode 100644 index 000000000..ffbb19568 --- /dev/null +++ b/shepherd.js/src/motion.ts @@ -0,0 +1,83 @@ +import type { Step, Tour } from './shepherd.ts'; + +/** + * Glides each step's dialog from where the previous one sat instead of cutting + * to its new position, on the same curve as the modal overlay's opening. + * + * It animates the `translate` property, which composes with whatever positions + * the dialog, and does nothing under `prefers-reduced-motion: reduce`. + * + * @param tour The tour to animate. + * @param duration Length of the glide in milliseconds. + * @returns A function that stops gliding. + */ +export function glideSteps(tour: Tour, duration = 420) { + let from: DOMRect | undefined; + // A step's `show` can land after the stop, once its `beforeShowPromise` settles. + let stopped = false; + + const onShow = ({ step }: { step: Step }) => { + step.once('show', () => + requestAnimationFrame(() => { + const el = step.getElement(); + if ( + !stopped && + el && + from?.width && + !matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + const to = el.getBoundingClientRect(); + const dx = from.left + from.width / 2 - to.left - to.width / 2; + const dy = from.top + from.height / 2 - to.top - to.height / 2; + // Holding opacity at 1 reads as one dialog moving, not a new one fading. + el.animate( + { translate: [`${dx}px ${dy}px`, '0 0'], opacity: [1, 1] }, + { duration, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' } + ); + } + }) + ); + step.once('before-hide', () => { + from = step.getElement()?.getBoundingClientRect(); + }); + }; + + const onStart = () => { + from = undefined; + }; + + tour.on('start', onStart); + tour.on('show', onShow); + return () => { + stopped = true; + tour.off('start', onStart); + tour.off('show', onShow); + }; +} + +/** + * Exposes each step's position as the `--shepherd-step` and `--shepherd-steps` + * custom properties on its dialog, which `shepherd.js/dist/css/theme.css` draws + * as a counter and a progress bar. + * + * @param tour The tour to report progress for. + * @returns A function that stops updating the properties. + */ +export function stepProgress(tour: Tour) { + let stopped = false; + + const onShow = ({ step }: { step: Step }) => { + step.once('show', () => { + if (stopped) return; + const style = step.getElement()?.style; + style?.setProperty('--shepherd-step', `${tour.steps.indexOf(step) + 1}`); + style?.setProperty('--shepherd-steps', `${tour.steps.length}`); + }); + }; + + tour.on('show', onShow); + return () => { + stopped = true; + tour.off('show', onShow); + }; +} diff --git a/shepherd.js/src/shepherd.ts b/shepherd.js/src/shepherd.ts index 1e141b874..7d85373ab 100644 --- a/shepherd.js/src/shepherd.ts +++ b/shepherd.js/src/shepherd.ts @@ -1,11 +1,48 @@ -import { Shepherd, Tour } from './tour.ts'; -import { StepNoOp, TourNoOp } from './utils/general.ts'; -import { Step } from './step.ts'; +import { Shepherd, Tour as TourClass } from './tour.ts'; +import { Step as StepClass } from './step.ts'; +// Server rendering runs module-level tour setup, so every method must exist. +// `isActive()` and `isOpen()` answer false and `steps` is empty, as before a +// tour starts, so server and client markup agree. +const NoOp = class { + constructor() { + const noOp: object = new Proxy(this, { + get: (_target, key) => + key === 'then' + ? undefined + : key === 'steps' + ? [] + : () => (String(key).startsWith('is') ? false : noOp) + }); + return noOp; + } +} as unknown as typeof TourClass & typeof StepClass; const isServerSide = typeof window === 'undefined'; -Shepherd.Step = (isServerSide ? StepNoOp : Step) as unknown as typeof Step; -Shepherd.Tour = (isServerSide ? TourNoOp : Tour) as unknown as typeof Tour; +/** + * The `Step` class, also available as `Shepherd.Step`. Steps are usually created + * through `tour.addStep`. + */ +export const Step: typeof StepClass = isServerSide ? NoOp : StepClass; +/** + * A step instance. + */ +// eslint-disable-next-line no-redeclare -- A type and a value may share a name. +export type Step = StepClass; + +/** + * The `Tour` class, also available as `Shepherd.Tour`. On the server every + * method of it does nothing, so tour setup at module scope is safe. + */ +export const Tour: typeof TourClass = isServerSide ? NoOp : TourClass; +/** + * A tour instance. + */ +// eslint-disable-next-line no-redeclare -- A type and a value may share a name. +export type Tour = TourClass; + +Shepherd.Step = Step; +Shepherd.Tour = Tour; export { ShepherdBase } from './tour.ts'; export default Shepherd; diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts index 7b4a6bbd4..c9f791039 100644 --- a/shepherd.js/src/step.ts +++ b/shepherd.js/src/step.ts @@ -1,7 +1,11 @@ -import { deepmerge } from 'deepmerge-ts'; import { Evented } from './evented.ts'; import autoBind from './utils/auto-bind.ts'; -import { isElement, isHTMLElement, isFunction } from './utils/type-check.ts'; +import { + isElement, + isHTMLElement, + isFunction, + isString +} from './utils/type-check.ts'; import { bindAdvance } from './utils/bind.ts'; import { parseAttachTo, @@ -9,24 +13,25 @@ import { uuid, parseExtraHighlights } from './utils/general.ts'; -import { - setupTooltip, - destroyTooltip, - mergeTooltipConfig -} from './utils/floating-ui.ts'; +import { setupTooltip, destroyTooltip } from './utils/floating-ui.ts'; import { createShepherdElement, type ShepherdElementResult } from './components/shepherd-element.ts'; import { type Tour } from './tour.ts'; -import type { ComputePositionConfig } from '@floating-ui/dom'; +/** + * The body of a step. Strings render as HTML, so escape untrusted input. + */ export type StepText = | string | ReadonlyArray | HTMLElement | (() => string | ReadonlyArray | HTMLElement); +/** + * A string, or a function returning one when the step is built. + */ export type StringOrStringFunction = string | (() => string); /** @@ -72,17 +77,30 @@ export interface StepOptions { /** * A function that returns a promise. * When the promise resolves, the rest of the `show` code for the step will execute. + * Pass the function, not a promise: a promise starts its work when the tour is + * defined, and is ignored. + * + * @example + * ```js + * beforeShowPromise: () => openSettingsPanel() + * ``` */ beforeShowPromise?: () => Promise; /** * An array of buttons to add to the step. These will be rendered in a - * footer below the main body text. + * footer below the main body text. A step's `buttons` replace the ones from + * `defaultStepOptions`; they are not merged. */ buttons?: ReadonlyArray; /** * Should a cancel “✕” be shown in the header of the step? + * + * @example + * ```js + * cancelIcon: { enabled: true } + * ``` */ cancelIcon?: StepOptionsCancelIcon; @@ -180,15 +198,17 @@ export interface StepOptions { modalOverlayOpeningYOffset?: number; /** - * Extra [options to pass to FloatingUI]{@link https://floating-ui.com/docs/tutorial/} + * How the step is placed against its target: the gap, the distance kept from + * the edges, the CSS `position`, and whether it may flip. See + * {@link StepOptionsPosition}. Values from `defaultStepOptions` and the step + * combine key by key, so a step can change one and keep the rest. * - * This includes `strategy`, the CSS `position` used for the step element, - * which defaults to `'absolute'`. Centered steps are always `position: fixed` - * and ignore `strategy` -- a step counts as centered when it has no - * `attachTo` at all, or when its `attachTo` is missing either `element` or - * `on`. + * @example + * ```js + * positionOptions: { offset: 12, padding: 8 } + * ``` */ - floatingUIOptions?: ComputePositionConfig; + positionOptions?: StepOptionsPosition; /** * Should the element be scrolled to when this step is shown? @@ -226,7 +246,8 @@ export interface StepOptions { skipMissingElement?: boolean; /** - * The text in the body of the step. It can be one of four types: + * The text in the body of the step. Strings render as HTML, so escape + * untrusted input. It can be one of four types: * ``` * - HTML string * - Array of HTML strings @@ -237,7 +258,8 @@ export interface StepOptions { text?: StepText; /** - * The step's title. It becomes an `h3` at the top of the step. + * The step's title. It becomes an `h3` at the top of the step. Strings render + * as HTML, so escape untrusted input. * ``` * - HTML string * - `Function` to be executed when the step is built. It must return HTML string. @@ -248,10 +270,10 @@ export interface StepOptions { /** * The maximum amount of time, in milliseconds, to wait for the * `attachTo.element` to appear in the DOM before showing the step. The DOM - * is watched with a `MutationObserver` (falling back to polling when it is - * unavailable), so the step attaches as soon as the element appears. If the - * timeout expires, the step falls back to its default behavior: skipped - * when `skipMissingElement` is `true`, otherwise shown centered. + * is watched with a `MutationObserver`, so the step attaches as soon as the + * element appears. If the timeout expires, the step falls back to its default + * behavior: skipped when `skipMissingElement` is `true`, otherwise shown + * centered. * * The wait starts before the step's own `beforeShowPromise` and `before-show` * handlers run, so it cannot observe a target that those handlers create, and @@ -272,6 +294,10 @@ export interface StepOptions { when?: StepOptionsWhen; } +/** + * Where a step sits relative to its target. Other values, such as `'below'` or + * `'bottom-left'`, are invalid. `-start` and `-end` align the step to an edge. + */ export type PopperPlacement = | 'auto' | 'auto-start' @@ -289,41 +315,148 @@ export type PopperPlacement = | 'left-start' | 'left-end'; +/** + * How a step is placed against its target, set through + * {@link StepOptions.positionOptions}. Shepherd positions steps itself, with no + * dependency on Floating UI. These cover what its `offset()`, `flip()`, + * `shift()` and `strategy` were used for. A centered step, one whose `attachTo` + * lacks `element` or `on`, ignores all of them. + * + * @example + * ```js + * tour.addStep({ + * text: 'Sticks to the header while the page scrolls.', + * attachTo: { element: '#header', on: 'bottom-start' }, + * positionOptions: { offset: 12, padding: 16, strategy: 'fixed' } + * }); + * ``` + */ +export interface StepOptionsPosition { + /** + * The gap between the step and its target, in pixels. A number moves the step + * away from the target. `crossAxis` moves it along the side it sits on, toward + * the end of that side, with left and right swapped in right-to-left pages + * for `top` and `bottom` placements. The same as Floating UI's `offset()`. + * + * @defaultValue `0`. The arrow, when shown, makes up the visible gap. + */ + offset?: number | { mainAxis?: number; crossAxis?: number }; + /** + * The least distance, in pixels, kept between the step and the edges of the + * viewport and of any scrolling or clipping ancestor. It applies both when + * choosing a side, so a step flips sooner, and when sliding the step along + * its side to keep it in view. + * + * @defaultValue `0` + */ + padding?: number; + /** + * The CSS `position` of the step element. Use `'fixed'` when the target is + * itself `position: fixed`, such as a sticky header. + * + * @defaultValue `'absolute'` + */ + strategy?: 'absolute' | 'fixed'; + /** + * Whether the step may move to the opposite side, or the opposite alignment, + * when `attachTo.on` would not fit. `false` keeps `attachTo.on` even when the + * step overflows. `auto` placements always choose their own side. + * + * @defaultValue `true` + */ + flip?: boolean; +} + +/** + * Options for the arrow pointing at the target. + */ export interface StepOptionsArrow { - /* + /** * The padding from the edge for the arrow. * Not used if this is not a -start or -end placement. */ padding?: number; } +/** + * What a step attaches to. + * + * @example + * ```js + * attachTo: { element: '#save', on: 'bottom' } + * ``` + */ export interface StepOptionsAttachTo { + /** + * A selector, an element, or a function returning either. A selector or + * function is resolved each time the step shows. A selector that matches + * nothing logs an error and centers the step; see `waitForElement` and + * `skipMissingElement`. + */ element?: | HTMLElement | string | null | (() => HTMLElement | string | null | undefined); + /** + * Where the step sits relative to the element. Without it the element is + * highlighted, and the step is centered with no arrow. + */ on?: PopperPlacement; } +/** + * A page event that moves the tour to the next step. + * + * @example + * ```js + * advanceOn: { selector: '#menu', event: 'click' } + * ``` + */ export interface StepOptionsAdvanceOn { + /** + * The DOM event name, such as `click`. + */ event: string; + /** + * The selector of the element to listen on. It must exist when the step shows. + */ selector: string; } +/** + * The tour methods a button can run by name. + */ +export type StepButtonAction = 'back' | 'cancel' | 'complete' | 'next'; + +/** + * A button in the footer of a step. + */ export interface StepOptionsButton { /** - * A function executed when the button is clicked on - * It is automatically bound to the `tour` the step is associated with, so things like `this.next` will - * work inside the action. - * You can use action to skip steps or navigate to specific steps, with something like: + * What the button does when clicked. Either the name of a tour method, which + * keeps a tour definition plain JSON and works before the tour exists, or a + * function. + * + * A function is bound to the tour, so `this.next()` works inside a `function` + * or method. An arrow function does not get that binding, so `() => this.next()` + * fails; use `() => tour.next()` there. + * + * @example * ```js - * action() { - * return this.show('some_step_name'); - * } + * buttons: [ + * { text: 'Back', action: 'back', secondary: true }, + * { text: 'Next', action: 'next' }, + * { + * text: 'Skip to pricing', + * action() { + * return this.show('pricing'); + * } + * } + * ] * ``` */ - action?: (this: Tour) => void; + action?: StepButtonAction | ((this: Tour) => void); /** * Additional HTML attributes to apply to the button element. @@ -378,10 +511,16 @@ export interface StepOptionsButton { text?: StringOrStringFunction; } +/** + * A map of event names to handlers for a button. + */ export interface StepOptionsButtonEvent { [key: string]: () => void; } +/** + * The close "×" in the header of a step. It is off unless `enabled` is `true`. + */ export interface StepOptionsCancelIcon { /** * Additional HTML attributes to apply to the cancel icon button element. @@ -417,38 +556,72 @@ export interface StepOptionsCancelIcon { label?: string; } +/** + * Step event handlers by event name: `before-show`, `show`, `before-hide`, + * `hide`, `cancel`, `complete` or `destroy`. Each is called with `this` set to + * the step. + */ export interface StepOptionsWhen { [key: string]: (this: Step) => void; } +let stylesheetChecked = false; + /** * A class representing steps to be added to a tour. * @extends {Evented} */ export class Step extends Evented { + /** @internal */ _advanceOnCleanup?: (() => void) | null; + /** @internal */ _resolvedAttachTo: StepOptionsAttachTo | null; + /** @internal */ _resolvedExtraHighlightElements?: HTMLElement[]; - _originalTabIndexes: Map; - _showRequest = 0; + /** @internal */ + _originalTabIndex?: string | null; + /** @internal */ + _showRequest: number; + /** + * The tour's `classPrefix`, ending in `-`. + */ classPrefix?: string; + /** + * Stops keeping the step positioned. Set while the step is rendered. + */ declare cleanup: Function | null; + /** + * The step's element. Prefer `getElement()`. + */ el?: HTMLElement | null; + /** + * The step's `id` option, or a generated one. + */ declare id: string; + /** + * The step's options, merged with the tour's `defaultStepOptions`. + */ declare options: StepOptions; + /** + * The step's rendered element and its teardown. + */ shepherdElementComponent?: ShepherdElementResult; + /** + * The element the step attached to. Prefer `getTarget()`. + */ target?: HTMLElement | null; + /** + * The tour the step belongs to. + */ tour: Tour; constructor(tour: Tour, options: StepOptions = {}) { super(); + this._showRequest = 0; + this.tour = tour; - this.classPrefix = this.tour.options - ? normalizePrefix(this.tour.options.classPrefix) - : ''; - // @ts-expect-error TODO: investigate where styles comes from - this.styles = tour.styles; + this.classPrefix = normalizePrefix(tour.options?.classPrefix); /** * Resolved attachTo options. Due to lazy evaluation, we only resolve the options during `before-show` phase. @@ -458,18 +631,9 @@ export class Step extends Evented { */ this._resolvedAttachTo = null; - /** - * Map to store original tabIndex values of elements that are modified during the tour. - * @type {Map} - * @private - */ - this._originalTabIndexes = new Map(); - autoBind(this); this._setOptions(options); - - return this; } /** @@ -491,7 +655,7 @@ export class Step extends Evented { } /** - * Remove the step, delete the step's element, and destroy the FloatingUI instance for the step. + * Remove the step, delete the step's element, and stop repositioning it. * Triggers `destroy` event */ destroy() { @@ -502,20 +666,16 @@ export class Step extends Evented { /** * Internal cleanup that tears down the tooltip, component, and DOM element * without emitting the public "destroy" event. - * @private + * @internal */ _teardownElements() { - if (this._advanceOnCleanup) { - this._advanceOnCleanup(); - this._advanceOnCleanup = null; - } + this._advanceOnCleanup?.(); + this._advanceOnCleanup = null; destroyTooltip(this); - if (this.shepherdElementComponent) { - this.shepherdElementComponent.cleanup(); - this.shepherdElementComponent = undefined; - } + this.shepherdElementComponent?.cleanup(); + this.shepherdElementComponent = undefined; if (isHTMLElement(this.el)) { this.el.remove(); @@ -523,7 +683,6 @@ export class Step extends Evented { } this._updateStepTargetOnHide(); - this._originalTabIndexes.clear(); } /** @@ -546,6 +705,10 @@ export class Step extends Evented { this.el.hidden = true; } + destroyTooltip(this); + // Another step can share the target, and would hear its keys twice. + this.shepherdElementComponent?.cleanup(); + this._updateStepTargetOnHide(); this.trigger('hide'); @@ -554,6 +717,7 @@ export class Step extends Evented { /** * Resolves attachTo options. * @returns {{}|{element, on}} + * @internal */ _resolveExtraHiglightElements() { this._resolvedExtraHighlightElements = parseExtraHighlights(this); @@ -563,6 +727,7 @@ export class Step extends Evented { /** * Resolves attachTo options. * @returns {{}|{element, on}} + * @internal */ _resolveAttachToOptions() { this._resolvedAttachTo = parseAttachTo(this); @@ -572,14 +737,10 @@ export class Step extends Evented { /** * A selector for resolved attachTo options. * @returns {{}|{element, on}} - * @private + * @internal */ _getResolvedAttachToOptions() { - if (this._resolvedAttachTo === null) { - return this._resolveAttachToOptions(); - } - - return this._resolvedAttachTo; + return this._resolvedAttachTo || this._resolveAttachToOptions(); } /** @@ -615,20 +776,26 @@ export class Step extends Evented { } /** - * Updates the options of the step. + * Updates the options of the step. `positionOptions` combines key by key with + * the current ones, and `classes` with the tour's default classes, as when + * the step was created. * * @param {StepOptions} options The options for the step */ updateStepOptions(options: StepOptions) { - Object.assign(this.options, options); - - if (this.shepherdElementComponent) { - // Recreate the element with updated options - if (this.el) { - this._teardownElements(); - this._setupElements(); - } + // A hidden step picks up its new options the next time it is shown. + if (!this.isOpen()) { + this._mergeOptions(options); + return; } + + // Unmark the old target with the options that marked it, before they change. + this._teardownElements(); + this._mergeOptions(options); + this._resolveAttachToOptions(); + this._resolveExtraHiglightElements(); + this._setupElements(); + this._reveal(); } /** @@ -648,57 +815,50 @@ export class Step extends Evented { } /** - * Stores the original tabIndex value of an element before modifying it. - * Only stores the value if the element has a tabindex attribute. + * Remembers the target's tabindex attribute before modifying it. The step + * only ever has one target, set just after this runs. * @param {Element} element The element whose tabIndex will be stored - * @private + * @internal */ _storeOriginalTabIndex(element: Element): void { - const originalValue = element.getAttribute('tabindex'); - if (originalValue !== null) { - this._originalTabIndexes.set(element, originalValue); - } + this._originalTabIndex = element.getAttribute('tabindex'); } /** - * Restores the original tabIndex values for all elements that were modified during the tour. - * If an element is in the map, restores its original value. - * If an element is not in the map, removes the tabindex attribute (it didn't have one originally). - * Note: Does not clear the map to allow for multiple show/hide cycles. - * @private + * Puts back the target's tabindex attribute, removing it if it had none. + * Only once per show, so a later teardown leaves the app's own changes. + * @internal */ - _restoreOriginalTabIndexes(): void { - const target = this.target; - if (target) { - if (this._originalTabIndexes.has(target)) { - target.setAttribute('tabindex', this._originalTabIndexes.get(target)!); - } else { - target.removeAttribute('tabindex'); - } + _restoreOriginalTabIndex(): void { + if (this._originalTabIndex === undefined) return; + + if (isString(this._originalTabIndex)) { + this.target?.setAttribute('tabindex', this._originalTabIndex); + } else { + this.target?.removeAttribute('tabindex'); } + this._originalTabIndex = undefined; } /** * Creates Shepherd element for step based on options * * @return {HTMLElement} The DOM element for the step tooltip - * @private + * @internal */ _createTooltipContent() { - const descriptionId = `${this.id}-description`; - const labelId = `${this.id}-label`; - this.shepherdElementComponent = createShepherdElement({ classPrefix: this.classPrefix, - descriptionId, - labelId, + descriptionId: `${this.id}-description`, + labelId: `${this.id}-label`, step: this }); + const { element } = this.shepherdElementComponent; const target = this.tour.options.stepsContainer || document.body; - target.append(this.shepherdElementComponent.element); + target.append(element); - return this.shepherdElementComponent.element; + return element; } /** @@ -707,7 +867,7 @@ export class Step extends Evented { * * @param {boolean | ScrollIntoViewOptions} scrollToOptions - If true, uses the default `scrollIntoView`, * if an object, passes that object as the params to `scrollIntoView` i.e. `{ behavior: 'smooth', block: 'center' }` - * @private + * @internal */ _scrollTo(scrollToOptions: boolean | ScrollIntoViewOptions) { const { element } = this._getResolvedAttachToOptions(); @@ -726,65 +886,72 @@ export class Step extends Evented { * _getClassOptions gets all possible classes for the step * @param {StepOptions} stepOptions The step specific options * @returns {string} unique string from array of classes + * @internal */ _getClassOptions(stepOptions: StepOptions) { - const defaultStepOptions = - this.tour && this.tour.options && this.tour.options.defaultStepOptions; - const stepClasses = stepOptions.classes ? stepOptions.classes : ''; - const defaultStepOptionsClasses = - defaultStepOptions && defaultStepOptions.classes - ? defaultStepOptions.classes - : ''; - const allClasses = [ - ...stepClasses.split(' '), - ...defaultStepOptionsClasses.split(' ') - ]; - const uniqClasses = new Set(allClasses); + const defaultClasses = this.tour.options?.defaultStepOptions?.classes || ''; + const allClasses = `${stepOptions.classes || ''} ${defaultClasses}`; + + return [...new Set(allClasses.split(' '))].join(' ').trim(); + } - return Array.from(uniqClasses).join(' ').trim(); + /** + * Applies updated options, combining them as `_setOptions` does. + * @internal + */ + _mergeOptions(options: StepOptions) { + Object.assign(this.options, { + ...options, + positionOptions: { + ...this.options.positionOptions, + ...options.positionOptions + }, + ...(options.classes !== undefined && { + classes: this._getClassOptions(options) + }) + }); } /** * Sets the options for the step, maps `when` to events, sets up buttons * @param options - The options for the step + * @internal */ _setOptions(options: StepOptions = {}) { - let tourOptions = - this.tour && this.tour.options && this.tour.options.defaultStepOptions; - - tourOptions = deepmerge({}, tourOptions || {}); - - this.options = Object.assign( - { - arrow: true - }, - tourOptions, - options, - mergeTooltipConfig(tourOptions, options) - ); + const tourOptions = this.tour.options?.defaultStepOptions || {}; + + this.options = { + arrow: true, + ...tourOptions, + ...options, + positionOptions: { + ...tourOptions.positionOptions, + ...options.positionOptions + } + }; - const { when } = this.options; + if ('floatingUIOptions' in this.options) { + console.warn( + 'Shepherd: floatingUIOptions was removed. Use positionOptions: { offset, padding, strategy, flip }.' + ); + } this.options.classes = this._getClassOptions(options); - this._teardownElements(); this.id = this.options.id || `step-${uuid()}`; - if (when) { - Object.keys(when).forEach((event) => { - // @ts-expect-error TODO: fix this type error - this.on(event, when[event], this); - }); + for (const [event, handler] of Object.entries(this.options.when || {})) { + this.on(event, handler, this); } } /** - * Create the element and set up the FloatingUI instance + * Create the element and start positioning it * * The element is recreated on every show, so any previously mounted element is * torn down first. That teardown is internal — it must not emit the public * `destroy` event, which means "this step is gone for good". - * @private + * @internal */ _setupElements() { if (isHTMLElement(this.el)) { @@ -793,6 +960,15 @@ export class Step extends Evented { this.el = this._createTooltipContent(); + if (!stylesheetChecked) { + stylesheetChecked = true; + if (getComputedStyle(this.el).zIndex === 'auto') { + console.warn( + "Shepherd: stylesheet not found. Add import 'shepherd.js/dist/css/shepherd.css'." + ); + } + } + if (this.options.advanceOn) { this._advanceOnCleanup = bindAdvance(this) ?? null; } @@ -804,8 +980,8 @@ export class Step extends Evented { /** * Triggers `before-show`, generates the tooltip DOM content, - * sets up a FloatingUI instance for the tooltip, then triggers `show`. - * @private + * starts positioning the tooltip, then triggers `show`. + * @internal */ _show() { this.trigger('before-show'); @@ -819,14 +995,8 @@ export class Step extends Evented { this.tour.setupModal(); } - this.tour.modal?.setupForStep(this); - this._styleTargetElementForStep(this); - - if (this.el) { - this.el.hidden = false; - } + this._reveal(); - // start scrolling to target before showing the step if (this.options.scrollTo) { setTimeout(() => { this._scrollTo( @@ -835,90 +1005,71 @@ export class Step extends Evented { }); } - if (this.el) { - this.el.hidden = false; - } - - const content = this.shepherdElementComponent!.element; - const target = this.target || document.body; - const extraHighlightElements = this._resolvedExtraHighlightElements; - - target.classList.add(`${this.classPrefix}shepherd-enabled`); - target.classList.add(`${this.classPrefix}shepherd-target`); - content.classList.add('shepherd-enabled'); - - extraHighlightElements?.forEach((el) => { - el.classList.add(`${this.classPrefix}shepherd-enabled`); - el.classList.add(`${this.classPrefix}shepherd-target`); - }); - this.trigger('show'); } /** - * Modulates the styles of the passed step's target element, based on the step's options and - * the tour's `modal` option, to visually emphasize the element - * - * @param {Step} step The step object that attaches to the element - * @private + * Shows the rendered step and marks its target, as `_updateStepTargetOnHide` + * undoes. + * @internal */ - _styleTargetElementForStep(step: Step) { - const targetElement = step.target; - const extraHighlightElements = step._resolvedExtraHighlightElements; + _reveal() { + this.tour.modal?.setupForStep(this); + this.el!.hidden = false; + this.el!.classList.add('shepherd-enabled'); - if (!targetElement) { - return; - } + const { highlightClass, canClickTarget } = this.options; - const highlightClass = step.options.highlightClass; - if (highlightClass) { - targetElement.classList.add(highlightClass); - extraHighlightElements?.forEach((el) => el.classList.add(highlightClass)); - } + for (const { classList } of this._highlightedElements()) { + classList.add( + `${this.classPrefix}shepherd-enabled`, + `${this.classPrefix}shepherd-target` + ); - targetElement.classList.remove('shepherd-target-click-disabled'); - extraHighlightElements?.forEach((el) => - el.classList.remove('shepherd-target-click-disabled') - ); + if (this.target) { + if (highlightClass) { + classList.add(highlightClass); + } - if (step.options.canClickTarget === false) { - targetElement.classList.add('shepherd-target-click-disabled'); - extraHighlightElements?.forEach((el) => - el.classList.add('shepherd-target-click-disabled') - ); + classList.toggle( + 'shepherd-target-click-disabled', + canClickTarget === false + ); + } } } + /** + * The elements a showing step marks, which is `body` for a step without a target. + * @internal + */ + _highlightedElements() { + return [ + this.target || document.body, + ...(this._resolvedExtraHighlightElements || []) + ]; + } + /** * When a step is hidden, remove the highlightClass and 'shepherd-enabled' * and 'shepherd-target' classes - * @private + * @internal */ _updateStepTargetOnHide() { - const target = this.target || document.body; - const extraHighlightElements = this._resolvedExtraHighlightElements; - - const highlightClass = this.options.highlightClass; - if (highlightClass) { - target.classList.remove(highlightClass); - extraHighlightElements?.forEach((el) => - el.classList.remove(highlightClass) - ); - } + const { highlightClass } = this.options; + + for (const { classList } of this._highlightedElements()) { + if (highlightClass) { + classList.remove(highlightClass); + } - target.classList.remove( - 'shepherd-target-click-disabled', - `${this.classPrefix}shepherd-enabled`, - `${this.classPrefix}shepherd-target` - ); - extraHighlightElements?.forEach((el) => { - el.classList.remove( + classList.remove( 'shepherd-target-click-disabled', `${this.classPrefix}shepherd-enabled`, `${this.classPrefix}shepherd-target` ); - }); + } - this._restoreOriginalTabIndexes(); + this._restoreOriginalTabIndex(); } } diff --git a/shepherd.js/src/theme.css b/shepherd.js/src/theme.css new file mode 100644 index 000000000..3d617e28d --- /dev/null +++ b/shepherd.js/src/theme.css @@ -0,0 +1,239 @@ +/* + * Optional theme: import after shepherd.css. Every colour and dimension is a + * custom property, so restyling means overriding a token, not a selector. It + * follows the OS colour scheme; set `--shepherd-color-scheme` on :root to pin it. + * Pair it with `defaultStepOptions: { modalOverlayOpeningPadding: 8, + * modalOverlayOpeningRadius: 10 }` so the opening matches the surface. + * Positioning reads `--shepherd-gap` as extra offset from the target. + */ +/* Registered so it computes to pixels, which positioning reads, from rem or calc(). */ +@property --shepherd-gap { + syntax: ''; + inherits: true; + initial-value: 0px; +} + +.shepherd-element, +.shepherd-modal-overlay-container, +.shepherd-target { + --shepherd-accent: #fa7fee; + --shepherd-on-accent: #16202d; + --shepherd-ink: light-dark(#16202d, #eef1f5); + --shepherd-muted: light-dark(#4f5b6a, #aab4c0); + --shepherd-surface: light-dark(#fff, #1b2635); + --shepherd-line: light-dark(rgb(22 32 45 / 0.12), rgb(255 255 255 / 0.14)); + --shepherd-shade: #0b121c; + --shepherd-shade-opacity: 0.62; + --shepherd-radius: 14px; + --shepherd-gap: 16px; + --shepherd-ease: cubic-bezier(0.33, 1, 0.68, 1); +} + +.shepherd-element { + background: var(--shepherd-surface); + border-radius: var(--shepherd-radius); + box-shadow: + 0 0 0 1px var(--shepherd-line), + 0 2px 6px rgb(11 18 28 / 0.14), + 0 18px 40px -12px rgb(11 18 28 / 0.42); + color: var(--shepherd-ink); + color-scheme: var(--shepherd-color-scheme, light dark); + max-width: 22.5rem; + transition: opacity 0.24s; +} + +.shepherd-modal-overlay-container path { + fill: var(--shepherd-shade); +} + +.shepherd-modal-overlay-container.shepherd-modal-is-visible { + opacity: var(--shepherd-shade-opacity); +} + +.shepherd-arrow:before, +.shepherd-element.shepherd-has-title[data-popper-placement^='bottom'] + > .shepherd-arrow::before { + background: var(--shepherd-surface); +} + +.shepherd-content { + border-radius: inherit; + overflow: hidden; + position: relative; +} + +.shepherd-has-title .shepherd-content .shepherd-header { + background: none; + padding: 1.125rem 1.125rem 0; +} + +.shepherd-title { + color: inherit; + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.3; + text-wrap: balance; +} + +.shepherd-text { + color: var(--shepherd-muted); + font-size: 0.9375rem; + line-height: 1.5; + padding: 0.5rem 1.125rem 1.125rem; + text-wrap: pretty; +} + +.shepherd-footer { + align-items: center; + gap: 0.5rem; + padding: 0 1.125rem 1.125rem; +} + +.shepherd-button { + background: var(--shepherd-accent); + border-radius: 8px; + color: var(--shepherd-on-accent); + font: inherit; + font-size: 0.875rem; + font-weight: 600; + margin: 0; + padding: 0.5rem 0.875rem; + transition: + filter 0.15s, + background-color 0.15s, + scale 0.15s; +} + +.shepherd-button:not(:disabled):hover { + background: var(--shepherd-accent); + color: var(--shepherd-on-accent); + filter: brightness(1.06) saturate(1.1); +} + +.shepherd-button:not(:disabled):active { + scale: 0.97; +} + +.shepherd-button:focus-visible, +.shepherd-cancel-icon:focus-visible { + outline: 2px solid var(--shepherd-ink); + outline-offset: 2px; +} + +.shepherd-button.shepherd-button-secondary { + background: transparent; + box-shadow: inset 0 0 0 1px var(--shepherd-line); + color: var(--shepherd-ink); +} + +.shepherd-button.shepherd-button-secondary:not(:disabled):hover { + background: var(--shepherd-line); + color: var(--shepherd-ink); + filter: none; +} + +.shepherd-button:disabled { + opacity: 0.5; +} + +.shepherd-cancel-icon, +.shepherd-has-title .shepherd-content .shepherd-cancel-icon { + border-radius: 6px; + color: var(--shepherd-muted); + font-size: 1.5rem; + line-height: 1; + transition: color 0.15s; +} + +.shepherd-cancel-icon:hover, +.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover { + color: var(--shepherd-ink); +} + +/* stepProgress() from shepherd.js/motion supplies the two counts. */ +.shepherd-element[style*='--shepherd-step'] .shepherd-footer::before { + color: var(--shepherd-muted); + content: counter(shepherd-step) ' / ' counter(shepherd-steps); + counter-reset: shepherd-step var(--shepherd-step) shepherd-steps + var(--shepherd-steps); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + margin-right: auto; +} + +.shepherd-element[style*='--shepherd-step'] .shepherd-content::after { + background: var(--shepherd-accent); + content: ''; + height: 3px; + inset: 0 0 auto; + position: absolute; + transform: scaleX(calc(var(--shepherd-step) / var(--shepherd-steps))); + transform-origin: 0 0; +} + +@media (prefers-reduced-motion: no-preference) { + .shepherd-element { + transition: + opacity 0.24s, + scale 0.42s var(--shepherd-ease), + translate 0.42s var(--shepherd-ease); + } + + @starting-style { + .shepherd-enabled.shepherd-element { + opacity: 0; + scale: 0.96; + } + + .shepherd-enabled.shepherd-element:not( + .shepherd-centered + )[data-popper-placement^='bottom'] { + translate: 0 -8px; + } + + .shepherd-enabled.shepherd-element:not( + .shepherd-centered + )[data-popper-placement^='top'] { + translate: 0 8px; + } + + .shepherd-enabled.shepherd-element:not( + .shepherd-centered + )[data-popper-placement^='right'] { + translate: -8px 0; + } + + .shepherd-enabled.shepherd-element:not( + .shepherd-centered + )[data-popper-placement^='left'] { + translate: 8px 0; + } + + .shepherd-element[style*='--shepherd-step'] .shepherd-content::after { + transform: scaleX( + calc((var(--shepherd-step) - 1) / var(--shepherd-steps)) + ); + } + } + + .shepherd-element[style*='--shepherd-step'] .shepherd-content::after { + transition: transform 0.42s var(--shepherd-ease); + } + + .shepherd-target:not(body) { + animation: shepherd-landed 0.7s 0.3s var(--shepherd-ease); + } + + @keyframes shepherd-landed { + from { + outline: 2px solid var(--shepherd-accent); + outline-offset: 2px; + } + + to { + outline: 2px solid transparent; + outline-offset: 14px; + } + } +} diff --git a/shepherd.js/src/tour.ts b/shepherd.js/src/tour.ts index d916706cf..df968174d 100644 --- a/shepherd.js/src/tour.ts +++ b/shepherd.js/src/tour.ts @@ -7,7 +7,6 @@ import { isString, isUndefined } from './utils/type-check.ts'; -import { cleanupSteps } from './utils/cleanup.ts'; import { normalizePrefix, resolveAttachToElement, @@ -19,12 +18,27 @@ import { type ShepherdModalAPI } from './components/shepherd-modal.ts'; +/** + * What the `Shepherd` object's event handlers receive. + */ export interface EventOptions { + /** + * The step shown before this one, for `show`. + */ previous?: Step | null; + /** + * The step being shown, for `show`. + */ step?: Step | null; + /** + * The tour that fired the event. + */ tour: Tour; } +/** + * Whether to confirm before cancelling. `true` uses `window.confirm`. + */ export type TourConfirmCancel = boolean | (() => boolean) | Promise | (() => Promise); @@ -52,6 +66,9 @@ export interface TourOptions { classPrefix?: string; /** * Default options for Steps ({@link Step#constructor}), created through `addStep`. + * A step's own value replaces the default for that option, even for objects such + * as `cancelIcon` and arrays such as `buttons`. Only `classes` and + * `positionOptions` combine, `positionOptions` key by key. */ defaultStepOptions?: StepOptions; /** @@ -88,91 +105,131 @@ export interface TourOptions { */ tourName?: string; /** + * Set this on the tour, not on a step. * Whether or not steps should be placed above a darkened * modal overlay. If true, the overlay will create an opening around the target element so that it - * can remain interactive + * can remain interactive. The opening glides between steps for the `transition-duration` set on + * the overlay's path, unless the user prefers reduced motion. */ useModalOverlay?: boolean; } +/** + * The type of the package's default export. It re-fires each tour's `active`, + * `cancel`, `complete`, `inactive`, `show` and `start` events. + */ export class ShepherdBase extends Evented { + /** + * The running tour. It is `null` or `undefined` until a tour starts, so read it + * when a button is clicked, not when defining the button. + */ activeTour?: Tour | null; + /** + * The `Step` class. + */ declare Step: typeof Step; + /** + * The `Tour` class. + */ declare Tour: typeof Tour; - - constructor() { - super(); - - autoBind(this); - } } /** - * Class representing the site tour - * @extends {Evented} + * Class representing the site tour. + * + * Fires `start`, `active`, `show`, `complete`, `cancel` and `inactive`. A `show` + * handler receives `{ step, previous }`. + * + * @example + * ```js + * import Shepherd from 'shepherd.js'; + * import 'shepherd.js/dist/css/shepherd.css'; + * + * const tour = new Shepherd.Tour({ useModalOverlay: true }); + * tour.addStep({ + * title: 'Save', + * text: 'Saves your work.', + * attachTo: { element: '#save', on: 'bottom' }, + * buttons: [{ text: 'Done', action: 'complete' }] + * }); + * tour.start(); + * ``` */ export class Tour extends Evented { - trackedEvents = ['active', 'cancel', 'complete', 'show']; + /** + * The names of the events a tour fires most often. + */ + trackedEvents: string[]; + /** + * The `classPrefix` option, ending in `-`. + */ classPrefix: string; + /** + * The current step. Prefer `getCurrentStep()`. + */ currentStep?: Step | null; + /** + * The element to return focus to when the tour ends. + */ focusedElBeforeOpen?: HTMLElement | null; + /** + * The tour's id: `${tourName}--${id}`. + */ id?: string; + /** + * The overlay controller, once the tour has started. + */ modal?: ShepherdModalAPI | null; + /** + * The options the tour was created with, plus defaults. + */ options: TourOptions; + /** + * The tour's steps, in order. + */ steps: Array; /** * Monotonically increasing id of `show()` calls and tour endings, used to * invalidate pending `waitForElement` and `beforeShowPromise` waits. - * @private + * @internal */ - _showGeneration = 0; + _showGeneration: number; /** * Changes whenever a run of the tour begins or ends, used to invalidate * pending `confirmCancel` answers that belong to an earlier run. - * @private + * @internal */ - _runGeneration = 0; + _runGeneration: number; constructor(options: TourOptions = {}) { super(); - autoBind(this); + this.trackedEvents = ['active', 'cancel', 'complete', 'show']; + this._showGeneration = 0; + this._runGeneration = 0; - const defaultTourOptions = { - exitOnEsc: true, - keyboardNavigation: true - }; + autoBind(this); - this.options = Object.assign({}, defaultTourOptions, options); + this.options = { exitOnEsc: true, keyboardNavigation: true, ...options }; this.classPrefix = normalizePrefix(this.options.classPrefix); this.steps = []; this.addSteps(this.options.steps); // Pass these events onto the global Shepherd object - const events = [ - 'active', - 'cancel', - 'complete', - 'inactive', - 'show', - 'start' - ]; - events.map((event) => { - ((e) => { - this.on(e, (opts?: { [key: string]: unknown }) => { + ['active', 'cancel', 'complete', 'inactive', 'show', 'start'].forEach( + (event) => { + this.on(event, (opts?: { [key: string]: unknown }) => { opts = opts || {}; opts['tour'] = this; - Shepherd.trigger(e, opts); + Shepherd.trigger(event, opts); }); - })(event); - }); - - this._setTourID(options.id); + } + ); - return this; + this.id = `${this.options.tourName || 'tour'}--${options.id || uuid()}`; } /** @@ -183,18 +240,13 @@ export class Tour extends Evented { * @return The newly added step */ addStep(options: StepOptions | Step, index?: number) { - let step = options; + const step = options instanceof Step ? options : new Step(this, options); + step.tour = this; - if (!(step instanceof Step)) { - step = new Step(this, step); + if (isUndefined(index)) { + this.steps.push(step); } else { - step.tour = this; - } - - if (!isUndefined(index)) { - this.steps.splice(index, 0, step as Step); - } else { - this.steps.push(step as Step); + this.steps.splice(index, 0, step); } return step; @@ -226,31 +278,43 @@ export class Tour extends Evented { * Calls _done() triggering the 'cancel' event * If `confirmCancel` is true, will show a window.confirm before cancelling * If `confirmCancel` is a function, will call it and wait for the return value, - * and only cancel when the value returned is true + * and only cancel when the value returned is true. A promise is waited on the + * same way. */ async cancel() { - if (this.options.confirmCancel) { - const cancelMessage = - this.options.confirmCancelMessage || - 'Are you sure you want to stop the tour?'; - let stopTour; - - if (isFunction(this.options.confirmCancel)) { - const run = this._runGeneration; - stopTour = await this.options.confirmCancel(); - - // The answer only applies to the run that asked for it. - if (run !== this._runGeneration) { - return; - } - } else { - stopTour = window.confirm(cancelMessage); + let stopTour = true; + + const { confirmCancel } = this.options; + if ( + isFunction(confirmCancel) || + isFunction((confirmCancel as Promise | undefined)?.then) + ) { + const run = this._runGeneration; + try { + stopTour = Boolean( + await (isFunction(confirmCancel) ? confirmCancel() : confirmCancel) + ); + } catch (error) { + // Escape and the close icon do not await this, so nothing else would. + console.error( + 'Shepherd: confirmCancel failed, so the tour stays open.', + error + ); + return; } - if (stopTour) { - this._done('cancel'); + // The answer only applies to the run that asked for it. + if (run !== this._runGeneration) { + return; } - } else { + } else if (confirmCancel) { + stopTour = window.confirm( + this.options.confirmCancelMessage || + 'Are you sure you want to stop the tour?' + ); + } + + if (stopTour) { this._done('cancel'); } } @@ -268,9 +332,7 @@ export class Tour extends Evented { * @return The step corresponding to the `id` */ getById(id: number | string) { - return this.steps.find((step) => { - return step.id === id; - }); + return this.steps.find((step) => step.id === id); } /** @@ -287,11 +349,7 @@ export class Tour extends Evented { // A show still waiting on a promise or element must not undo this hide. this._showGeneration++; - const currentStep = this.getCurrentStep(); - - if (currentStep) { - return currentStep.hide(); - } + return this.getCurrentStep()?.hide(); } /** @@ -321,20 +379,16 @@ export class Tour extends Evented { */ removeStep(name: string) { const current = this.getCurrentStep(); + const step = this.getById(name); - // Find the step, destroy it and remove it from this.steps - this.steps.some((step, i) => { - if (step.id === name) { - if (step.isOpen()) { - step.hide(); - } - - step.destroy(); - this.steps.splice(i, 1); - - return true; + if (step) { + if (step.isOpen()) { + step.hide(); } - }); + + step.destroy(); + this.steps.splice(this.steps.indexOf(step), 1); + } if (current && current.id === name) { // The removed step may still be waiting on its `beforeShowPromise`. @@ -365,18 +419,14 @@ export class Tour extends Evented { // Invalidate any pending wait from a previous call. const generation = ++this._showGeneration; - const shouldSkipStep = - isFunction(step.options.showOn) && !step.options.showOn(); - // If `showOn` returns false, we want to skip the step, otherwise, show the step like normal // Return the result, since the step we skip to may itself be waiting on an element. - if (shouldSkipStep) { + if (isFunction(step.options.showOn) && !step.options.showOn()) { return this._skipStep(step, forward); } const { skipMissingElement, waitForElement } = step.options; - const attachToElement = - step.options.attachTo && step.options.attachTo.element; + const attachToElement = step.options.attachTo?.element; // `waitForElement` and `skipMissingElement` only apply to steps that // locate their target dynamically. Steps without an `attachTo` element // are intentionally centered and never treated as missing. @@ -408,9 +458,7 @@ export class Tour extends Evented { }); } - if (skipMissingElement) { - return this._skipStep(step, forward); - } + return this._skipStep(step, forward); } this._showStep(step); @@ -419,7 +467,7 @@ export class Tour extends Evented { /** * Sets the given step as the current step and shows it * @param {Step} step - The step to show - * @private + * @internal */ _showStep(step: Step) { // `currentStep` is `undefined` until a step has shown, so normalize to @@ -427,10 +475,7 @@ export class Tour extends Evented { const previous = this.currentStep ?? null; this.currentStep = step; - this.trigger('show', { - step, - previous - }); + this.trigger('show', { step, previous }); step.show(); } @@ -467,7 +512,7 @@ export class Tour extends Evented { /** * Called whenever the tour is cancelled or completed, basically anytime we exit the tour * @param {string} event - The event name to trigger - * @private + * @internal */ _done(event: string) { // Invalidate any pending `beforeShowPromise`, which could otherwise bring @@ -476,11 +521,7 @@ export class Tour extends Evented { this._runGeneration++; const index = this.steps.indexOf(this.currentStep as Step); - if (Array.isArray(this.steps)) { - this.steps.forEach((step) => step.destroy()); - } - - cleanupSteps(this); + this.steps.forEach((step) => step.destroy()); this.trigger(event, { index }); @@ -491,14 +532,8 @@ export class Tour extends Evented { this.trigger('inactive', { tour: this }); if (this.modal) { - this.modal.hide(); - } - - if (event === 'cancel' || event === 'complete') { - if (this.modal) { - this.modal.destroy(); - this.modal = null; - } + this.modal.destroy(); + this.modal = null; } // Focus the element that was focused before the tour started @@ -509,6 +544,7 @@ export class Tour extends Evented { /** * Make this tour "active" + * @internal */ _setupActiveTour() { this._runGeneration++; @@ -541,7 +577,7 @@ export class Tour extends Evented { * Called when `showOn` evaluates to false, to skip the step or complete/cancel the tour if there are no more steps * @param {Step} step - The step to skip * @param {boolean} forward - True if we are going forward, false if backward - * @private + * @internal */ _skipStep(step: Step, forward: boolean) { const index = this.steps.indexOf(step); @@ -559,7 +595,7 @@ export class Tour extends Evented { /** * Before showing, hide the current step and if the tour is not * already active, call `this._setupActiveTour`. - * @private + * @internal */ _updateStateBeforeShow() { if (this.currentStep) { @@ -570,18 +606,6 @@ export class Tour extends Evented { this._setupActiveTour(); } } - - /** - * Sets this.id to a provided tourName and id or `${tourName}--${uuid}` - * @param {string} optionsId - True if we are going forward, false if backward - * @private - */ - _setTourID(optionsId: string | undefined) { - const tourName = this.options.tourName || 'tour'; - const tourId = optionsId || uuid(); - - this.id = `${tourName}--${tourId}`; - } } /** diff --git a/shepherd.js/src/utils/auto-bind.ts b/shepherd.js/src/utils/auto-bind.ts index 528ffecea..953983c1a 100644 --- a/shepherd.js/src/utils/auto-bind.ts +++ b/shepherd.js/src/utils/auto-bind.ts @@ -2,18 +2,12 @@ * Binds all the methods on a JS Class to the `this` context of the class. * Adapted from https://github.com/sindresorhus/auto-bind * @param self The `this` context of the class - * @return The `this` context of the class */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export default function autoBind(self: any) { - const keys = Object.getOwnPropertyNames(self.constructor.prototype); - for (let i = 0; i < keys.length; i++) { - const key = keys[i] as keyof typeof self; - const val = self[key]; - if (key !== 'constructor' && typeof val === 'function') { - self[key] = val.bind(self); + for (const key of Object.getOwnPropertyNames(self.constructor.prototype)) { + if (key !== 'constructor' && typeof self[key] === 'function') { + self[key] = self[key].bind(self); } } - - return self; } diff --git a/shepherd.js/src/utils/bind.ts b/shepherd.js/src/utils/bind.ts index eea7939cc..ceb309da9 100644 --- a/shepherd.js/src/utils/bind.ts +++ b/shepherd.js/src/utils/bind.ts @@ -1,27 +1,6 @@ import type { Step } from '../step.ts'; import { isUndefined } from './type-check.ts'; -/** - * Sets up the handler to determine if we should advance the tour - * @param step The step instance - * @param selector - * @private - */ -function _setupAdvanceOnHandler(step: Step, selector?: string) { - return (event: Event) => { - if (step.isOpen()) { - const targetIsEl = step.el && event.currentTarget === step.el; - const targetIsSelector = - !isUndefined(selector) && - (event.currentTarget as HTMLElement).matches(selector); - - if (targetIsSelector || targetIsEl) { - step.tour.next(); - } - } - }; -} - /** * Bind the event handler for advanceOn * @param step The step instance @@ -36,7 +15,15 @@ export function bindAdvance(step: Step): (() => void) | undefined { return; } - const handler = _setupAdvanceOnHandler(step, selector); + const handler = (e: Event) => { + if ( + step.isOpen() && + !isUndefined(selector) && + (e.currentTarget as HTMLElement).matches(selector) + ) { + step.tour.next(); + } + }; if (!isUndefined(selector)) { const el = document.querySelector(selector); diff --git a/shepherd.js/src/utils/cleanup.ts b/shepherd.js/src/utils/cleanup.ts deleted file mode 100644 index 4949d9575..000000000 --- a/shepherd.js/src/utils/cleanup.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Tour } from '../tour.ts'; -import { isHTMLElement } from './type-check.ts'; - -/** - * Cleanup the steps and set pointerEvents back to 'auto' - * @param tour The tour object - */ -export function cleanupSteps(tour: Tour) { - if (tour) { - const { steps } = tour; - - steps.forEach((step) => { - if ( - step.options && - step.options.canClickTarget === false && - step.options.attachTo - ) { - if (isHTMLElement(step.target)) { - step.target.classList.remove('shepherd-target-click-disabled'); - } - - if (step._resolvedExtraHighlightElements) { - step._resolvedExtraHighlightElements.forEach((element) => { - if (isHTMLElement(element)) { - element.classList.remove('shepherd-target-click-disabled'); - } - }); - } - } - }); - } -} diff --git a/shepherd.js/src/utils/dom.ts b/shepherd.js/src/utils/dom.ts index 70132bba5..c10242766 100644 --- a/shepherd.js/src/utils/dom.ts +++ b/shepherd.js/src/utils/dom.ts @@ -1,6 +1,3 @@ -type Attrs = Record; -type Child = Node | string | null | undefined | false; - /** * Create an HTML element with attributes and children. * Attributes starting with "on" are added as event listeners. @@ -8,31 +5,10 @@ type Child = Node | string | null | undefined | false; */ export function h( tag: string, - attrs?: Attrs | null, - ...children: Child[] + attrs: Record, + ...children: Array ): HTMLElement { const el = document.createElement(tag); - applyAttrs(el, attrs); - appendChildren(el, children); - return el; -} - -/** - * Create an SVG element with attributes and children. - */ -export function svgEl( - tag: string, - attrs?: Attrs | null, - ...children: Child[] -): SVGElement { - const el = document.createElementNS('http://www.w3.org/2000/svg', tag); - applyAttrs(el, attrs); - appendChildren(el, children); - return el; -} - -function applyAttrs(el: Element, attrs?: Attrs | null) { - if (!attrs) return; for (const [key, value] of Object.entries(attrs)) { if (value === null || value === undefined || value === false) continue; @@ -45,14 +21,8 @@ function applyAttrs(el: Element, attrs?: Attrs | null) { el.setAttribute(key, String(value)); } } -} -function appendChildren(el: Element, children: Child[]) { - for (const child of children) { - if (child != null && child !== false) { - el.append( - typeof child === 'string' ? document.createTextNode(child) : child - ); - } - } + el.append(...(children.filter(Boolean) as Array)); + + return el; } diff --git a/shepherd.js/src/utils/floating-ui.ts b/shepherd.js/src/utils/floating-ui.ts index 0a4e5c298..30ed1f90c 100644 --- a/shepherd.js/src/utils/floating-ui.ts +++ b/shepherd.js/src/utils/floating-ui.ts @@ -1,4 +1,3 @@ -import { deepmerge } from 'deepmerge-ts'; import { shouldCenterStep } from './general.ts'; import { autoUpdate, @@ -6,16 +5,15 @@ import { computePosition, flip, autoPlacement, - limitShift, + offset, shift, - type ComputePositionConfig, + type ComputePositionResult, type Middleware, - type MiddlewareData, + type PositionConfig, type Placement, - type Alignment, - type Strategy -} from '@floating-ui/dom'; -import type { Step, StepOptions, StepOptionsAttachTo } from '../step.ts'; + type Alignment +} from './position.ts'; +import type { Step, StepOptionsAttachTo } from '../step.ts'; import { isHTMLElement } from './type-check.ts'; /** @@ -23,7 +21,7 @@ import { isHTMLElement } from './type-check.ts'; * * @param step The step instance */ -export function setupTooltip(step: Step): ComputePositionConfig { +export function setupTooltip(step: Step): PositionConfig { if (step.cleanup) { step.cleanup(); } @@ -31,7 +29,7 @@ export function setupTooltip(step: Step): ComputePositionConfig { const attachToOptions = step._getResolvedAttachToOptions(); let target = attachToOptions.element as HTMLElement; - const floatingUIOptions = getFloatingUIOptions(attachToOptions, step); + const config = getPositionConfig(attachToOptions, step); const shouldCenter = shouldCenterStep(attachToOptions); // Keep this function-local so every fresh step render gets one focus attempt. let shouldFocusAfterRender = true; @@ -49,35 +47,20 @@ export function setupTooltip(step: Step): ComputePositionConfig { return; } - setPosition(target, step, floatingUIOptions, shouldCenter, { - shouldFocusAfterRender - }); + // Rebuilt each time, so a theme gap changed by a responsive rule applies. + setPosition( + target, + step, + getPositionConfig(attachToOptions, step), + shouldCenter, + { shouldFocusAfterRender } + ); shouldFocusAfterRender = false; }); step.target = attachToOptions.element as HTMLElement; - return floatingUIOptions; -} - -/** - * Merge tooltip options handling nested keys. - * - * @param tourOptions - The default tour options. - * @param options - Step specific options. - * - * @return {floatingUIOptions: FloatingUIOptions} - */ -export function mergeTooltipConfig( - tourOptions: StepOptions, - options: StepOptions -): { floatingUIOptions: ComputePositionConfig } { - return { - floatingUIOptions: deepmerge( - tourOptions.floatingUIOptions || {}, - options.floatingUIOptions || {} - ) - }; + return config; } /** @@ -96,93 +79,76 @@ export function destroyTooltip(step: Step) { function setPosition( target: HTMLElement, step: Step, - floatingUIOptions: ComputePositionConfig, + config: PositionConfig, shouldCenter: boolean, { shouldFocusAfterRender }: { shouldFocusAfterRender: boolean } ) { - const positionPromise = computePosition( - target, - step.el as HTMLElement, - floatingUIOptions - ).then(floatingUIposition(step, shouldCenter)); - - if (!shouldFocusAfterRender) { - return positionPromise; + try { + // Measured against the offset parent it will have once written. + if (!shouldCenter && step.el) step.el.style.position = config.strategy; + writePosition( + step, + shouldCenter, + computePosition(target, step.el as HTMLElement, config) + ); + } catch (error) { + console.error('Shepherd: could not position a step.', error); } - return ( - positionPromise - // Wait before forcing focus. - .then( - (step: Step) => - new Promise((resolve) => { - setTimeout(() => resolve(step), 300); - }) - ) - // Replaces focusAfterRender modifier. - .then((step: Step) => { - if (step?.el) { - step.el.tabIndex = 0; - step.el.focus({ preventScroll: true }); - } - }) - ); + if (shouldFocusAfterRender) { + // Focusing flushes layout, which the next frame does anyway. jsdom only has + // frames when asked to. + (window.requestAnimationFrame || setTimeout)(() => { + if (step.el) { + step.el.tabIndex = 0; + step.el.focus({ preventScroll: true }); + } + }); + } } -function floatingUIposition(step: Step, shouldCenter: boolean) { - return ({ - x, - y, - strategy, - placement, - middlewareData - }: { - x: number; - y: number; - strategy: Strategy; - placement: Placement; - middlewareData: MiddlewareData; - }) => { - if (!step.el) { - return step; - } - - if (shouldCenter) { - // `position: fixed` is intentional here and must NOT follow `strategy`. - // Centering relies on `left`/`top: 50%` plus a `translate(-50%, -50%)`, - // and those percentages have to resolve against the viewport. Under the - // default `absolute` strategy they would resolve against the document - // instead, placing the step at 50% of the *page* height so it scrolls - // off screen. A step centers when it has no `attachTo`, or when its - // `attachTo` is missing either `element` or `on` (see `shouldCenterStep`); - // all of those are modal dialogs, so viewport centering is the correct - // behavior regardless of `strategy`. - Object.assign(step.el.style, { - position: 'fixed', - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)' - }); - } else { - Object.assign(step.el.style, { - position: strategy, - left: `${x}px`, - top: `${y}px` - }); - } - - step.el.dataset['popperPlacement'] = placement; +function writePosition( + step: Step, + shouldCenter: boolean, + { x, y, strategy, placement, middlewareData }: ComputePositionResult +) { + const el = step.el as HTMLElement; + if (shouldCenter) { + // `position: fixed` is intentional here and must NOT follow `strategy`. + // Centering relies on `left`/`top: 50%` plus a `translate(-50%, -50%)`, + // and those percentages have to resolve against the viewport. Under the + // default `absolute` strategy they would resolve against the document + // instead, placing the step at 50% of the *page* height so it scrolls + // off screen. A step centers when it has no `attachTo`, or when its + // `attachTo` is missing either `element` or `on` (see `shouldCenterStep`); + // all of those are modal dialogs, so viewport centering is the correct + // behavior regardless of `strategy`. + Object.assign(el.style, { + position: 'fixed', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)' + }); + } else { + Object.assign(el.style, { + position: strategy, + left: `${x}px`, + top: `${y}px` + }); + } - placeArrow(step.el, middlewareData); + el.dataset['popperPlacement'] = placement; - return step; - }; + placeArrow(el, middlewareData); } -function placeArrow(el: HTMLElement, middlewareData: MiddlewareData) { +function placeArrow( + el: HTMLElement, + middlewareData: ComputePositionResult['middlewareData'] +) { const arrowEl = el.querySelector('.shepherd-arrow'); - if (isHTMLElement(arrowEl) && middlewareData.arrow) { - const { x: arrowX, y: arrowY } = middlewareData.arrow; + if (isHTMLElement(arrowEl) && middlewareData['arrow']) { + const { x: arrowX, y: arrowY } = middlewareData['arrow']; Object.assign(arrowEl.style, { left: arrowX != null ? `${arrowX}px` : '', top: arrowY != null ? `${arrowY}px` : '' @@ -191,122 +157,61 @@ function placeArrow(el: HTMLElement, middlewareData: MiddlewareData) { } /** - * Gets the `Floating UI` options from a set of base `attachTo` options - * @param attachToOptions + * Builds the positioning config for a step from its `attachTo` and + * `positionOptions`. + * + * @param attachToOptions The step's resolved `attachTo` * @param step The step instance * @private */ -export function getFloatingUIOptions( +export function getPositionConfig( attachToOptions: StepOptionsAttachTo, step: Step -): ComputePositionConfig { - const options: ComputePositionConfig = { - strategy: 'absolute' +): PositionConfig { + const { + offset: userOffset, + padding, + strategy = 'absolute', + flip: flips = true + } = step.options.positionOptions || {}; + const on = attachToOptions.on || ''; + const hasAutoPlacement = on.includes('auto'); + const middleware: Middleware[] = []; + const config: PositionConfig = { + placement: on && !hasAutoPlacement ? (on as Placement) : 'bottom', + strategy, + middleware }; - options.middleware = []; - - const arrowEl = addArrow(step); - - const shouldCenter = shouldCenterStep(attachToOptions); - - const hasAutoPlacement = attachToOptions.on?.includes('auto'); - - const hasEdgeAlignment = - attachToOptions?.on?.includes('-start') || - attachToOptions?.on?.includes('-end'); - - if (!shouldCenter) { - if (hasAutoPlacement) { - options.middleware.push( - autoPlacement({ - crossAxis: true, - alignment: hasEdgeAlignment - ? (attachToOptions?.on?.split('-').pop() as Alignment) - : null - }) - ); - } else { - options.middleware.push(flip()); - } - - options.middleware.push( - // Replicate PopperJS default behavior. - shift({ - limiter: limitShift(), - crossAxis: true - }) + if (shouldCenterStep(attachToOptions)) return config; + + const alignment = on.split('-')[1] as Alignment | undefined; + // The theme's gap counts as offset, so flipping and shifting account for it. + const themeGap = step.el + ? parseFloat( + getComputedStyle(step.el).getPropertyValue('--shepherd-gap') + ) || 0 + : 0; + if (userOffset || themeGap) { + middleware.push( + offset( + typeof userOffset === 'object' + ? { ...userOffset, mainAxis: (userOffset.mainAxis ?? 0) + themeGap } + : (userOffset ?? 0) + themeGap + ) ); - - if (!hasAutoPlacement) options.placement = attachToOptions.on as Placement; - } - - const mergedOptions: ComputePositionConfig = deepmerge( - options, - step.options.floatingUIOptions || {} - ); - - // `arrow()` has to be the *last* middleware to run. `Floating UI` executes - // middleware sequentially, threading `x`/`y` from one to the next, and - // `arrow()` computes its offset from the coordinates as they stand on its own - // turn. Any middleware that runs after it (a user supplied `offset()`, - // `shift()`, etc.) moves the tooltip again and silently invalidates - // `middlewareData.arrow`, which `placeArrow()` writes straight to the DOM. - // Since user options are merged in above -- and `deepmerge` concatenates - // arrays -- Shepherd's arrow is appended afterwards rather than pushed in - // before the merge. - if ( - !shouldCenter && - arrowEl && - !hasArrowMiddleware(mergedOptions.middleware) - ) { - const arrowOptions = - typeof step.options.arrow === 'object' - ? step.options.arrow - : { padding: 4 }; - - mergedOptions.middleware = [ - ...(mergedOptions.middleware ?? []), - arrow({ - element: arrowEl, - padding: hasEdgeAlignment ? arrowOptions.padding : 0 - }) - ]; } - - return mergedOptions; -} - -/** - * Type guard filtering out the falsy entries `Floating UI` allows in a - * middleware array. - * - * @param middleware A single entry of a `middleware` array - * @private - */ -function isMiddleware( - middleware: Middleware | false | null | undefined -): middleware is Middleware { - return Boolean(middleware); -} - -/** - * Determines whether a middleware array already contains an `arrow` middleware, - * in which case the user's own arrow wins and Shepherd does not add its own. - * - * @param middleware The merged `middleware` array, which may contain falsy entries - * @private - */ -function hasArrowMiddleware(middleware: ComputePositionConfig['middleware']) { - return Boolean( - middleware?.some((item) => isMiddleware(item) && item.name === 'arrow') - ); -} - -function addArrow(step: Step) { - if (step.options.arrow && step.el) { - return step.el.querySelector('.shepherd-arrow'); + if (hasAutoPlacement) middleware.push(autoPlacement(alignment, padding)); + else if (flips) middleware.push(flip(padding)); + middleware.push(shift(padding)); + + const arrowEl = + step.options.arrow && step.el?.querySelector('.shepherd-arrow'); + if (arrowEl) { + const arrowPadding = + typeof step.options.arrow === 'object' ? step.options.arrow.padding : 4; + middleware.push(arrow(arrowEl, alignment ? arrowPadding : 0)); } - return false; + return config; } diff --git a/shepherd.js/src/utils/general.ts b/shepherd.js/src/utils/general.ts index f1f74b6c4..3a72b6d4c 100644 --- a/shepherd.js/src/utils/general.ts +++ b/shepherd.js/src/utils/general.ts @@ -1,96 +1,69 @@ -import { type Tour, type TourOptions } from '../tour.ts'; -import { - type StepOptionsAttachTo, - type Step, - type StepOptions -} from '../step.ts'; +import { type StepOptionsAttachTo, type Step } from '../step.ts'; import { isFunction, isString } from './type-check.ts'; -export class StepNoOp { - constructor(_options: StepOptions) {} -} - -export class TourNoOp { - constructor(_tour: Tour, _options: TourOptions) {} -} - /** * Ensure class prefix ends in `-` * @param prefix - The prefix to prepend to the class names generated by nano-css * @return The prefix ending in `-` */ export function normalizePrefix(prefix?: string) { - if (!isString(prefix) || prefix === '') { + if (!isString(prefix) || !prefix) { return ''; } - return prefix.charAt(prefix.length - 1) !== '-' ? `${prefix}-` : prefix; + return prefix.endsWith('-') ? prefix : `${prefix}-`; } -/** - * Resolves attachTo options, converting element option value to a qualified HTMLElement. - * @param step - The step instance - * @returns {{}|{element, on}} - * `element` is a qualified HTML Element - * `on` is a string position value - */ -export function parseAttachTo(step: Step) { - const options = step.options.attachTo || {}; - const returnOpts = Object.assign({}, options); - - if (isFunction(returnOpts.element)) { - // Bind the callback to step so that it has access to the object, to enable running additional logic - returnOpts.element = returnOpts.element.call(step); - } +function locateAttachToElement(step: Step, logMissing?: boolean) { + const attachTo = step.options.attachTo || {}; + let element = isFunction(attachTo.element) + ? attachTo.element.call(step) + : attachTo.element; - if (isString(returnOpts.element)) { - // Can't override the element in user opts reference because we can't - // guarantee that the element will exist in the future. + if (isString(element)) { try { - returnOpts.element = document.querySelector( - returnOpts.element - ) as HTMLElement; - } catch (_e) { - // TODO + element = document.querySelector(element) as HTMLElement | null; + } catch { + element = null; } - if (!returnOpts.element && !step.options.skipMissingElement) { + + if (!element && logMissing) { console.error( - `The element for this Shepherd step was not found ${options.element}` + `Shepherd: attachTo.element not found: ${attachTo.element}. The step is centered instead. Fix the selector, or set waitForElement or skipMissingElement.` ); } } - return returnOpts; + return element as HTMLElement | null | undefined; +} + +/** + * Resolves attachTo options, converting element option value to a qualified HTMLElement. + * @param step - The step instance + * @returns {{}|{element, on}} + * `element` is a qualified HTML Element + * `on` is a string position value + */ +export function parseAttachTo(step: Step): StepOptionsAttachTo { + return { + ...step.options.attachTo, + element: locateAttachToElement(step, !step.options.skipMissingElement) + }; } /** - * Resolves a step's `attachTo.element` to an HTMLElement, evaluating function - * locators and querying selector strings. Unlike `parseAttachTo`, this never - * logs and simply returns `null` when the element cannot be resolved. + * Resolves a step's `attachTo.element` to an HTMLElement. Unlike + * `parseAttachTo`, this never logs. * @param step - The step instance * @returns The resolved element, or `null` if it could not be found */ export function resolveAttachToElement(step: Step): HTMLElement | null { - const attachTo = step.options.attachTo || {}; - let element = isFunction(attachTo.element) - ? attachTo.element.call(step) - : attachTo.element; - - if (isString(element)) { - try { - element = document.querySelector(element) as HTMLElement | null; - } catch (_e) { - element = null; - } - } - - return element || null; + return locateAttachToElement(step) || null; } /** * Waits for a step's `attachTo.element` to be resolvable, watching the DOM - * with a `MutationObserver` (falling back to polling when it is unavailable) - * until `timeout` milliseconds have elapsed. + * with a `MutationObserver` until `timeout` milliseconds have elapsed. * @param step - The step instance * @param timeout - The maximum amount of time, in milliseconds, to wait * @returns A promise resolving to the element, or `null` if it did not appear @@ -108,44 +81,26 @@ export function waitForAttachToElement( return; } - let observer: MutationObserver | null = null; - let pollTimer: ReturnType | null = null; - let timeoutTimer: ReturnType | null = null; - const finish = (result: HTMLElement | null) => { - observer?.disconnect(); - - if (pollTimer !== null) { - clearInterval(pollTimer); - } - - if (timeoutTimer !== null) { - clearTimeout(timeoutTimer); - } - + observer.disconnect(); + clearTimeout(timeoutTimer); resolve(result); }; - const check = () => { + const observer = new MutationObserver(() => { const found = resolveAttachToElement(step); if (found) { finish(found); } - }; + }); + const timeoutTimer = setTimeout(() => finish(null), timeout); - timeoutTimer = setTimeout(() => finish(null), timeout); - - if (typeof MutationObserver !== 'undefined') { - observer = new MutationObserver(check); - observer.observe(document.documentElement, { - attributes: true, - childList: true, - subtree: true - }); - } else { - pollTimer = setInterval(check, 50); - } + observer.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true + }); }); } @@ -153,12 +108,9 @@ export function waitForAttachToElement( * Resolves the step's `extraHighlights` option, converting any locator values to HTMLElements. */ export function parseExtraHighlights(step: Step): HTMLElement[] { - if (step.options.extraHighlights) { - return step.options.extraHighlights.flatMap((highlight) => { - return Array.from(document.querySelectorAll(highlight)) as HTMLElement[]; - }); - } - return []; + return (step.options.extraHighlights || []).flatMap((highlight) => [ + ...document.querySelectorAll(highlight) + ]); } /** @@ -166,24 +118,15 @@ export function parseExtraHighlights(step: Step): HTMLElement[] { * alternative for the deprecated step.isCentered() method. */ export function shouldCenterStep(resolvedAttachToOptions: StepOptionsAttachTo) { - if ( - resolvedAttachToOptions === undefined || - resolvedAttachToOptions === null - ) { - return true; - } - - return !resolvedAttachToOptions.element || !resolvedAttachToOptions.on; + return !resolvedAttachToOptions?.element || !resolvedAttachToOptions.on; } /** * Create a unique id for steps, tours, modals, etc */ export function uuid() { - let d = Date.now(); return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = ((d + Math.random() * 16) % 16) | 0; - d = Math.floor(d / 16); + const r = (Math.random() * 16) | 0; return (c == 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } diff --git a/shepherd.js/src/utils/overlay-path.ts b/shepherd.js/src/utils/overlay-path.ts index cf3cb43f1..acffbbfdb 100644 --- a/shepherd.js/src/utils/overlay-path.ts +++ b/shepherd.js/src/utils/overlay-path.ts @@ -40,23 +40,15 @@ export function makeOverlayPath(overlayPaths: OverlayPathParams[]) { openings += `M${x + topLeft},${y}` + - `a${topLeft},${topLeft},0,0,0-${topLeft},${topLeft}` + + `a${topLeft},${topLeft},0,0,0,-${topLeft},${topLeft}` + `V${height + y - bottomLeft}` + `a${bottomLeft},${bottomLeft},0,0,0,${bottomLeft},${bottomLeft}` + `H${width + x - bottomRight}` + - `a${bottomRight},${bottomRight},0,0,0,${bottomRight}-${bottomRight}` + + `a${bottomRight},${bottomRight},0,0,0,${bottomRight},-${bottomRight}` + `V${y + topRight}` + - `a${topRight},${topRight},0,0,0-${topRight}-${topRight}` + + `a${topRight},${topRight},0,0,0,-${topRight},-${topRight}` + `Z`; }); - return ( - `M${w},${h}` + - `H0` + - `V0` + - `H${w}` + - `V${h}` + - `Z` + - `${openings}` - ).replace(/\s/g, ''); + return `M${w},${h}H0V0H${w}V${h}Z${openings}`; } diff --git a/shepherd.js/src/utils/position.test-d.ts b/shepherd.js/src/utils/position.test-d.ts new file mode 100644 index 000000000..f2e702acb --- /dev/null +++ b/shepherd.js/src/utils/position.test-d.ts @@ -0,0 +1,21 @@ +// Compile-time checks, run by `tsc`, of what `positionOptions` accepts. +import type { StepOptions, StepOptionsPosition } from '../step.ts'; + +export const everyKey: StepOptionsPosition = { + offset: { mainAxis: 8, crossAxis: -4 }, + padding: 16, + strategy: 'fixed', + flip: false +}; + +export const onAStep: StepOptions = { positionOptions: { offset: 12 } }; + +export const removed: StepOptions = { + // @ts-expect-error `floatingUIOptions` was replaced by `positionOptions`. + floatingUIOptions: { strategy: 'fixed' } +}; + +export const badStrategy: StepOptionsPosition = { + // @ts-expect-error Only `absolute` and `fixed`. + strategy: 'sticky' +}; diff --git a/shepherd.js/src/utils/position.ts b/shepherd.js/src/utils/position.ts new file mode 100644 index 000000000..7ee4a3d74 --- /dev/null +++ b/shepherd.js/src/utils/position.ts @@ -0,0 +1,1032 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- + This walks DOM nodes across realms and shadow roots, whose types are unions + of elements and windows. */ +type Side = 'top' | 'right' | 'bottom' | 'left'; +type Axis = 'x' | 'y'; +type Coords = Record; +type Strategy = 'absolute' | 'fixed'; + +/** The side of its target a step sits on, optionally aligned along that side. */ +export type Placement = Side | `${Side}-${Alignment}`; + +/** Which edge of its target a step lines up with. */ +export type Alignment = 'start' | 'end'; + +/** One positioning step, run in order and rerun from the start on a reset. */ +export interface Middleware { + /** Keys the data it shares with later ones. */ + name: string; + fn(state: MiddlewareState): MiddlewareResult; +} + +export interface PositionConfig { + placement: Placement; + strategy: Strategy; + middleware: Middleware[]; +} + +interface Rect extends Coords { + width: number; + height: number; +} + +interface ElementRects { + reference: Rect; + floating: Rect; +} + +/** Floating UI's `MiddlewareState`, less what only consumer middleware read. */ +interface MiddlewareState extends Coords, Measurements { + initialPlacement: Placement; + placement: Placement; + strategy: Strategy; + middlewareData: Record; +} + +interface MiddlewareResult extends Partial { + data?: Record; + reset?: boolean | { placement: Placement }; +} + +export interface ComputePositionResult extends Coords { + placement: Placement; + strategy: Strategy; + middlewareData: Record; +} + +type Attempt = { placement: Placement; overflows: number[] }; + +const { min, max, round } = Math; + +const ONE: Coords = { x: 1, y: 1 }; +const SIDES: Side[] = ['top', 'right', 'bottom', 'left']; +const OPPOSITES: Record = { + top: 'bottom', + bottom: 'top', + left: 'right', + right: 'left', + start: 'end', + end: 'start' +}; + +const clamp = (low: number, value: number, high: number) => + max(low, min(value, high)); +const sideOf = (placement: Placement) => placement.split('-')[0] as Side; +const alignmentOf = (placement: Placement) => + placement.split('-')[1] as Alignment | undefined; +const sideAxis = (placement: Placement): Axis => + /^[tb]/.test(placement) ? 'y' : 'x'; +const otherAxis = (axis: Axis): Axis => (axis === 'x' ? 'y' : 'x'); +const lengthOf = (axis: Axis) => (axis === 'y' ? 'height' : 'width'); +const swap = (placement: string, part: RegExp) => + placement.replace(part, (word) => OPPOSITES[word] as string) as Placement; +const oppositeSide = (placement: string) => swap(placement, /^\w+/); +const oppositeAlignment = (placement: string) => swap(placement, /start|end/); + +function placementCoords( + { reference, floating }: ElementRects, + placement: Placement, + rtl?: boolean +) { + const alignment = alignmentOf(placement); + const axis = sideAxis(placement); + const cross = otherAxis(axis); + const center = (reference[lengthOf(cross)] - floating[lengthOf(cross)]) / 2; + + return { + [axis]: /^[tl]/.test(placement) + ? reference[axis] - floating[lengthOf(axis)] + : reference[axis] + reference[lengthOf(axis)], + [cross]: + reference[cross] + + center + + (alignment + ? center * + (alignment === 'end' ? 1 : -1) * + (rtl && axis === 'y' ? -1 : 1) + : 0) + } as Coords; +} + +function alignmentSides( + placement: Placement, + { reference, floating }: ElementRects, + rtl?: boolean +) { + const alignment = alignmentOf(placement); + const axis = otherAxis(sideAxis(placement)); + const side = + axis === 'x' + ? alignment === (rtl ? 'end' : 'start') + ? 'right' + : 'left' + : alignment === 'start' + ? 'bottom' + : 'top'; + const first = + reference[lengthOf(axis)] > floating[lengthOf(axis)] + ? oppositeSide(side) + : side; + + return [first, oppositeSide(first)] as [Side, Side]; +} + +const getDocument = (node: any): Document => + node.ownerDocument || node.document || document; +const getWindow = (node: any) => + (getDocument(node).defaultView || window) as Window & typeof globalThis; +const styleOf = (element: Element) => + getWindow(element).getComputedStyle(element); +const htmlOf = (node: any) => getDocument(node).documentElement; +const isElement = (value: any): value is Element => value?.nodeType === 1; +const isHTMLElement = (value: any): value is HTMLElement => + isElement(value) && 'offsetTop' in value; +const isRoot = (node: Node) => /^(html|body|#document)$/i.test(node.nodeName); +const isStatic = (element: Element) => styleOf(element).position === 'static'; + +let webKit: boolean | undefined; +const isWebKit = () => + webKit ?? + (webKit = + typeof CSS !== 'undefined' && + CSS.supports?.('-webkit-backdrop-filter', 'none')); + +function parentOf(node: any): any { + const parent = node.assignedSlot || node.parentNode || htmlOf(node); + return (parent.nodeType === 11 && parent.host) || parent; +} + +function ancestorsOf(node: any) { + const ancestors: Element[] = []; + for ( + let parent = parentOf(node); + isElement(parent) && !isRoot(parent); + parent = parentOf(parent) + ) { + ancestors.push(parent); + } + return ancestors; +} + +function clipsOverflow({ overflow, display }: CSSStyleDeclaration) { + return ( + /auto|scroll|overlay|hidden|clip/.test(overflow) && + display !== 'inline' && + display !== 'contents' + ); +} + +function isContainingBlock(style: CSSStyleDeclaration) { + return ( + [ + style.transform, + style.translate, + style.scale, + style.rotate, + style.perspective, + ...(isWebKit() ? [] : [style.backdropFilter, style.filter]) + ].some((value) => value && value !== 'none') || + /transform|translate|scale|rotate|perspective|filter/.test( + style.willChange || '' + ) || + /paint|layout|strict|content/.test(style.contain || '') + ); +} + +function isTopLayer(element: Element) { + try { + // Apart, as a list fails whole. `:modal` shipped first in every browser. + return element.matches(':modal') || element.matches(':popover-open'); + } catch { + return false; + } +} + +function getOffsetParent(element: Element): Element | Window { + const win = getWindow(element); + if (isTopLayer(element)) return win; + + let parent: any = element; + do { + parent = styleOf(parent).position === 'fixed' ? null : parent.offsetParent; + } while ( + parent && + /^(table|td|th)$/i.test(parent.nodeName) && + isStatic(parent) + ); + if (parent) { + // Firefox reports a positioned , while the math needs . + if (parent === htmlOf(element)) parent = getDocument(element).body; + return isRoot(parent) && + isStatic(parent) && + !isContainingBlock(styleOf(parent)) + ? win + : parent; + } + + for (const ancestor of ancestorsOf(element)) { + if (isContainingBlock(styleOf(ancestor))) return ancestor; + if (isTopLayer(ancestor)) break; + } + return win; +} + +function getDimensions(element: any) { + const style = styleOf(element); + const width = parseFloat(style.width) || 0; + const height = parseFloat(style.height) || 0; + const offsetWidth = element.offsetWidth ?? width; + const offsetHeight = element.offsetHeight ?? height; + const rounded = + round(width) !== offsetWidth || round(height) !== offsetHeight; + + return rounded + ? { width: offsetWidth, height: offsetHeight, rounded } + : { width, height, rounded }; +} + +function getScale(element: any): Coords { + if (!isHTMLElement(element)) return ONE; + + const rect = element.getBoundingClientRect(); + const { width, height, rounded } = getDimensions(element); + const ratio = (size: number, cssSize: number) => { + const scale = (rounded ? round(size) : size) / cssSize; + return scale && Number.isFinite(scale) ? scale : 1; + }; + + return { x: ratio(rect.width, width), y: ratio(rect.height, height) }; +} + +/** + * Where `left: 0; top: 0` lands in client coordinates for an element with this + * offset parent. Mapping through one origin and scale in both directions keeps + * the measured rects and overflow detection in the same coordinate space. + */ +function originOf(offsetParent: any, strategy: Strategy) { + if (isHTMLElement(offsetParent)) { + const scale = getScale(offsetParent); + const { x, y } = offsetParent.getBoundingClientRect(); + // In quirks mode reports the viewport's scroll, which is not its own. + const scrolls = + offsetParent !== getDocument(offsetParent).body || + clipsOverflow(styleOf(htmlOf(offsetParent))); + return { + x: + x + + (offsetParent.clientLeft - (scrolls ? offsetParent.scrollLeft : 0)) * + scale.x, + y: + y + + (offsetParent.clientTop - (scrolls ? offsetParent.scrollTop : 0)) * + scale.y, + scale + }; + } + + const html = htmlOf(offsetParent); + const { x, y } = html.getBoundingClientRect(); + if (strategy !== 'fixed') return { x, y, scale: ONE }; + + // WebKit's client coordinates are relative to the visual viewport, while + // `position: fixed` stays relative to the layout viewport. + const viewport = isWebKit() && getWindow(offsetParent).visualViewport; + return { + x: x + html.scrollLeft - (viewport ? viewport.offsetLeft : 0), + y: viewport ? -viewport.offsetTop : 0, + scale: ONE + }; +} + +/** The element's client rect, carried up through iframes into `targetWindow`. */ +function rectInWindow(element: any, targetWindow: Window) { + let { x, y, width, height } = element.getBoundingClientRect(); + + for ( + let win = getWindow(element), frame: Element | null; + win !== targetWindow && (frame = win.frameElement); + win = getWindow(frame) + ) { + const scale = getScale(frame); + const rect = frame.getBoundingClientRect(); + const style = styleOf(frame); + x = + rect.x + (x + frame.clientLeft + parseFloat(style.paddingLeft)) * scale.x; + y = rect.y + (y + frame.clientTop + parseFloat(style.paddingTop)) * scale.y; + width *= scale.x; + height *= scale.y; + } + + return { x, y, width, height }; +} + +function viewportRect(node: any): Rect { + const html = htmlOf(node); + const { x, width: rootWidth } = html.getBoundingClientRect(); + const viewport = getWindow(node).visualViewport; + // WebKit's client coordinates are relative to the visual viewport, others' + // to the layout viewport. + const offset = !isWebKit() && viewport; + const left = x + html.scrollLeft; + // With no scrollbar to show, `scrollbar-gutter: stable` narrows the root without + // its `clientWidth` showing it. A root offset to the left has no such gutter. + const gutter = left > 0 ? 0 : html.clientWidth - rootWidth; + + return { + x: left + (offset ? offset.offsetLeft : 0), + y: offset ? offset.offsetTop : 0, + width: + (viewport ? viewport.width : html.clientWidth) - + (gutter > 0 && gutter < 26 ? gutter : 0), + height: viewport ? viewport.height : html.clientHeight + }; +} + +function clippingAncestors(element: Element) { + let chain = styleOf(element).position; + + return ancestorsOf(element).filter((ancestor) => { + const style = styleOf(ancestor); + // A fixed chain escapes every ancestor up to the next containing block, an + // absolute chain escapes static ancestors. + if ( + !isContainingBlock(style) && + (chain === 'fixed' || + (chain === 'absolute' && style.position === 'static')) + ) { + return false; + } + chain = style.position; + return isHTMLElement(ancestor) && clipsOverflow(style); + }); +} + +function clipRect(clip: Element): Rect { + const scale = getScale(clip); + const { x, y } = clip.getBoundingClientRect(); + return { + x: x + clip.clientLeft * scale.x, + y: y + clip.clientTop * scale.y, + width: clip.clientWidth * scale.x, + height: clip.clientHeight * scale.y + }; +} + +/** The area visible through every clipping ancestor and the viewport. */ +function getClippingRect(element: Element): Rect { + const rects = [ + ...(isTopLayer(element) ? [] : clippingAncestors(element).map(clipRect)), + viewportRect(element) + ]; + const x = max(...rects.map((rect) => rect.x)); + const y = max(...rects.map((rect) => rect.y)); + + return { + x, + y, + width: min(...rects.map((rect) => rect.x + rect.width)) - x, + height: min(...rects.map((rect) => rect.y + rect.height)) - y + }; +} + +/** + * Everything positioning reads from the DOM, read once per position. Nothing + * a middleware does moves the step's offset parent or clipping ancestors. + */ +export interface Measurements { + rects: ElementRects; + /** The visible area, in client coordinates. */ + clip: Rect; + /** Where the offset parent's origin lands in client coordinates, and its scale. */ + origin: Coords & { scale: Coords }; + rtl: boolean; + floating: HTMLElement; +} + +export function measure( + reference: Element, + floating: HTMLElement, + strategy: Strategy +): Measurements { + const offsetParent = getOffsetParent(floating); + const origin = originOf(offsetParent, strategy); + const { x, y, scale } = origin; + const rect = rectInWindow(reference, getWindow(offsetParent)); + const { width, height } = getDimensions(floating); + + return { + rects: { + reference: { + x: (rect.x - x) / scale.x, + y: (rect.y - y) / scale.y, + width: rect.width / scale.x, + height: rect.height / scale.y + }, + floating: { x: 0, y: 0, width, height } + }, + clip: getClippingRect(floating), + origin, + rtl: styleOf(floating).direction === 'rtl', + floating + }; +} + +function detectOverflow( + { x, y, rects: { floating }, clip, origin }: MiddlewareState, + padding = 0 +): Record { + const { scale } = origin; + const left = x * scale.x + origin.x; + const top = y * scale.y + origin.y; + + return { + top: (clip.y - top + padding) / scale.y, + bottom: + (top + floating.height * scale.y - clip.y - clip.height + padding) / + scale.y, + left: (clip.x - left + padding) / scale.x, + right: + (left + floating.width * scale.x - clip.x - clip.width + padding) / + scale.x + }; +} + +/** Runs the middleware against measurements taken by `measure`. */ +export function place( + measurements: Measurements, + { placement: initialPlacement, strategy, middleware }: PositionConfig +): ComputePositionResult { + const { rects, rtl } = measurements; + const middlewareData: Record = {}; + let { x, y } = placementCoords(rects, initialPlacement, rtl); + let placement = initialPlacement; + let resets = 0; + + for (let i = 0; i < middleware.length; i++) { + const current = middleware[i] as Middleware; + const result = current.fn({ + ...measurements, + x, + y, + initialPlacement, + placement, + strategy, + middlewareData + }); + x = result.x ?? x; + y = result.y ?? y; + middlewareData[current.name] = { + ...middlewareData[current.name], + ...result.data + }; + + const { reset } = result; + if (reset && resets < 50) { + resets++; + if (typeof reset === 'object') { + placement = reset.placement; + ({ x, y } = placementCoords(rects, placement, rtl)); + } + i = -1; + } + } + + return { x, y, placement, strategy, middlewareData }; +} + +export function computePosition( + reference: Element, + floating: HTMLElement, + config: PositionConfig +): ComputePositionResult { + return place(measure(reference, floating, config.strategy), config); +} + +function minBy(list: T[], key: (item: T) => number) { + return list.reduce( + (best, item) => (best && key(best) <= key(item) ? best : item), + undefined + ); +} + +const fits = (overflow: number) => overflow <= 0; + +/** + * Moves the element away from its reference along the side it sits on, and + * along that side by `crossAxis`. Floating UI's `offset()`, which `shift`'s + * limit reads back from `middlewareData.offset`. + */ +export function offset( + value: number | { mainAxis?: number; crossAxis?: number } +): Middleware { + const { mainAxis = 0, crossAxis = 0 } = + typeof value === 'number' ? { mainAxis: value } : value; + + return { + name: 'offset', + fn({ x, y, placement, middlewareData, rtl }) { + // After the arrow's nudge resets, the offset is already in `x` and `y`. + if ( + placement === middlewareData['offset']?.placement && + middlewareData['arrow']?.alignmentOffset + ) { + return {}; + } + const axis = sideAxis(placement); + const delta = { + [axis]: /^[tl]/.test(placement) ? -mainAxis : mainAxis, + [otherAxis(axis)]: rtl && axis === 'y' ? -crossAxis : crossAxis + } as Coords; + + return { + x: x + delta.x, + y: y + delta.y, + data: { ...delta, placement } + }; + } + }; +} + +/** + * Tries candidate placements in order, one reset at a time, recording how each + * overflows. `choose` picks among them once every candidate has been tried. + * `flipping` stops at the first fit and defers to any placement another + * middleware reset to, which is the only difference `autoPlacement` has. + */ +function placementSearch( + name: string, + padding: number | undefined, + flipping: boolean, + candidates: (initial: Placement) => Placement[], + choose: (attempts: Attempt[]) => Attempt | undefined +): Middleware { + return { + name, + fn(state) { + const { placement, initialPlacement, middlewareData, rects, rtl } = state; + const { index = 0, overflows: previous = [] } = + middlewareData[name] || {}; + const placements = candidates(initialPlacement); + const current = placements[index]; + + if (flipping ? middlewareData['arrow']?.alignmentOffset : !current) { + return {}; + } + if (!flipping && placement !== current) { + return { reset: { placement: placements[0] as Placement } }; + } + + const overflow = detectOverflow(state, padding); + const [first, second] = alignmentSides(placement, rects, rtl); + const overflows: number[] = [ + overflow[sideOf(placement)], + overflow[first], + overflow[second] + ]; + + if (flipping && overflows.every(fits)) return {}; + + const attempts: Attempt[] = [...previous, { placement, overflows }]; + const next = + placements[index + 1] || (choose(attempts) as Attempt).placement; + + return next === placement + ? {} + : { + data: { index: index + 1, overflows: attempts }, + reset: { placement: next } + }; + } + }; +} + +export function flip(padding?: number): Middleware { + return placementSearch( + 'flip', + padding, + true, + (initial) => { + const opposite = oppositeSide(initial); + return alignmentOf(initial) + ? [ + initial, + oppositeAlignment(initial), + opposite, + oppositeAlignment(opposite) + ] + : [initial, opposite]; + }, + (attempts) => + minBy( + attempts.filter(({ overflows }) => fits(overflows[0] as number)), + ({ overflows }) => overflows[1] as number + ) || + minBy(attempts, ({ overflows }) => + overflows.reduce((sum, overflow) => sum + max(overflow, 0), 0) + ) + ); +} + +export function autoPlacement( + alignment: Alignment | undefined, + padding?: number +): Middleware { + const placements = ( + alignment + ? [alignment, OPPOSITES[alignment]].flatMap((align) => + SIDES.map((side) => `${side}-${align}`) + ) + : SIDES + ) as Placement[]; + const score = ({ placement, overflows }: Attempt) => + (overflows[0] as number) + + (alignmentOf(placement) ? (overflows[1] as number) : 0); + + return placementSearch( + 'autoPlacement', + padding, + false, + () => placements, + (attempts) => + minBy( + attempts.filter(({ placement, overflows }) => + // Aligned placements do not check their opposite cross-axis side. + (alignmentOf(placement) ? overflows.slice(0, 2) : overflows).every( + fits + ) + ), + score + ) || minBy(attempts, score) + ); +} + +/** + * Keeps the element in view on both axes, stopping once it would detach from + * the reference. Floating UI spells this `shift({ limiter: limitShift(), + * crossAxis: true, padding })`. + */ +export function shift(padding?: number): Middleware { + return { + name: 'shift', + fn(state) { + const { x, y, placement, rects, middlewareData } = state; + const { reference, floating } = rects; + const overflow = detectOverflow(state, padding); + const cross = sideAxis(placement); + const main = otherAxis(cross); + const fromOrigin = /^[tl]/.test(placement); + const limit = (axis: Axis, offset: number) => + clamp( + reference[axis] - + floating[lengthOf(axis)] + + (fromOrigin ? offset : 0), + clamp( + state[axis] + overflow[axis === 'y' ? 'top' : 'left'], + state[axis], + state[axis] - overflow[axis === 'y' ? 'bottom' : 'right'] + ), + reference[axis] + + reference[lengthOf(axis)] + + (fromOrigin ? 0 : offset) + ); + const limited = { + [main]: limit(main, 0), + [cross]: limit(cross, middlewareData['offset']?.[cross] || 0) + } as Coords; + + return { + ...limited, + data: { x: limited.x - x, y: limited.y - y } + }; + } + }; +} + +export function arrow(element: Element, padding = 0): Middleware { + return { + name: 'arrow', + fn(state) { + const { placement, rects, floating, middlewareData } = state; + const axis = otherAxis(sideAxis(placement)); + const length = lengthOf(axis); + const start = state[axis]; + const arrowLength = getDimensions(element)[length]; + const free = + (axis === 'y' ? floating.clientHeight : floating.clientWidth) - + arrowLength; + // Padding large enough to push the arrow off center gets capped. + const edge = min(padding, free / 2 - 1); + const end = free - edge; + const center = + free / 2 + + (rects.reference[length] - rects.floating[length]) / 2 + + rects.reference[axis] - + start; + const offset = clamp(edge, center, end); + // A reference too small for the padded arrow to reach gets the floating + // element nudged toward it, with one reset so `shift` still runs. + const nudge = + !middlewareData['arrow'] && + alignmentOf(placement) != null && + center !== offset && + rects.reference[length] / 2 - edge - arrowLength / 2 < 0; + const alignmentOffset = nudge + ? center < edge + ? center - edge + : center - end + : 0; + + return { + [axis]: start + alignmentOffset, + data: { + [axis]: offset, + ...(nudge && { alignmentOffset }) + }, + reset: nudge + }; + } + }; +} + +/** + * Reports when `element` moves without any scroll or resize event, by watching + * an intersection root shrunk to exactly its box. + * https://samthor.au/2021/observing-dom/ + */ +function observeMove(element: Element, onMove: () => void) { + const doc = getDocument(element); + // The viewport's size. In quirks mode that is 's client size, not the + // root's, which spans the whole document. + const viewport = doc.scrollingElement || htmlOf(element); + let observer: IntersectionObserver | undefined; + let timer: ReturnType; + let disposed = false; + + const stop = () => { + clearTimeout(timer); + observer?.disconnect(); + observer = undefined; + }; + + const refresh = (skip?: boolean, threshold = 1) => { + // A notification queued before disposal must not reach `onMove`. + if (disposed) return; + stop(); + + const { x, y, width, height } = element.getBoundingClientRect(); + if (!skip) onMove(); + // `onMove` can dispose of this tracker, and an observer made after that + // would never be disconnected. + if (disposed) return; + // A zero-size box cannot be observed, such as a target at `scale(0)`. + if (!width || !height) { + timer = setTimeout(refresh, 1000); + return; + } + + let first = true; + observer = new IntersectionObserver( + (entries) => { + const ratio = (entries[0] as IntersectionObserverEntry) + .intersectionRatio; + const now = element.getBoundingClientRect(); + // The entry is a snapshot, so a moved element makes it stale. + if ( + now.x !== x || + now.y !== y || + now.width !== width || + now.height !== height + ) { + return refresh(); + } + if (ratio !== threshold) { + if (!first) return refresh(); + // Clipped in place reports 0; throttle or this loops forever. + if (ratio) refresh(false, ratio); + else timer = setTimeout(() => refresh(false, 1e-7), 1000); + } + first = false; + }, + { + root: doc, + rootMargin: [ + y, + viewport.clientWidth - x - width, + viewport.clientHeight - y - height, + x + ] + .map((inset) => `${-Math.floor(inset)}px`) + .join(' '), + threshold + } + ); + observer.observe(element); + }; + + const onResize = () => refresh(true); + const win = getWindow(element); + win.addEventListener('resize', onResize); + refresh(true); + + return () => { + disposed = true; + win.removeEventListener('resize', onResize); + stop(); + }; +} + +/** + * Repositions whenever what the placement depends on changes: a scroll or + * resize in any document from the reference's up to the floating element's, a + * resize of either element or of what clips the floating element, the + * reference or a frame holding it moving with no event at all, which + * `observeMove` reports, and every frame of a CSS animation or transition on + * the reference, a frame holding it, or an ancestor of either. Each of those + * reaches `check`, which updates only when something it compares really + * changed. + */ +export function autoUpdate( + reference: Element, + floating: HTMLElement, + update: () => void +) { + const win = getWindow(floating); + let last: string; + let connected = false; + let clips: Element[] = []; + let frame = 0; + let stopped = false; + + // The frames between the reference and the floating element's window. A + // cross-origin frame hides its `frameElement`, which ends the chain. + const frames: Element[] = []; + for ( + let current = getWindow(reference), host: Element | null; + current !== win && (host = current.frameElement); + current = getWindow(host) + ) { + frames.push(host); + } + + const resizes = + typeof ResizeObserver === 'function' + ? new ResizeObserver(() => check()) + : undefined; + + // Clipping ancestors are known once the floating element is on the page, + // which a detached `stepsContainer` reaches only later. + const attach = () => { + connected = true; + clips = clippingAncestors(floating); + clips.forEach((clip) => resizes?.observe(clip)); + }; + + const check = () => { + // A tour abandoned by a route change has no other way to stop this. + if (floating.isConnected) { + if (!connected) attach(); + } else if (connected) { + return stop(); + } + + const target = rectInWindow(reference, win); + const own = floating.getBoundingClientRect(); + const viewport = win.visualViewport; + const now = [ + target.x, + target.y, + target.width, + target.height, + own.left, + own.top, + own.width, + own.height, + win.innerWidth, + win.innerHeight, + viewport?.offsetLeft, + viewport?.offsetTop, + viewport?.width, + viewport?.height, + ...clips.flatMap((clip) => [clip.clientWidth, clip.clientHeight]) + ].join(); + + if (now !== last) { + last = now; + update(); + } + }; + + // Scroll does not bubble but does capture, so this hears every scroller in + // these documents. A scroller inside a shadow root is left to `observeMove`. + const targets = [ + ...new Set([win, getWindow(reference), ...frames.map(getWindow)]) + ]; + if (win.visualViewport) targets.push(win.visualViewport as any); + targets.forEach((target) => { + target.addEventListener('scroll', check, true); + target.addEventListener('resize', check); + }); + + // A transform moves the reference without any event or resize, so follow it + // frame by frame while an animation or transition runs on anything whose + // transform carries it: itself, a frame holding it, or an ancestor of either, + // across shadow boundaries. + const chain = [reference, ...frames]; + // `ancestorsOf` stops short of the root, which can animate too. A document + // still parsing has no body yet. + const carriers = new Set( + chain + .flatMap((element) => [ + element, + ...ancestorsOf(element), + getDocument(element).body, + htmlOf(element) + ]) + .filter(Boolean) + ); + // Those events and `getAnimations()` stay within their own shadow root or + // document, so both are read from every root a carrier lives in. + const roots = [ + ...new Set( + [...carriers].flatMap((element) => [ + (element.getRootNode?.() ?? getDocument(element)) as + Document | ShadowRoot, + getDocument(element) + ]) + ) + ]; + // Keyframe bookkeeping and properties that only repaint cannot move the + // reference, so an endless background or color animation on the page does + // not keep tracking running. Anything unreadable counts as moving. + const moves = ({ effect, transitionProperty }: Animation & CSSTransition) => + (transitionProperty + ? [transitionProperty] + : ((effect as KeyframeEffect | null) + ?.getKeyframes?.() + .flatMap(Object.keys) ?? ['']) + ).some( + (property) => + !/^((computed)?offset|easing|composite)$|^(background|color|opacity|box-?shadow|outline|border-?color|filter)/i.test( + property + ) + ); + const animating = () => + roots.some((root) => + root + .getAnimations?.() + .some( + (animation) => + animation.playState === 'running' && + carriers.has( + (animation.effect as KeyframeEffect | null)?.target as Element + ) && + moves(animation as Animation & CSSTransition) + ) + ); + const follow = () => { + check(); + frame = !stopped && animating() ? requestAnimationFrame(follow) : 0; + }; + const onAnimate = (event: Event) => { + if (!frame && carriers.has(event.target as Element)) follow(); + }; + const animationEvents = ['transitionrun', 'animationstart']; + roots.forEach((root) => + animationEvents.forEach((type) => + root.addEventListener(type, onAnimate, true) + ) + ); + + // jsdom has neither observer. Removing the step from the page resizes it to + // nothing, which is what tells an abandoned tour to stop. + const stopWatching = + typeof IntersectionObserver === 'function' + ? chain.map((element) => observeMove(element, check)) + : []; + [reference, floating, ...frames].forEach((element) => + resizes?.observe(element) + ); + + const stop = () => { + stopped = true; + targets.forEach((target) => { + target.removeEventListener('scroll', check, true); + target.removeEventListener('resize', check); + }); + roots.forEach((root) => + animationEvents.forEach((type) => + root.removeEventListener(type, onAnimate, true) + ) + ); + if (frame) cancelAnimationFrame(frame); + stopWatching.forEach((stopOne) => stopOne()); + resizes?.disconnect(); + }; + + if (floating.isConnected) attach(); + update(); + // An animation that started before the step opened sent its event already. + if (animating()) follow(); + + return stop; +} diff --git a/shepherd.js/test/cypress/integration/a11y.cy.js b/shepherd.js/test/cypress/integration/a11y.cy.js index 3799a950e..d5be6c424 100644 --- a/shepherd.js/test/cypress/integration/a11y.cy.js +++ b/shepherd.js/test/cypress/integration/a11y.cy.js @@ -28,6 +28,14 @@ describe('a11y', () => { tour.complete(); }); + it('shepherd-element takes focus without waiting for the fade in', () => { + cy.document().then(async (document) => { + tour.start(); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(document.activeElement).to.have.class('shepherd-element'); + }); + }); + it('shepherd-element should have focus on tour start', () => { tour.start(); diff --git a/shepherd.js/test/cypress/integration/positioning-strategy.cy.js b/shepherd.js/test/cypress/integration/positioning-strategy.cy.js index 94d717ac4..1021b0cac 100644 --- a/shepherd.js/test/cypress/integration/positioning-strategy.cy.js +++ b/shepherd.js/test/cypress/integration/positioning-strategy.cy.js @@ -36,14 +36,14 @@ describe('positioning strategy', () => { }); }; - const startTour = (floatingUIOptions) => { + const startTour = (positionOptions) => { const tour = setupTour(Shepherd, { scrollTo: false }, () => [ { attachTo: { element: '.page-target', on: 'bottom' }, id: 'strategy', title: 'Strategy step', text: 'positioned against a target the page scrolls past', - floatingUIOptions + positionOptions } ]); @@ -85,6 +85,47 @@ describe('positioning strategy', () => { }); }); + it('places a `fixed` step right on its first position inside a moved container', () => { + cy.scrollTo(0, 700); + + cy.document().then((doc) => { + const container = doc.createElement('div'); + container.style.cssText = 'position: absolute; top: 150px; left: 200px'; + doc.body.append(container); + + const offset = (step) => { + const target = doc + .querySelector('.page-target') + .getBoundingClientRect(); + const box = step.getElement().getBoundingClientRect(); + return { + dx: Math.round(box.left - target.left), + dy: Math.round(box.top - target.top) + }; + }; + + let first; + const tour = new Shepherd.Tour({ stepsContainer: container }); + tour.addStep({ + id: 'fixed-first', + text: 'Positioned once, before anything can correct it', + attachTo: { element: '.page-target', on: 'bottom' }, + positionOptions: { strategy: 'fixed' }, + when: { + show() { + first = offset(this); + } + } + }); + tour.start(); + + cy.wait(250).then(() => { + expect(first).to.deep.equal(offset(tour.getCurrentStep())); + tour.complete(); + }); + }); + }); + it('keeps a default strategy step locked to its target while the page scrolls', () => { startTour(undefined); diff --git a/shepherd.js/test/cypress/utils/default-steps.js b/shepherd.js/test/cypress/utils/default-steps.js index ba5e6dc4a..e033a3ab6 100644 --- a/shepherd.js/test/cypress/utils/default-steps.js +++ b/shepherd.js/test/cypress/utils/default-steps.js @@ -4,12 +4,12 @@ export default function (shepherd) { text: `

Shepherd is a JavaScript library for guiding users through your app. - It uses Floating UI, - another open source library, to render dialogs for each tour "step". + It renders a dialog for each tour "step", positioned + by its own small engine, with no dependencies.

- Among many things, Floating UI makes sure your steps never end up off screen or cropped by an overflow. + Among many things, it makes sure your steps never end up off screen or cropped by an overflow. (Try resizing your browser to see what we mean.)

`, attachTo: { @@ -33,7 +33,7 @@ export default function (shepherd) { }, { title: 'Including', - text: 'Including Shepherd is easy! Just include shepherd.js. The styles are bundled with the JS.', + text: 'Including Shepherd is easy! Just import shepherd.js and its stylesheet.', attachTo: { element: '.hero-including', on: 'bottom' diff --git a/shepherd.js/test/llm-eval/README.md b/shepherd.js/test/llm-eval/README.md new file mode 100644 index 000000000..2d8c54bbf --- /dev/null +++ b/shepherd.js/test/llm-eval/README.md @@ -0,0 +1,70 @@ +# LLM eval + +Measures whether a coding agent with no prior context writes Shepherd code that +works. Solutions are loaded in a real browser with Playwright and graded on what +the page does. Nothing here reads or scores source code, and nothing is +non-deterministic except the agent that wrote the solution. + +From `shepherd.js/`, after `pnpm build`: + +| Command | What it does | +| ---------------------------------------------------------------- | ------------------------------------------------------------- | +| `node test/llm-eval/run.mjs test/llm-eval/reference` | Grades the reference solutions. Must always be 8/8. | +| `node test/llm-eval/run.mjs --json out.json` | Grades a directory of solutions. | +| `node test/llm-eval/run.mjs --write-sandbox ` | Creates the directory a cold agent works in. | +| `node test/llm-eval/run.mjs --write-prompts ` | Writes only the task prompts. | +| `node test/llm-eval/mistakes.mjs --json out.json` | Records what each known mistake does. Needs no agent. | + +All of them take `--pkg ` to test another build, such as an older package +extracted somewhere. `run.mjs` also takes `--task `. + +## Running it with cold agents + +Run the same steps for each build you are comparing, with the same model and at +least 3 agents per task, because one run says little about a model's error rate. + +1. `node test/llm-eval/run.mjs --write-sandbox --pkg `. The sandbox + has `node_modules/` holding exactly what `npm pack` publishes, the prompts in + `prompts/`, and an empty `solutions/`. +2. Start a new agent for each prompt, with its working directory set to the + sandbox, no access to this repository, and no network. Send it the prompt file + as is, and add no hints. Have it write to `solutions/`. It may read anything + in `node_modules/`. +3. `node test/llm-eval/run.mjs /solutions --pkg --json out.json`. + Grade against the same build the agent saw. + +A solution is named `.`. A missing file counts as a failed task. + +Compare `passed` and the first failed check of each task between builds. The check +names say which mistake happened, such as `default styling applied` for a +forgotten stylesheet. Also compare `console`, which holds what the page printed. + +## Tasks + +| Task | The trap | +| ----------------- | ------------------------------------------------------------------------ | +| `first-tour` | The CSS import, button actions, `attachTo.on`, centered steps | +| `modal-focus` | `useModalOverlay`, `canClickTarget`, `cancelIcon`, `cancel` once | +| `shared-defaults` | `defaultStepOptions` inheritance, and overriding it per step | +| `async-targets` | `beforeShowPromise`, `waitForElement`, `skipMissingElement` | +| `events-persist` | `advanceOn`, tour events, restart after a reload | +| `react-strict` | The current `react-shepherd` API, and StrictMode's double effect | +| `typed-json` | The `Tour` type vs value, passing `tsc --strict`, data-driven steps | +| `script-tag` | No bundler: `shepherd.mjs` is a module and defines no global | + +## Mistakes + +`mistakes.mjs` runs small programs that each contain one expected mistake, against +a build, with no agent involved. Each ends up as: + +| Outcome | Meaning | +| ---------------- | --------------------------------------------------------- | +| `WORKS` | The tour behaves as the author meant, despite the mistake | +| `DIAGNOSED` | It fails, and a message names the fix | +| `LOUD-UNHELPFUL` | It fails with an error that does not name the fix | +| `SILENT` | It fails with no error at all | +| `BROKEN` | It fails, for a mistake with no message to judge | + +The `tsc` column means the compiler rejects the code. The `docs` column means the +shipped `AGENTS.md` mentions the fix, which says only that the text is there, not +that an agent reads it. The cold agent run measures that. diff --git a/shepherd.js/test/llm-eval/lib.mjs b/shepherd.js/test/llm-eval/lib.mjs new file mode 100644 index 000000000..748e0e67e --- /dev/null +++ b/shepherd.js/test/llm-eval/lib.mjs @@ -0,0 +1,306 @@ +import { createServer } from 'node:http'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import ts from 'typescript'; + +export const evalDir = fileURLToPath(new URL('.', import.meta.url)); +export const defaultPkgDir = resolve(evalDir, '../..'); +const reactPkgDir = resolve(defaultPkgDir, '../packages/react'); + +const MIME = { + '.css': 'text/css', + '.html': 'text/html', + '.js': 'text/javascript', + '.json': 'application/json', + '.mjs': 'text/javascript' +}; + +/** + * A throwaway consumer project. `shepherd.js` is linked into `node_modules`, so + * imports resolve through the package's real `exports` map, the same way they + * do for an installed package. + */ +export function createWorkspace(pkgDir) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'shepherd-llm-eval-'))); + mkdirSync(join(dir, 'node_modules')); + symlinkSync(pkgDir, join(dir, 'node_modules', 'shepherd.js')); + for (const name of ['react', 'react-dom', '@types/react']) { + const source = join(reactPkgDir, 'node_modules', name); + mkdirSync(dirname(join(dir, 'node_modules', name)), { recursive: true }); + symlinkSync(source, join(dir, 'node_modules', name)); + } + return dir; +} + +const workspaceResolver = (workspace) => ({ + name: 'workspace-resolver', + setup(pluginBuild) { + // The wrapper is taken from source, so it needs no build of its own, and its + // own imports must land on the package under test, not the workspace copy. + pluginBuild.onResolve({ filter: /^react-shepherd$/ }, () => ({ + path: join(reactPkgDir, 'src/index.tsx') + })); + pluginBuild.onResolve( + { filter: /^(shepherd\.js|react|react-dom)($|\/)/ }, + (args) => { + if (args.pluginData?.resolved) return undefined; + return pluginBuild.resolve(args.path, { + kind: args.kind, + resolveDir: workspace, + pluginData: { resolved: true } + }); + } + ); + // The lockfile pins react 19.3 and react-dom 19.2, and react-dom refuses to + // load against a different react. + pluginBuild.onLoad({ filter: /react-dom-client\.[\w.]+\.js$/ }, (args) => ({ + contents: readFileSync(args.path, 'utf8').replace( + /"[\d.]+" !== isomorphicReactPackageVersion/, + 'false' + ), + loader: 'js' + })); + } +}); + +/** + * Bundles a solution as a consumer's bundler would. Returns the emitted files, + * or the bundler's error text when the code does not even build. + */ +export async function bundle(workspace, entryPath) { + try { + const result = await build({ + absWorkingDir: workspace, + entryPoints: [entryPath], + bundle: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + jsx: 'automatic', + define: { 'process.env.NODE_ENV': '"development"' }, + outfile: 'out/bundle.js', + write: false, + logLevel: 'silent', + plugins: [workspaceResolver(workspace)] + }); + const files = {}; + for (const file of result.outputFiles) { + files[`/${file.path.split('/').pop()}`] = file.text; + } + return { files }; + } catch (error) { + return { + error: (error.errors ?? []) + .map((item) => item.text) + .join('\n') + .trim() + .concat(error.errors?.length ? '' : String(error)) + }; + } +} + +/** Runs the compiler over one file with the strict settings of a modern app. */ +export function typecheck(workspace, filePath) { + const program = ts.createProgram([filePath], { + allowImportingTsExtensions: true, + jsx: ts.JsxEmit.ReactJSX, + lib: ['lib.es2022.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022, + typeRoots: [join(workspace, 'node_modules/@types')], + types: [] + }); + return ts.getPreEmitDiagnostics(program).map((diagnostic) => { + const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + return `TS${diagnostic.code}: ${text}`; + }); +} + +/** + * Serves a task's page. `pkgDir` is exposed under `/node_modules/shepherd.js/` + * for the task that has no build step. + */ +export function serve({ html, files = {}, pkgDir }) { + const server = createServer((req, res) => { + const path = normalize(decodeURIComponent(req.url.split('?')[0])); + const send = (body, ext) => { + res.writeHead(200, { + 'content-type': MIME[ext] ?? 'application/octet-stream', + 'cache-control': 'no-store' + }); + res.end(body); + }; + if (path === '/') return send(html, '.html'); + if (files[path] !== undefined) return send(files[path], extname(path)); + const prefix = '/node_modules/shepherd.js/'; + if (path.startsWith(prefix)) { + const file = join(pkgDir, path.slice(prefix.length)); + if (existsSync(file) && statSync(file).isFile()) { + return send(readFileSync(file), extname(file)); + } + } + res.writeHead(404); + res.end(); + }); + return new Promise((done) => { + server.listen(0, '127.0.0.1', () => + done({ + url: `http://127.0.0.1:${server.address().port}/`, + close: () => + new Promise((closed) => { + server.closeAllConnections(); + server.close(closed); + }) + }) + ); + }); +} + +export function writeEntry(workspace, name, source) { + const path = join(workspace, name); + writeFileSync(path, source); + return path; +} + +/** Polls until `predicate` holds, so checks never depend on a fixed delay. */ +export async function poll(predicate, message, timeout = 3000) { + const deadline = Date.now() + timeout; + let last; + while (Date.now() < deadline) { + last = await predicate(); + if (last === true) return; + await new Promise((done) => setTimeout(done, 25)); + } + const detail = last === false || last === undefined ? '' : ` (got ${last})`; + throw new Error(`${message}${detail}`); +} + +const OPEN = '.shepherd-element:not([hidden])'; + +/** + * The vocabulary checks are written in. Everything asserts what a user would + * see or be able to do on the page, never how the solution's code is written. + */ +export function createDriver(page) { + const rect = (locator) => + locator.evaluate((el) => { + const { left, right, top, bottom } = el.getBoundingClientRect(); + return { left, right, top, bottom }; + }); + const step = (title) => page.locator(OPEN, { hasText: title }); + + const driver = { + page, + openCount: () => page.locator(OPEN).count(), + step, + + async expectStep(title) { + await poll( + async () => (await step(title).count()) === 1, + `a single open step containing "${title}"` + ); + await poll( + async () => (await page.locator(OPEN).count()) === 1, + 'exactly one open step', + 1000 + ); + }, + + async expectNoStep(timeout = 1500) { + await poll( + async () => (await page.locator(OPEN).count()) === 0, + 'no open step', + timeout + ); + }, + + async stayClosed(ms = 400) { + await page.waitForTimeout(ms); + const count = await page.locator(OPEN).count(); + if (count !== 0) throw new Error(`expected no step, found ${count}`); + }, + + async click(title, name) { + await step(title) + .getByRole('button', { name, exact: true }) + .click({ timeout: 2000 }); + }, + + async expectNoButton(title, name) { + const count = await step(title) + .getByRole('button', { name, exact: true }) + .count(); + if (count !== 0) throw new Error(`unexpected "${name}" button`); + }, + + async expectStylesheet() { + await poll( + () => + page + .locator(OPEN) + .first() + .evaluate((el) => getComputedStyle(el).maxWidth === '400px'), + "Shepherd's stylesheet is not applied to the step" + ); + }, + + async expectPlaced(title, selector, side) { + const target = page.locator(selector).first(); + await poll(async () => { + const a = await rect(step(title)); + const b = await rect(target); + const overlap = (lo1, hi1, lo2, hi2) => + Math.min(hi1, hi2) - Math.max(lo1, lo2) > 0; + const across = overlap(a.left, a.right, b.left, b.right); + const along = overlap(a.top, a.bottom, b.top, b.bottom); + // A centered step can also sit below or beside its target by chance, + // so the gap has to be small enough to read as attached. + const gap = { + bottom: a.top - b.bottom, + top: b.top - a.bottom, + right: a.left - b.right, + left: b.left - a.right + }[side]; + const placed = + gap >= -1 && + gap <= 40 && + (side === 'top' || side === 'bottom' ? across : along); + return placed === true || JSON.stringify({ step: a, target: b }); + }, `step "${title}" placed ${side} of ${selector}`); + }, + + async expectCentered(title) { + await poll(async () => { + const box = await rect(step(title)); + const { width, height } = page.viewportSize(); + const dx = Math.abs((box.left + box.right) / 2 - width / 2); + const dy = Math.abs((box.top + box.bottom) / 2 - height / 2); + return (dx < 2 && dy < 2) || JSON.stringify({ dx, dy }); + }, `step "${title}" centered in the viewport`); + }, + + async expectInViewport(selector) { + await poll(async () => { + const box = await rect(page.locator(selector).first()); + return box.top >= 0 && box.bottom <= page.viewportSize().height; + }, `${selector} scrolled into view`); + } + }; + return driver; +} diff --git a/shepherd.js/test/llm-eval/mistakes.mjs b/shepherd.js/test/llm-eval/mistakes.mjs new file mode 100644 index 000000000..cae5728d2 --- /dev/null +++ b/shepherd.js/test/llm-eval/mistakes.mjs @@ -0,0 +1,417 @@ +// Mistakes a coding agent is expected to make, one per entry, from reading the +// API, types, docs and README cold, and from older Shepherd APIs in training +// data. Each is a whole small program, and the matrix records what really +// happens to it: does it work anyway, does tsc catch it, and does the runtime +// say what to do. `fix` is what a useful message has to mention. +// +// node test/llm-eval/mistakes.mjs [--pkg ] [--json ] +import process from 'node:process'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { chromium } from 'playwright-core'; +import { + bundle, + createDriver, + createWorkspace, + defaultPkgDir, + serve, + typecheck, + writeEntry +} from './lib.mjs'; + +const CSS = `import 'shepherd.js/dist/css/shepherd.css';`; +const IMPORT = `import Shepherd from 'shepherd.js';\n${CSS}`; + +const HTML = (script) => ` + + +
A
+
B
+${script}`; + +/** A two step tour; each mistake replaces one part of it. */ +const program = ({ + head = IMPORT, + make = 'new Shepherd.Tour()', + tourOptions, + step1 = '', + attach = `attachTo: { element: '#a', on: 'bottom' },`, + next = 'tour.next', + done = 'tour.complete', + nextButton +} = {}) => `${head} +const tour = ${tourOptions ? `new Shepherd.Tour(${tourOptions})` : make}; +tour.addStep({ + title: 'First', + text: 'one', + ${attach} + ${step1} + buttons: [${nextButton ?? `{ text: 'Next', action: ${next} }`}] +}); +tour.addStep({ + title: 'Second', + text: 'two', + attachTo: { element: '#b', on: 'bottom' }, + buttons: [{ text: 'Done', action: ${done} }] +}); +tour.start(); +`; + +export const MISTAKES = [ + { + id: 'control', + why: 'Correct code. Proves the matrix can report a pass.', + code: program() + }, + { + id: 'no-css-import', + why: 'The JS import is the only thing most examples lead with; the stylesheet is a separate import.', + code: program({ head: `import Shepherd from 'shepherd.js';` }), + fix: /shepherd\.css/ + }, + { + id: 'wrong-css-path', + why: 'Older versions shipped theme files such as shepherd-theme-arrows.css.', + code: program({ + head: `import Shepherd from 'shepherd.js';\nimport 'shepherd.js/dist/css/shepherd-theme-arrows.css';` + }), + fix: /dist\/css\/shepherd\.css/ + }, + { + id: 'named-import', + why: 'Named exports are the default habit, and the types do export a Tour name.', + code: program({ + head: `import { Tour } from 'shepherd.js';\n${CSS}`, + make: 'new Tour()' + }), + fix: /Shepherd\.Tour|default/ + }, + { + id: 'cjs-require', + why: 'require() of the package returns an object with `default`, not Shepherd.', + code: program({ + head: `const Shepherd = require('shepherd.js');\n${CSS}` + }), + fix: /default/ + }, + { + id: 'window-global', + why: 'usage.md says "Shepherd exposes a single object onto the window".', + code: program({ head: CSS }), + fix: /import/ + }, + { + id: 'steps-reference-tour', + why: '`action: tour.next` inside the constructor reads tour before it exists.', + code: `${IMPORT} +const tour = new Shepherd.Tour({ + steps: [ + { + title: 'First', + text: 'one', + attachTo: { element: '#a', on: 'bottom' }, + buttons: [{ text: 'Next', action: tour.next }] + }, + { + title: 'Second', + text: 'two', + attachTo: { element: '#b', on: 'bottom' }, + buttons: [{ text: 'Done', action: tour.complete }] + } + ] +}); +tour.start(); +`, + fix: /action\(\)|this\.next/ + }, + { + id: 'arrow-this', + why: 'Docs say action is bound to the tour, which an arrow function ignores.', + code: program({ next: '() => this.next()' }), + fix: /this|arrow/i + }, + { + id: 'action-string', + why: 'Generated tours want plain data, and other tour libraries take names.', + code: program({ next: `'next'`, done: `'complete'` }), + fix: /this\.next|tour\.next/ + }, + { + id: 'button-type', + why: 'ember-shepherd and old examples use `type: "next"` on a button.', + code: program({ nextButton: `{ text: 'Next', type: 'next' }` }), + fix: /no `?action/ + }, + { + id: 'active-tour-eager', + why: '`Shepherd.activeTour.next` reads as natural, but is null until started.', + code: program({ next: 'Shepherd.activeTour.next' }), + fix: /activeTour|start/ + }, + { + id: 'on-below', + why: 'Other libraries name placements differently.', + code: program({ attach: `attachTo: { element: '#a', on: 'below' },` }), + fix: /bottom/ + }, + { + id: 'on-bottom-left', + why: 'CSS-style corner names instead of -start and -end.', + code: program({ + attach: `attachTo: { element: '#a', on: 'bottom-left' },` + }), + fix: /bottom-start|-start/ + }, + { + id: 'position-key', + why: 'Intro.js and others call it position. Without `on` the step centers silently.', + code: program({ + attach: `attachTo: { element: '#a', position: 'bottom' },` + }), + fix: /\bon\b/ + }, + { + id: 'attach-to-string', + why: 'Shepherd before v6 accepted the string form "#a bottom".', + code: program({ attach: `attachTo: '#a bottom',` }), + fix: /element/ + }, + { + id: 'missing-target', + why: 'A wrong selector, the most common runtime failure. Falls back to centered.', + code: program({ attach: `attachTo: { element: '#nope', on: 'bottom' },` }), + fix: /#nope[\s\S]*(waitForElement|skipMissingElement)/ + }, + { + id: 'invalid-selector', + why: 'An unescaped id or a generated selector that is not valid CSS.', + code: program({ attach: `attachTo: { element: '#a[', on: 'bottom' },` }), + fix: /#a\[/ + }, + { + id: 'promise-not-function', + why: 'beforeShowPromise reads as "a promise". Passing one runs it eagerly and skips the wait.', + code: program({ + step1: `beforeShowPromise: new Promise((resolve) => setTimeout(() => { window.ready = true; resolve(); }, 300)), + when: { show() { window.shownReady = window.ready === true; } },` + }), + fix: /beforeShowPromise/, + extra: async (d) => { + const waited = await d.page.evaluate(() => window.shownReady); + if (!waited) throw new Error('the step did not wait for the promise'); + } + }, + { + id: 'stale-show-cancel-link', + why: 'showCancelLink was replaced by cancelIcon in v6.', + code: program({ step1: 'showCancelLink: true,' }), + fix: /cancelIcon/, + extra: async (d) => { + const icons = await d + .step('First') + .locator('.shepherd-cancel-icon') + .count(); + if (icons !== 1) throw new Error('no close icon'); + } + }, + { + id: 'stale-defaults', + why: 'Tour option `defaults` became `defaultStepOptions` in v5.', + code: program({ tourOptions: `{ defaults: { classes: 'mine' } }` }), + fix: /defaultStepOptions/, + extra: async (d) => { + const has = await d + .step('First') + .evaluate((el) => el.classList.contains('mine')); + if (!has) throw new Error('default classes were not applied'); + } + }, + { + id: 'stale-modal', + why: 'Other libraries and old Shepherd examples use modal or overlay flags.', + code: program({ tourOptions: `{ modal: true }` }), + fix: /useModalOverlay/, + extra: async (d) => { + const visible = await d.page + .locator('.shepherd-modal-is-visible') + .count(); + if (visible !== 1) throw new Error('no visible overlay'); + } + }, + { + id: 'floating-ui-options', + why: 'Shepherd 15 and earlier took Floating UI config in floatingUIOptions, which positionOptions replaced.', + code: program({ step1: `floatingUIOptions: { strategy: 'fixed' },` }), + fix: /positionOptions/, + extra: async (d) => { + const position = await d + .step('First') + .evaluate((el) => getComputedStyle(el).position); + if (position !== 'fixed') throw new Error('the step is not fixed'); + } + }, + { + id: 'content-not-text', + why: 'Most tour libraries call the body content or description.', + code: program({ step1: `content: 'body copy',` }), + fix: /\btext\b/, + extra: async (d) => { + const text = await d.step('First').innerText(); + if (!text.includes('body copy')) throw new Error('body was not rendered'); + } + }, + { + id: 'stale-react-wrapper', + why: 'react-shepherd before v6 exported ShepherdTour and ShepherdTourContext.', + code: `import { ShepherdTour } from 'react-shepherd';\nconsole.log(ShepherdTour);`, + fix: /useShepherd|ShepherdJourneyProvider/, + skipTsc: true + }, + { + id: 'ssr-module-scope', + why: 'A module-level tour runs during server rendering, even in a client component.', + server: `import Shepherd from 'shepherd.js'; +const tour = new Shepherd.Tour(); +tour.addStep({ text: 'hi' }); +tour.on('complete', () => {}); +tour.start();` + } +]; + +async function exercise(browser, mistake, built, pkgDir, console_) { + const server = await serve({ + html: HTML(''), + files: { '/bundle.css': '', ...built.files }, + pkgDir + }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 } + }); + const page = await context.newPage(); + page.setDefaultTimeout(1500); + page.on('console', (message) => { + if (['error', 'warning'].includes(message.type())) { + console_.push(message.text()); + } + }); + page.on('pageerror', (error) => console_.push(error.message)); + try { + await page.goto(server.url); + const d = createDriver(page); + await d.expectStep('First'); + await d.expectStylesheet(); + await d.expectPlaced('First', '#a', 'bottom'); + await mistake.extra?.(d); + await d.click('First', 'Next'); + await d.expectStep('Second'); + await d.click('Second', 'Done'); + await d.expectNoStep(); + return { works: true }; + } catch (error) { + return { works: false, failure: error.message.split('\n')[0] }; + } finally { + await context.close(); + await server.close(); + } +} + +function runOnServer(workspace, source) { + const path = writeEntry(workspace, 'server.mjs', source); + const run = spawnSync(process.execPath, [path], { + cwd: workspace, + encoding: 'utf8' + }); + const error = run.stderr.match(/^\w*Error: .*$/m)?.[0]; + return { + works: run.status === 0, + failure: error, + console: error ? [error] : [] + }; +} + +function classify(mistake, row) { + if (row.works) return 'WORKS'; + if (!mistake.fix) return 'BROKEN'; + if (row.console.some((text) => mistake.fix.test(text))) return 'DIAGNOSED'; + return row.console.length ? 'LOUD-UNHELPFUL' : 'SILENT'; +} + +async function main() { + const args = process.argv.slice(2); + const option = (name) => { + const at = args.indexOf(`--${name}`); + return at >= 0 ? args[at + 1] : undefined; + }; + const pkgDir = resolve(option('pkg') ?? defaultPkgDir); + const agentDocsPath = join(pkgDir, 'AGENTS.md'); + const agentDocs = existsSync(agentDocsPath) + ? readFileSync(agentDocsPath, 'utf8') + : null; + const browser = await chromium.launch(); + const rows = []; + const example = agentDocs?.match(/```js\n([\s\S]*?)```/)?.[1]; + const entries = example + ? [ + ...MISTAKES, + { + id: 'agents-md-example', + why: 'Not a mistake. The first example in AGENTS.md has to run as written.', + code: example + } + ] + : MISTAKES; + for (const mistake of entries) { + const workspace = createWorkspace(pkgDir); + const row = { id: mistake.id, console: [] }; + try { + if (mistake.server) { + Object.assign(row, runOnServer(workspace, mistake.server)); + } else { + const path = writeEntry(workspace, 'solution.ts', mistake.code); + if (!mistake.skipTsc) { + const tsErrors = typecheck(workspace, path); + row.tsc = tsErrors.length ? tsErrors[0] : null; + } + const built = await bundle(workspace, path); + if (built.error) { + row.works = false; + row.failure = 'does not build'; + row.console.push(built.error); + } else { + Object.assign( + row, + await exercise(browser, mistake, built, pkgDir, row.console) + ); + } + } + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + row.outcome = classify(mistake, row); + row.tscCaught = Boolean(row.tsc); + row.tscNamesFix = Boolean(row.tsc && mistake.fix?.test(row.tsc)); + row.agentDocsNameFix = Boolean(agentDocs && mistake.fix?.test(agentDocs)); + rows.push(row); + console.log( + `${row.outcome.padEnd(15)}${row.tsc === undefined ? ' - ' : row.tscCaught ? ' tsc ' : ' . '}${row.agentDocsNameFix ? 'docs ' : ' . '}${mistake.id}` + ); + } + await browser.close(); + const tally = {}; + for (const { outcome } of rows) tally[outcome] = (tally[outcome] ?? 0) + 1; + console.log(tally); + const out = option('json'); + if (out) { + mkdirSync(dirname(resolve(out)), { recursive: true }); + writeFileSync(out, `${JSON.stringify({ pkgDir, tally, rows }, null, 1)}\n`); + } +} + +await main(); diff --git a/shepherd.js/test/llm-eval/reference/async-targets.js b/shepherd.js/test/llm-eval/reference/async-targets.js new file mode 100644 index 000000000..3532dc3db --- /dev/null +++ b/shepherd.js/test/llm-eval/reference/async-targets.js @@ -0,0 +1,41 @@ +import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; + +const tour = new Shepherd.Tour(); +const next = { text: 'Next', action: tour.next }; +tour.addSteps([ + { + title: 'Start', + text: '1', + attachTo: { element: '#open-settings', on: 'bottom' }, + buttons: [next] + }, + { + title: 'Panel', + text: '2', + attachTo: { element: '#settings-panel', on: 'bottom' }, + beforeShowPromise: () => window.app.openSettings(), + buttons: [next] + }, + { + title: 'Live', + text: '3', + attachTo: { element: '#live-widget', on: 'bottom' }, + waitForElement: 2000, + buttons: [next] + }, + { + title: 'Legacy', + text: '4', + attachTo: { element: '#legacy-banner', on: 'bottom' }, + skipMissingElement: true, + buttons: [next] + }, + { + title: 'Bye', + text: '5', + attachTo: { element: '#footer', on: 'bottom' }, + buttons: [{ text: 'Done', action: tour.complete }] + } +]); +tour.start(); diff --git a/shepherd.js/test/llm-eval/reference/events-persist.js b/shepherd.js/test/llm-eval/reference/events-persist.js new file mode 100644 index 000000000..4e56885cc --- /dev/null +++ b/shepherd.js/test/llm-eval/reference/events-persist.js @@ -0,0 +1,34 @@ +import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; + +const KEY = 'acme-tour-seen'; + +if (!localStorage.getItem(KEY)) { + const tour = new Shepherd.Tour(); + const finish = (outcome) => () => { + localStorage.setItem(KEY, '1'); + window.analytics.track('tour_finished', { outcome }); + }; + tour.on('complete', finish('completed')); + tour.on('cancel', finish('dismissed')); + tour.on('show', ({ step }) => + window.analytics.track('tour_step', { id: step.id }) + ); + tour.addSteps([ + { + id: 'menu', + title: 'Menu', + text: 'Open the menu.', + attachTo: { element: '#menu', on: 'bottom' }, + advanceOn: { selector: '#menu', event: 'click' } + }, + { + id: 'item', + title: 'Item', + text: 'Here it is.', + attachTo: { element: '#menu-item', on: 'bottom' }, + buttons: [{ text: 'Done', action: tour.complete }] + } + ]); + tour.start(); +} diff --git a/shepherd.js/test/llm-eval/reference/first-tour.js b/shepherd.js/test/llm-eval/reference/first-tour.js new file mode 100644 index 000000000..81248f3d6 --- /dev/null +++ b/shepherd.js/test/llm-eval/reference/first-tour.js @@ -0,0 +1,28 @@ +import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; + +const tour = new Shepherd.Tour(); +tour.addStep({ + title: 'Welcome', + text: 'Thanks for joining.', + buttons: [{ text: 'Next', action: tour.next }] +}); +tour.addStep({ + title: 'Projects', + text: 'Create projects here.', + attachTo: { element: '#new-project', on: 'bottom' }, + buttons: [ + { text: 'Back', action: tour.back, secondary: true }, + { text: 'Next', action: tour.next } + ] +}); +tour.addStep({ + title: 'Navigation', + text: 'Everything else lives here.', + attachTo: { element: '#sidebar', on: 'right' }, + buttons: [ + { text: 'Back', action: tour.back, secondary: true }, + { text: 'Done', action: tour.complete } + ] +}); +tour.start(); diff --git a/shepherd.js/test/llm-eval/reference/modal-focus.js b/shepherd.js/test/llm-eval/reference/modal-focus.js new file mode 100644 index 000000000..6886d326f --- /dev/null +++ b/shepherd.js/test/llm-eval/reference/modal-focus.js @@ -0,0 +1,13 @@ +import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; + +const tour = new Shepherd.Tour({ useModalOverlay: true }); +tour.on('cancel', () => window.onTourEnd('cancelled')); +tour.addStep({ + title: 'Careful', + text: 'This cannot be undone.', + attachTo: { element: '#delete', on: 'top' }, + canClickTarget: false, + cancelIcon: { enabled: true } +}); +tour.start(); diff --git a/shepherd.js/test/llm-eval/reference/react-strict.tsx b/shepherd.js/test/llm-eval/reference/react-strict.tsx new file mode 100644 index 000000000..a55238c65 --- /dev/null +++ b/shepherd.js/test/llm-eval/reference/react-strict.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from 'react'; +import { ShepherdJourneyProvider, useShepherd } from 'react-shepherd'; +import type { Tour } from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; + +function Page() { + const Shepherd = useShepherd(); + const [count, setCount] = useState(0); + const tourRef = useRef(null); + + useEffect(() => { + const tour = new Shepherd.Tour({ + steps: [ + { + title: 'Step A', + text: 'A', + attachTo: { element: '#a', on: 'bottom' }, + buttons: [ + { + text: 'Next', + action() { + return this.next(); + } + } + ] + }, + { + title: 'Step B', + text: 'B', + attachTo: { element: '#b', on: 'bottom' }, + buttons: [ + { + text: 'Done', + action() { + return this.complete(); + } + } + ] + } + ] + }); + tourRef.current = tour; + tour.start(); + return () => { + tour.complete(); + }; + }, [Shepherd]); + + return ( + <> +
+ A +
+
+ B +
+ + + {count} + + ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/shepherd.js/test/llm-eval/reference/script-tag.html b/shepherd.js/test/llm-eval/reference/script-tag.html new file mode 100644 index 000000000..03199bd34 --- /dev/null +++ b/shepherd.js/test/llm-eval/reference/script-tag.html @@ -0,0 +1,28 @@ + + + + + Buy + + + + + + + diff --git a/shepherd.js/test/llm-eval/reference/shared-defaults.js b/shepherd.js/test/llm-eval/reference/shared-defaults.js new file mode 100644 index 000000000..414e4121b --- /dev/null +++ b/shepherd.js/test/llm-eval/reference/shared-defaults.js @@ -0,0 +1,42 @@ +import Shepherd from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; + +const tour = new Shepherd.Tour({ + defaultStepOptions: { + classes: 'acme-step', + scrollTo: { block: 'center' }, + cancelIcon: { enabled: true }, + buttons: [ + { + text: 'Next', + action() { + return this.next(); + } + } + ] + } +}); +tour.addSteps([ + { title: 'One', text: '1', attachTo: { element: '#one', on: 'bottom' } }, + { + title: 'Two', + text: '2', + attachTo: { element: '#two', on: 'bottom' }, + cancelIcon: { enabled: false } + }, + { title: 'Three', text: '3', attachTo: { element: '#three', on: 'bottom' } }, + { + title: 'Four', + text: '4', + attachTo: { element: '#four', on: 'bottom' }, + buttons: [ + { + text: 'Finish', + action() { + return this.complete(); + } + } + ] + } +]); +tour.start(); diff --git a/shepherd.js/test/llm-eval/reference/typed-json.ts b/shepherd.js/test/llm-eval/reference/typed-json.ts new file mode 100644 index 000000000..d2a11949c --- /dev/null +++ b/shepherd.js/test/llm-eval/reference/typed-json.ts @@ -0,0 +1,31 @@ +import Shepherd, { type StepOptionsButton, type Tour } from 'shepherd.js'; +import 'shepherd.js/dist/css/shepherd.css'; + +export interface ProductStep { + id: string; + selector: string; + placement: 'top' | 'bottom' | 'left' | 'right'; + title: string; + body: string; +} + +export function buildTour(steps: ProductStep[]): Tour { + const tour = new Shepherd.Tour(); + steps.forEach((step, index) => { + const buttons: StepOptionsButton[] = []; + if (index > 0) buttons.push({ text: 'Back', action: tour.back }); + buttons.push( + index < steps.length - 1 + ? { text: 'Next', action: tour.next } + : { text: 'Done', action: tour.complete } + ); + tour.addStep({ + id: step.id, + title: step.title, + text: step.body, + attachTo: { element: step.selector, on: step.placement }, + buttons + }); + }); + return tour; +} diff --git a/shepherd.js/test/llm-eval/run.mjs b/shepherd.js/test/llm-eval/run.mjs new file mode 100644 index 000000000..d1025e79f --- /dev/null +++ b/shepherd.js/test/llm-eval/run.mjs @@ -0,0 +1,210 @@ +// Grades solution files by running them in a real browser. See README.md. +// +// node test/llm-eval/run.mjs [--pkg ] [--json ] [--task ] +// node test/llm-eval/run.mjs --write-prompts +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { chromium } from 'playwright-core'; +import { + bundle, + createDriver, + createWorkspace, + defaultPkgDir, + serve, + typecheck, + writeEntry +} from './lib.mjs'; +import { promptFor, TASKS } from './tasks.mjs'; + +async function gradeTask(browser, task, solutionsDir, pkgDir) { + const result = { id: task.id, pass: false, checks: [], console: [] }; + const ext = task.extensions.find((candidate) => + existsSync(join(solutionsDir, `${task.id}.${candidate}`)) + ); + if (!ext) { + result.failure = 'no solution file'; + return result; + } + const source = readFileSync(join(solutionsDir, `${task.id}.${ext}`), 'utf8'); + const workspace = createWorkspace(pkgDir); + + let html = source; + let files = { ...task.files }; + try { + if (ext !== 'html') { + const solutionPath = writeEntry(workspace, `solution.${ext}`, source); + + if (task.typecheck) { + const errors = typecheck(workspace, solutionPath); + result.checks.push({ + name: 'passes tsc --strict', + pass: errors.length === 0, + error: errors.join('\n') || undefined + }); + } + + const entry = task.entry + ? writeEntry( + workspace, + `entry.${task.entryExt}`, + task.entry(solutionPath) + ) + : solutionPath; + const built = await bundle(workspace, entry); + if (built.error) { + result.checks.push({ name: 'builds', pass: false, error: built.error }); + result.failure = `build: ${built.error}`; + return result; + } + files = { ...files, ...built.files }; + const link = built.files['/bundle.css'] + ? '' + : ''; + html = task.html + .replace('', `${link}`) + .replace( + '', + '' + ); + } + + const server = await serve({ html, files, pkgDir }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 } + }); + const page = await context.newPage(); + page.setDefaultTimeout(3000); + page.on('console', (message) => { + if (['error', 'warning'].includes(message.type())) { + result.console.push(`${message.type()}: ${message.text()}`); + } + }); + page.on('pageerror', (error) => { + result.console.push(`pageerror: ${error.message}`); + }); + try { + await page.goto(server.url); + for (const [name, run] of task.check(createDriver(page))) { + try { + await run(); + result.checks.push({ name, pass: true }); + } catch (error) { + result.checks.push({ + name, + pass: false, + error: error.message.split('\n')[0] + }); + break; + } + } + } finally { + await context.close(); + await server.close(); + } + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + result.pass = result.checks.every((check) => check.pass); + return result; +} + +/** Copies exactly what `npm pack` would publish, so agents read the real thing. */ +function copyPublishedFiles(from, to) { + const run = spawnSync( + 'npm', + ['pack', '--dry-run', '--json', '--ignore-scripts', '--silent'], + { cwd: from, encoding: 'utf8' } + ); + const [info] = JSON.parse(run.stdout); + for (const { path } of info.files) { + mkdirSync(dirname(join(to, path)), { recursive: true }); + cpSync(join(from, path), join(to, path)); + } +} + +function parseArgs(argv) { + const options = { positional: [] }; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) options[argv[i].slice(2)] = argv[++i]; + else options.positional.push(argv[i]); + } + return options; +} + +const options = parseArgs(process.argv.slice(2)); + +if (options['write-prompts']) { + const dir = resolve(options['write-prompts']); + mkdirSync(dir, { recursive: true }); + for (const task of TASKS) { + writeFileSync(join(dir, `${task.id}.md`), promptFor(task)); + } + console.log(`Wrote ${TASKS.length} prompts to ${dir}`); + process.exit(0); +} + +if (options['write-sandbox']) { + const dir = resolve(options['write-sandbox']); + const pkgDir = resolve(options.pkg ?? defaultPkgDir); + copyPublishedFiles(pkgDir, join(dir, 'node_modules/shepherd.js')); + copyPublishedFiles( + resolve(defaultPkgDir, '../packages/react'), + join(dir, 'node_modules/react-shepherd') + ); + mkdirSync(join(dir, 'prompts'), { recursive: true }); + mkdirSync(join(dir, 'solutions'), { recursive: true }); + for (const task of TASKS) { + writeFileSync(join(dir, 'prompts', `${task.id}.md`), promptFor(task)); + } + console.log(`Wrote a sandbox to ${dir}`); + process.exit(0); +} + +const solutionsDir = options.positional[0] && resolve(options.positional[0]); +if (!solutionsDir || !existsSync(solutionsDir)) { + console.error( + 'usage: run.mjs [--pkg dir] [--json out] [--task id]' + ); + process.exit(2); +} +const pkgDir = resolve(options.pkg ?? defaultPkgDir); +const tasks = + 'task' in options ? TASKS.filter((task) => task.id === options.task) : TASKS; +if (!tasks.length) { + console.error(`no task named ${options.task}`); + process.exit(2); +} + +const browser = await chromium.launch(); +const results = []; +for (const task of tasks) { + const result = await gradeTask(browser, task, solutionsDir, pkgDir); + results.push(result); + const failed = result.checks.find((check) => !check.pass); + const reason = + result.failure ?? (failed && `${failed.name}: ${failed.error}`); + console.log( + `${result.pass ? 'PASS' : 'FAIL'} ${task.id}${reason ? `\n ${reason}` : ''}` + ); +} +await browser.close(); + +const passed = results.filter((result) => result.pass).length; +console.log(`\n${passed}/${results.length} tasks passed`); +if (options.json) { + mkdirSync(dirname(resolve(options.json)), { recursive: true }); + writeFileSync( + options.json, + `${JSON.stringify({ pkgDir, passed, total: results.length, results }, null, 1)}\n` + ); +} +process.exit(passed === results.length ? 0 : 1); diff --git a/shepherd.js/test/llm-eval/tasks.mjs b/shepherd.js/test/llm-eval/tasks.mjs new file mode 100644 index 000000000..4bf269808 --- /dev/null +++ b/shepherd.js/test/llm-eval/tasks.mjs @@ -0,0 +1,637 @@ +// The tasks a cold coding agent is given. Prompts are written the way a developer +// would ask: they state the outcome, and never mention a mistake being tested. +// Each `check` is a list of named assertions run in order in a real browser. +// A failed assertion stops its task, since later ones depend on the page state. +import { poll } from './lib.mjs'; + +const page = (body, head = '') => ` +Acme +${head} +${body}`; + +const FIRST_TOUR_PAGE = page(` + + `); + +const MODAL_PAGE = page(` + + `); + +const DEFAULTS_PAGE = page(` +
+
One
+
Two
+
Three
+
Four
+
`); + +const ASYNC_PAGE = page(` + +
+ + `); + +const EVENTS_PAGE = page(` + + + `); + +const REACT_PAGE = page('
'); + +const JSON_PAGE = page(` +
Plans
+
Usage
+
Billing
`); + +const TOUR_JSON = JSON.stringify([ + { + id: 'plans', + selector: '#plans', + placement: 'bottom', + title: 'Plans', + body: 'Pick a plan.' + }, + { + id: 'usage', + selector: '#usage', + placement: 'right', + title: 'Usage', + body: 'See usage.' + }, + { + id: 'billing', + selector: '#billing', + placement: 'top', + title: 'Billing', + body: 'Manage billing.' + } +]); + +export const TASKS = [ + { + id: 'first-tour', + extensions: ['js', 'ts'], + html: FIRST_TOUR_PAGE, + prompt: `Add a three step product tour that starts as soon as the page loads. + +1. Title "Welcome". Text "Thanks for joining." Not attached to anything, in the middle of the screen. Button: "Next". +2. Title "Projects". Text "Create projects here." Attached below \`#new-project\`. Buttons: "Back" and "Next". +3. Title "Navigation". Text "Everything else lives here." Attached to the right of \`#sidebar\`. Buttons: "Back" and "Done". "Done" finishes the tour.`, + check: (d) => [ + ['first step opens', () => d.expectStep('Welcome')], + ['default styling applied', () => d.expectStylesheet()], + ['first step centered', () => d.expectCentered('Welcome')], + [ + 'Next advances', + async () => { + await d.click('Welcome', 'Next'); + await d.expectStep('Projects'); + } + ], + [ + 'second step below target', + () => d.expectPlaced('Projects', '#new-project', 'bottom') + ], + [ + 'Back returns', + async () => { + await d.click('Projects', 'Back'); + await d.expectStep('Welcome'); + await d.click('Welcome', 'Next'); + await d.expectStep('Projects'); + } + ], + [ + 'third step right of target', + async () => { + await d.click('Projects', 'Next'); + await d.expectStep('Navigation'); + await d.expectPlaced('Navigation', '#sidebar', 'right'); + } + ], + [ + 'Done ends the tour', + async () => { + await d.click('Navigation', 'Done'); + await d.expectNoStep(); + } + ] + ] + }, + { + id: 'modal-focus', + extensions: ['js', 'ts'], + html: MODAL_PAGE, + prompt: `Add a one step tour that starts on page load, explaining the \`#delete\` button before anyone uses it. + +The step has the title "Careful" and the text "This cannot be undone.", placed above the button. Everything on the page except the button should be dimmed, and the button itself must not be clickable while the tour is open. The user closes the step with a close "×" in its header or the Escape key. Whichever way it is closed, call \`window.onTourEnd('cancelled')\` once. There are no other buttons.`, + check: (d) => { + const overlay = '.shepherd-modal-overlay-container'; + return [ + ['step opens', () => d.expectStep('Careful')], + ['default styling applied', () => d.expectStylesheet()], + [ + 'step above target', + () => d.expectPlaced('Careful', '#delete', 'top') + ], + [ + 'page is dimmed', + () => + poll( + () => + d.page.locator(overlay).evaluate((el) => { + const style = getComputedStyle(el); + return ( + el.classList.contains('shepherd-modal-is-visible') && + style.position === 'fixed' && + Number(style.opacity) > 0 + ); + }), + 'a visible, fixed overlay' + ) + ], + [ + 'overlay has an opening around the target', + () => + poll( + () => + d.page + .locator(`${overlay} path`) + .evaluate( + (el) => + (el.getAttribute('d').match(/M/gi) ?? []).length >= 2 + ), + 'a cut-out in the overlay path' + ) + ], + [ + 'target cannot be clicked', + async () => { + const box = await d.page.locator('#delete').boundingBox(); + await d.page.mouse.click( + box.x + box.width / 2, + box.y + box.height / 2 + ); + const clicks = await d.page.evaluate(() => window.deleteClicks); + if (clicks) throw new Error('the target received the click'); + } + ], + [ + 'close icon ends the tour once', + async () => { + await d.step('Careful').locator('.shepherd-cancel-icon').click(); + await d.expectNoStep(); + const ended = await d.page.evaluate(() => window.tourEnded); + if (ended.join() !== 'cancelled') { + throw new Error(`onTourEnd calls: ${JSON.stringify(ended)}`); + } + } + ], + [ + 'Escape ends the tour once', + async () => { + await d.page.reload(); + await d.expectStep('Careful'); + await poll( + () => + d.page.evaluate( + () => + document.activeElement?.closest('.shepherd-element') !== + null + ), + 'focus moved into the step' + ); + await d.page.keyboard.press('Escape'); + await d.expectNoStep(); + const ended = await d.page.evaluate(() => window.tourEnded); + if (ended.join() !== 'cancelled') { + throw new Error(`onTourEnd calls: ${JSON.stringify(ended)}`); + } + } + ] + ]; + } + }, + { + id: 'shared-defaults', + extensions: ['js', 'ts'], + html: DEFAULTS_PAGE, + prompt: `Add a four step tour that starts on page load, with steps attached below \`#one\`, \`#two\`, \`#three\` and \`#four\`, titled "One", "Two", "Three" and "Four" (any text). + +These things hold for every step, so configure them once instead of repeating them: the step element has the CSS class \`acme-step\`, the page scrolls so the target is on screen, there is a close "×" in the header, and there is a "Next" button. + +Two exceptions: step "Two" has no close "×", and step "Four" has a "Finish" button that ends the tour instead of "Next".`, + check: (d) => { + const closeIcon = (title) => + d.step(title).locator('.shepherd-cancel-icon').count(); + const stepHas = async (title, selector, placed = true) => { + await d.expectStep(title); + const hasClass = await d + .step(title) + .evaluate((el) => el.classList.contains('acme-step')); + if (!hasClass) throw new Error(`"${title}" lacks acme-step`); + await d.expectInViewport(selector); + if (placed) await d.expectPlaced(title, selector, 'bottom'); + }; + return [ + [ + 'step One from defaults', + async () => { + await stepHas('One', '#one'); + await d.expectStylesheet(); + if ((await closeIcon('One')) !== 1) throw new Error('no ×'); + } + ], + [ + 'step Two overrides the close icon', + async () => { + await d.click('One', 'Next'); + await stepHas('Two', '#two'); + if ((await closeIcon('Two')) !== 0) throw new Error('has ×'); + } + ], + [ + 'step Three keeps the defaults', + async () => { + await d.click('Two', 'Next'); + await stepHas('Three', '#three'); + if ((await closeIcon('Three')) !== 1) throw new Error('no ×'); + } + ], + [ + 'step Four overrides the buttons', + async () => { + await d.click('Three', 'Next'); + await stepHas('Four', '#four'); + await d.expectNoButton('Four', 'Next'); + } + ], + [ + 'Finish ends the tour', + async () => { + await d.click('Four', 'Finish'); + await d.expectNoStep(); + } + ] + ]; + } + }, + { + id: 'async-targets', + extensions: ['js', 'ts'], + html: ASYNC_PAGE, + prompt: `Add a tour that starts on page load. Every attached step goes below its target and has a "Next" button, except the last, which has "Done". Step titles are given in quotes; any text. + +1. "Start", attached to \`#open-settings\`. +2. "Panel", attached to \`#settings-panel\`. That element does not exist until the settings panel is opened with \`window.app.openSettings()\`, which returns a promise that resolves once it is rendered. Only open the panel when the tour reaches this step. +3. "Live", attached to \`#live-widget\`. The app renders this element by itself, up to two seconds after the settings panel opens. +4. "Legacy", attached to \`#legacy-banner\`. This element exists for some accounts only. When it is not on the page, the tour leaves this step out. +5. "Bye", attached to \`#footer\`.`, + check: (d) => [ + [ + 'first step opens without opening settings', + async () => { + await d.expectStep('Start'); + await d.expectStylesheet(); + await d.expectPlaced('Start', '#open-settings', 'bottom'); + const calls = await d.page.evaluate(() => window.app.calls); + if (calls !== 0) throw new Error(`openSettings called ${calls}x`); + } + ], + [ + 'second step waits for its promise and attaches', + async () => { + await d.click('Start', 'Next'); + await d.expectStep('Panel'); + await d.expectPlaced('Panel', '#settings-panel', 'bottom'); + const calls = await d.page.evaluate(() => window.app.calls); + if (calls !== 1) throw new Error(`openSettings called ${calls}x`); + } + ], + [ + 'third step waits for a late element and attaches', + async () => { + await d.click('Panel', 'Next'); + await d.expectStep('Live'); + await d.expectPlaced('Live', '#live-widget', 'bottom'); + } + ], + [ + 'absent step is left out', + async () => { + await d.click('Live', 'Next'); + await d.expectStep('Bye'); + await d.expectPlaced('Bye', '#footer', 'bottom'); + } + ], + [ + 'Done ends the tour', + async () => { + await d.click('Bye', 'Done'); + await d.expectNoStep(); + } + ] + ] + }, + { + id: 'events-persist', + extensions: ['js', 'ts'], + html: EVENTS_PAGE, + prompt: `Add a tour for first-time visitors that starts on page load. Once somebody has finished or dismissed it, it must not start again on later visits, including after a reload. Store that in \`localStorage\`. + +1. id \`menu\`, title "Menu", attached below \`#menu\`. It has no buttons. It moves on when the user clicks \`#menu\`. +2. id \`item\`, title "Item", attached below \`#menu-item\`, with a "Done" button. + +Pressing Escape dismisses the tour. + +Report to analytics with \`window.analytics.track(name, props)\`: +- each time a step is shown: \`track('tour_step', { id })\` with the step's id +- when the tour ends: \`track('tour_finished', { outcome })\`, with \`'completed'\` after "Done" and \`'dismissed'\` after Escape.`, + check: (d) => { + const expectCalls = async (expected) => { + const calls = await d.page.evaluate(() => window.analytics.calls); + if (JSON.stringify(calls) !== JSON.stringify(expected)) { + throw new Error(`analytics calls: ${JSON.stringify(calls)}`); + } + }; + return [ + [ + 'first step opens and is tracked', + async () => { + await d.expectStep('Menu'); + await d.expectStylesheet(); + await d.expectPlaced('Menu', '#menu', 'bottom'); + await expectCalls([['tour_step', { id: 'menu' }]]); + } + ], + [ + 'clicking the target advances', + async () => { + await d.page.locator('#menu').click(); + await d.expectStep('Item'); + await d.expectPlaced('Item', '#menu-item', 'bottom'); + } + ], + [ + 'completion is reported', + async () => { + await d.click('Item', 'Done'); + await d.expectNoStep(); + await expectCalls([ + ['tour_step', { id: 'menu' }], + ['tour_step', { id: 'item' }], + ['tour_finished', { outcome: 'completed' }] + ]); + } + ], + [ + 'does not restart after completing', + async () => { + await d.page.reload(); + await d.stayClosed(); + } + ], + [ + 'dismissal is reported and remembered', + async () => { + await d.page.evaluate(() => localStorage.clear()); + await d.page.reload(); + await d.expectStep('Menu'); + await poll( + () => + d.page.evaluate( + () => + document.activeElement?.closest('.shepherd-element') !== + null + ), + 'focus moved into the step' + ); + await d.page.keyboard.press('Escape'); + await d.expectNoStep(); + await expectCalls([ + ['tour_step', { id: 'menu' }], + ['tour_finished', { outcome: 'dismissed' }] + ]); + await d.page.reload(); + await d.stayClosed(); + } + ] + ]; + } + }, + { + id: 'react-strict', + extensions: ['tsx', 'jsx'], + html: REACT_PAGE, + entry: (solution) => ` + import { StrictMode } from 'react'; + import { createRoot } from 'react-dom/client'; + import App from ${JSON.stringify(solution)}; + createRoot(document.getElementById('root')).render( + + );`, + entryExt: 'jsx', + prompt: `This is a React 19 app using the \`react-shepherd\` package, which is installed alongside \`shepherd.js\`. Write the \`App\` component as this module's default export. It is rendered into the page inside \`\`. + +\`App\` renders: +- \`
A
\` +- \`
B
\` +- \`\`, which runs the tour from the beginning +- \`\` and \`\`, a counter in React state that \`Bump\` increments + +The tour starts by itself when the app first mounts. Step "Step A" is below \`#a\` with a "Next" button, and step "Step B" is below \`#b\` with a "Done" button that ends it.`, + check: (d) => [ + [ + 'one step opens on mount', + async () => { + await d.expectStep('Step A'); + await d.page.waitForTimeout(300); + await d.expectStep('Step A'); + await d.expectStylesheet(); + await d.expectPlaced('Step A', '#a', 'bottom'); + } + ], + [ + 'a re-render leaves the tour alone', + async () => { + await d.page.locator('#bump').click(); + await poll( + async () => (await d.page.locator('#count').innerText()) === '1', + 'the counter updated' + ); + await d.expectStep('Step A'); + } + ], + [ + 'tour survives a re-render and finishes', + async () => { + await d.click('Step A', 'Next'); + await d.expectStep('Step B'); + await d.expectPlaced('Step B', '#b', 'bottom'); + await d.page.locator('#bump').click(); + await d.expectStep('Step B'); + await d.click('Step B', 'Done'); + await d.expectNoStep(); + } + ], + [ + 'a re-render does not restart a finished tour', + async () => { + await d.page.locator('#bump').click(); + await d.stayClosed(); + } + ], + [ + 'replay runs it again', + async () => { + await d.page.locator('#replay').click(); + await d.expectStep('Step A'); + await d.click('Step A', 'Next'); + await d.expectStep('Step B'); + await d.click('Step B', 'Done'); + await d.expectNoStep(); + } + ] + ] + }, + { + id: 'typed-json', + extensions: ['ts'], + html: JSON_PAGE, + files: { '/tour.json': TOUR_JSON }, + typecheck: true, + entry: (solution) => ` + import { buildTour } from ${JSON.stringify(solution)}; + const steps = await (await fetch('/tour.json')).json(); + window.tour = buildTour(steps); + window.tour.start();`, + entryExt: 'js', + prompt: `Our CMS serves tours as JSON. Write a TypeScript module exporting this function, which must pass \`tsc --strict\`: + +\`\`\`ts +export interface ProductStep { + id: string; + selector: string; + placement: 'top' | 'bottom' | 'left' | 'right'; + title: string; + /** Trusted HTML from the CMS. */ + body: string; +} + +export function buildTour(steps: ProductStep[]): Tour; +\`\`\` + +\`Tour\` is the type of a Shepherd tour. \`buildTour\` returns the tour, and the caller starts it. Each step attaches to its \`selector\` at its \`placement\`, and keeps its \`id\`. Every step except the first has a "Back" button. Every step except the last has a "Next" button. The last step has a "Done" button that ends the tour.`, + check: (d) => [ + [ + 'first step opens with its id and placement', + async () => { + await d.expectStep('Plans'); + await d.expectStylesheet(); + await d.expectPlaced('Plans', '#plans', 'bottom'); + await d.expectNoButton('Plans', 'Back'); + const id = await d.page.evaluate( + () => window.tour.getCurrentStep().id + ); + if (id !== 'plans') throw new Error(`step id is ${id}`); + } + ], + [ + 'body renders as HTML', + async () => { + await d.click('Plans', 'Next'); + await d.expectStep('Usage'); + await d.expectPlaced('Usage', '#usage', 'right'); + const bold = await d.step('Usage').locator('b').count(); + if (bold !== 1) throw new Error('body was not rendered as HTML'); + } + ], + [ + 'Back returns', + async () => { + await d.click('Usage', 'Back'); + await d.expectStep('Plans'); + await d.click('Plans', 'Next'); + await d.expectStep('Usage'); + } + ], + [ + 'last step has Done, which ends the tour', + async () => { + await d.click('Usage', 'Next'); + await d.expectStep('Billing'); + await d.expectPlaced('Billing', '#billing', 'top'); + await d.expectNoButton('Billing', 'Next'); + await d.click('Billing', 'Done'); + await d.expectNoStep(); + } + ] + ] + }, + { + id: 'script-tag', + extensions: ['html'], + prompt: `This site has no build step and no bundler. Write a complete HTML page containing \`\` and a one step tour that starts on page load: title "Buy now", text "Checkout is one click.", below \`#buy\`, with a "Got it" button that ends the tour. + +The \`shepherd.js\` package is served as is at \`/node_modules/shepherd.js/\`, so its files are at the same paths as on disk. The page has no other server support and no internet access.`, + check: (d) => [ + ['step opens', () => d.expectStep('Buy now')], + ['default styling applied', () => d.expectStylesheet()], + ['step below target', () => d.expectPlaced('Buy now', '#buy', 'bottom')], + [ + 'Got it ends the tour', + async () => { + await d.click('Buy now', 'Got it'); + await d.expectNoStep(); + } + ] + ] + } +]; + +export function promptFor(task) { + const file = `${task.id}.${task.extensions[0]}`; + const delivery = + task.extensions[0] === 'html' + ? `Write your answer to \`${file}\`.` + : `Write your answer to \`${file}\`${ + task.extensions.length > 1 + ? ` (or \`${task.id}.${task.extensions[1]}\`)` + : '' + }. It is compiled by a standard bundler that handles JavaScript, TypeScript, JSX and CSS imports, and it loads as a module script in the page below. You cannot edit the page.`; + const html = task.html + ? `\n\nThe page:\n\n\`\`\`html\n${task.html}\n\`\`\`` + : ''; + return `You are working in a project that has the \`shepherd.js\` npm package installed in \`node_modules\`. Treat this as production code. + +${task.prompt} + +${delivery}${html} +`; +} diff --git a/shepherd.js/test/position-differential/README.md b/shepherd.js/test/position-differential/README.md new file mode 100644 index 000000000..86f1aaf78 --- /dev/null +++ b/shepherd.js/test/position-differential/README.md @@ -0,0 +1,61 @@ +# Position differential + +Shepherd has its own positioning engine, `src/utils/position.ts`, which replaced +`@floating-ui/dom`. Nothing else in the repo holds the engine to stock behavior: +the scorecard's goldens are frozen and cover 197 cases in one page shape, and unit +tests run with no layout. This suite positions the same DOM with both, in real +Chromium, Firefox and WebKit, and fails on a difference that is not recorded. + +```sh +pnpm test:position +``` + +It takes about three minutes and needs no build. It reads `src/`, not `dist/`. + +## What it covers + +Every fixture in `fixtures.js` runs in standards and quirks mode, under both +`strategy` values: + +- **Shepherd's wiring.** `getPositionConfig` from `src/utils/floating-ui.ts` + against the stock middleware it stands for, built by `stockOptions`. Each + `POSITION_CONFIGS` entry is a `positionOptions` a consumer might pass: + `offset` maps to stock `offset()` first, `padding` to the `padding` of + `flip()`, `autoPlacement()` and `shift()`, and `flip: false` drops `flip()`. +- **The tracker.** `autoUpdate` must react to a window or ancestor scroll, a resize + of either element, a layout shift, a transform, and a shift after settling, and + must do nothing while idle or after cleanup. + +## Reading a failure + +A failure names the browser, mode, fixture and config, with the first case that +differs and both results. Most likely the engine regressed, so follow that case. +`--browser`, `--fixture` and `--config` narrow a run, and `--json` saves all of it. + +Only after you have read every new key and decided it is right: + +```sh +node test/position-differential/run.mjs --update-known +``` + +## Known differences + +`known-differences.json` records how many cases differ per browser, mode, fixture +and config. Every `positionOptions` config hits the same rows as `none`, because +the differences come from the page, not the options. + +| Fixtures | Difference | Why it stays | +| --- | --- | --- | +| `bodySlightlyNarrow`, `bodyNarrowerThanHtml`, and the `scrollbarGutter`, `gutterHidden`, `gutterStable` fixtures in quirks mode | Stock treats a `` up to 25px narrower than the root as a scrollbar gutter and pulls the right edge in. The engine does not. | No gutter exists there. A real gutter is handled, by the root's own width. | +| `scaledBorderedContainer` | Under a scaled, bordered offset parent, `auto` placements that tie can pick another side or alignment. | Stock adds that parent's border unscaled, so its overflow numbers are off by the border. Both results sit against the target and in view. | +| `staticFloatingInTd`, `svgArrowNested` | The engine's arrow assumes the step is its offset parent. | `.shepherd-arrow` is always a direct child of a positioned step. | + +## Other checks + +Each needs `pnpm build` first and exercises the published `dist`: + +```sh +node test/position-differential/dist-position.mjs # each positionOptions key, through property mangling +node test/position-differential/cross-origin.mjs # a tour inside a cross-origin iframe +node test/position-differential/jsdom.mjs # a tour in jsdom, without and with --visual +``` diff --git a/shepherd.js/test/position-differential/cross-origin.mjs b/shepherd.js/test/position-differential/cross-origin.mjs new file mode 100644 index 000000000..a155ee310 --- /dev/null +++ b/shepherd.js/test/position-differential/cross-origin.mjs @@ -0,0 +1,71 @@ +// A tour inside a cross-origin iframe, where reading `window.frameElement` past +// the frame's own document throws or returns null. Run `pnpm build` first. +import process from 'node:process'; +import * as playwright from 'playwright-core'; +import { packageDir, serve } from '../../scripts/scorecard/lib.mjs'; + +const server = await serve(packageDir); +const { port } = new URL(server.url); +let failed = false; + +async function runTour() { + const { default: Shepherd } = await import('/dist/js/shepherd.mjs'); + const target = document.createElement('div'); + target.className = 'target'; + target.style.left = '300px'; + target.style.top = '200px'; + document.getElementById('page').append(target); + const tour = new Shepherd.Tour({ useModalOverlay: true }); + tour.addStep({ + text: 'hi', + arrow: true, + attachTo: { element: target, on: 'bottom' } + }); + await tour.start(); + const el = tour.getCurrentStep().el; + const start = performance.now(); + while (!el.dataset.popperPlacement && performance.now() - start < 3000) { + await new Promise(requestAnimationFrame); + } + const rect = el.getBoundingClientRect(); + window.scrollTo(0, 40); + await new Promise((done) => setTimeout(done, 100)); + const after = el.getBoundingClientRect(); + tour.complete(); + return { + placement: el.dataset.popperPlacement, + y: rect.y, + yAfterScroll: after.y + }; +} + +for (const name of ['chromium', 'firefox', 'webkit']) { + const browser = await playwright[name].launch(); + const page = await browser.newPage({ + viewport: { width: 1000, height: 700 } + }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + // `localhost` and `127.0.0.1` are different origins for the same server. + await page.goto(`http://localhost:${port}/page.html`); + await page.evaluate((src) => { + const frame = document.createElement('iframe'); + frame.src = src; + frame.style.cssText = 'width:800px;height:600px;border:0'; + document.body.prepend(frame); + return new Promise((done) => { + frame.onload = done; + }); + }, `http://127.0.0.1:${port}/page.html`); + const frame = page.frames().find((f) => f !== page.mainFrame()); + const out = await frame.evaluate(runTour); + const follows = Math.abs(out.y - 40 - out.yAfterScroll) <= 1; + failed ||= errors.length > 0 || !out.placement || !follows; + console.log(name, JSON.stringify(out), 'errors:', JSON.stringify(errors)); + await browser.close(); +} +await server.close(); +process.exitCode = failed ? 1 : 0; diff --git a/shepherd.js/test/position-differential/dist-position.mjs b/shepherd.js/test/position-differential/dist-position.mjs new file mode 100644 index 000000000..7851ced4b --- /dev/null +++ b/shepherd.js/test/position-differential/dist-position.mjs @@ -0,0 +1,100 @@ +// The published build with each `positionOptions` key, in real browsers, so +// property mangling cannot break what the source-level differential checks. +// Run `pnpm build` first. +import process from 'node:process'; +import * as playwright from 'playwright-core'; +import { packageDir, serve } from '../../scripts/scorecard/lib.mjs'; + +const server = await serve(packageDir); + +async function runScenarios() { + const { default: Shepherd } = await import('/dist/js/shepherd.mjs'); + const pageEl = document.getElementById('page'); + const run = async (options, on, left = 440, top = 330) => { + const target = document.createElement('div'); + target.className = 'target'; + target.style.left = `${left}px`; + target.style.top = `${top}px`; + pageEl.replaceChildren(target); + const tour = new Shepherd.Tour(); + tour.addStep({ + text: 'hi', + arrow: true, + attachTo: { element: target, on }, + ...options + }); + await tour.start(); + const el = tour.getCurrentStep().el; + const start = performance.now(); + while (!el.dataset.popperPlacement && performance.now() - start < 1500) { + await new Promise(requestAnimationFrame); + } + const rect = el.getBoundingClientRect(); + const result = { + placement: el.dataset.popperPlacement ?? null, + position: el.style.position, + x: Math.round(rect.x), + y: Math.round(rect.y), + bottom: Math.round(rect.bottom) + }; + tour.complete(); + return result; + }; + const warnings = []; + const warn = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + const removed = await run( + { floatingUIOptions: { strategy: 'fixed' } }, + 'bottom' + ); + console.warn = warn; + return { + plain: await run({}, 'bottom'), + offset: await run({ positionOptions: { offset: 20 } }, 'bottom'), + fixed: await run({ positionOptions: { strategy: 'fixed' } }, 'bottom'), + atTop: await run({}, 'right', 440, 0), + padding: await run({ positionOptions: { padding: 40 } }, 'right', 440, 0), + flipped: await run({}, 'right', 880), + noFlip: await run({ positionOptions: { flip: false } }, 'right', 880), + removed, + warnings + }; +} + +const checks = { + 'offset moves the step 20px further': (r) => r.offset.y - r.plain.y === 20, + 'strategy fixed is written': (r) => r.fixed.position === 'fixed', + 'a step that does not fit on the right flips': (r) => + r.flipped.placement === 'left', + 'flip: false keeps right': (r) => r.noFlip.placement === 'right', + 'padding keeps the step 40px from the edge': (r) => + r.atTop.y < 39 && r.padding.y >= 39, + 'floatingUIOptions is ignored and names positionOptions': (r) => + r.removed.position === 'absolute' && + r.warnings.some((text) => text.includes('positionOptions')) +}; + +let failed = false; +for (const name of ['chromium', 'firefox', 'webkit']) { + const browser = await playwright[name].launch(); + const page = await browser.newPage({ + viewport: { width: 1000, height: 700 } + }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(`${server.url}/page.html`); + const out = await page.evaluate(runScenarios); + const failures = Object.entries(checks) + .filter(([, check]) => !check(out)) + .map(([label]) => label); + failed ||= errors.length > 0 || failures.length > 0; + console.log( + name, + failures.length ? `FAILED: ${failures.join('; ')}` : 'all checks pass', + errors.length ? `errors: ${errors.join('; ')}` : '' + ); + if (failures.length) console.log(' ', JSON.stringify(out)); + await browser.close(); +} +await server.close(); +process.exitCode = failed ? 1 : 0; diff --git a/shepherd.js/test/position-differential/fixtures.js b/shepherd.js/test/position-differential/fixtures.js new file mode 100644 index 000000000..d0f2f0db3 --- /dev/null +++ b/shepherd.js/test/position-differential/fixtures.js @@ -0,0 +1,737 @@ +// Runs inside the page. Each fixture builds a DOM and returns { reference, floating, arrow }. +// fixedOnly skips the `absolute` strategy, keepPosition leaves the floating element's CSS position alone. +function div(parent, text = '', tag = 'div') { + const e = (parent.ownerDocument || document).createElement(tag); + e.style.cssText = text; + parent.appendChild(e); + return e; +} +const REF = 'width:120px;height:40px;background:#ccd;'; +const FLOAT = + 'width:200.5px;height:90.25px;background:#fcc;position:absolute;left:0;top:0;'; +const ARROW = 'position:absolute;width:16px;height:16px;background:#000;'; +function mkFloating(parent, extra = '') { + const floating = div(parent, FLOAT + extra); + const arrow = div(floating, ARROW); + arrow.className = 'shepherd-arrow'; + return { floating, arrow }; +} +function svgEl(tag, attrs = {}) { + const e = document.createElementNS('http://www.w3.org/2000/svg', tag); + for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); + return e; +} +function bigPage() { + div( + document.body, + 'height:3000px;width:100%;position:absolute;top:0;left:0;z-index:-1;pointer-events:none' + ); +} +function classicBar() { + const style = document.createElement('style'); + style.textContent = + '::-webkit-scrollbar{width:15px;height:15px;background:#eee}'; + document.head.append(style); +} +const abs = (left, top) => `position:absolute;left:${left}px;top:${top}px;`; +const CENTER = REF + abs(440, 330); +const BOX = 'position:absolute;left:50px;top:80px;width:800px;height:500px;'; + +window.FIXTURES = { + plain() { + const reference = div(document.body, CENTER); + return { reference, ...mkFloating(document.body) }; + }, + cornerTopLeft() { + const reference = div(document.body, REF + abs(0, 0)); + return { reference, ...mkFloating(document.body) }; + }, + cornerBottomRight() { + const reference = div( + document.body, + REF + 'position:absolute;left:880px;top:660px' + ); + return { reference, ...mkFloating(document.body) }; + }, + bodyMargin() { + document.body.style.margin = '37px 19px'; + const reference = div(document.body, REF + 'margin:100px 0 0 300px'); + return { reference, ...mkFloating(document.body) }; + }, + bodyNarrow() { + document.body.style.cssText = 'width:800px;margin:0 auto;'; + const reference = div(document.body, REF + 'margin:200px 0 0 300px'); + return { reference, ...mkFloating(document.body) }; + }, + bodySlightlyNarrow() { + document.body.style.cssText = 'width:calc(100% - 20px);margin:0;'; + const reference = div(document.body, REF + 'margin:200px 0 0 860px'); + return { reference, ...mkFloating(document.body) }; + }, + bodyPositioned() { + document.body.style.cssText = 'position:relative;margin:23px;'; + const reference = div(document.body, REF + 'margin:100px 0 0 300px'); + return { reference, ...mkFloating(document.body) }; + }, + htmlPositioned() { + document.documentElement.style.cssText = 'position:relative;margin:11px;'; + const reference = div(document.body, REF + 'margin:100px 0 0 300px'); + return { reference, ...mkFloating(document.body) }; + }, + htmlMarginBorder() { + document.documentElement.style.cssText = + 'margin:13px;border:7px solid red;'; + const reference = div(document.body, REF + 'margin:100px 0 0 300px'); + return { reference, ...mkFloating(document.body) }; + }, + bodyTransformed() { + document.body.style.cssText = + 'transform:translate(30px,40px);margin:5px;height:1500px;'; + const reference = div(document.body, REF + 'margin:100px 0 0 300px'); + return { reference, ...mkFloating(document.body) }; + }, + scrolled() { + bigPage(); + const reference = div(document.body, REF + abs(440, 830)); + const r = { reference, ...mkFloating(document.body) }; + window.scrollTo(0, 500); + return r; + }, + scrolledXY() { + div( + document.body, + 'position:absolute;left:0;top:0;width:4000px;height:4000px;z-index:-1' + ); + const reference = div(document.body, REF + abs(1440, 1830)); + const r = { reference, ...mkFloating(document.body) }; + window.scrollTo(1000, 1500); + return r; + }, + scrolledNearEdge() { + bigPage(); + const reference = div(document.body, REF + abs(440, 510)); + const r = { reference, ...mkFloating(document.body) }; + window.scrollTo(0, 500); + return r; + }, + rtl() { + document.documentElement.setAttribute('dir', 'rtl'); + bigPage(); + const reference = div(document.body, CENTER); + return { reference, ...mkFloating(document.body) }; + }, + rtlOverflowX() { + document.documentElement.setAttribute('dir', 'rtl'); + div(document.body, 'width:3000px;height:10px'); + const reference = div( + document.body, + REF + 'position:absolute;left:-600px;top:330px' + ); + const r = { reference, ...mkFloating(document.body) }; + window.scrollTo(-500, 0); + return r; + }, + rtlFloatingOnly() { + const reference = div(document.body, CENTER); + const m = mkFloating(document.body); + m.floating.setAttribute('dir', 'rtl'); + return { reference, ...m }; + }, + overflowParent() { + const host = div( + document.body, + 'position:absolute;left:300px;top:200px;width:400px;height:300px;overflow:auto' + ); + const inner = div(host, 'position:relative;height:1200px;width:900px'); + const reference = div(inner, REF + abs(140, 300)); + host.scrollTop = 200; + return { reference, ...mkFloating(document.body) }; + }, + overflowParentBoth() { + const host = div( + document.body, + 'position:absolute;left:300px;top:200px;width:400px;height:300px;overflow:auto;border:5px solid red' + ); + const inner = div(host, 'position:relative;height:1200px;width:900px'); + const reference = div(inner, REF + abs(140, 300)); + host.scrollTop = 200; + host.scrollLeft = 50; + return { reference, ...mkFloating(inner) }; + }, + overflowStaticWrapperEscaped() { + const host = div( + document.body, + 'overflow:hidden;width:300px;height:200px;margin:150px 0 0 300px' + ); + const reference = div(host, REF); + return { reference, ...mkFloating(host) }; + }, + overflowPositionedWrapperClips() { + const host = div( + document.body, + 'position:relative;overflow:hidden;width:300px;height:200px;margin:150px 0 0 300px' + ); + const reference = div(host, REF); + return { reference, ...mkFloating(host) }; + }, + overflowFixedEscapes() { + const host = div( + document.body, + 'overflow:hidden;width:300px;height:200px;margin:150px 0 0 300px;position:relative' + ); + const reference = div(host, REF); + return { + reference, + ...mkFloating(host, 'position:fixed;'), + fixedOnly: true + }; + }, + overflowFixedInTransformClips() { + const host = div( + document.body, + 'overflow:hidden;width:300px;height:200px;margin:150px 0 0 300px;transform:translateX(5px)' + ); + const reference = div(host, REF); + return { + reference, + ...mkFloating(host, 'position:fixed;'), + fixedOnly: true + }; + }, + overflowNested() { + const a = div( + document.body, + 'position:relative;overflow:hidden;width:500px;height:400px;margin:50px' + ); + const b = div( + a, + 'position:relative;overflow:auto;width:300px;height:500px;margin:20px' + ); + const reference = div(b, REF + 'margin:100px'); + return { reference, ...mkFloating(b) }; + }, + htmlMarginOverflowParent() { + document.documentElement.style.margin = '11px'; + const host = div( + document.body, + 'position:relative;overflow:hidden;width:300px;height:200px;margin:150px 0 0 300px' + ); + const reference = div(host, REF); + return { reference, ...mkFloating(host) }; + }, + htmlMarginTransformed() { + document.documentElement.style.margin = '11px'; + const c = div( + document.body, + BOX + 'transform:translate(10px,10px);overflow:hidden' + ); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c) }; + }, + overflowInline() { + const host = div(document.body, 'display:inline;overflow:hidden'); + const reference = div(host, REF + 'margin:100px'); + return { reference, ...mkFloating(host) }; + }, + overflowContents() { + const host = div(document.body, 'display:contents;overflow:hidden'); + const reference = div(host, REF + 'margin:100px'); + return { reference, ...mkFloating(host) }; + }, + bodyOverflowOwnScroller() { + document.documentElement.style.cssText = 'height:100%;overflow:hidden'; + document.body.style.cssText = 'height:100%;overflow:auto;margin:0'; + div(document.body, 'height:3000px'); + const reference = div(document.body, REF + abs(300, 900)); + const r = { reference, ...mkFloating(document.body) }; + document.body.scrollTop = 600; + return r; + }, + bodyOverflowPropagated() { + document.documentElement.style.cssText = 'height:100%'; + document.body.style.cssText = 'height:100%;overflow:auto;margin:0'; + div(document.body, 'height:3000px'); + const reference = div(document.body, REF + abs(300, 900)); + const r = { reference, ...mkFloating(document.body) }; + window.scrollTo(0, 600); + return r; + }, + htmlOverflowHidden() { + document.documentElement.style.cssText = 'overflow:hidden'; + bigPage(); + const reference = div(document.body, REF + abs(300, 300)); + return { reference, ...mkFloating(document.body) }; + }, + transformedContainer() { + const c = div(document.body, BOX + 'transform:translate(10px,10px)'); + const reference = div(document.body, CENTER); + return { reference, ...mkFloating(c) }; + }, + scaledContainer() { + const c = div( + document.body, + BOX + 'transform:scale(0.5);transform-origin:0 0' + ); + const reference = div(document.body, CENTER); + return { reference, ...mkFloating(c) }; + }, + scaledBorderedContainer() { + const c = div( + document.body, + BOX + 'transform:scale(0.5);transform-origin:0 0;border:4px solid red' + ); + const reference = div(document.body, CENTER); + return { reference, ...mkFloating(c) }; + }, + scaledNonUniformBoth() { + const c = div( + document.body, + BOX + 'transform:scale(1.5,0.75);transform-origin:0 0' + ); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c) }; + }, + scaleProperty() { + const c = div(document.body, BOX + 'scale:0.7;transform-origin:0 0'); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c) }; + }, + zoomedContainer() { + const c = div(document.body, BOX + 'zoom:1.5'); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c) }; + }, + fixedTarget() { + bigPage(); + const reference = div( + document.body, + REF + 'position:fixed;left:440px;top:330px' + ); + const r = { reference, ...mkFloating(document.body) }; + window.scrollTo(0, 250); + return r; + }, + fixedInFilter() { + const c = div(document.body, BOX + 'filter:blur(0)'); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c, 'position:fixed;'), fixedOnly: true }; + }, + fixedInWillChange() { + const c = div(document.body, BOX + 'will-change:transform'); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c, 'position:fixed;'), fixedOnly: true }; + }, + fixedInContainPaint() { + const c = div(document.body, BOX + 'contain:paint'); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c, 'position:fixed;'), fixedOnly: true }; + }, + fixedInPerspective() { + const c = div(document.body, BOX + 'perspective:100px'); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c, 'position:fixed;'), fixedOnly: true }; + }, + fixedInBackdrop() { + const c = div(document.body, BOX + 'backdrop-filter:blur(1px)'); + const reference = div(c, REF + abs(140, 130)); + return { reference, ...mkFloating(c, 'position:fixed;'), fixedOnly: true }; + }, + staticTd() { + const t = div( + document.body, + 'margin:150px 0 0 300px;border-collapse:collapse', + 'table' + ); + const tr = div(t, '', 'tr'); + const td = div( + tr, + 'width:300px;height:200px;padding:5px;position:static', + 'td' + ); + const reference = div(td, REF); + return { reference, ...mkFloating(td) }; + }, + positionedTd() { + const t = div(document.body, 'margin:150px 0 0 300px;', 'table'); + const tr = div(t, '', 'tr'); + const td = div( + tr, + 'width:300px;height:200px;padding:5px;position:relative', + 'td' + ); + const reference = div(td, REF); + return { reference, ...mkFloating(td) }; + }, + staticTdInPositioned() { + const wrap = div(document.body, 'position:relative;margin:100px'); + const t = div(wrap, '', 'table'); + const tr = div(t, '', 'tr'); + const td = div(tr, 'width:300px;height:200px', 'td'); + const reference = div(td, REF); + return { reference, ...mkFloating(td) }; + }, + staticFloatingInTd() { + const wrap = div(document.body, 'position:relative;margin:100px'); + const t = div(wrap, '', 'table'); + const tr = div(t, '', 'tr'); + const td = div(tr, 'width:300px;height:200px', 'td'); + const reference = div(td, REF); + const m = mkFloating(td, 'position:static;'); + return { reference, ...m, keepPosition: true }; + }, + staticFloatingInBody() { + const reference = div(document.body, CENTER); + const m = mkFloating(document.body, 'position:static;'); + return { reference, ...m, keepPosition: true }; + }, + svgReference() { + const s = svgEl('svg', { width: 400, height: 300 }); + s.style.cssText = abs(300, 200); + document.body.appendChild(s); + const r = svgEl('rect', { x: 50, y: 60, width: 100, height: 40 }); + s.appendChild(r); + return { reference: r, ...mkFloating(document.body) }; + }, + svgArrow() { + const reference = div(document.body, CENTER); + const m = mkFloating(document.body); + const s = svgEl('svg', { width: 16, height: 16, class: 'shepherd-arrow' }); + s.style.cssText = 'position:absolute'; + m.floating.appendChild(s); + m.arrow.remove(); + m.arrow = s; + return { reference, ...m }; + }, + svgArrowNested() { + const reference = div(document.body, CENTER); + const m = mkFloating(document.body); + const wrap = div( + m.floating, + 'position:absolute;left:0;top:0;width:100px;height:50px' + ); + const s = svgEl('svg', { width: 16, height: 16, class: 'shepherd-arrow' }); + s.style.cssText = 'position:absolute'; + wrap.appendChild(s); + m.arrow.remove(); + m.arrow = s; + return { reference, ...m }; + }, + shadowReference() { + const h = div(document.body, abs(300, 200)); + const root = h.attachShadow({ mode: 'open' }); + const scroller = document.createElement('div'); + scroller.style.cssText = + 'overflow:auto;width:300px;height:200px;position:relative'; + root.appendChild(scroller); + const inner = div(scroller, 'height:800px'); + const reference = div(inner, REF + 'margin-top:150px'); + scroller.scrollTop = 100; + return { reference, ...mkFloating(document.body) }; + }, + shadowFloating() { + const h = div(document.body, abs(300, 200) + 'width:300px;height:200px'); + const root = h.attachShadow({ mode: 'open' }); + const reference = div(document.body, CENTER); + const wrap = document.createElement('div'); + wrap.style.cssText = + 'position:relative;overflow:hidden;width:300px;height:200px'; + root.appendChild(wrap); + return { reference, ...mkFloating(wrap) }; + }, + slotted() { + const h = div(document.body, abs(300, 200)); + const root = h.attachShadow({ mode: 'open' }); + const wrap = document.createElement('div'); + wrap.style.cssText = + 'overflow:hidden;position:relative;width:400px;height:300px'; + wrap.appendChild(document.createElement('slot')); + root.appendChild(wrap); + const reference = div(h, REF + 'margin:60px'); + return { reference, ...mkFloating(h) }; + }, + popoverFloating() { + const reference = div(document.body, CENTER); + const m = mkFloating(document.body, 'margin:0;inset:auto;'); + if (!m.floating.showPopover) return null; + m.floating.setAttribute('popover', 'manual'); + m.floating.showPopover(); + return { reference, ...m }; + }, + popoverInTransform() { + const c = div( + document.body, + BOX + 'transform:translate(10px,10px);overflow:hidden' + ); + const reference = div(c, REF + abs(140, 130)); + const m = mkFloating(c, 'margin:0;inset:auto;'); + if (!m.floating.showPopover) return null; + m.floating.setAttribute('popover', 'manual'); + m.floating.showPopover(); + return { reference, ...m }; + }, + dialogModalFloating() { + const reference = div(document.body, CENTER); + const d = document.createElement('dialog'); + d.style.cssText = FLOAT + 'margin:0;padding:0;border:0;'; + const a = div(d, ARROW); + a.className = 'shepherd-arrow'; + document.body.appendChild(d); + d.showModal(); + return { reference, floating: d, arrow: a, closeDialog: d }; + }, + floatingInModalDialog() { + const d = document.createElement('dialog'); + d.style.cssText = 'width:500px;height:400px;'; + document.body.appendChild(d); + d.showModal(); + const reference = div(d, REF + 'margin:50px'); + return { reference, ...mkFloating(d), closeDialog: d }; + }, + fixedFloatingInModalInTransform() { + const w = div( + document.body, + 'transform:translate(10px,10px);' + + abs(30, 30) + + 'width:100px;height:100px' + ); + const d = document.createElement('dialog'); + d.style.cssText = 'width:500px;height:400px;'; + w.appendChild(d); + d.showModal(); + const reference = div(d, REF + 'margin:50px'); + return { + reference, + ...mkFloating(d, 'position:fixed;'), + closeDialog: d, + fixedOnly: true + }; + }, + dialogNonModal() { + const d = document.createElement('dialog'); + d.style.cssText = FLOAT + 'margin:0;padding:0;border:0;'; + const a = div(d, ARROW); + a.className = 'shepherd-arrow'; + document.body.appendChild(d); + d.show(); + const reference = div(document.body, CENTER); + return { reference, floating: d, arrow: a }; + }, + classicScrollbar() { + classicBar(); + document.documentElement.style.overflowY = 'scroll'; + div(document.body, 'height:3000px'); + const reference = div(document.body, REF + abs(880, 330)); + return { reference, ...mkFloating(document.body) }; + }, + classicScrollbarRtl() { + classicBar(); + document.documentElement.setAttribute('dir', 'rtl'); + document.documentElement.style.overflowY = 'scroll'; + div(document.body, 'height:3000px'); + const reference = div(document.body, REF + abs(0, 330)); + return { reference, ...mkFloating(document.body) }; + }, + scrollbarGutter() { + classicBar(); + document.documentElement.style.cssText = + 'scrollbar-gutter:stable;overflow-y:auto'; + const reference = div(document.body, REF + abs(880, 200)); + return { reference, ...mkFloating(document.body) }; + }, + scrollbarGutterBoth() { + classicBar(); + document.documentElement.style.cssText = + 'scrollbar-gutter:stable both-edges;overflow-y:auto'; + const reference = div(document.body, REF + abs(880, 200)); + return { reference, ...mkFloating(document.body) }; + }, + bodyNarrowerThanHtml() { + document.body.style.cssText = 'margin:0;width:calc(100% - 20px)'; + div(document.body, 'position:relative;width:100%;height:3000px'); + const reference = div(document.body, REF + abs(880, 330)); + return { reference, ...mkFloating(document.body) }; + }, + gutterHidden() { + classicBar(); + document.documentElement.style.cssText = + 'overflow:hidden;scrollbar-gutter:stable'; + const reference = div(document.body, REF + abs(880, 200)); + return { reference, ...mkFloating(document.body) }; + }, + gutterHiddenBoth() { + classicBar(); + document.documentElement.style.cssText = + 'overflow:hidden;scrollbar-gutter:stable both-edges'; + const reference = div(document.body, REF + abs(880, 200)); + return { reference, ...mkFloating(document.body) }; + }, + gutterStable() { + document.documentElement.style.scrollbarGutter = 'stable'; + const reference = div( + document.body, + REF + 'position:absolute;right:0;top:330px' + ); + return { reference, ...mkFloating(document.body) }; + }, + gutterBoth() { + document.documentElement.style.scrollbarGutter = 'stable both-edges'; + const reference = div( + document.body, + REF + 'position:absolute;right:0;top:330px' + ); + return { reference, ...mkFloating(document.body) }; + }, + scrollbars() { + bigPage(); + div(document.body, 'width:3000px;height:1px'); + const reference = div(document.body, REF + abs(0, 330)); + return { reference, ...mkFloating(document.body) }; + }, + stickyReference() { + bigPage(); + const w = div(document.body, 'position:relative;height:2000px;width:100%'); + const reference = div( + w, + REF + 'position:sticky;top:10px;margin-left:300px' + ); + const r = { reference, ...mkFloating(document.body) }; + window.scrollTo(0, 400); + return r; + }, + zeroSizeReference() { + const reference = div(document.body, abs(440, 330) + 'width:0;height:0'); + return { reference, ...mkFloating(document.body) }; + }, + hiddenReference() { + const reference = div(document.body, REF + 'display:none'); + return { reference, ...mkFloating(document.body) }; + }, + hiddenFloating() { + const reference = div(document.body, CENTER); + return { reference, ...mkFloating(document.body, 'display:none;') }; + }, + detachedReference() { + const reference = document.createElement('div'); + return { reference, ...mkFloating(document.body) }; + }, + tinyReference() { + const reference = div( + document.body, + abs(440, 330) + 'width:6px;height:6px;background:#000' + ); + return { reference, ...mkFloating(document.body) }; + }, + hugeReference() { + const reference = div( + document.body, + abs(10, 10) + 'width:900px;height:600px;background:#000' + ); + return { reference, ...mkFloating(document.body) }; + }, + bodyAsReference() { + return { reference: document.body, ...mkFloating(document.body) }; + }, + floatingInFlowRelative() { + const c = div( + document.body, + 'position:relative;border:6px solid red;padding:10px;margin:60px;width:600px;height:400px' + ); + const reference = div(c, REF + 'margin:100px'); + return { reference, ...mkFloating(c) }; + }, + iframeReference() { + const fr = document.createElement('iframe'); + fr.style.cssText = + abs(100, 80) + + 'width:600px;height:400px;border:9px solid red;padding:6px;'; + document.body.appendChild(fr); + const d = fr.contentDocument; + d.body.style.margin = '0'; + const reference = div(d.body, REF + abs(200, 150)); + return { reference, ...mkFloating(document.body) }; + }, + iframeScaled() { + const fr = document.createElement('iframe'); + fr.style.cssText = + abs(100, 80) + + 'width:600px;height:400px;border:0;transform:scale(0.5);transform-origin:0 0'; + document.body.appendChild(fr); + const d = fr.contentDocument; + d.body.style.margin = '0'; + const reference = div(d.body, REF + abs(200, 150)); + return { reference, ...mkFloating(document.body) }; + }, + iframeNested() { + const fr = document.createElement('iframe'); + fr.style.cssText = + abs(50, 40) + 'width:800px;height:600px;border:3px solid red;'; + document.body.appendChild(fr); + const d = fr.contentDocument; + d.body.style.margin = '0'; + const fr2 = d.createElement('iframe'); + fr2.style.cssText = + abs(60, 50) + 'width:500px;height:400px;border:5px solid blue;'; + d.body.appendChild(fr2); + const d2 = fr2.contentDocument; + d2.body.style.margin = '0'; + const reference = div(d2.body, REF + abs(100, 80)); + return { reference, ...mkFloating(document.body) }; + }, + iframeBothInside() { + const fr = document.createElement('iframe'); + fr.style.cssText = abs(100, 80) + 'width:700px;height:500px;border:0;'; + document.body.appendChild(fr); + const d = fr.contentDocument; + d.body.style.margin = '0'; + const reference = div(d.body, REF + abs(200, 150)); + const m = mkFloating(d.body); + return { reference, ...m }; + }, + iframeScrolledReference() { + const fr = document.createElement('iframe'); + fr.style.cssText = abs(100, 80) + 'width:600px;height:400px;border:0;'; + document.body.appendChild(fr); + const d = fr.contentDocument; + d.body.style.margin = '0'; + div(d.body, 'height:3000px'); + const reference = div(d.body, REF + abs(200, 850)); + fr.contentWindow.scrollTo(0, 700); + return { reference, ...mkFloating(document.body) }; + } +}; + +// What getPositionConfig() builds, in stock middleware. +window.stockOptions = function (FUI, on, arrowEl, strategy, position) { + const { offset, padding, flip = true } = position; + const hasAuto = on.includes('auto'); + const edge = on.includes('-start') || on.includes('-end'); + const middleware = []; + if (offset) middleware.push(FUI.offset(offset)); + if (hasAuto) { + middleware.push( + FUI.autoPlacement({ + crossAxis: true, + alignment: edge ? on.split('-').pop() : null, + padding + }) + ); + } else if (flip) { + middleware.push(FUI.flip({ padding })); + } + middleware.push( + FUI.shift({ limiter: FUI.limitShift(), crossAxis: true, padding }) + ); + middleware.push(FUI.arrow({ element: arrowEl, padding: edge ? 4 : 0 })); + return { strategy, placement: hasAuto ? undefined : on, middleware }; +}; + +// Each `positionOptions` a consumer can pass, other than `strategy`, which +// every fixture runs both ways. +window.POSITION_CONFIGS = { + none: {}, + offset: { offset: 12 }, + offsetCross: { offset: { mainAxis: 4, crossAxis: 8 } }, + padding: { padding: 24 }, + noFlip: { flip: false }, + combined: { offset: 8, padding: 12, flip: false } +}; diff --git a/shepherd.js/test/position-differential/jsdom.mjs b/shepherd.js/test/position-differential/jsdom.mjs new file mode 100644 index 000000000..d2500b998 --- /dev/null +++ b/shepherd.js/test/position-differential/jsdom.mjs @@ -0,0 +1,61 @@ +// A consumer's unit tests often run tours in jsdom, which has no layout, +// ResizeObserver or IntersectionObserver, and by default no +// requestAnimationFrame. A tour must still run there without an unhandled +// rejection. Run `pnpm build` first. +import process from 'node:process'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { JSDOM } from 'jsdom'; +import { packageDir } from '../../scripts/scorecard/lib.mjs'; + +const visual = process.argv.includes('--visual'); +const dom = new JSDOM( + '
target
', + { pretendToBeVisual: visual, url: 'http://localhost/' } +); +for (const key of Object.getOwnPropertyNames(dom.window)) { + if (!(key in globalThis)) { + try { + globalThis[key] = dom.window[key]; + } catch { + // Read-only on the Node global. + } + } +} +globalThis.window = dom.window; +globalThis.document = dom.window.document; + +const errors = []; +process.on('unhandledRejection', (error) => errors.push(String(error))); + +const { default: Shepherd } = await import( + pathToFileURL(join(packageDir, 'dist/js/shepherd.mjs')).href +); +const pause = () => new Promise((done) => setTimeout(done, 30)); +// The overlay's frame loop needs requestAnimationFrame. +const tour = new Shepherd.Tour({ useModalOverlay: visual }); +tour.addStep({ + text: 'hi', + arrow: true, + attachTo: { element: '#a', on: 'bottom' } +}); +tour.addStep({ text: 'auto', attachTo: { element: '#a', on: 'auto-start' } }); +tour.addStep({ text: 'centered' }); + +const placements = []; +await tour.start(); +await pause(); +placements.push(tour.getCurrentStep().el?.dataset.popperPlacement); +await tour.next(); +await pause(); +placements.push(tour.getCurrentStep().el?.dataset.popperPlacement); +await tour.next(); +await pause(); +const { el } = tour.getCurrentStep(); +placements.push(el?.dataset.popperPlacement); +const focused = dom.window.document.activeElement === el; +tour.complete(); + +console.log(JSON.stringify({ placements, focused, unhandled: errors })); +dom.window.close(); +process.exit(errors.length || !focused ? 1 : 0); diff --git a/shepherd.js/test/position-differential/known-differences.json b/shepherd.js/test/position-differential/known-differences.json new file mode 100644 index 000000000..1539c4710 --- /dev/null +++ b/shepherd.js/test/position-differential/known-differences.json @@ -0,0 +1,176 @@ +{ + "chromium standards": { + "bodyNarrowerThanHtml/combined": 8, + "bodyNarrowerThanHtml/noFlip": 8, + "bodyNarrowerThanHtml/none": 16, + "bodyNarrowerThanHtml/offset": 6, + "bodyNarrowerThanHtml/offsetCross": 6, + "bodyNarrowerThanHtml/padding": 6, + "bodySlightlyNarrow/combined": 8, + "bodySlightlyNarrow/noFlip": 6, + "bodySlightlyNarrow/none": 4, + "bodySlightlyNarrow/offset": 2, + "bodySlightlyNarrow/offsetCross": 6, + "bodySlightlyNarrow/padding": 6, + "scaledBorderedContainer/combined": 4, + "scaledBorderedContainer/noFlip": 4, + "scaledBorderedContainer/none": 4, + "scaledBorderedContainer/offset": 4, + "scaledBorderedContainer/offsetCross": 2, + "scaledBorderedContainer/padding": 4, + "staticFloatingInTd/combined": 12, + "staticFloatingInTd/noFlip": 12, + "staticFloatingInTd/none": 30, + "staticFloatingInTd/offset": 12, + "staticFloatingInTd/offsetCross": 12, + "staticFloatingInTd/padding": 12, + "svgArrowNested/combined": 12, + "svgArrowNested/noFlip": 12, + "svgArrowNested/none": 30, + "svgArrowNested/offset": 12, + "svgArrowNested/offsetCross": 12, + "svgArrowNested/padding": 12 + }, + "chromium quirks": { + "gutterHidden/combined": 8, + "gutterHidden/noFlip": 8, + "gutterHidden/none": 16, + "gutterHidden/offset": 6, + "gutterHidden/offsetCross": 6, + "gutterHidden/padding": 6, + "gutterStable/combined": 8, + "gutterStable/noFlip": 6, + "gutterStable/none": 4, + "gutterStable/offset": 2, + "gutterStable/offsetCross": 6, + "gutterStable/padding": 6, + "scaledBorderedContainer/combined": 4, + "scaledBorderedContainer/noFlip": 4, + "scaledBorderedContainer/none": 4, + "scaledBorderedContainer/offset": 4, + "scaledBorderedContainer/offsetCross": 2, + "scaledBorderedContainer/padding": 4, + "scrollbarGutter/combined": 8, + "scrollbarGutter/noFlip": 8, + "scrollbarGutter/none": 16, + "scrollbarGutter/offset": 6, + "scrollbarGutter/offsetCross": 6, + "scrollbarGutter/padding": 6, + "staticFloatingInTd/combined": 12, + "staticFloatingInTd/noFlip": 12, + "staticFloatingInTd/none": 30, + "staticFloatingInTd/offset": 12, + "staticFloatingInTd/offsetCross": 12, + "staticFloatingInTd/padding": 12, + "svgArrowNested/combined": 12, + "svgArrowNested/noFlip": 12, + "svgArrowNested/none": 30, + "svgArrowNested/offset": 12, + "svgArrowNested/offsetCross": 12, + "svgArrowNested/padding": 12 + }, + "firefox standards": { + "bodyNarrowerThanHtml/combined": 8, + "bodyNarrowerThanHtml/noFlip": 8, + "bodyNarrowerThanHtml/none": 16, + "bodyNarrowerThanHtml/offset": 6, + "bodyNarrowerThanHtml/offsetCross": 6, + "bodyNarrowerThanHtml/padding": 6, + "bodySlightlyNarrow/combined": 8, + "bodySlightlyNarrow/noFlip": 6, + "bodySlightlyNarrow/none": 4, + "bodySlightlyNarrow/offset": 2, + "bodySlightlyNarrow/offsetCross": 6, + "bodySlightlyNarrow/padding": 6, + "scaledBorderedContainer/combined": 4, + "scaledBorderedContainer/noFlip": 4, + "scaledBorderedContainer/none": 4, + "scaledBorderedContainer/offset": 4, + "scaledBorderedContainer/offsetCross": 2, + "scaledBorderedContainer/padding": 4, + "staticFloatingInTd/combined": 12, + "staticFloatingInTd/noFlip": 12, + "staticFloatingInTd/none": 30, + "staticFloatingInTd/offset": 12, + "staticFloatingInTd/offsetCross": 12, + "staticFloatingInTd/padding": 12, + "svgArrowNested/combined": 12, + "svgArrowNested/noFlip": 12, + "svgArrowNested/none": 30, + "svgArrowNested/offset": 12, + "svgArrowNested/offsetCross": 12, + "svgArrowNested/padding": 12 + }, + "firefox quirks": { + "scaledBorderedContainer/combined": 4, + "scaledBorderedContainer/noFlip": 4, + "scaledBorderedContainer/none": 4, + "scaledBorderedContainer/offset": 4, + "scaledBorderedContainer/offsetCross": 2, + "scaledBorderedContainer/padding": 4, + "staticFloatingInTd/combined": 12, + "staticFloatingInTd/noFlip": 12, + "staticFloatingInTd/none": 30, + "staticFloatingInTd/offset": 12, + "staticFloatingInTd/offsetCross": 12, + "staticFloatingInTd/padding": 12, + "svgArrowNested/combined": 12, + "svgArrowNested/noFlip": 12, + "svgArrowNested/none": 30, + "svgArrowNested/offset": 12, + "svgArrowNested/offsetCross": 12, + "svgArrowNested/padding": 12 + }, + "webkit standards": { + "bodyNarrowerThanHtml/combined": 8, + "bodyNarrowerThanHtml/noFlip": 8, + "bodyNarrowerThanHtml/none": 16, + "bodyNarrowerThanHtml/offset": 6, + "bodyNarrowerThanHtml/offsetCross": 6, + "bodyNarrowerThanHtml/padding": 6, + "bodySlightlyNarrow/combined": 8, + "bodySlightlyNarrow/noFlip": 6, + "bodySlightlyNarrow/none": 4, + "bodySlightlyNarrow/offset": 2, + "bodySlightlyNarrow/offsetCross": 6, + "bodySlightlyNarrow/padding": 6, + "scaledBorderedContainer/combined": 4, + "scaledBorderedContainer/noFlip": 4, + "scaledBorderedContainer/none": 4, + "scaledBorderedContainer/offset": 4, + "scaledBorderedContainer/offsetCross": 2, + "scaledBorderedContainer/padding": 4, + "staticFloatingInTd/combined": 12, + "staticFloatingInTd/noFlip": 12, + "staticFloatingInTd/none": 30, + "staticFloatingInTd/offset": 12, + "staticFloatingInTd/offsetCross": 12, + "staticFloatingInTd/padding": 12, + "svgArrowNested/combined": 12, + "svgArrowNested/noFlip": 12, + "svgArrowNested/none": 30, + "svgArrowNested/offset": 12, + "svgArrowNested/offsetCross": 12, + "svgArrowNested/padding": 12 + }, + "webkit quirks": { + "scaledBorderedContainer/combined": 4, + "scaledBorderedContainer/noFlip": 4, + "scaledBorderedContainer/none": 4, + "scaledBorderedContainer/offset": 4, + "scaledBorderedContainer/offsetCross": 2, + "scaledBorderedContainer/padding": 4, + "staticFloatingInTd/combined": 12, + "staticFloatingInTd/noFlip": 12, + "staticFloatingInTd/none": 30, + "staticFloatingInTd/offset": 12, + "staticFloatingInTd/offsetCross": 12, + "staticFloatingInTd/padding": 12, + "svgArrowNested/combined": 12, + "svgArrowNested/noFlip": 12, + "svgArrowNested/none": 30, + "svgArrowNested/offset": 12, + "svgArrowNested/offsetCross": 12, + "svgArrowNested/padding": 12 + } +} diff --git a/shepherd.js/test/position-differential/run.mjs b/shepherd.js/test/position-differential/run.mjs new file mode 100644 index 000000000..0dcebbcd8 --- /dev/null +++ b/shepherd.js/test/position-differential/run.mjs @@ -0,0 +1,398 @@ +// Positions the same DOM with stock @floating-ui/dom and with Shepherd's engine, +// in real browsers, and fails on any difference not in known-differences.json. +// node test/position-differential/run.mjs +// [--browser chromium|firefox|webkit|all] [--fixture name] [--config name] +// [--json out.json] [--update-known] +import process from 'node:process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import * as playwright from 'playwright-core'; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageDir = join(here, '../..'); +const knownFile = join(here, 'known-differences.json'); +const args = process.argv.slice(2); +const flag = (name, fallback) => + args.includes(name) ? args[args.indexOf(name) + 1] : fallback; + +const bundle = async (contents, globalName) => { + const result = await build({ + stdin: { contents, resolveDir: packageDir, loader: 'ts' }, + bundle: true, + format: 'iife', + globalName, + write: false, + target: 'es2020', + logLevel: 'error', + loader: { '.css': 'empty' } + }); + return result.outputFiles[0].text; +}; + +const STOCK = await bundle(`export * from '@floating-ui/dom';`, 'STOCK'); +const ENGINE = await bundle( + `export { computePosition, autoUpdate } from './src/utils/position.ts'; + export { getPositionConfig } from './src/utils/floating-ui.ts';`, + 'ENGINE' +); +const FIXTURES = readFileSync(join(here, 'fixtures.js'), 'utf8'); +const PAGE = + ''; +// In quirks mode has its default margin, since several checks depend on it. +const QUIRKS_PAGE = + ''; + +const ALL_PLACEMENTS = [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'right', + 'right-start', + 'right-end', + 'auto', + 'auto-start', + 'auto-end' +]; +const CORE_PLACEMENTS = [ + 'top', + 'bottom-start', + 'left-end', + 'right', + 'auto', + 'auto-end' +]; +// Filled by the engine's own middleware only for what Shepherd reads, so stock's +// bookkeeping under these names is not compared. +const ENGINE_MIDDLEWARE = ['flip', 'autoPlacement', 'shift']; + +async function runFixture({ + name, + only, + ALL_PLACEMENTS, + CORE_PLACEMENTS, + ENGINE_MIDDLEWARE +}) { + const { STOCK, ENGINE, FIXTURES, POSITION_CONFIGS, stockOptions } = window; + const round = (value) => + JSON.parse( + JSON.stringify(value, (key, v) => { + if (typeof v === 'number') { + return Number.isFinite(v) ? Math.round(v * 100) / 100 : String(v); + } + return v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries( + Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1)) + ) + : v; + }) + ); + const attempt = async (position) => { + try { + return round(await position()); + } catch (error) { + return { threw: String(error.message).slice(0, 100) }; + } + }; + const comparableData = (result) => { + if (result.threw) return result.threw; + const data = { ...result.middlewareData }; + for (const key of ENGINE_MIDDLEWARE) delete data[key]; + if (data.arrow) data.arrow = { x: data.arrow.x, y: data.arrow.y }; + return JSON.stringify(data); + }; + + const fx = FIXTURES[name](); + if (!fx) return { name, skipped: true }; + const { reference, floating, arrow } = fx; + const out = { name, cases: 0, differences: [] }; + const compare = (config, strategy, on, stock, engine) => { + out.cases++; + if (stock.threw && engine.threw) return; + const dx = Math.abs(stock.x - engine.x); + const dy = Math.abs(stock.y - engine.y); + const coords = + Boolean(stock.threw || engine.threw) || + dx > 0.01 || + dy > 0.01 || + stock.placement !== engine.placement; + const stockData = comparableData(stock); + const engineData = comparableData(engine); + if (coords || stockData !== engineData) { + out.differences.push({ + config, + strategy, + on, + coords, + over1px: Boolean( + stock.threw || + engine.threw || + dx > 1 || + dy > 1 || + stock.placement !== engine.placement + ), + stock: { + x: stock.x, + y: stock.y, + placement: stock.placement, + threw: stock.threw + }, + engine: { + x: engine.x, + y: engine.y, + placement: engine.placement, + threw: engine.threw + }, + data: coords ? undefined : { stock: stockData, engine: engineData } + }); + } + }; + + const strategies = fx.fixedOnly ? ['fixed'] : ['absolute', 'fixed']; + for (const strategy of strategies) { + if (!fx.keepPosition) floating.style.position = strategy; + for (const [config, position] of Object.entries(POSITION_CONFIGS)) { + if (only && only !== config) continue; + for (const on of config === 'none' ? ALL_PLACEMENTS : CORE_PLACEMENTS) { + const stock = await attempt(() => + STOCK.computePosition( + reference, + floating, + stockOptions(STOCK, on, arrow, strategy, position) + ) + ); + const engine = await attempt(() => + ENGINE.computePosition( + reference, + floating, + ENGINE.getPositionConfig( + { element: reference, on }, + { + options: { + arrow: true, + positionOptions: { strategy, ...position } + }, + el: floating + } + ) + ) + ); + compare(config, strategy, on, stock, engine); + } + } + } + fx.closeDialog?.close(); + return out; +} + +async function runTracker({ which }) { + const lib = window[which]; + const page = document.createElement('div'); + page.style.cssText = 'height:3000px;position:relative'; + document.body.append(page); + const scroller = document.createElement('div'); + scroller.style.cssText = + 'position:absolute;left:100px;top:100px;width:300px;height:200px;overflow:auto'; + page.append(scroller); + const inner = document.createElement('div'); + inner.style.cssText = 'height:900px;position:relative'; + scroller.append(inner); + const spacer = document.createElement('div'); + spacer.style.cssText = 'height:20px'; + inner.append(spacer); + const reference = document.createElement('div'); + reference.style.cssText = 'width:100px;height:40px;background:#ccd'; + inner.append(reference); + const floating = document.createElement('div'); + floating.style.cssText = + 'position:absolute;left:0;top:0;width:100px;height:50px;background:#fcc'; + document.body.append(floating); + let count = 0; + const tick = async () => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + // Longer than a settled tracker's heartbeat. + await new Promise((done) => setTimeout(done, 250)); + }; + const results = {}; + const measure = async (name, act) => { + await tick(); + const before = count; + act(); + await tick(); + results[name] = count - before; + }; + const cleanup = lib.autoUpdate(reference, floating, () => count++); + results.initial = count; + await measure('windowScroll', () => window.scrollTo(0, 37)); + await measure('ancestorScroll', () => { + scroller.scrollTop = 11; + }); + await measure('referenceResize', () => { + reference.style.width = '111px'; + }); + await measure('floatingResize', () => { + floating.style.height = '77px'; + }); + await measure('layoutShift', () => { + spacer.style.height = '60px'; + }); + await measure('transformShift', () => { + inner.style.transform = 'translateY(7px)'; + }); + await measure('outsideShift', () => { + scroller.style.top = '140px'; + }); + await new Promise((done) => setTimeout(done, 1200)); + await measure('idle', () => {}); + await measure('settledShift', () => { + scroller.style.top = '150px'; + }); + cleanup(); + await measure('afterCleanup', () => { + window.scrollTo(0, 80); + reference.style.width = '131px'; + floating.style.height = '33px'; + scroller.style.top = '90px'; + }); + window.scrollTo(0, 0); + return results; +} + +const browserArg = flag('--browser', 'all'); +const browsers = + browserArg === 'all' ? ['chromium', 'firefox', 'webkit'] : [browserArg]; +const onlyFixture = flag('--fixture'); +const onlyConfig = flag('--config'); +const report = {}; + +for (const [browserName, mode] of browsers.flatMap((browserName) => [ + [browserName, 'standards'], + [browserName, 'quirks'] +])) { + const browser = await playwright[browserName].launch( + // Classic scrollbars, because the viewport math depends on them. + browserName === 'chromium' + ? { + ignoreDefaultArgs: ['--hide-scrollbars'], + args: ['--disable-features=OverlayScrollbar'] + } + : {} + ); + const context = await browser.newContext({ + viewport: { width: 1000, height: 700 } + }); + const page = await context.newPage(); + await page.route('http://differential.test/**', (route) => + route.fulfill({ + contentType: 'text/html', + body: mode === 'quirks' ? QUIRKS_PAGE : `${PAGE}` + }) + ); + const load = async () => { + await page.goto('http://differential.test/'); + await page.addScriptTag({ content: STOCK }); + await page.addScriptTag({ content: ENGINE }); + await page.addScriptTag({ content: FIXTURES }); + }; + + await load(); + const names = onlyFixture + ? [onlyFixture] + : await page.evaluate(() => Object.keys(window.FIXTURES)); + const byFixture = {}; + let cases = 0; + let differences = 0; + let coords = 0; + let over1px = 0; + for (const name of names) { + await load(); + const result = await page.evaluate(runFixture, { + name, + only: onlyConfig, + ALL_PLACEMENTS, + CORE_PLACEMENTS, + ENGINE_MIDDLEWARE + }); + if (result.skipped) continue; + cases += result.cases; + differences += result.differences.length; + coords += result.differences.filter((d) => d.coords).length; + over1px += result.differences.filter((d) => d.over1px).length; + for (const difference of result.differences) { + const key = `${name}/${difference.config}`; + (byFixture[key] ??= { count: 0, first: difference }).count++; + } + } + + const tracker = {}; + for (const which of ['STOCK', 'ENGINE']) { + await load(); + tracker[which] = await page.evaluate(runTracker, { which }); + } + await browser.close(); + + report[`${browserName} ${mode}`] = { + cases, + differences, + coords, + over1px, + byFixture, + tracker + }; + console.log( + `${browserName} ${mode}: ${cases} cases, ${differences} differ, ${coords} in position or placement, ${over1px} by more than 1px` + ); +} + +const known = JSON.parse(readFileSync(knownFile, 'utf8')); + +const unexpected = []; +for (const [browserName, result] of Object.entries(report)) { + for (const [key, { count, first }] of Object.entries(result.byFixture)) { + const allowed = known[browserName]?.[key] ?? 0; + if (count > allowed) { + unexpected.push( + `${browserName} ${key}: ${count} differ, ${allowed} known. First: ${JSON.stringify(first)}` + ); + } + } + const { tracker } = result; + for (const [name, reactions] of Object.entries(tracker.ENGINE)) { + const quiet = name === 'idle' || name === 'afterCleanup'; + if (quiet ? reactions !== 0 : reactions < 1) { + unexpected.push( + `${browserName} tracker ${name}: ${reactions} updates, stock ${tracker.STOCK[name]}` + ); + } + } +} + +if (args.includes('--update-known')) { + const next = {}; + for (const [browserName, result] of Object.entries(report)) { + next[browserName] = Object.fromEntries( + Object.entries(result.byFixture) + .map(([key, { count }]) => [key, count]) + .sort(([a], [b]) => (a < b ? -1 : 1)) + ); + } + writeFileSync(knownFile, `${JSON.stringify(next, null, 2)}\n`); + console.log(`Wrote ${knownFile}`); +} else if (unexpected.length) { + console.log(`\n${unexpected.length} unexpected:\n${unexpected.join('\n')}`); + process.exitCode = 1; +} else { + console.log('Every difference is known.'); +} + +const jsonPath = flag('--json'); +if (jsonPath) writeFileSync(jsonPath, JSON.stringify(report, null, 1)); diff --git a/shepherd.js/test/unit/agent-docs.spec.js b/shepherd.js/test/unit/agent-docs.spec.js new file mode 100644 index 000000000..9a22fe344 --- /dev/null +++ b/shepherd.js/test/unit/agent-docs.spec.js @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; +import { describe, expect, it } from 'vitest'; +import { Tour } from '../../src/shepherd'; + +const read = (path) => readFileSync(join(process.cwd(), path), 'utf8'); +const schema = JSON.parse(read('tour.schema.json')); +const stepSource = read('src/step.ts'); +const tourSource = read('src/tour.ts'); + +const optionNames = (source, name) => { + const start = source.indexOf(`export interface ${name} {`); + const end = source.indexOf('\n}', start); + return [...source.slice(start, end).matchAll(/^ {2}(\w+)\?:/gm)].map( + ([, option]) => option + ); +}; + +describe('tour.schema.json', () => { + const step = schema.definitions.stepOptions.properties; + + it('only names real step options', () => { + const real = optionNames(stepSource, 'StepOptions'); + expect(Object.keys(step).filter((key) => !real.includes(key))).toEqual([]); + }); + + it('only names real tour options', () => { + const real = optionNames(tourSource, 'TourOptions'); + expect( + Object.keys(schema.properties).filter((key) => !real.includes(key)) + ).toEqual([]); + }); + + it('lists every button action and placement in the types', () => { + const union = (name) => + [ + ...stepSource + .slice( + stepSource.indexOf(`export type ${name} =`), + stepSource.indexOf(';', stepSource.indexOf(`export type ${name} =`)) + ) + .matchAll(/'([\w-]+)'/g) + ] + .map(([, value]) => value) + .sort(); + + expect([...step.attachTo.properties.on.enum].sort()).toEqual( + union('PopperPlacement') + ); + expect([...step.buttons.items.properties.action.enum].sort()).toEqual( + union('StepButtonAction') + ); + }); + + it('describes a tour that really runs', async () => { + const tour = new Tour( + JSON.parse(`{ + "useModalOverlay": true, + "steps": [ + { "id": "a", "text": "one", "buttons": [{ "text": "Next", "action": "next" }] }, + { "id": "b", "text": "two", "buttons": [{ "text": "Done", "action": "complete" }] } + ] + }`) + ); + + await tour.start(); + tour.getCurrentStep().getElement().querySelector('button').click(); + expect(tour.getCurrentStep().id).toBe('b'); + + tour.getCurrentStep().getElement().querySelector('button').click(); + expect(tour.isActive()).toBe(false); + }); +}); diff --git a/shepherd.js/test/unit/components/shepherd-button.spec.js b/shepherd.js/test/unit/components/shepherd-button.spec.js index 79fa6a0f0..e4dd2552c 100644 --- a/shepherd.js/test/unit/components/shepherd-button.spec.js +++ b/shepherd.js/test/unit/components/shepherd-button.spec.js @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createShepherdButton } from '../../../src/components/shepherd-button.ts'; describe('component/ShepherdButton', () => { @@ -13,6 +13,77 @@ describe('component/ShepherdButton', () => { container.remove(); }); + describe('action', () => { + it.each(['back', 'cancel', 'complete', 'next'])( + 'runs the tour method named %s', + (name) => { + const tour = { [name]: vi.fn() }; + const button = createShepherdButton({ action: name }, { tour }); + container.appendChild(button); + + button.click(); + + expect(tour[name]).toHaveBeenCalledTimes(1); + } + ); + + it.each([[{ text: 'Next' }], [{ text: 'Next', action: 'finish' }]])( + 'warns about a button that would do nothing: %j', + (config) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createShepherdButton(config, { tour: {} }); + + expect(warn.mock.calls[0][0]).toContain('"Next" has no action'); + warn.mockRestore(); + } + ); + + it('ignores a tour method outside the named actions', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const tour = { hide: vi.fn() }; + + const button = createShepherdButton( + { text: 'Hide', action: 'hide' }, + { tour } + ); + + expect(button.onclick).toBeNull(); + expect(warn.mock.calls[0][0]).toContain('"Hide" has no action'); + warn.mockRestore(); + }); + + it('warns instead of throwing when the name is not a method', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const button = createShepherdButton( + { text: 'Next', action: 'steps' }, + { tour: { steps: [] } } + ); + + expect(button.onclick).toBeNull(); + expect(warn.mock.calls[0][0]).toContain('"Next" has no action'); + warn.mockRestore(); + }); + + it('still binds a function to the tour', () => { + const tour = { next: vi.fn() }; + const button = createShepherdButton( + { + action() { + this.next(); + } + }, + { tour } + ); + container.appendChild(button); + + button.click(); + + expect(tour.next).toHaveBeenCalledTimes(1); + }); + }); + describe('disabled', () => { it('should be enabled by default', () => { const button = createShepherdButton({}, undefined); diff --git a/shepherd.js/test/unit/components/shepherd-element.spec.js b/shepherd.js/test/unit/components/shepherd-element.spec.js index 09a20d6d4..6d3fec177 100644 --- a/shepherd.js/test/unit/components/shepherd-element.spec.js +++ b/shepherd.js/test/unit/components/shepherd-element.spec.js @@ -129,6 +129,34 @@ describe('components/ShepherdElement', () => { cleanup(); }); + it('handles keys for subclasses, whose inherited methods are not auto-bound', async () => { + class CustomTour extends Tour {} + class CustomStep extends Step {} + + const tour = new CustomTour(); + const step = new CustomStep(tour, {}); + const nextSpy = vi.spyOn(Tour.prototype, 'next'); + const backSpy = vi.spyOn(Tour.prototype, 'back'); + + const { element, cleanup } = createShepherdElement({ + descriptionId: 'test-desc', + labelId: 'test-label', + step + }); + container.appendChild(element); + + fireKeyDown(element, 39); + fireKeyDown(element, 37); + expect(nextSpy.mock.contexts).toEqual([tour]); + expect(backSpy.mock.contexts).toEqual([tour]); + fireKeyDown(element, 27); + await Promise.resolve(); + + nextSpy.mockRestore(); + backSpy.mockRestore(); + cleanup(); + }); + it('exitOnEsc: false - ESC does not cancel the tour', () => { const tour = new Tour({ exitOnEsc: false }); const step = new Step(tour, {}); diff --git a/shepherd.js/test/unit/components/shepherd-footer.spec.js b/shepherd.js/test/unit/components/shepherd-footer.spec.js index d20cf91fa..9328299af 100644 --- a/shepherd.js/test/unit/components/shepherd-footer.spec.js +++ b/shepherd.js/test/unit/components/shepherd-footer.spec.js @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createShepherdFooter } from '../../../src/components/shepherd-footer.ts'; +import { createShepherdContent } from '../../../src/components/shepherd-content'; import defaultButtons from '../../cypress/utils/default-buttons.js'; describe('components/ShepherdFooter', () => { @@ -21,7 +21,7 @@ describe('components/ShepherdFooter', () => { } }; - const el = createShepherdFooter(step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const buttons = container.querySelectorAll( @@ -33,7 +33,7 @@ describe('components/ShepherdFooter', () => { it('renders no buttons if nothing is passed to `options.buttons`', () => { const step = { options: {} }; - const el = createShepherdFooter(step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const buttons = container.querySelectorAll( @@ -49,7 +49,7 @@ describe('components/ShepherdFooter', () => { } }; - const el = createShepherdFooter(step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const buttons = container.querySelectorAll( diff --git a/shepherd.js/test/unit/components/shepherd-header.spec.js b/shepherd.js/test/unit/components/shepherd-header.spec.js index 2838365a1..052a43d0b 100644 --- a/shepherd.js/test/unit/components/shepherd-header.spec.js +++ b/shepherd.js/test/unit/components/shepherd-header.spec.js @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createShepherdHeader } from '../../../src/components/shepherd-header.ts'; +import { createShepherdContent } from '../../../src/components/shepherd-content'; import { Tour } from '../../../src/tour'; import { Step } from '../../../src/step'; @@ -24,7 +24,7 @@ describe('components/ShepherdHeader', () => { } }; - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const cancelIcon = container.querySelector('.shepherd-cancel-icon'); @@ -42,7 +42,7 @@ describe('components/ShepherdHeader', () => { } }; - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const cancelIcon = container.querySelector('.shepherd-cancel-icon'); @@ -60,7 +60,7 @@ describe('components/ShepherdHeader', () => { } }; - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); expect(container.querySelector('.shepherd-cancel-icon')).toHaveAttribute( @@ -78,7 +78,7 @@ describe('components/ShepherdHeader', () => { }); const stepCancelSpy = vi.spyOn(step, 'cancel'); - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); container.querySelector('.shepherd-cancel-icon').click(); @@ -99,7 +99,7 @@ describe('components/ShepherdHeader', () => { } }; - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const cancelIcon = container.querySelector('.shepherd-cancel-icon'); @@ -121,7 +121,7 @@ describe('components/ShepherdHeader', () => { } }; - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const cancelIcon = container.querySelector('.shepherd-cancel-icon'); @@ -146,7 +146,7 @@ describe('components/ShepherdHeader', () => { } }; - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const cancelIcon = container.querySelector('.shepherd-cancel-icon'); @@ -166,7 +166,7 @@ describe('components/ShepherdHeader', () => { } }; - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const cancelIcon = container.querySelector('.shepherd-cancel-icon'); @@ -187,7 +187,7 @@ describe('components/ShepherdHeader', () => { } }; - const el = createShepherdHeader('test-label', step); + const el = createShepherdContent('test-desc', 'test-label', step); container.appendChild(el); const cancelIcon = container.querySelector('.shepherd-cancel-icon'); diff --git a/shepherd.js/test/unit/components/shepherd-modal.spec.js b/shepherd.js/test/unit/components/shepherd-modal.spec.js index fb4bfc06a..560da4d3b 100644 --- a/shepherd.js/test/unit/components/shepherd-modal.spec.js +++ b/shepherd.js/test/unit/components/shepherd-modal.spec.js @@ -15,6 +15,193 @@ describe('components/ShepherdModal', () => { container.remove(); }); + describe('gliding between steps', () => { + const rect = (x, y, width, height) => ({ + x, + y, + width, + height, + top: y, + left: x, + bottom: y + height, + right: x + width + }); + let modal; + let now; + + function makeStep(box, options = {}) { + const target = document.createElement('div'); + target.getBoundingClientRect = () => box; + target.style.position = 'fixed'; + document.body.append(target); + const tour = new Tour({ useModalOverlay: true }); + const step = new Step(tour, { ...options }); + step.target = target; + return step; + } + + const path = () => modal.getElement().querySelector('path'); + // `+ 0` reads `-0` and `0` as the same coordinate. + const numbers = (d) => d.match(/-?[\d.]+(e-\d+)?/g).map((n) => +n + 0); + const settled = (x, y, width, height) => + `M1024,768H0V0H1024V768ZM${x},${y}a0,0,0,0,0,-0,0V${y + height}a0,0,0,0,0,0,0H${x + width}a0,0,0,0,0,0,-0V${y}a0,0,0,0,0,-0,-0Z`; + + beforeEach(() => { + now = 1000; + vi.spyOn(Date, 'now').mockImplementation(() => now); + modal = createShepherdModal(container); + }); + + afterEach(() => { + modal.destroy(); + vi.restoreAllMocks(); + }); + + function setDuration(value) { + path().style.transitionDuration = value; + } + + it('cuts straight to the target without a transition duration', () => { + modal.setupForStep(makeStep(rect(0, 0, 100, 100))); + modal.setupForStep(makeStep(rect(200, 300, 50, 50))); + + expect(path().getAttribute('d')).toBe(settled(200, 300, 50, 50)); + }); + + it('does not glide into the first opening', () => { + setDuration('1s'); + modal.setupForStep(makeStep(rect(200, 300, 50, 50))); + + expect(path().getAttribute('d')).toBe(settled(200, 300, 50, 50)); + }); + + it('starts from the previous opening and lands exactly on the target', () => { + setDuration('1s'); + modal.setupForStep(makeStep(rect(0, 0, 100, 100))); + const to = makeStep(rect(200, 300, 50, 50)); + modal.setupForStep(to); + + expect(numbers(path().getAttribute('d'))).toEqual( + numbers(settled(0, 0, 100, 100)) + ); + + now += 500; + modal.positionModal(0, 0, 0, 0, null, to.target); + const [, x, y] = path() + .getAttribute('d') + .match(/ZM([\d.]+),([\d.]+)/); + // 1 - 0.5^3 of the way there + expect(Number(x)).toBeCloseTo(175); + expect(Number(y)).toBeCloseTo(262.5); + + now += 500; + modal.positionModal(0, 0, 0, 0, null, to.target); + expect(path().getAttribute('d')).toBe(settled(200, 300, 50, 50)); + }); + + it('writes the path on every frame of a glide, then stops', () => { + setDuration('1s'); + modal.setupForStep(makeStep(rect(0, 0, 100, 100))); + const to = makeStep(rect(200, 300, 50, 50)); + modal.setupForStep(to); + const writePath = vi.spyOn(path(), 'setAttribute'); + const frame = () => modal.positionModal(0, 0, 0, 0, null, to.target); + + for (const expected of [1, 2, 3]) { + now += 250; + frame(); + expect(writePath, 'mid glide').toHaveBeenCalledTimes(expected); + } + + now += 250; + frame(); + expect(writePath, 'landing').toHaveBeenCalledTimes(4); + + now += 16; + frame(); + frame(); + expect(writePath, 'settled').toHaveBeenCalledTimes(4); + }); + + it('keeps every number apart at the start of a glide into a rounded opening', () => { + setDuration('1s'); + modal.setupForStep(makeStep(rect(0, 0, 100, 100))); + modal.setupForStep( + makeStep(rect(200, 300, 50, 50), { modalOverlayOpeningRadius: 8 }) + ); + + expect(numbers(path().getAttribute('d'))).toEqual( + numbers(settled(0, 0, 100, 100)) + ); + }); + + it('does not glide back after the number of openings changes mid-glide', () => { + setDuration('1s'); + modal.setupForStep(makeStep(rect(0, 0, 100, 100))); + const to = makeStep(rect(200, 300, 50, 50)); + modal.setupForStep(to); + const extra = document.createElement('div'); + extra.getBoundingClientRect = () => rect(400, 100, 20, 20); + document.body.append(extra); + + now += 250; + modal.positionModal(0, 0, 0, 0, null, to.target, [extra]); + now += 250; + modal.positionModal(0, 0, 0, 0, null, to.target); + + expect(path().getAttribute('d')).toBe(settled(200, 300, 50, 50)); + extra.remove(); + }); + + it('cuts instead of gliding when the number of openings changes', () => { + setDuration('1s'); + modal.setupForStep(makeStep(rect(0, 0, 100, 100))); + const to = makeStep(rect(200, 300, 50, 50)); + const extra = document.createElement('div'); + extra.getBoundingClientRect = () => rect(400, 100, 20, 20); + document.body.append(extra); + to._resolvedExtraHighlightElements = [extra]; + + modal.setupForStep(to); + const first = path().getAttribute('d'); + now += 1000; + modal.positionModal(0, 0, 0, 0, null, to.target, [extra]); + + expect(first).toBe(path().getAttribute('d')); + extra.remove(); + }); + + it('stays aligned when a zero-radius glide is interrupted', () => { + setDuration('1s'); + modal.setupForStep(makeStep(rect(0, 0, 100, 100))); + const middle = makeStep(rect(200, 300, 50, 50)); + modal.setupForStep(middle); + now += 500; + modal.positionModal(0, 0, 0, 0, null, middle.target); + const interrupted = path().getAttribute('d'); + expect(numbers(interrupted)).toHaveLength( + numbers(settled(0, 0, 0, 0)).length + ); + + const to = makeStep(rect(600, 500, 20, 20)); + modal.setupForStep(to); + expect(path().getAttribute('d')).toBe(interrupted); + + now += 1000; + modal.positionModal(0, 0, 0, 0, null, to.target); + expect(path().getAttribute('d')).toBe(settled(600, 500, 20, 20)); + }); + + it('closes without gliding towards the origin', () => { + setDuration('1s'); + modal.setupForStep(makeStep(rect(200, 300, 50, 50))); + modal.setupForStep(makeStep(rect(10, 10, 50, 50))); + modal.closeModalOpening(); + + expect(path().getAttribute('d')).toBe(settled(0, 0, 0, 0)); + }); + }); + describe('closeModalOpening()', function () { it('sets values back to 0', () => { const modal = createShepherdModal(container); @@ -33,7 +220,7 @@ describe('components/ShepherdModal', () => { let modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM20,20a0,0,0,0,0-0,0V270a0,0,0,0,0,0,0H520a0,0,0,0,0,0-0V20a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM20,20a0,0,0,0,0,-0,0V270a0,0,0,0,0,0,0H520a0,0,0,0,0,0,-0V20a0,0,0,0,0,-0,-0Z' ); modal.closeModalOpening(); @@ -41,19 +228,38 @@ describe('components/ShepherdModal', () => { modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0,-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0,-0V0a0,0,0,0,0,-0,-0Z' ); }); }); describe('positionModal()', function () { + it('leaves the path alone when the opening has not moved', () => { + const modal = createShepherdModal(container); + const path = modal.getElement().querySelector('path'); + let y = 20; + const target = { + getBoundingClientRect: () => ({ height: 250, x: 20, y, width: 500 }) + }; + + modal.positionModal(0, 0, 0, 0, null, target); + const writePath = vi.spyOn(path, 'setAttribute'); + + modal.positionModal(0, 0, 0, 0, null, target); + expect(writePath, 'nothing moved').not.toHaveBeenCalled(); + + y = 28; + modal.positionModal(0, 0, 0, 0, null, target); + expect(writePath, 'the target moved').toHaveBeenCalledTimes(1); + }); + it('sets the correct attributes when positioning modal opening', () => { const modal = createShepherdModal(container); let modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0,-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0,-0V0a0,0,0,0,0,-0,-0Z' ); modal.closeModalOpening(); @@ -61,7 +267,7 @@ describe('components/ShepherdModal', () => { modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0,-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0,-0V0a0,0,0,0,0,-0,-0Z' ); modal.positionModal(0, 0, 0, 0, null, { @@ -78,7 +284,7 @@ describe('components/ShepherdModal', () => { modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM20,20a0,0,0,0,0-0,0V270a0,0,0,0,0,0,0H520a0,0,0,0,0,0-0V20a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM20,20a0,0,0,0,0,-0,0V270a0,0,0,0,0,0,0H520a0,0,0,0,0,0,-0V20a0,0,0,0,0,-0,-0Z' ); }); @@ -88,7 +294,7 @@ describe('components/ShepherdModal', () => { let modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0,-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0,-0V0a0,0,0,0,0,-0,-0Z' ); modal.positionModal(10, 0, 0, 0, null, { @@ -105,7 +311,7 @@ describe('components/ShepherdModal', () => { modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM10,10a0,0,0,0,0-0,0V280a0,0,0,0,0,0,0H530a0,0,0,0,0,0-0V10a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM10,10a0,0,0,0,0,-0,0V280a0,0,0,0,0,0,0H530a0,0,0,0,0,0,-0V10a0,0,0,0,0,-0,-0Z' ); }); @@ -115,7 +321,7 @@ describe('components/ShepherdModal', () => { let modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0,-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0,-0V0a0,0,0,0,0,-0,-0Z' ); modal.closeModalOpening(); @@ -123,7 +329,7 @@ describe('components/ShepherdModal', () => { modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0,-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0,-0V0a0,0,0,0,0,-0,-0Z' ); modal.positionModal(0, 10, 0, 0, null, { @@ -140,7 +346,7 @@ describe('components/ShepherdModal', () => { modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM30,20a10,10,0,0,0-10,10V260a10,10,0,0,0,10,10H510a10,10,0,0,0,10-10V30a10,10,0,0,0-10-10Z' + 'M1024,768H0V0H1024V768ZM30,20a10,10,0,0,0,-10,10V260a10,10,0,0,0,10,10H510a10,10,0,0,0,10,-10V30a10,10,0,0,0,-10,-10Z' ); }); @@ -150,7 +356,7 @@ describe('components/ShepherdModal', () => { let modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0,-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0,-0V0a0,0,0,0,0,-0,-0Z' ); modal.closeModalOpening(); @@ -158,7 +364,7 @@ describe('components/ShepherdModal', () => { modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0,-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0,-0V0a0,0,0,0,0,-0,-0Z' ); modal.positionModal( @@ -182,7 +388,7 @@ describe('components/ShepherdModal', () => { modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM21,20a1,1,0,0,0-1,1V268a2,2,0,0,0,2,2H517a3,3,0,0,0,3-3V20a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM21,20a1,1,0,0,0,-1,1V268a2,2,0,0,0,2,2H517a3,3,0,0,0,3,-3V20a0,0,0,0,0,-0,-0Z' ); }); @@ -219,7 +425,7 @@ describe('components/ShepherdModal', () => { const modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM10,100a0,0,0,0,0-0,0V350a0,0,0,0,0,0,0H510a0,0,0,0,0,0-0V100a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM10,100a0,0,0,0,0,-0,0V350a0,0,0,0,0,0,0H510a0,0,0,0,0,0,-0V100a0,0,0,0,0,-0,-0Z' ); }); @@ -256,7 +462,7 @@ describe('components/ShepherdModal', () => { const modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM10,100a0,0,0,0,0-0,0V350a0,0,0,0,0,0,0H510a0,0,0,0,0,0-0V100a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM10,100a0,0,0,0,0,-0,0V350a0,0,0,0,0,0,0H510a0,0,0,0,0,0,-0V100a0,0,0,0,0,-0,-0Z' ); }); @@ -278,7 +484,7 @@ describe('components/ShepherdModal', () => { expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM60,10a0,0,0,0,0-0,0V260a0,0,0,0,0,0,0H560a0,0,0,0,0,0-0V10a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM60,10a0,0,0,0,0,-0,0V260a0,0,0,0,0,0,0H560a0,0,0,0,0,0,-0V10a0,0,0,0,0,-0,-0Z' ); modal.positionModal(0, 0, 100, 0, null, { @@ -296,7 +502,7 @@ describe('components/ShepherdModal', () => { expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM110,10a0,0,0,0,0-0,0V260a0,0,0,0,0,0,0H610a0,0,0,0,0,0-0V10a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM110,10a0,0,0,0,0,-0,0V260a0,0,0,0,0,0,0H610a0,0,0,0,0,0,-0V10a0,0,0,0,0,-0,-0Z' ); }); @@ -318,7 +524,7 @@ describe('components/ShepherdModal', () => { expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM10,45a0,0,0,0,0-0,0V295a0,0,0,0,0,0,0H510a0,0,0,0,0,0-0V45a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM10,45a0,0,0,0,0,-0,0V295a0,0,0,0,0,0,0H510a0,0,0,0,0,0,-0V45a0,0,0,0,0,-0,-0Z' ); modal.positionModal(0, 0, 0, 75, null, { @@ -336,7 +542,7 @@ describe('components/ShepherdModal', () => { expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM10,85a0,0,0,0,0-0,0V335a0,0,0,0,0,0,0H510a0,0,0,0,0,0-0V85a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM10,85a0,0,0,0,0,-0,0V335a0,0,0,0,0,0,0H510a0,0,0,0,0,0,-0V85a0,0,0,0,0,-0,-0Z' ); }); @@ -376,7 +582,7 @@ describe('components/ShepherdModal', () => { const modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM20,20a0,0,0,0,0-0,0V270a0,0,0,0,0,0,0H520a0,0,0,0,0,0-0V20a0,0,0,0,0-0-0ZM50,50a0,0,0,0,0-0,0V150a0,0,0,0,0,0,0H150a0,0,0,0,0,0-0V50a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM20,20a0,0,0,0,0,-0,0V270a0,0,0,0,0,0,0H520a0,0,0,0,0,0,-0V20a0,0,0,0,0,-0,-0ZM50,50a0,0,0,0,0,-0,0V150a0,0,0,0,0,0,0H150a0,0,0,0,0,0,-0V50a0,0,0,0,0,-0,-0Z' ); }); @@ -426,7 +632,7 @@ describe('components/ShepherdModal', () => { const modalPath = modal.getElement().querySelector('path'); expect(modalPath).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM20,20a0,0,0,0,0-0,0V270a0,0,0,0,0,0,0H520a0,0,0,0,0,0-0V20a0,0,0,0,0-0-0ZM50,50a0,0,0,0,0-0,0V150a0,0,0,0,0,0,0H150a0,0,0,0,0,0-0V50a0,0,0,0,0-0-0ZM200,200a0,0,0,0,0-0,0V250a0,0,0,0,0,0,0H250a0,0,0,0,0,0-0V200a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM20,20a0,0,0,0,0,-0,0V270a0,0,0,0,0,0,0H520a0,0,0,0,0,0,-0V20a0,0,0,0,0,-0,-0ZM50,50a0,0,0,0,0,-0,0V150a0,0,0,0,0,0,0H150a0,0,0,0,0,0,-0V50a0,0,0,0,0,-0,-0ZM200,200a0,0,0,0,0,-0,0V250a0,0,0,0,0,0,0H250a0,0,0,0,0,0,-0V200a0,0,0,0,0,-0,-0Z' ); }); @@ -834,7 +1040,7 @@ describe('components/ShepherdModal', () => { expect(modal.getElement().querySelector('path')).toHaveAttribute( 'd', - 'M1024,768H0V0H1024V768ZM10,10a0,0,0,0,0-0,0V60a0,0,0,0,0,0,0H110a0,0,0,0,0,0-0V10a0,0,0,0,0-0-0ZM200,150a0,0,0,0,0-0,0V200a0,0,0,0,0,0,0H300a0,0,0,0,0,0-0V150a0,0,0,0,0-0-0Z' + 'M1024,768H0V0H1024V768ZM10,10a0,0,0,0,0,-0,0V60a0,0,0,0,0,0,0H110a0,0,0,0,0,0,-0V10a0,0,0,0,0,-0,-0ZM200,150a0,0,0,0,0,-0,0V200a0,0,0,0,0,0,0H300a0,0,0,0,0,0,-0V150a0,0,0,0,0,-0,-0Z' ); }); diff --git a/shepherd.js/test/unit/components/shepherd-text.spec.js b/shepherd.js/test/unit/components/shepherd-text.spec.js index 1a4552303..f6e7b06c1 100644 --- a/shepherd.js/test/unit/components/shepherd-text.spec.js +++ b/shepherd.js/test/unit/components/shepherd-text.spec.js @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createShepherdText } from '../../../src/components/shepherd-text.ts'; +import { createShepherdContent } from '../../../src/components/shepherd-content'; describe('components/ShepherdText', () => { let container; @@ -20,7 +20,11 @@ describe('components/ShepherdText', () => { } }; - const el = createShepherdText('test-desc', step); + const el = createShepherdContent( + 'test-desc', + 'test-label', + step + ).querySelector('.shepherd-text'); container.appendChild(el); expect(container.querySelector('.shepherd-text')).toHaveTextContent( @@ -35,7 +39,11 @@ describe('components/ShepherdText', () => { } }; - const el = createShepherdText('test-desc', step); + const el = createShepherdContent( + 'test-desc', + 'test-label', + step + ).querySelector('.shepherd-text'); container.appendChild(el); expect(container.querySelector('.shepherd-text')).toContainHTML( @@ -50,7 +58,11 @@ describe('components/ShepherdText', () => { } }; - const el = createShepherdText('test-desc', step); + const el = createShepherdContent( + 'test-desc', + 'test-label', + step + ).querySelector('.shepherd-text'); container.appendChild(el); expect(container.querySelector('.shepherd-text')).toHaveTextContent( @@ -68,7 +80,11 @@ describe('components/ShepherdText', () => { } }; - const el = createShepherdText('test-desc', step); + const el = createShepherdContent( + 'test-desc', + 'test-label', + step + ).querySelector('.shepherd-text'); container.appendChild(el); const textEl = container.querySelector('.shepherd-text'); diff --git a/shepherd.js/test/unit/components/shepherd-title.spec.js b/shepherd.js/test/unit/components/shepherd-title.spec.js index fb6fe8e3f..3b7212cb5 100644 --- a/shepherd.js/test/unit/components/shepherd-title.spec.js +++ b/shepherd.js/test/unit/components/shepherd-title.spec.js @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createShepherdTitle } from '../../../src/components/shepherd-title.ts'; +import { createShepherdContent } from '../../../src/components/shepherd-content'; describe('components/ShepherdTitle', () => { let container; @@ -14,7 +14,9 @@ describe('components/ShepherdTitle', () => { }); it('adds plain title to the content', () => { - const el = createShepherdTitle('test-label', 'I am some test title.'); + const el = createShepherdContent('test-desc', 'test-label', { + options: { title: 'I am some test title.' } + }).querySelector('.shepherd-title'); container.appendChild(el); expect(container.querySelector('.shepherd-title')).toHaveTextContent( @@ -23,7 +25,9 @@ describe('components/ShepherdTitle', () => { }); it('applies the title from a function', () => { - const el = createShepherdTitle('test-label', () => 'I am some test title.'); + const el = createShepherdContent('test-desc', 'test-label', { + options: { title: () => 'I am some test title.' } + }).querySelector('.shepherd-title'); container.appendChild(el); expect(container.querySelector('.shepherd-title')).toHaveTextContent( diff --git a/shepherd.js/test/unit/exports.spec.js b/shepherd.js/test/unit/exports.spec.js new file mode 100644 index 000000000..a40bf07f6 --- /dev/null +++ b/shepherd.js/test/unit/exports.spec.js @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import Shepherd, { Step, Tour } from '../../src/shepherd'; + +describe('package exports', () => { + it('exposes Tour and Step as named exports, the same classes as on the default', () => { + expect(Tour).toBe(Shepherd.Tour); + expect(Step).toBe(Shepherd.Step); + }); + + it('builds a working tour from the named export', () => { + const tour = new Tour(); + tour.addStep({ id: 'only', text: 'hi' }); + expect(tour.getById('only')).toBeInstanceOf(Step); + }); +}); diff --git a/shepherd.js/test/unit/fake-timers.spec.js b/shepherd.js/test/unit/fake-timers.spec.js new file mode 100644 index 000000000..88c153a22 --- /dev/null +++ b/shepherd.js/test/unit/fake-timers.spec.js @@ -0,0 +1,36 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Tour } from '../../src/shepherd'; + +// A consumer's test suite runs in a DOM with no layout and often calls +// `runAllTimers`, which throws if a timer keeps rescheduling itself. +describe('under fake timers', () => { + let tour; + + beforeEach(() => { + vi.useFakeTimers(); + const target = document.createElement('div'); + target.id = 'fake-timers-target'; + document.body.append(target); + }); + + afterEach(() => { + tour.complete(); + vi.useRealTimers(); + document.getElementById('fake-timers-target').remove(); + }); + + it('lets runAllTimers finish while a step is showing', () => { + tour = new Tour(); + tour.addStep({ + text: 'hi', + attachTo: { element: '#fake-timers-target', on: 'bottom' } + }); + tour.start(); + + expect(() => vi.runAllTimers()).not.toThrow(); + }); +}); diff --git a/shepherd.js/test/unit/motion.spec.js b/shepherd.js/test/unit/motion.spec.js new file mode 100644 index 000000000..fe660b3c1 --- /dev/null +++ b/shepherd.js/test/unit/motion.spec.js @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { glideSteps, stepProgress } from '../../src/motion.ts'; +import { Tour } from '../../src/tour'; + +describe('motion', () => { + let tour; + let reduced; + + beforeEach(() => { + reduced = false; + vi.stubGlobal('matchMedia', () => ({ matches: reduced })); + vi.stubGlobal('requestAnimationFrame', (callback) => { + callback(0); + return 1; + }); + Element.prototype.animate = vi.fn(); + tour = new Tour(); + tour.addStep({ id: 'one', text: 'one' }); + tour.addStep({ id: 'two', text: 'two' }); + }); + + afterEach(() => { + if (tour.isActive()) { + tour.complete(); + } + vi.unstubAllGlobals(); + delete Element.prototype.animate; + }); + + describe('glideSteps', () => { + function rectEachElement() { + const original = Element.prototype.getBoundingClientRect; + let left = 0; + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + function () { + if (!this.classList?.contains('shepherd-element')) { + return original.call(this); + } + // Each element sits 100px right of the one created before it. + this.left ??= left++ * 100; + return { left: this.left, top: 0, width: 50, height: 50 }; + } + ); + } + + afterEach(() => vi.restoreAllMocks()); + + it('leaves the first step alone', () => { + rectEachElement(); + glideSteps(tour); + tour.start(); + + expect(Element.prototype.animate).not.toHaveBeenCalled(); + }); + + it('animates translate from the previous dialog to the new one', () => { + rectEachElement(); + glideSteps(tour, 300); + tour.start(); + tour.next(); + + expect(Element.prototype.animate).toHaveBeenCalledTimes(1); + const [keyframes, options] = Element.prototype.animate.mock.calls[0]; + expect(keyframes.translate[0]).toMatch(/^-?\d+(\.\d+)?px 0px$/); + expect(keyframes.translate[1]).toBe('0 0'); + expect(options.duration).toBe(300); + }); + + it('does nothing under reduced motion', () => { + rectEachElement(); + reduced = true; + glideSteps(tour); + tour.start(); + tour.next(); + + expect(Element.prototype.animate).not.toHaveBeenCalled(); + }); + + it('does not glide from the last run when the tour restarts', () => { + rectEachElement(); + glideSteps(tour); + tour.start(); + tour.complete(); + tour.start(); + + expect(Element.prototype.animate).not.toHaveBeenCalled(); + }); + + it('stops when disposed', () => { + rectEachElement(); + const stop = glideSteps(tour); + stop(); + tour.start(); + tour.next(); + + expect(Element.prototype.animate).not.toHaveBeenCalled(); + }); + }); + + describe('stepProgress', () => { + it('exposes the position and count on each dialog', () => { + stepProgress(tour); + tour.start(); + tour.next(); + + const { style } = tour.getCurrentStep().getElement(); + expect(style.getPropertyValue('--shepherd-step')).toBe('2'); + expect(style.getPropertyValue('--shepherd-steps')).toBe('2'); + }); + + it('does nothing for a step still waiting when it is stopped', async () => { + let release; + tour.steps[0].options.beforeShowPromise = () => + new Promise((resolve) => (release = resolve)); + const stop = stepProgress(tour); + const shown = tour.start(); + + stop(); + release(); + await shown; + + const { style } = tour.getCurrentStep().getElement(); + expect(style.getPropertyValue('--shepherd-step')).toBe(''); + }); + }); +}); diff --git a/shepherd.js/test/unit/server.spec.js b/shepherd.js/test/unit/server.spec.js index c05dcf6b9..19552fba8 100644 --- a/shepherd.js/test/unit/server.spec.js +++ b/shepherd.js/test/unit/server.spec.js @@ -1,16 +1,30 @@ /** - * @jest-environment node + * @vitest-environment node */ import { describe, expect, it } from 'vitest'; -import Shepherd from '../../src/shepherd'; +import Shepherd, { Step, Tour } from '../../src/shepherd'; describe('Server Side Render', function () { - describe('Tour constructor', function () { - it('does not start a tour when window is undefined', () => { - const instance = new Shepherd.Tour(); + it('accepts module-level tour setup without doing anything', async () => { + const tour = new Tour(); - expect(instance).toBeTruthy(); - }); + tour.addStep({ text: 'hi' }).on('complete', () => {}); + + expect(await tour.start()).toBe(tour); + expect(new Step(tour, {}).show()).toBeTruthy(); + }); + + it('reports an inactive tour and a closed step, as a browser would', () => { + const tour = new Tour(); + + expect(tour.isActive()).toBe(false); + expect(tour.steps).toEqual([]); + expect(new Step(tour, {}).isOpen()).toBe(false); + }); + + it('exposes the same no-op on the default export', () => { + expect(Shepherd.Tour).toBe(Tour); + expect(Shepherd.Step).toBe(Step); }); }); diff --git a/shepherd.js/test/unit/step-lifecycle-bugs.spec.js b/shepherd.js/test/unit/step-lifecycle-bugs.spec.js new file mode 100644 index 000000000..97c448a1a --- /dev/null +++ b/shepherd.js/test/unit/step-lifecycle-bugs.spec.js @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Tour } from '../../src/tour'; + +describe('step and tour behavior found in review', () => { + let tour; + let targets; + + const target = (id) => { + const el = document.createElement('div'); + el.id = id; + document.body.append(el); + targets.push(el); + return el; + }; + + beforeEach(() => { + targets = []; + }); + + afterEach(() => { + if (tour?.isActive()) tour.complete(); + targets.forEach((el) => el.remove()); + vi.restoreAllMocks(); + }); + + describe('updateStepOptions on an open step', () => { + it('keeps the step visible and its target marked', () => { + const a = target('a'); + tour = new Tour(); + tour.addStep({ + id: 'one', + text: 'one', + attachTo: { element: a, on: 'bottom' } + }); + tour.start(); + const step = tour.getCurrentStep(); + + step.updateStepOptions({ text: 'changed' }); + + expect(step.isOpen()).toBe(true); + expect(step.getElement().classList).toContain('shepherd-enabled'); + expect(a.classList).toContain('shepherd-target'); + expect(a.classList).toContain('shepherd-enabled'); + }); + + it('moves to a new attachTo target', () => { + const a = target('a'); + const b = target('b'); + tour = new Tour(); + tour.addStep({ + id: 'one', + text: 'one', + attachTo: { element: a, on: 'bottom' } + }); + tour.start(); + const step = tour.getCurrentStep(); + + step.updateStepOptions({ attachTo: { element: b, on: 'top' } }); + + expect(step.getTarget()).toBe(b); + expect(b.classList).toContain('shepherd-target'); + expect(a.classList).not.toContain('shepherd-target'); + }); + + it('swaps the highlight class on the target', () => { + const a = target('a'); + tour = new Tour(); + tour.addStep({ + id: 'one', + text: 'one', + attachTo: { element: a, on: 'bottom' }, + highlightClass: 'old' + }); + tour.start(); + + tour.getCurrentStep().updateStepOptions({ highlightClass: 'new' }); + expect(a.classList).not.toContain('old'); + expect(a.classList).toContain('new'); + + tour.complete(); + expect(a.classList).not.toContain('new'); + }); + }); + + it('combines updated positionOptions and classes as on creation', () => { + tour = new Tour({ + defaultStepOptions: { classes: 'base', positionOptions: { padding: 8 } } + }); + tour.addStep({ id: 'one', text: 'one', classes: 'own' }); + const step = tour.getById('one'); + + step.updateStepOptions({ positionOptions: { offset: 12 }, classes: 'new' }); + + expect(step.options.positionOptions).toEqual({ padding: 8, offset: 12 }); + expect(step.options.classes.split(' ').sort()).toEqual(['base', 'new']); + }); + + it('keeps the tour open and reports a rejected confirmCancel', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + tour = new Tour({ confirmCancel: () => Promise.reject(new Error('no')) }); + tour.addStep({ id: 'one', text: 'one' }); + tour.start(); + + await expect(tour.cancel()).resolves.toBeUndefined(); + + expect(tour.isActive()).toBe(true); + expect(error.mock.calls[0][0]).toContain('confirmCancel failed'); + }); + + it('leaves a tabindex the app set after the step was hidden', () => { + const a = target('a'); + const b = target('b'); + tour = new Tour(); + tour.addStep({ + id: 'one', + text: 'one', + attachTo: { element: a, on: 'bottom' } + }); + tour.addStep({ + id: 'two', + text: 'two', + attachTo: { element: b, on: 'bottom' } + }); + tour.start(); + tour.next(); + expect(a.hasAttribute('tabindex'), 'restored on hide').toBe(false); + + a.setAttribute('tabindex', '5'); + tour.complete(); + + expect(a.getAttribute('tabindex')).toBe('5'); + }); + + it('advances once on an arrow key when steps share a target', () => { + const a = target('a'); + tour = new Tour(); + for (const id of ['one', 'two', 'three', 'four']) { + tour.addStep({ id, text: id, attachTo: { element: a, on: 'bottom' } }); + } + tour.start(); + tour.next(); + + a.dispatchEvent( + new KeyboardEvent('keydown', { keyCode: 39, bubbles: true }) + ); + + expect(tour.getCurrentStep().id).toBe('three'); + }); + + it('renders text given as an array without separators', () => { + tour = new Tour(); + tour.addStep({ id: 'one', text: ['

a

', '

b

'] }); + tour.start(); + + expect( + tour.getCurrentStep().getElement().querySelector('.shepherd-text') + .innerHTML + ).toBe('

a

b

'); + }); + + it('moves Tab focus from the target to a dialog with no controls', () => { + const a = target('a'); + tour = new Tour(); + tour.addStep({ + id: 'one', + text: 'one', + attachTo: { element: a, on: 'bottom' } + }); + tour.start(); + a.focus(); + + const event = new KeyboardEvent('keydown', { + keyCode: 9, + bubbles: true, + cancelable: true + }); + a.dispatchEvent(event); + + expect(document.activeElement).toBe(tour.getCurrentStep().getElement()); + }); + + it('uses a promise given as confirmCancel instead of asking the browser', async () => { + const confirm = vi.fn(() => true); + window.confirm = confirm; + tour = new Tour({ confirmCancel: Promise.resolve(false) }); + tour.addStep({ id: 'one', text: 'one' }); + tour.start(); + + await tour.cancel(); + + expect(confirm).not.toHaveBeenCalled(); + expect(tour.isActive()).toBe(true); + delete window.confirm; + }); +}); diff --git a/shepherd.js/test/unit/step.spec.js b/shepherd.js/test/unit/step.spec.js index aad3caf08..d698364c5 100644 --- a/shepherd.js/test/unit/step.spec.js +++ b/shepherd.js/test/unit/step.spec.js @@ -3,7 +3,6 @@ import Shepherd from '../../src/shepherd'; import { Step } from '../../src/step'; import { Tour } from '../../src/tour'; import ResizeObserver from 'resize-observer-polyfill'; -import { offset } from '@floating-ui/dom'; // since importing non UMD, needs assignment window.Shepherd = Shepherd; @@ -27,15 +26,13 @@ describe('Tour | Step', () => { }); describe('Shepherd.Step()', () => { - const defaultOffsetMiddleware = offset({ mainAxis: 0, crossAxis: 32 }); - const fooMiddleware = { name: 'foo', options: 'bar', fn: (args) => args }; - const instance = new Shepherd.Tour({ defaultStepOptions: { classes: DEFAULT_STEP_CLASS, scrollTo: true, - floatingUIOptions: { - middleware: [defaultOffsetMiddleware] + positionOptions: { + offset: { mainAxis: 0, crossAxis: 32 }, + padding: 8 }, showOn, when @@ -53,9 +50,7 @@ describe('Tour | Step', () => { } ], id: 'test', - floatingUIOptions: { - middleware: [fooMiddleware] - } + positionOptions: { offset: 16 } }); const showTestStep = instance.addStep({ @@ -87,10 +82,6 @@ describe('Tour | Step', () => { ] }); - const stepWithoutNameWithoutIdOffsetMiddleware = offset({ - mainAxis: 0, - crossAxis: -32 - }); const stepWithoutNameWithoutId = instance.addStep({ attachTo: { element: 'body' }, highlightClass: 'highlight', @@ -101,9 +92,7 @@ describe('Tour | Step', () => { action: instance.next } ], - floatingUIOptions: { - middleware: [stepWithoutNameWithoutIdOffsetMiddleware] - } + positionOptions: { offset: { mainAxis: 0, crossAxis: -32 } } }); const beforeShowPromise = () => @@ -129,7 +118,7 @@ describe('Tour | Step', () => { 'arrow', 'classes', 'scrollTo', - 'floatingUIOptions', + 'positionOptions', 'showOn', 'when', 'attachTo', @@ -167,30 +156,27 @@ describe('Tour | Step', () => { id: 'test', scrollTo: true, text: 'This is a step for testing', - floatingUIOptions: { - middleware: [defaultOffsetMiddleware, fooMiddleware] - }, + positionOptions: { offset: 16, padding: 8 }, showOn, when }); }); - it('allows the step to override a previously defined modifier', () => { - stepWithoutNameWithoutId.show(); - const offsetMiddleware = - stepWithoutNameWithoutId.options.floatingUIOptions.middleware.filter( - ({ name }) => name === 'offset' - ); - const offsetResult = offsetMiddleware.reduce( - (agg, current) => { - agg.mainAxis += current.options.mainAxis; - agg.crossAxis += current.options.crossAxis; - return agg; - }, - { mainAxis: 0, crossAxis: 0 } - ); + it('lets a step override one positionOptions key and keep the rest', () => { + expect(stepWithoutNameWithoutId.options.positionOptions).toEqual({ + offset: { mainAxis: 0, crossAxis: -32 }, + padding: 8 + }); + }); + + it('warns that floatingUIOptions was replaced', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + instance.addStep({ floatingUIOptions: { strategy: 'fixed' } }); - expect(offsetResult).toEqual({ mainAxis: 0, crossAxis: 0 }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('positionOptions') + ); + warn.mockRestore(); }); describe('.hide()', () => { @@ -614,15 +600,11 @@ describe('Tour | Step', () => { }); describe('correct operation of classes on body element when step not attached to an element', () => { - const offsetMiddleware = offset({ crossAxis: 32 }); - const defaultCallback = (args) => args; const instance = new Shepherd.Tour({ defaultStepOptions: { classes: DEFAULT_STEP_CLASS, scrollTo: true, - floatingUIOptions: { - middleware: [offsetMiddleware] - }, + positionOptions: { offset: { crossAxis: 32 } }, showOn, when } @@ -638,9 +620,7 @@ describe('Tour | Step', () => { } ], id: 'test', - floatingUIOptions: { - middleware: [{ name: 'foo', options: 'bar', fn: defaultCallback }] - } + positionOptions: { flip: false } }); afterEach(() => { @@ -1281,6 +1261,96 @@ describe('Tour | Step', () => { expect(testElement.getAttribute('tabindex')).toBe('2'); }); + + describe('position tracking', () => { + const liveWindowListeners = (type) => { + let live = 0; + vi.spyOn(window, 'addEventListener').mockImplementation( + function (listenerType) { + if (listenerType === type) live++; + } + ); + vi.spyOn(window, 'removeEventListener').mockImplementation( + function (listenerType) { + if (listenerType === type) live--; + } + ); + return () => live; + }; + + const threeStepTour = () => + new Shepherd.Tour({ + steps: ['first', 'second', 'third'].map((id) => ({ + id, + text: id, + attachTo: { element: '#lifecycle-test-element', on: 'top' } + })) + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('tracks only the current step, however many steps were shown', async () => { + instance = threeStepTour(); + const live = liveWindowListeners('scroll'); + + await instance.start(); + const onOneStep = live(); + expect(onOneStep, 'the shown step follows scrolling').toBeGreaterThan( + 0 + ); + + await instance.next(); + await instance.next(); + expect(live(), 'hidden steps stopped listening').toBe(onOneStep); + + instance.complete(); + expect(live(), 'nothing is left after the tour ends').toBe(0); + }); + + it('resumes tracking when going back to a hidden step', async () => { + instance = threeStepTour(); + const live = liveWindowListeners('scroll'); + + await instance.start(); + const onOneStep = live(); + await instance.next(); + await instance.back(); + + expect(instance.getCurrentStep().id).toBe('first'); + expect(live(), 'the shown step is subscribed once').toBe(onOneStep); + expect(instance.getCurrentStep().cleanup).toBeTypeOf('function'); + }); + + it('keeps a hidden step hidden when its options are updated', async () => { + instance = threeStepTour(); + const live = liveWindowListeners('scroll'); + + await instance.start(); + const onOneStep = live(); + await instance.next(); + const first = instance.getById('first'); + first.updateStepOptions({ text: 'updated' }); + + expect(first.isOpen(), 'the update did not show it').toBe(false); + expect(live(), 'the update did not subscribe it').toBe(onOneStep); + + await instance.back(); + expect(first.el).toHaveTextContent('updated'); + }); + + it('stops tracking when a step is hidden directly', async () => { + instance = threeStepTour(); + const live = liveWindowListeners('scroll'); + + await instance.start(); + instance.getCurrentStep().hide(); + + expect(live()).toBe(0); + expect(instance.getCurrentStep().cleanup).toBeNull(); + }); + }); }); describe('data option', () => { diff --git a/shepherd.js/test/unit/stylesheet-warning.spec.js b/shepherd.js/test/unit/stylesheet-warning.spec.js new file mode 100644 index 000000000..d37c1d7df --- /dev/null +++ b/shepherd.js/test/unit/stylesheet-warning.spec.js @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('missing stylesheet warning', () => { + let warn; + + const showTwoSteps = async () => { + vi.resetModules(); + const { default: Shepherd } = await import('../../src/shepherd'); + const tour = new Shepherd.Tour(); + tour.addStep({ id: 'one', text: 'one' }); + tour.addStep({ id: 'two', text: 'two' }); + await tour.start(); + await tour.next(); + tour.complete(); + }; + + const stubZIndex = (zIndex) => + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ zIndex }); + + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('names the import, once per page, when the stylesheet is absent', async () => { + stubZIndex('auto'); + + await showTwoSteps(); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain( + 'shepherd.js/dist/css/shepherd.css' + ); + }); + + it('stays quiet when the stylesheet is applied', async () => { + stubZIndex('9999'); + + await showTwoSteps(); + + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/shepherd.js/test/unit/tour.spec.js b/shepherd.js/test/unit/tour.spec.js index 09da12494..ff639818f 100644 --- a/shepherd.js/test/unit/tour.spec.js +++ b/shepherd.js/test/unit/tour.spec.js @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Shepherd from '../../src/shepherd'; import ResizeObserver from 'resize-observer-polyfill'; import { setupTooltip } from '../../src/utils/floating-ui'; -import { offset } from '@floating-ui/dom'; +import { place } from '../../src/utils/position'; const { Step } = Shepherd; @@ -24,14 +24,10 @@ describe('Tour | Top-Level Class', function () { show() {} }; - const offsetMiddleware = offset({ crossAxis: 32 }); - const defaultStepOptions = { classes: DEFAULT_STEP_CLASS, scrollTo: true, - floatingUIOptions: { - middleware: [offsetMiddleware] - }, + positionOptions: { offset: { crossAxis: 32 } }, showOn, when }; @@ -60,9 +56,7 @@ describe('Tour | Top-Level Class', function () { expect(instance.options.defaultStepOptions).toEqual({ classes: DEFAULT_STEP_CLASS, scrollTo: true, - floatingUIOptions: { - middleware: [offsetMiddleware] - }, + positionOptions: { offset: { crossAxis: 32 } }, showOn, when }); @@ -926,83 +920,79 @@ describe('Tour | Top-Level Class', function () { }); }); - describe('floatingUIOptions', () => { - it('applies the default modifiers from defaultStepOptions', function () { + describe('positionOptions', () => { + const names = ({ middleware }) => middleware.map(({ name }) => name); + let target; + + beforeEach(() => { + target = document.createElement('div'); + target.classList.add('modifiers-test'); + document.body.appendChild(target); + }); + + afterEach(() => { + target.remove(); + }); + + it('applies positionOptions from defaultStepOptions', function () { instance = new Shepherd.Tour({ defaultStepOptions }); const step = instance.addStep({ + attachTo: { element: '.modifiers-test', on: 'top' }, id: 'test', title: 'This is a test step for our tour' }); instance.start(); - const floatingUIOptions = setupTooltip(step); - expect(floatingUIOptions.middleware.length).toBe(1); + const config = setupTooltip(step); + expect(names(config)).toEqual(['offset', 'flip', 'shift', 'arrow']); + expect(config.strategy).toBe('absolute'); }); - it('adds a step modifer to default modifiers', function () { + it("combines a step's positionOptions with the defaults key by key", function () { instance = new Shepherd.Tour({ defaultStepOptions }); const step = instance.addStep({ + attachTo: { element: '.modifiers-test', on: 'top' }, id: 'test', title: 'This is a test step for our tour', - floatingUIOptions: { - middleware: [{ name: 'foo', options: 'bar', fn: (args) => args }] - } + positionOptions: { strategy: 'fixed', flip: false } }); instance.start(); - const floatingUIOptions = setupTooltip(step); - expect(floatingUIOptions.middleware.length).toBe(2); + const config = setupTooltip(step); + expect(names(config)).toEqual(['offset', 'shift', 'arrow']); + expect(config.strategy).toBe('fixed'); }); - it('correctly changes modifiers when going from centered to attached', function () { - const div = document.createElement('div'); - div.classList.add('modifiers-test'); - document.body.appendChild(div); + it('correctly changes middleware when going from centered to attached', function () { instance = new Shepherd.Tour({ defaultStepOptions }); const centeredStep = instance.addStep({ id: 'centered', - title: 'This is a centered step for our tour', - floatingUIOptions: { - middleware: [{ name: 'foo', options: 'bar', fn: (args) => args }] - } + title: 'This is a centered step for our tour' }); const attachedStep = instance.addStep({ attachTo: { element: '.modifiers-test', on: 'top' }, id: 'attached', - title: 'This is an attached step for our tour', - floatingUIOptions: { - middleware: [{ name: 'foo', options: 'bar', fn: (args) => args }] - } + title: 'This is an attached step for our tour' }); instance.start(); - const centeredOptions = setupTooltip(centeredStep); - const centeredMiddlewareNames = centeredOptions.middleware.map( - ({ name }) => name - ); - expect(centeredOptions.middleware.length).toBe(2); - expect(centeredMiddlewareNames.includes('offset')).toBe(true); - expect(centeredMiddlewareNames.includes('foo')).toBe(true); - expect(centeredMiddlewareNames.includes('arrow')).toBe(false); + expect(names(setupTooltip(centeredStep))).toEqual([]); instance.next(); - const options = setupTooltip(attachedStep); - const middlewareNames = options.middleware.map(({ name }) => name); - expect(options.middleware.length).toBe(5); - expect(middlewareNames.includes('offset')).toBe(true); - expect(middlewareNames.includes('foo')).toBe(true); - expect(middlewareNames.includes('shift')).toBe(true); - expect(middlewareNames.includes('arrow')).toBe(true); - - document.body.removeChild(div); + expect(names(setupTooltip(attachedStep))).toEqual([ + 'offset', + 'flip', + 'shift', + 'arrow' + ]); }); it('renders step in stepsContainer', () => { @@ -1027,59 +1017,41 @@ describe('Tour | Top-Level Class', function () { expect(stepsContainer.contains(stepElement)).toBe(true); }); - it('adds autoPlacement middleware when attachTo.on is set to auto', () => { - const div = document.createElement('div'); - div.classList.add('modifiers-test'); - document.body.appendChild(div); - instance = new Shepherd.Tour(); - - const step1 = instance.addStep({ - id: 'test', - title: 'This is a test step for our tour', - attachTo: { element: '.modifiers-test', on: 'auto' } - }); - - const step2 = instance.addStep({ - id: 'test', - title: 'This is a test step for our tour', - attachTo: { element: '.modifiers-test', on: 'auto-start' } - }); - - const step3 = instance.addStep({ - id: 'test', - title: 'This is a test step for our tour', - attachTo: { element: '.modifiers-test', on: 'auto-end' } - }); - - instance.start(); - - const step1FloatingUIOptions = setupTooltip(step1); - const step1MiddlewareNames = step1FloatingUIOptions.middleware.map( - ({ name }) => name - ); - const step1PlacementMiddleware = step1FloatingUIOptions.middleware.find( - ({ name }) => name === 'autoPlacement' - ); - expect(step1MiddlewareNames.includes('autoPlacement')).toBe(true); - expect(step1MiddlewareNames.includes('flip')).toBe(false); - expect(step1PlacementMiddleware.options.alignment).toBe(null); - - instance.next(); + it.each([ + ['auto', /^(top|right|bottom|left)$/], + ['auto-start', /-start$/], + ['auto-end', /-end$/] + ])( + 'chooses a side with autoPlacement for %s, keeping its alignment', + (on, expected) => { + instance = new Shepherd.Tour(); - const step2FloatingUIOptions = setupTooltip(step2); - const step2PlacementMiddleware = step2FloatingUIOptions.middleware.find( - ({ name }) => name === 'autoPlacement' - ); - expect(step2PlacementMiddleware.options.alignment).toBe('start'); + const step = instance.addStep({ + id: 'test', + title: 'This is a test step for our tour', + attachTo: { element: '.modifiers-test', on } + }); - instance.next(); + instance.start(); - const step3FloatingUIOptions = setupTooltip(step3); - const step3PlacementMiddleware = step3FloatingUIOptions.middleware.find( - ({ name }) => name === 'autoPlacement' - ); - expect(step3PlacementMiddleware.options.alignment).toBe('end'); - }); + const config = setupTooltip(step); + expect(names(config)).toEqual(['autoPlacement', 'shift', 'arrow']); + const { placement } = place( + { + rects: { + reference: { x: 450, y: 330, width: 100, height: 40 }, + floating: { x: 0, y: 0, width: 300, height: 120 } + }, + clip: { x: 0, y: 0, width: 1000, height: 700 }, + origin: { x: 0, y: 0, scale: { x: 1, y: 1 } }, + rtl: false, + floating: step.el + }, + config + ); + expect(placement).toMatch(expected); + } + ); describe('strategy', () => { let target; @@ -1108,14 +1080,14 @@ describe('Tour | Top-Level Class', function () { await vi.waitFor(() => expect(step.el.style.position).toBe('absolute')); }); - it('honors `strategy: fixed` from step floatingUIOptions', async () => { + it('honors `strategy: fixed` from step positionOptions', async () => { instance = new Shepherd.Tour(); const step = instance.addStep({ id: 'test', title: 'This is a test step for our tour', attachTo: { element: '.strategy-test', on: 'top' }, - floatingUIOptions: { strategy: 'fixed' } + positionOptions: { strategy: 'fixed' } }); instance.start(); @@ -1123,10 +1095,10 @@ describe('Tour | Top-Level Class', function () { await vi.waitFor(() => expect(step.el.style.position).toBe('fixed')); }); - it('honors `strategy: fixed` from defaultStepOptions floatingUIOptions', async () => { + it('honors `strategy: fixed` from defaultStepOptions positionOptions', async () => { instance = new Shepherd.Tour({ defaultStepOptions: { - floatingUIOptions: { strategy: 'fixed' } + positionOptions: { strategy: 'fixed' } } }); @@ -1153,7 +1125,7 @@ describe('Tour | Top-Level Class', function () { const step = instance.addStep({ id: 'test', title: 'This is a test step for our tour', - floatingUIOptions: strategy ? { strategy } : {} + positionOptions: { strategy } }); instance.start(); @@ -1834,6 +1806,18 @@ describe('Tour | Top-Level Class', function () { }); }); + describe('removeStep() without a matching step', () => { + it('does not start a tour that has no current step', () => { + const tour = new Shepherd.Tour({ steps: [{ id: 'only' }] }); + const showSpy = vi.spyOn(tour, 'show'); + + tour.removeStep(undefined); + + expect(showSpy).not.toHaveBeenCalled(); + expect(tour.steps.length).toBe(1); + }); + }); + describe('lifecycle edge cases', () => { const tours = []; const flush = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/shepherd.js/test/unit/utils/cleanup.spec.js b/shepherd.js/test/unit/utils/cleanup.spec.js index 1eb9107c1..bd9090d27 100644 --- a/shepherd.js/test/unit/utils/cleanup.spec.js +++ b/shepherd.js/test/unit/utils/cleanup.spec.js @@ -1,106 +1,61 @@ -import { describe, expect, it } from 'vitest'; -import { cleanupSteps } from '../../../src/utils/cleanup'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Shepherd from '../../../src/shepherd'; -describe('Cleanup Utils', function () { - // Create some elements to use to attach to - const firstAttachElement = document.createElement('div'); - firstAttachElement.classList.add('first-attach-to'); - firstAttachElement.classList.add('shepherd-target-click-disabled'); - document.body.appendChild(firstAttachElement); +const CLICK_DISABLED = 'shepherd-target-click-disabled'; - const secondAttachElement = document.createElement('div'); - secondAttachElement.classList.add('second-attach-to'); - secondAttachElement.classList.add('shepherd-target-click-disabled'); - document.body.appendChild(secondAttachElement); +describe('Ending a tour', function () { + let elements; - // Element with extra highlights - const thirdAttachElement = document.createElement('div'); - thirdAttachElement.classList.add('third-attach-to'); - thirdAttachElement.classList.add('shepherd-target-click-disabled'); - document.body.appendChild(thirdAttachElement); + const addElement = (className) => { + const el = document.createElement('div'); + el.classList.add(className); + document.body.appendChild(el); + elements.push(el); + return el; + }; - // Extra highlight elements for the third step - const extraHighlightElement = document.createElement('div'); - extraHighlightElement.classList.add('extra-highlight'); - extraHighlightElement.classList.add('shepherd-target-click-disabled'); - document.body.appendChild(extraHighlightElement); - const extraHighlightElement2 = document.createElement('div'); - extraHighlightElement2.classList.add('extra-highlight'); - extraHighlightElement2.classList.add('shepherd-target-click-disabled'); - document.body.appendChild(extraHighlightElement2); + beforeEach(() => { + elements = []; + }); - const mockedTour = { - steps: [ - { - options: { - attachTo: { - element: '.first-attach-to', - on: 'bottom' - }, - canClickTarget: false - }, - // Manually add the target. The tour would do this, if it were a real tour - target: firstAttachElement - }, - { - options: { - attachTo: { - element: '.second-attach-to', - on: 'bottom' - }, - canClickTarget: false - }, - // Manually add the target. The tour would do this, if it were a real tour - target: secondAttachElement - }, - { - options: { - attachTo: { - element: '.third-attach-to', - on: 'bottom' + afterEach(() => { + elements.forEach((el) => el.remove()); + }); + + describe.each(['cancel', 'complete'])('%s()', function (ending) { + it('removes shepherd-target-click-disabled from every target and extra highlight', async () => { + const first = addElement('first-attach-to'); + const second = addElement('second-attach-to'); + const extra = addElement('extra-highlight'); + const otherExtra = addElement('extra-highlight'); + + const tour = new Shepherd.Tour({ + steps: [ + { + id: 'first', + attachTo: { element: '.first-attach-to', on: 'bottom' }, + canClickTarget: false }, - canClickTarget: false - }, - // Manually add the target. The tour would do this, if it were a real tour - target: thirdAttachElement, - // Add extra highlights - _resolvedExtraHighlightElements: [ - extraHighlightElement, - extraHighlightElement2 + { + id: 'second', + attachTo: { element: '.second-attach-to', on: 'bottom' }, + canClickTarget: false, + extraHighlights: ['.extra-highlight'] + } ] - } - ] - }; + }); + + await tour.start(); + expect(first).toHaveClass(CLICK_DISABLED); - describe('cleanupSteps', function () { - it('cleans up steps and removes shepherd-target-click-disabled class', () => { - expect(firstAttachElement).toHaveClass('shepherd-target-click-disabled'); - expect(secondAttachElement).toHaveClass('shepherd-target-click-disabled'); - expect(thirdAttachElement).toHaveClass('shepherd-target-click-disabled'); - expect(extraHighlightElement).toHaveClass( - 'shepherd-target-click-disabled' - ); - expect(extraHighlightElement2).toHaveClass( - 'shepherd-target-click-disabled' - ); + await tour.next(); + expect(second).toHaveClass(CLICK_DISABLED); + expect(extra).toHaveClass(CLICK_DISABLED); + expect(otherExtra).toHaveClass(CLICK_DISABLED); - cleanupSteps(mockedTour); + await tour[ending](); - expect(firstAttachElement).not.toHaveClass( - 'shepherd-target-click-disabled' - ); - expect(secondAttachElement).not.toHaveClass( - 'shepherd-target-click-disabled' - ); - expect(thirdAttachElement).not.toHaveClass( - 'shepherd-target-click-disabled' - ); - expect(extraHighlightElement).not.toHaveClass( - 'shepherd-target-click-disabled' - ); - expect(extraHighlightElement2).not.toHaveClass( - 'shepherd-target-click-disabled' - ); + elements.forEach((el) => expect(el).not.toHaveClass(CLICK_DISABLED)); }); }); }); diff --git a/shepherd.js/test/unit/utils/floating-ui-position.spec.js b/shepherd.js/test/unit/utils/floating-ui-position.spec.js index 9e785bbbb..66ad1a679 100644 --- a/shepherd.js/test/unit/utils/floating-ui-position.spec.js +++ b/shepherd.js/test/unit/utils/floating-ui-position.spec.js @@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ResizeObserver from 'resize-observer-polyfill'; // happy-dom has no layout engine: every `getBoundingClientRect()` is 0x0 at the -// origin, so a real `computePosition` call always resolves `{ x: 0, y: 0 }` and +// origin, so a real `computePosition` call always returns `{ x: 0, y: 0 }` and // no assertion about coordinates can fail. These tests therefore stub the -// Floating UI boundary and assert the one thing Shepherd actually owns: what it -// writes to `step.el.style` for a given resolved position payload. +// positioning engine's boundary and assert what Shepherd writes to +// `step.el.style` for a given resolved position payload. const floatingUI = vi.hoisted(() => ({ // Overwritten per test to control exactly what the positioning callback gets. position: { @@ -17,12 +17,12 @@ const floatingUI = vi.hoisted(() => ({ } })); -vi.mock('@floating-ui/dom', async (importOriginal) => { +vi.mock('../../../src/utils/position.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - computePosition: vi.fn(() => Promise.resolve({ ...floatingUI.position })), + computePosition: vi.fn(() => ({ ...floatingUI.position })), // Run the position callback once, synchronously, rather than wiring up the // scroll/resize observers that happy-dom cannot drive. autoUpdate: vi.fn((_target, _element, update) => { @@ -99,13 +99,32 @@ describe('utils/floating-ui | position writing', function () { destroyTooltip(step); }); + it('stops repositioning a step once it is hidden', () => { + instance = new Shepherd.Tour(); + + const step = instance.addStep({ + id: 'test', + attachTo: { element: '.position-test', on: 'top' } + }); + + instance.start(); + + const stopRepositioning = vi.fn(); + step.cleanup = stopRepositioning; + + step.hide(); + + expect(stopRepositioning).toHaveBeenCalledTimes(1); + expect(step.cleanup).toBeNull(); + }); + it('writes coordinates unmodified while the page is scrolled', async () => { instance = new Shepherd.Tour(); const step = instance.addStep({ id: 'test', attachTo: { element: '.position-test', on: 'top' }, - floatingUIOptions: { strategy: 'fixed' } + positionOptions: { strategy: 'fixed' } }); instance.start(); @@ -133,10 +152,10 @@ describe('utils/floating-ui | position writing', function () { it('takes the strategy from the resolved payload rather than the step options', async () => { instance = new Shepherd.Tour(); - // No `floatingUIOptions` on the step at all. The CSS `position` still has - // to follow what `computePosition` reports it used, because that payload — - // not the raw user option — is what the returned coordinates are relative - // to. Reading `step.options.floatingUIOptions.strategy` instead would write + // No `positionOptions.strategy` on the step at all. The CSS `position` still + // has to follow what `computePosition` reports it used, because that payload + // — not the raw user option — is what the returned coordinates are relative + // to. Reading `step.options.positionOptions.strategy` instead would write // `absolute` here and reintroduce the coordinate-space mismatch. const step = instance.addStep({ id: 'test', @@ -145,7 +164,7 @@ describe('utils/floating-ui | position writing', function () { instance.start(); - expect(step.options.floatingUIOptions?.strategy).toBeUndefined(); + expect(step.options.positionOptions?.strategy).toBeUndefined(); await positionWith(step, { x: 13, diff --git a/shepherd.js/test/unit/utils/floating-ui.spec.js b/shepherd.js/test/unit/utils/floating-ui.spec.js index bb7ab6897..dfa08b696 100644 --- a/shepherd.js/test/unit/utils/floating-ui.spec.js +++ b/shepherd.js/test/unit/utils/floating-ui.spec.js @@ -6,7 +6,7 @@ const floatingUIMock = vi.hoisted(() => ({ updateCallbacks: [] })); -vi.mock('@floating-ui/dom', async (importOriginal) => { +vi.mock('../../../src/utils/position.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -16,39 +16,66 @@ vi.mock('@floating-ui/dom', async (importOriginal) => { }; }); -import { arrow, offset, shift } from '@floating-ui/dom'; import { Step } from '../../../src/step'; import { - getFloatingUIOptions, + getPositionConfig, setupTooltip } from '../../../src/utils/floating-ui'; +import { arrow, flip, place, shift } from '../../../src/utils/position'; describe('Floating UI Utils', function () { let targetElement; let stepElement; - /** - * Names of the middleware, in execution order. Falsy entries are preserved so - * that ordering assertions still line up when the user passes them in. - */ const middlewareNames = ({ middleware }) => - middleware.map((item) => (item ? item.name : item)); + middleware.map(({ name }) => name); const createStep = (options) => { const step = new Step({}, { arrow: true, ...options }); - // `addArrow()` only returns the arrow element when the step is rendered. + // The arrow middleware is only added once the step is rendered. step.el = stepElement; return step; }; + const configFor = (options) => { + const step = createStep(options); + return getPositionConfig(step.options.attachTo, step); + }; + + /** Where the arrow lands for a narrow target by the viewport's left edge. */ + const arrowAt = (config) => + place( + { + rects: { + reference: { x: 2, y: 300, width: 4, height: 4 }, + floating: { x: 0, y: 0, width: 300, height: 120 } + }, + clip: { x: 0, y: 0, width: 1000, height: 700 }, + origin: { x: 0, y: 0, scale: { x: 1, y: 1 } }, + rtl: false, + floating: stepElement + }, + config + ).middlewareData.arrow; + + const arrowWithPadding = (placement, padding) => + arrowAt({ + placement, + strategy: 'absolute', + middleware: [flip(), shift(), arrow(stepElement.firstChild, padding)] + }); + beforeEach(() => { targetElement = document.createElement('div'); targetElement.classList.add('floating-ui-test'); document.body.appendChild(targetElement); stepElement = document.createElement('div'); + Object.defineProperty(stepElement, 'clientWidth', { value: 300 }); + Object.defineProperty(stepElement, 'clientHeight', { value: 120 }); const arrowElement = document.createElement('div'); arrowElement.classList.add('shepherd-arrow'); + arrowElement.style.cssText = 'width: 16px; height: 16px'; stepElement.appendChild(arrowElement); document.body.appendChild(stepElement); }); @@ -60,147 +87,134 @@ describe('Floating UI Utils', function () { vi.restoreAllMocks(); }); - describe('getFloatingUIOptions()', function () { - it('keeps user middleware and places the arrow middleware last', function () { + describe('getPositionConfig()', function () { + it('counts the theme gap as offset, on top of positionOptions', () => { const step = createStep({ - attachTo: { element: '.floating-ui-test', on: 'right' }, - floatingUIOptions: { - middleware: [offset(16), shift({ padding: 32 })] - } + attachTo: { element: 'body', on: 'bottom' }, + positionOptions: { offset: 4 } }); + step.el.style.setProperty('--shepherd-gap', '16px'); - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step - ); + const { middleware } = getPositionConfig(step.options.attachTo, step); + const offsetMiddleware = middleware.find((m) => m.name === 'offset'); + const { y } = offsetMiddleware.fn({ + x: 0, + y: 0, + placement: 'bottom', + middlewareData: {}, + rects: { reference: {}, floating: {} } + }); - expect(middlewareNames(floatingUIOptions)).toEqual([ - 'flip', - 'shift', + expect(y).toBe(20); + step.el.style.removeProperty('--shepherd-gap'); + }); + + it('flips, shifts, and places the arrow last by default', function () { + const config = configFor({ + attachTo: { element: '.floating-ui-test', on: 'right' } + }); + + expect(middlewareNames(config)).toEqual(['flip', 'shift', 'arrow']); + expect(config.placement).toBe('right'); + expect(config.strategy).toBe('absolute'); + }); + + it('offsets before anything else measures the step', function () { + const config = configFor({ + attachTo: { element: '.floating-ui-test', on: 'right' }, + positionOptions: { offset: 12 } + }); + + expect(middlewareNames(config)).toEqual([ 'offset', + 'flip', 'shift', 'arrow' ]); }); - it('does not append its own arrow when the user supplies one', function () { - const userArrowElement = document.createElement('div'); - const step = createStep({ - attachTo: { element: '.floating-ui-test', on: 'right' }, - floatingUIOptions: { - middleware: [arrow({ element: userArrowElement }), offset(16)] - } + it('keeps attachTo.on when flip is false', function () { + const config = configFor({ + attachTo: { element: '.floating-ui-test', on: 'top' }, + positionOptions: { flip: false } }); - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step - ); - - const names = middlewareNames(floatingUIOptions); - expect(names).toEqual(['flip', 'shift', 'arrow', 'offset']); - expect(names.filter((name) => name === 'arrow')).toHaveLength(1); - - const arrowMiddleware = floatingUIOptions.middleware.find( - ({ name }) => name === 'arrow' - ); - expect(arrowMiddleware.options.element).toBe(userArrowElement); + expect(middlewareNames(config)).toEqual(['shift', 'arrow']); + expect(config.placement).toBe('top'); }); - it('adds the default middleware when the user supplies none', function () { - const step = createStep({ - attachTo: { element: '.floating-ui-test', on: 'right' } + it('chooses a side with autoPlacement for auto placements', function () { + const config = configFor({ + attachTo: { element: '.floating-ui-test', on: 'auto-start' }, + positionOptions: { flip: false } }); - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step - ); - - expect(middlewareNames(floatingUIOptions)).toEqual([ - 'flip', + expect(middlewareNames(config)).toEqual([ + 'autoPlacement', 'shift', 'arrow' ]); - expect(floatingUIOptions.placement).toBe('right'); - expect(floatingUIOptions.strategy).toBe('absolute'); - - const arrowMiddleware = floatingUIOptions.middleware.at(-1); - expect(arrowMiddleware.options.element).toBe( - stepElement.querySelector('.shepherd-arrow') - ); - // Padding only applies to edge aligned placements. - expect(arrowMiddleware.options.padding).toBe(0); }); - it('passes the arrow padding through for edge aligned placements', function () { - const step = createStep({ - arrow: { padding: 10 }, - attachTo: { element: '.floating-ui-test', on: 'right-start' } + it('passes strategy through', function () { + const config = configFor({ + attachTo: { element: '.floating-ui-test', on: 'right' }, + positionOptions: { strategy: 'fixed' } }); - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step - ); - - const arrowMiddleware = floatingUIOptions.middleware.at(-1); - expect(arrowMiddleware.name).toBe('arrow'); - expect(arrowMiddleware.options.padding).toBe(10); + expect(config.strategy).toBe('fixed'); }); - it('defaults the arrow padding for edge aligned placements', function () { - const step = createStep({ - attachTo: { element: '.floating-ui-test', on: 'right-start' } + it('ignores the arrow padding for placements without an alignment', function () { + const config = configFor({ + arrow: { padding: 40 }, + attachTo: { element: '.floating-ui-test', on: 'bottom' } }); - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step - ); + expect(arrowAt(config)).toEqual(arrowWithPadding('bottom', 0)); + expect(arrowAt(config)).not.toEqual(arrowWithPadding('bottom', 40)); + }); + + it('passes the arrow padding through for edge aligned placements', function () { + const config = configFor({ + arrow: { padding: 40 }, + attachTo: { element: '.floating-ui-test', on: 'bottom-start' } + }); - expect(floatingUIOptions.middleware.at(-1).options.padding).toBe(4); + expect(arrowAt(config)).toEqual(arrowWithPadding('bottom-start', 40)); + expect(arrowAt(config)).not.toEqual(arrowWithPadding('bottom-start', 4)); }); - it('does not add any middleware for a centered step', function () { - const step = createStep({ - attachTo: { element: '.floating-ui-test' }, - floatingUIOptions: { - middleware: [offset(16)] - } + it('defaults the arrow padding for edge aligned placements', function () { + const config = configFor({ + attachTo: { element: '.floating-ui-test', on: 'bottom-start' } }); - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step + expect(arrowAt(config)).toEqual(arrowWithPadding('bottom-start', 4)); + expect(arrowAt(config)).not.toEqual(arrowWithPadding('bottom-start', 0)); + }); + + it('positions an SVG arrow', function () { + const svgArrow = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'svg' ); + svgArrow.classList.add('shepherd-arrow'); + stepElement.replaceChildren(svgArrow); + const config = configFor({ + attachTo: { element: '.floating-ui-test', on: 'right' } + }); - expect(middlewareNames(floatingUIOptions)).toEqual(['offset']); - expect(floatingUIOptions.placement).toBeUndefined(); + expect(middlewareNames(config)).toEqual(['flip', 'shift', 'arrow']); }); - it('tolerates falsy entries in the user middleware', function () { - const step = createStep({ - attachTo: { element: '.floating-ui-test', on: 'right' }, - floatingUIOptions: { - middleware: [false, offset(16), null, undefined] - } + it('adds no middleware for a centered step', function () { + const config = configFor({ + attachTo: { element: '.floating-ui-test' }, + positionOptions: { offset: 16 } }); - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step - ); - - expect(middlewareNames(floatingUIOptions)).toEqual([ - 'flip', - 'shift', - false, - 'offset', - null, - undefined, - 'arrow' - ]); + expect(config.middleware).toEqual([]); }); }); @@ -217,7 +231,7 @@ describe('Floating UI Utils', function () { return vi.fn(); } ); - floatingUIMock.computePosition.mockResolvedValue({ + floatingUIMock.computePosition.mockReturnValue({ middlewareData: {}, placement: 'bottom', strategy: 'absolute', @@ -226,6 +240,28 @@ describe('Floating UI Utils', function () { }); }); + it('reads the theme gap again on every reposition', () => { + const { step, stepElement } = createTooltipFixture(); + const gapOf = (call) => + call[2].middleware + .find((m) => m.name === 'offset') + ?.fn({ + x: 0, + y: 0, + placement: 'bottom', + middlewareData: {}, + rects: { reference: {}, floating: {} } + }).y ?? 0; + + setupTooltip(step); + stepElement.style.setProperty('--shepherd-gap', '24px'); + floatingUIMock.updateCallbacks[0](); + + const calls = floatingUIMock.computePosition.mock.calls; + expect(gapOf(calls.at(-1))).toBe(24); + stepElement.style.removeProperty('--shepherd-gap'); + }); + it('only focuses the step element after the first render', async () => { const { input, step, stepElement } = createTooltipFixture(); const focusSpy = vi.spyOn(stepElement, 'focus'); @@ -247,6 +283,30 @@ describe('Floating UI Utils', function () { expect(document.activeElement).toBe(input); }); + it('focuses the step in the frame after it is positioned', async () => { + const { step, stepElement } = createTooltipFixture(); + + setupTooltip(step); + await vi.advanceTimersByTimeAsync(20); + + expect(document.activeElement).toBe(stepElement); + }); + + it('reports a step it could not position, and still focuses it', async () => { + const { step, stepElement } = createTooltipFixture(); + const cause = new Error('no platform'); + floatingUIMock.computePosition.mockImplementationOnce(() => { + throw cause; + }); + const consoleError = vi.spyOn(console, 'error'); + + setupTooltip(step); + await vi.advanceTimersByTimeAsync(20); + + expect(consoleError).toHaveBeenCalledWith(expect.any(String), cause); + expect(document.activeElement).toBe(stepElement); + }); + it('focuses each fresh tooltip setup once', async () => { const first = createTooltipFixture(); const firstFocusSpy = vi.spyOn(first.stepElement, 'focus'); @@ -289,8 +349,7 @@ function createTooltipFixture() { el: stepElement, options: { arrow: false, - attachTo: { element: target, on: 'bottom' }, - floatingUIOptions: {} + attachTo: { element: target, on: 'bottom' } }, shepherdElementComponent: { element: stepElement diff --git a/shepherd.js/test/unit/utils/general.spec.js b/shepherd.js/test/unit/utils/general.spec.js index 397548e07..692af4335 100644 --- a/shepherd.js/test/unit/utils/general.spec.js +++ b/shepherd.js/test/unit/utils/general.spec.js @@ -7,7 +7,7 @@ import { resolveAttachToElement, waitForAttachToElement } from '../../../src/utils/general'; -import { getFloatingUIOptions } from '../../../src/utils/floating-ui'; +import { getPositionConfig } from '../../../src/utils/floating-ui'; describe('General Utils', function () { let optionsElement; @@ -96,6 +96,38 @@ describe('General Utils', function () { spy.mockRestore(); }); + it('names the selector and the fix when the target is missing', function () { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const step = new Step( + {}, + { attachTo: { element: '.element-does-not-exist', on: 'bottom' } } + ); + + parseAttachTo(step); + + const [message] = spy.mock.calls[0]; + expect(message).toContain('.element-does-not-exist'); + expect(message).toContain('waitForElement'); + expect(message).toContain('skipMissingElement'); + + spy.mockRestore(); + }); + + it('treats an invalid selector as a missing target', function () { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const step = new Step( + {}, + { attachTo: { element: '#not[valid', on: 'bottom' } } + ); + + const { element } = parseAttachTo(step); + + expect(element).toBeNull(); + expect(spy.mock.calls[0][0]).toContain('#not[valid'); + + spy.mockRestore(); + }); + it('does not log a console.error when skipMissingElement is true', function () { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const step = new Step( @@ -289,53 +321,6 @@ describe('General Utils', function () { const element = await waitForAttachToElement(step, 0); expect(element).toBeNull(); }); - - it('falls back to polling when MutationObserver is unavailable', async function () { - vi.stubGlobal('MutationObserver', undefined); - - const step = new Step( - {}, - { - attachTo: { element: '.polled-for-xyz', on: 'center' } - } - ); - - const promise = waitForAttachToElement(step, 1000); - let addedElement; - - setTimeout(() => { - addedElement = document.createElement('div'); - addedElement.classList.add('polled-for-xyz'); - document.body.appendChild(addedElement); - }, 20); - - const element = await promise; - - expect( - element, - 'polling resolves with the element without a MutationObserver' - ).toBe(addedElement); - - document.body.removeChild(addedElement); - vi.unstubAllGlobals(); - }); - - it('resolves with null after timeout when polling and the element never appears', async function () { - vi.stubGlobal('MutationObserver', undefined); - - const step = new Step( - {}, - { - attachTo: { element: '.never-appears-xyz', on: 'center' } - } - ); - - const element = await waitForAttachToElement(step, 80); - - expect(element, 'polling gives up once the timeout expires').toBeNull(); - - vi.unstubAllGlobals(); - }); }); describe('parseExtraHighlights()', function () { @@ -368,51 +353,15 @@ describe('General Utils', function () { }); }); - describe('floatingUIOptions', function () { - it('middleware options are passed in last', function () { - const step = new Step( - {}, - { - attachTo: { element: '.options-test', on: 'right' }, - floatingUIOptions: { - middleware: [ - { - name: 'preventOverflow', - options: { - altAxis: false - } - } - ] - } - } - ); - - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step - ); - // Shepherd pushes in flip and shift by default, so this is 3rd - expect(floatingUIOptions.middleware[2].options.altAxis).toBe(false); - }); - - it('positioning strategy is explicitly set', function () { + describe('positionOptions', function () { + it('positioning strategy defaults to absolute', function () { const step = new Step( {}, - { - attachTo: { element: '.options-test', on: 'center' }, - options: { - floatingUIOptions: { - strategy: 'absolute' - } - } - } + { attachTo: { element: '.options-test', on: 'right' } } ); - const floatingUIOptions = getFloatingUIOptions( - step.options.attachTo, - step - ); - expect(floatingUIOptions.strategy).toBe('absolute'); + const config = getPositionConfig(step.options.attachTo, step); + expect(config.strategy).toBe('absolute'); }); }); diff --git a/shepherd.js/test/unit/utils/position.spec.js b/shepherd.js/test/unit/utils/position.spec.js new file mode 100644 index 000000000..80c77aa0a --- /dev/null +++ b/shepherd.js/test/unit/utils/position.spec.js @@ -0,0 +1,885 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + arrow as floatingArrow, + autoPlacement as floatingAutoPlacement, + computePosition as floatingComputePosition, + flip as floatingFlip, + limitShift, + offset as floatingOffset, + shift as floatingShift +} from '@floating-ui/dom'; +import { + arrow, + autoPlacement, + autoUpdate, + flip, + measure, + offset, + place, + shift +} from '../../../src/utils/position.ts'; + +const PLACEMENTS = [ + 'top', + 'top-start', + 'top-end', + 'right', + 'right-start', + 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end' +]; +const VIEWPORTS = [ + { x: 0, y: 0, width: 1000, height: 700 }, + // Too small for the floating element on any side. + { x: 50, y: 50, width: 200, height: 90 } +]; +const FLOATING = { width: 300, height: 120 }; +const REFERENCES = [ + { x: 440, y: 330, width: 120, height: 40 }, + { x: 0, y: 0, width: 120, height: 40 }, + { x: 880, y: 660, width: 120, height: 40 }, + { x: 996, y: 350, width: 8, height: 8 }, + { x: 5, y: 350, width: 4, height: 4 }, + { x: 100, y: 100, width: 700, height: 500 }, + // Scrolled out of view, where shifting must stop at the reference. + { x: -600, y: 900, width: 120, height: 40 }, + { x: 1600, y: -300, width: 120, height: 40 } +]; +const ARROW_PADDINGS = [0, 4, 40, 500]; + +/** + * happy-dom has no layout, so the engine's math runs on given measurements and + * stock Floating UI on a platform returning the same ones. Real browsers are + * covered by the scorecard's parity goldens and `pnpm test:position`. + */ +function stubPlatform(reference, rtl, viewport = VIEWPORTS[0]) { + return { + getElementRects: () => ({ + reference, + floating: { x: 0, y: 0, ...FLOATING } + }), + getDimensions: () => ({ width: 16, height: 16 }), + getClippingRect: () => viewport, + getOffsetParent: () => window, + getScale: () => ({ x: 1, y: 1 }), + convertOffsetParentRelativeRectToViewportRelativeRect: ({ rect }) => rect, + isElement: () => false, + isRTL: () => rtl, + getClientRects: () => [] + }; +} + +const measurements = (floating, reference, rtl, viewport = VIEWPORTS[0]) => ({ + rects: { reference, floating: { x: 0, y: 0, ...FLOATING } }, + clip: viewport, + origin: { x: 0, y: 0, scale: { x: 1, y: 1 } }, + rtl, + floating +}); + +function elements() { + const reference = document.createElement('div'); + const floating = document.createElement('div'); + const arrowElement = document.createElement('div'); + floating.append(arrowElement); + // The engine reads the free space for the arrow from `clientWidth`. + Object.defineProperty(floating, 'clientWidth', { value: FLOATING.width }); + Object.defineProperty(floating, 'clientHeight', { value: FLOATING.height }); + arrowElement.style.cssText = 'width: 16px; height: 16px'; + Object.defineProperty(arrowElement, 'offsetWidth', { value: 16 }); + Object.defineProperty(arrowElement, 'offsetHeight', { value: 16 }); + return { reference, floating, arrowElement }; +} + +const outcome = ({ x, y, placement, middlewareData: { arrow: data } }) => ({ + x, + y, + placement, + arrowX: data?.x, + arrowY: data?.y +}); + +/** Both engines, given the same geometry, with each side's own middleware. */ +async function bothWays({ reference, rtl, viewport, placement, ours, theirs }) { + const { reference: ref, floating, arrowElement } = elements(); + const engine = place(measurements(floating, reference, rtl, viewport), { + placement: placement ?? 'bottom', + strategy: 'absolute', + middleware: ours(arrowElement) + }); + const stock = await floatingComputePosition(ref, floating, { + placement, + platform: stubPlatform(reference, rtl, viewport), + middleware: theirs(arrowElement) + }); + return [outcome(engine), outcome(stock)]; +} + +const stockShift = (options) => + floatingShift({ limiter: limitShift(), crossAxis: true, ...options }); + +describe('utils/position', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('place()', () => { + describe.each([false, true])('with rtl %s', (rtl) => { + describe.each(VIEWPORTS)('in a $width x $height viewport', (viewport) => { + it.each(REFERENCES)( + 'matches Floating UI with flip, shift, and arrow for $x,$y', + async (reference) => { + for (const placement of PLACEMENTS) { + for (const padding of ARROW_PADDINGS) { + const [ours, theirs] = await bothWays({ + reference, + rtl, + viewport, + placement, + ours: (element) => [flip(), shift(), arrow(element, padding)], + theirs: (element) => [ + floatingFlip(), + stockShift(), + floatingArrow({ element, padding }) + ] + }); + expect(ours, `${placement} ${padding}`).toEqual(theirs); + } + } + } + ); + + it.each(REFERENCES)( + 'matches Floating UI with offset first for $x,$y', + async (reference) => { + for (const placement of PLACEMENTS) { + for (const distance of [12, { mainAxis: 12, crossAxis: 7 }]) { + const [ours, theirs] = await bothWays({ + reference, + rtl, + viewport, + placement, + ours: (element) => [ + offset(distance), + flip(), + shift(), + arrow(element, 40) + ], + theirs: (element) => [ + floatingOffset(distance), + floatingFlip(), + stockShift(), + floatingArrow({ element, padding: 40 }) + ] + }); + expect( + ours, + `${placement} ${JSON.stringify(distance)}` + ).toEqual(theirs); + } + } + } + ); + + it.each(REFERENCES)( + 'matches Floating UI with padding for $x,$y', + async (reference) => { + for (const placement of PLACEMENTS) { + const [ours, theirs] = await bothWays({ + reference, + rtl, + viewport, + placement, + ours: (element) => [flip(24), shift(24), arrow(element, 4)], + theirs: (element) => [ + floatingFlip({ padding: 24 }), + stockShift({ padding: 24 }), + floatingArrow({ element, padding: 4 }) + ] + }); + expect(ours, placement).toEqual(theirs); + } + } + ); + + it.each(REFERENCES)( + 'matches Floating UI without flip for $x,$y', + async (reference) => { + for (const placement of PLACEMENTS) { + const [ours, theirs] = await bothWays({ + reference, + rtl, + viewport, + placement, + ours: (element) => [shift(), arrow(element, 4)], + theirs: (element) => [ + stockShift(), + floatingArrow({ element, padding: 4 }) + ] + }); + expect(ours, placement).toEqual(theirs); + } + } + ); + }); + + it.each(REFERENCES)( + 'matches Floating UI with autoPlacement for $x,$y', + async (reference) => { + for (const alignment of [undefined, 'start', 'end']) { + for (const padding of [undefined, 24]) { + const [ours, theirs] = await bothWays({ + reference, + rtl, + ours: () => [autoPlacement(alignment, padding), shift(padding)], + theirs: () => [ + floatingAutoPlacement({ + crossAxis: true, + alignment, + padding + }), + stockShift({ padding }) + ] + }); + expect(ours, `${alignment} ${padding}`).toEqual(theirs); + } + } + } + ); + }); + + it('stops resetting a middleware that never settles', () => { + const { floating } = elements(); + const fn = vi.fn(() => ({ reset: true })); + + place(measurements(floating, REFERENCES[0], false), { + placement: 'bottom', + strategy: 'absolute', + middleware: [{ name: 'restless', fn }] + }); + + expect(fn).toHaveBeenCalledTimes(51); + }); + }); + + describe('measure()', () => { + it('measures a modal dialog against the viewport where :popover-open is not supported', () => { + const { reference, floating } = elements(); + const container = document.createElement('div'); + container.style.transform = 'translateX(10px)'; + container.append(floating); + document.body.append(container, reference); + vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ + x: 100, + y: 100, + width: 500, + height: 500 + }); + vi.spyOn(reference, 'getBoundingClientRect').mockReturnValue({ + x: 300, + y: 200, + width: 10, + height: 10 + }); + const inContainer = measure(reference, floating, 'absolute'); + vi.spyOn(floating, 'matches').mockImplementation((selector) => { + if (selector.includes(':popover-open')) { + throw new DOMException('Unknown selector', 'SyntaxError'); + } + return selector === ':modal'; + }); + const asModal = measure(reference, floating, 'absolute'); + container.remove(); + reference.remove(); + + expect(inContainer.rects.reference).toMatchObject({ x: 200, y: 100 }); + expect(asModal.rects.reference).toMatchObject({ x: 300, y: 200 }); + }); + }); + + describe('autoUpdate()', () => { + function stubbedRects() { + const { reference, floating } = elements(); + const rect = { x: 10, y: 20, width: 30, height: 40 }; + vi.spyOn(reference, 'getBoundingClientRect').mockImplementation(() => ({ + ...rect + })); + vi.spyOn(floating, 'getBoundingClientRect').mockImplementation(() => ({ + x: 0, + y: 0, + width: 1, + height: 1 + })); + return { reference, floating, rect }; + } + + const nextFrames = async (count) => { + for (let i = 0; i < count; i++) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }; + + it('updates once immediately, then only when a rect changes', () => { + const { reference, floating, rect } = stubbedRects(); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + expect(update).toHaveBeenCalledTimes(1); + + window.dispatchEvent(new Event('resize')); + const settled = update.mock.calls.length; + window.dispatchEvent(new Event('resize')); + expect(update).toHaveBeenCalledTimes(settled); + + rect.y = 200; + window.dispatchEvent(new Event('resize')); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + }); + + it('follows a layout shift that fires no event', () => { + const observers = []; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback) { + observers.push(callback); + } + observe() {} + disconnect() {} + } + ); + const { reference, floating, rect } = stubbedRects(); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + observers.forEach((notify) => notify([])); + const settled = update.mock.calls.length; + + rect.width = 300; + observers.forEach((notify) => notify([])); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + vi.unstubAllGlobals(); + }); + + it('updates when only the visual viewport gets shorter', () => { + const viewport = Object.assign(new EventTarget(), { + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768 + }); + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: viewport + }); + const { reference, floating } = stubbedRects(); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + viewport.dispatchEvent(new Event('resize')); + const settled = update.mock.calls.length; + + viewport.height = 400; + viewport.dispatchEvent(new Event('resize')); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + delete window.visualViewport; + }); + + it('leaves no observer behind when the step leaves the page mid-check', () => { + const observers = []; + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback) { + this.callback = callback; + this.live = true; + observers.push(this); + } + observe() {} + disconnect() { + this.live = false; + } + } + ); + const { reference, floating, rect } = stubbedRects(); + document.body.append(floating); + + autoUpdate(reference, floating, () => {}); + floating.remove(); + rect.y = 400; + observers.at(-1).callback([{ intersectionRatio: 1 }]); + + expect(observers.filter((observer) => observer.live)).toEqual([]); + vi.unstubAllGlobals(); + }); + + it('wakes on a scroll in a frame between the target and the step', () => { + vi.stubGlobal('ResizeObserver', undefined); + vi.stubGlobal('IntersectionObserver', undefined); + const { floating, rect } = stubbedRects(); + const windowWith = (frameElement) => + Object.assign(new EventTarget(), { + frameElement, + getComputedStyle: () => ({ paddingLeft: '0', paddingTop: '0' }) + }); + const frameIn = (win) => ({ + ownerDocument: Object.assign(new EventTarget(), { + defaultView: win, + documentElement: document.documentElement + }), + getBoundingClientRect: () => ({ x: 0, y: 0, width: 100, height: 100 }), + clientLeft: 0, + clientTop: 0 + }); + const middle = windowWith(document.createElement('iframe')); + const inner = windowWith(frameIn(middle)); + const reference = { + ownerDocument: Object.assign(new EventTarget(), { + defaultView: inner, + documentElement: document.documentElement + }), + getBoundingClientRect: () => ({ ...rect }) + }; + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + const settled = update.mock.calls.length; + + rect.y = 500; + middle.dispatchEvent(new Event('scroll')); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + vi.unstubAllGlobals(); + }); + + it('updates when a container clipping the step shrinks', () => { + const observed = []; + const notify = []; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback) { + notify.push(callback); + } + observe(element) { + observed.push(element); + } + disconnect() {} + } + ); + const { reference, floating } = stubbedRects(); + const container = document.createElement('div'); + container.style.overflow = 'hidden'; + let width = 300; + Object.defineProperty(container, 'clientWidth', { get: () => width }); + container.append(floating); + document.body.append(container); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + expect(observed).toContain(container); + const settled = update.mock.calls.length; + + width = 100; + notify.forEach((callback) => callback([])); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + container.remove(); + vi.unstubAllGlobals(); + }); + + it('follows a transition on an ancestor of the target frame by frame', () => { + const frames = []; + vi.stubGlobal('requestAnimationFrame', (callback) => + frames.push(callback) + ); + vi.stubGlobal('cancelAnimationFrame', () => {}); + const { reference, floating, rect } = stubbedRects(); + const parent = document.createElement('div'); + parent.append(reference); + document.body.append(parent); + let running = true; + document.getAnimations = () => + running ? [{ playState: 'running', effect: { target: parent } }] : []; + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + parent.dispatchEvent(new Event('transitionrun', { bubbles: true })); + const settled = update.mock.calls.length; + + rect.x = 50; + frames.shift()(); + expect(update, 'mid transition').toHaveBeenCalledTimes(settled + 1); + + running = false; + rect.x = 60; + frames.shift()(); + expect(update, 'last frame').toHaveBeenCalledTimes(settled + 2); + expect(frames, 'stops with the transition').toEqual([]); + + stop(); + parent.remove(); + delete document.getAnimations; + vi.unstubAllGlobals(); + }); + + it('ignores a movement notification queued before it stopped', () => { + const observers = []; + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback) { + this.callback = callback; + observers.push(this); + } + observe() {} + disconnect() {} + } + ); + const { reference, floating, rect } = stubbedRects(); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + const settled = update.mock.calls.length; + stop(); + rect.y = 400; + observers.at(-1).callback([{ intersectionRatio: 1 }]); + + expect(update).toHaveBeenCalledTimes(settled); + vi.unstubAllGlobals(); + }); + + it('watches a clipping panel that its container is attached to later', () => { + const observed = []; + const notify = []; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback) { + notify.push(callback); + } + observe(element) { + observed.push(element); + } + disconnect() {} + } + ); + const { reference, floating } = stubbedRects(); + const panel = document.createElement('div'); + panel.style.overflow = 'hidden'; + let width = 300; + Object.defineProperty(panel, 'clientWidth', { get: () => width }); + document.body.append(panel); + const container = document.createElement('div'); + container.append(floating); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + panel.append(container); + window.dispatchEvent(new Event('resize')); + expect(observed).toContain(panel); + const settled = update.mock.calls.length; + + width = 100; + notify.forEach((callback) => callback([])); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + panel.remove(); + vi.unstubAllGlobals(); + }); + + it('follows a transition on the shadow host of a target', () => { + const frames = []; + vi.stubGlobal('requestAnimationFrame', (callback) => + frames.push(callback) + ); + vi.stubGlobal('cancelAnimationFrame', () => {}); + const { floating, rect } = stubbedRects(); + const host = document.createElement('div'); + document.body.append(host); + const reference = document.createElement('div'); + host.attachShadow({ mode: 'open' }).append(reference); + reference.getBoundingClientRect = () => ({ ...rect }); + let running = false; + document.getAnimations = () => + running ? [{ playState: 'running', effect: { target: host } }] : []; + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + running = true; + host.dispatchEvent(new Event('transitionrun', { bubbles: true })); + const settled = update.mock.calls.length; + + rect.x = 50; + frames.shift()(); + expect(update).toHaveBeenCalledTimes(settled + 1); + + running = false; + frames.shift()(); + stop(); + host.remove(); + delete document.getAnimations; + vi.unstubAllGlobals(); + }); + + it('follows a transition on the body', () => { + const frames = []; + vi.stubGlobal('requestAnimationFrame', (callback) => + frames.push(callback) + ); + vi.stubGlobal('cancelAnimationFrame', () => {}); + const { reference, floating, rect } = stubbedRects(); + document.body.append(reference); + let running = false; + document.getAnimations = () => + running + ? [{ playState: 'running', effect: { target: document.body } }] + : []; + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + running = true; + document.body.dispatchEvent(new Event('transitionrun')); + const settled = update.mock.calls.length; + + rect.x = 50; + frames.shift()(); + expect(update).toHaveBeenCalledTimes(settled + 1); + + running = false; + frames.shift()(); + stop(); + reference.remove(); + delete document.getAnimations; + vi.unstubAllGlobals(); + }); + + it('stops following an animation that only repaints', () => { + const frames = []; + vi.stubGlobal('requestAnimationFrame', (callback) => + frames.push(callback) + ); + vi.stubGlobal('cancelAnimationFrame', () => {}); + const { reference, floating } = stubbedRects(); + document.body.append(reference); + const animation = (keyframes, extra) => ({ + playState: 'running', + effect: { target: document.body, getKeyframes: () => keyframes }, + ...extra + }); + let animations = [ + animation([{ offset: 0, backgroundColor: '#fff', easing: 'ease' }]), + animation([], { transitionProperty: 'box-shadow' }) + ]; + document.getAnimations = () => animations; + + const stop = autoUpdate(reference, floating, vi.fn()); + document.body.dispatchEvent(new Event('animationstart')); + expect(frames, 'paint only').toEqual([]); + + animations = [animation([{ offset: 0, offsetDistance: '10%' }])]; + document.body.dispatchEvent(new Event('animationstart')); + expect(frames, 'moves along a path').toHaveLength(1); + + stop(); + reference.remove(); + delete document.getAnimations; + vi.unstubAllGlobals(); + }); + + it('follows an animation that was already running when it started', () => { + const frames = []; + vi.stubGlobal('requestAnimationFrame', (callback) => + frames.push(callback) + ); + vi.stubGlobal('cancelAnimationFrame', () => {}); + const { reference, floating, rect } = stubbedRects(); + const parent = document.createElement('div'); + parent.append(reference); + document.body.append(parent); + let running = true; + document.getAnimations = () => + running ? [{ playState: 'running', effect: { target: parent } }] : []; + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + const settled = update.mock.calls.length; + expect(frames, 'following from the start').toHaveLength(1); + + rect.x = 50; + frames.shift()(); + expect(update).toHaveBeenCalledTimes(settled + 1); + + running = false; + frames.shift()(); + stop(); + parent.remove(); + delete document.getAnimations; + vi.unstubAllGlobals(); + }); + + it('keeps checking a target that has no size yet', () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + disconnect() {} + } + ); + const { reference, floating, rect } = stubbedRects(); + rect.width = 0; + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + const settled = update.mock.calls.length; + + rect.width = 30; + vi.advanceTimersByTime(1000); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('wakes on a scroll anywhere in the document', async () => { + const { reference, floating, rect } = stubbedRects(); + const scroller = document.createElement('div'); + document.body.append(scroller); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + await nextFrames(2); + const settled = update.mock.calls.length; + + rect.y = 300; + scroller.dispatchEvent(new Event('scroll')); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + scroller.remove(); + }); + + it('wakes on a scroll in a framed target document', async () => { + // A stand-in for a framed element, which neither observer accepts. + vi.stubGlobal('ResizeObserver', undefined); + vi.stubGlobal('IntersectionObserver', undefined); + const { floating, rect } = stubbedRects(); + const targetWindow = new EventTarget(); + const reference = { + ownerDocument: Object.assign(new EventTarget(), { + defaultView: targetWindow, + documentElement: document.documentElement + }), + getBoundingClientRect: () => ({ ...rect }) + }; + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + await nextFrames(2); + const settled = update.mock.calls.length; + + rect.y = 500; + targetWindow.dispatchEvent(new Event('scroll')); + expect(update).toHaveBeenCalledTimes(settled + 1); + + stop(); + rect.y = 600; + targetWindow.dispatchEvent(new Event('scroll')); + expect(update).toHaveBeenCalledTimes(settled + 1); + vi.unstubAllGlobals(); + }); + + it('stops checking once cleaned up', async () => { + const { reference, floating, rect } = stubbedRects(); + const scroller = document.createElement('div'); + document.body.append(scroller); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + await nextFrames(2); + stop(); + const settled = update.mock.calls.length; + + rect.y = 400; + scroller.dispatchEvent(new Event('scroll')); + await nextFrames(3); + + expect(update).toHaveBeenCalledTimes(settled); + scroller.remove(); + }); + + it('stops on its own once the element leaves the page', async () => { + const { reference, floating, rect } = stubbedRects(); + document.body.append(floating); + const removeListener = vi.spyOn(window, 'removeEventListener'); + const update = vi.fn(); + + autoUpdate(reference, floating, update); + await nextFrames(2); + floating.remove(); + await nextFrames(2); + const settled = update.mock.calls.length; + + rect.y = 700; + window.dispatchEvent(new Event('scroll')); + await nextFrames(3); + + expect(update).toHaveBeenCalledTimes(settled); + expect(removeListener).toHaveBeenCalledWith( + 'scroll', + expect.any(Function), + true + ); + removeListener.mockRestore(); + }); + + it('waits for a container that attaches late', () => { + const { reference, floating, rect } = stubbedRects(); + floating.remove(); + const update = vi.fn(); + + const stop = autoUpdate(reference, floating, update); + window.dispatchEvent(new Event('scroll')); + document.body.append(floating); + window.dispatchEvent(new Event('scroll')); + const settled = update.mock.calls.length; + + rect.y = 800; + window.dispatchEvent(new Event('scroll')); + + expect(update).toHaveBeenCalledTimes(settled + 1); + stop(); + }); + + it('still positions once where requestAnimationFrame does not exist', () => { + const { reference, floating } = stubbedRects(); + const update = vi.fn(); + const original = window.requestAnimationFrame; + window.requestAnimationFrame = undefined; + + try { + const stop = autoUpdate(reference, floating, update); + expect(update).toHaveBeenCalledTimes(1); + stop(); + } finally { + window.requestAnimationFrame = original; + } + }); + }); +}); diff --git a/shepherd.js/test/verify/mutants.mjs b/shepherd.js/test/verify/mutants.mjs index 16404d65b..b6c484484 100644 --- a/shepherd.js/test/verify/mutants.mjs +++ b/shepherd.js/test/verify/mutants.mjs @@ -86,7 +86,13 @@ export const MUTANTS = { 'teardown-keeps-target-classes': { file: 'src/step.ts', from: ` this._updateStepTargetOnHide(); - this._originalTabIndexes.clear();`, - to: ' this._originalTabIndexes.clear();' + } + + /** + * Returns the tour for the step`, + to: ` } + + /** + * Returns the tour for the step` } }; diff --git a/shepherd.js/tour.schema.json b/shepherd.js/tour.schema.json new file mode 100644 index 000000000..5ac63aa23 --- /dev/null +++ b/shepherd.js/tour.schema.json @@ -0,0 +1,231 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://shepherdjs.dev/tour.schema.json", + "title": "Shepherd tour", + "description": "A Shepherd tour as plain JSON. Pass it to new Shepherd.Tour(). Function-valued options are not part of it.", + "type": "object", + "additionalProperties": false, + "required": ["steps"], + "properties": { + "id": { "type": "string" }, + "tourName": { "type": "string" }, + "classPrefix": { "type": "string" }, + "confirmCancel": { + "type": "boolean", + "description": "Ask with window.confirm before cancelling." + }, + "confirmCancelMessage": { "type": "string" }, + "exitOnEsc": { + "type": "boolean", + "description": "The Escape key cancels the tour. Defaults to true." + }, + "keyboardNavigation": { + "type": "boolean", + "description": "The left and right arrow keys move between steps. Defaults to true." + }, + "useModalOverlay": { + "type": "boolean", + "description": "Dim the page around the target." + }, + "defaultStepOptions": { + "$ref": "#/definitions/stepOptions", + "description": "Applied to every step. A step's own value replaces the default, except classes, which combine, and positionOptions, which combines key by key." + }, + "steps": { + "type": "array", + "items": { "$ref": "#/definitions/stepOptions" } + } + }, + "definitions": { + "stepOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "title": { + "type": "string", + "description": "Rendered as HTML." + }, + "text": { + "description": "The body. Rendered as HTML.", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "label": { + "type": "string", + "description": "The dialog's accessible name when it has no title." + }, + "attachTo": { + "type": "object", + "additionalProperties": false, + "required": ["element"], + "description": "What the step attaches to. Omit it to center the step.", + "properties": { + "element": { + "type": "string", + "description": "A CSS selector." + }, + "on": { + "description": "Where the step sits. Without it the step is centered.", + "enum": [ + "auto", + "auto-start", + "auto-end", + "top", + "top-start", + "top-end", + "bottom", + "bottom-start", + "bottom-end", + "right", + "right-start", + "right-end", + "left", + "left-start", + "left-end" + ] + } + } + }, + "advanceOn": { + "type": "object", + "additionalProperties": false, + "required": ["selector", "event"], + "description": "A page event that moves to the next step.", + "properties": { + "selector": { "type": "string" }, + "event": { "type": "string" } + } + }, + "arrow": { + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "additionalProperties": false, + "properties": { "padding": { "type": "number" } } + } + ] + }, + "buttons": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["action"], + "properties": { + "text": { "type": "string" }, + "label": { "type": "string" }, + "action": { "enum": ["back", "cancel", "complete", "next"] }, + "classes": { "type": "string" }, + "disabled": { "type": "boolean" }, + "secondary": { "type": "boolean" }, + "attrs": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "boolean"] + } + } + } + } + }, + "cancelIcon": { + "type": "object", + "additionalProperties": false, + "description": "The close icon in the header. Off unless enabled is true.", + "properties": { + "enabled": { "type": "boolean" }, + "label": { "type": "string" }, + "attrs": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "boolean"] + } + } + } + }, + "canClickTarget": { "type": "boolean" }, + "classes": { "type": "string" }, + "data": { "type": "object" }, + "extraHighlights": { + "type": "array", + "items": { "type": "string" } + }, + "highlightClass": { "type": "string" }, + "modalOverlayOpeningPadding": { "type": "number" }, + "modalOverlayOpeningRadius": { + "oneOf": [ + { "type": "number" }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "topLeft": { "type": "number" }, + "bottomLeft": { "type": "number" }, + "bottomRight": { "type": "number" }, + "topRight": { "type": "number" } + } + } + ] + }, + "modalOverlayOpeningXOffset": { "type": "number" }, + "modalOverlayOpeningYOffset": { "type": "number" }, + "positionOptions": { + "type": "object", + "additionalProperties": false, + "description": "How the step is placed against its target. Combines key by key with defaultStepOptions.", + "properties": { + "offset": { + "description": "Gap from the target in pixels, or { mainAxis, crossAxis }.", + "oneOf": [ + { "type": "number" }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "mainAxis": { "type": "number" }, + "crossAxis": { "type": "number" } + } + } + ] + }, + "padding": { + "type": "number", + "description": "Least distance from the viewport and clipping edges, used when flipping and shifting." + }, + "strategy": { "enum": ["absolute", "fixed"] }, + "flip": { + "type": "boolean", + "description": "false keeps attachTo.on even when the step overflows." + } + } + }, + "scrollTo": { + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "behavior": { "enum": ["auto", "instant", "smooth"] }, + "block": { "enum": ["start", "center", "end", "nearest"] }, + "inline": { "enum": ["start", "center", "end", "nearest"] } + } + } + ], + "description": "Scroll the target into view. Off by default. An object is passed to scrollIntoView." + }, + "skipMissingElement": { + "type": "boolean", + "description": "Skip the step when its target is not on the page." + }, + "waitForElement": { + "type": "number", + "description": "Milliseconds to wait for the target to appear." + } + } + } + } +} diff --git a/shepherd.js/tsconfig.json b/shepherd.js/tsconfig.json index 06628dc40..6b4b458fd 100644 --- a/shepherd.js/tsconfig.json +++ b/shepherd.js/tsconfig.json @@ -34,6 +34,7 @@ // generating code with `tsc` instead of Babel, and could thereby // result in broken code at runtime. "emitDeclarationOnly": true, + "stripInternal": true, "declaration": true, "declarationMap": true, "declarationDir": "dist",