diff --git a/README.md b/README.md index 8267a8d84..e6e4e4df9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,42 @@ Supported Browsers | --------- | --------- | --------- | --------- | | Edge| last 2 versions| last 2 versions| last 2 versions +## CSS Anchor Positioning + +Shepherd uses the modern CSS Anchor Positioning API for optimal performance and native browser support. This provides excellent positioning capabilities in browsers that support it: + +- **Chrome 125+**: Full native support +- **Safari 26+**: Full native support +- **Edge 125+**: Full native support +- **Firefox**: Support is coming but currently requires a polyfill + +### Adding Polyfill Support + +For broader browser compatibility (including Firefox), you can add the CSS Anchor Positioning polyfill: + +```html + +``` + +Or install via npm for bundled applications: + +```bash +npm install @oddbird/css-anchor-positioning +``` + +```javascript +// Add to your app initialization +if (!("anchorName" in document.documentElement.style)) { + import("@oddbird/css-anchor-positioning"); +} +``` + +The polyfill is approximately 40KB gzipped and provides comprehensive support for CSS anchor positioning in all modern browsers. + # Shepherd Shepherd makes it simple to create custom user on-boarding tours, trainings and announcements to drive user adoption. diff --git a/docs-src/src/content/docs/guides/install.mdx b/docs-src/src/content/docs/guides/install.mdx index 5d53aa106..cd3fd9070 100644 --- a/docs-src/src/content/docs/guides/install.mdx +++ b/docs-src/src/content/docs/guides/install.mdx @@ -37,6 +37,57 @@ Don't forget to add your styles import 'shepherd.js/dist/css/shepherd.css'; ``` +## Browser Compatibility + +Shepherd uses the modern CSS Anchor Positioning API for optimal performance and positioning. This provides excellent support in modern browsers: + +- **Chrome 125+**: Full native support +- **Safari 26+**: Full native support +- **Edge 125+**: Full native support +- **Firefox**: Support is coming but currently requires a polyfill + +### Adding Polyfill for Broader Support + +For applications that need to support browsers without native CSS anchor positioning (like Firefox), you can add the polyfill: + + + + ```html + + ``` + + + ```bash + npm install @oddbird/css-anchor-positioning + ``` + + ```javascript + // Add to your app initialization + if (!("anchorName" in document.documentElement.style)) { + import("@oddbird/css-anchor-positioning"); + } + ``` + + + ```bash + yarn add @oddbird/css-anchor-positioning + ``` + + ```javascript + // Add to your app initialization + if (!("anchorName" in document.documentElement.style)) { + import("@oddbird/css-anchor-positioning"); + } + ``` + + + +**Note**: The polyfill adds approximately 40KB gzipped to your bundle. Most modern browsers (Chrome 125+, Safari 26+) don't need it. + ### GitHub Releases Whenever we release a new version, the contents of the `dist` are uploaded diff --git a/docs-src/src/content/docs/guides/usage.md b/docs-src/src/content/docs/guides/usage.md index 6f9e1de73..7ca984268 100644 --- a/docs-src/src/content/docs/guides/usage.md +++ b/docs-src/src/content/docs/guides/usage.md @@ -230,7 +230,7 @@ If the element to highlight does not yet exist while instantiating tour steps, y - `id`: The string to use as the `id` for the step. If an id is not passed one will be generated. - `modalOverlayOpeningPadding`: An amount of padding to add around the modal overlay opening - `modalOverlayOpeningRadius`: An amount of border radius to add around the modal overlay opening. It can be either a number or an object with properties `{ topLeft, bottomLeft, bottomRight, topRight }` -- `floatingUIOptions`: Extra options to pass to [Floating UI](https://floating-ui.com/docs/getting-started) +- `anchorOptions`: Options for CSS anchor positioning including `placement`, `offset`, and `arrow` - `showOn`: A function that, when it returns true, will show the step. If it returns false, the step will be skipped. - `scrollTo`: Should the element be scrolled to when this step is shown? If true, uses the default `scrollIntoView`, if an object, passes that object as the params to `scrollIntoView` i.e. `{behavior: 'smooth', block: 'center'}` - `scrollToHandler`: A function that lets you override the default `scrollTo` behavior and define a custom action to do the scrolling, @@ -297,7 +297,7 @@ myTour.addStep({ }); ``` -You can also provide an options object, to configure the arrow's [padding](https://floating-ui.com/docs/arrow#padding). The padding is the closest the arrow will get to the edge of the step. +You can also provide an options object, to configure the arrow's padding. The padding is the closest the arrow will get to the edge of the step. ```js myTour.addStep({ diff --git a/docs-src/src/content/docs/recipes/cookbook.md b/docs-src/src/content/docs/recipes/cookbook.md index b43c646a4..d08210079 100644 --- a/docs-src/src/content/docs/recipes/cookbook.md +++ b/docs-src/src/content/docs/recipes/cookbook.md @@ -39,14 +39,13 @@ between them or if you need to fine tune the position according to some custom l For example: ```js -import { offset } from '@floating-ui/dom'; - const tour = new Shepherd.Tour({ steps: [ { ... - floatingUIOptions: { - middleware: [offset({ mainAxis: 0, crossAxis: 12 })] + anchorOptions: { + placement: 'bottom', + offset: 12 } ... } diff --git a/landing/src/pages/index.astro b/landing/src/pages/index.astro index efeb468e9..15d094747 100644 --- a/landing/src/pages/index.astro +++ b/landing/src/pages/index.astro @@ -120,7 +120,7 @@ import MainPage from '@layouts/MainPage.astro'; // 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 uses modern CSS anchor positioning to render dialogs for each tour "step".\n

\n \n

\n CSS anchor positioning ensures your steps are always positioned correctly relative to target elements.\n (Try resizing your browser to see what we mean.)\n

\n', attachTo: { element: '.hero-welcome', on: 'bottom' diff --git a/package.json b/package.json index 43be9555e..e9bf683de 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "devDependencies": { "@babel/core": "^7.28.4", "@babel/preset-env": "^7.28.3", + "@oddbird/css-anchor-positioning": "^0.7.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "autoprefixer": "^10.4.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e30d05095..a1fb904f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@babel/preset-env': specifier: ^7.28.3 version: 7.28.5(@babel/core@7.28.5) + '@oddbird/css-anchor-positioning': + specifier: ^0.7.0 + version: 0.7.0 '@typescript-eslint/eslint-plugin': specifier: ^7.18.0 version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) @@ -194,9 +197,6 @@ importers: shepherd.js: dependencies: - '@floating-ui/dom': - specifier: ^1.7.0 - version: 1.7.4 deepmerge-ts: specifier: ^7.1.5 version: 7.1.5 @@ -231,6 +231,9 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) + bundle-analyzer: + specifier: ^0.0.6 + version: 0.0.6 cssnano: specifier: ^7.1.1 version: 7.1.2(postcss@8.5.6) @@ -1830,6 +1833,9 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@oddbird/css-anchor-positioning@0.7.0': + resolution: {integrity: sha512-xpVtnzYSsRTzNoaHi5Hbyg2qsLwbmaHhq3lCoaTLmImEYJmdCASg1DcZVd3Qov0/PLGKoW0aOXpEQoUSInTFNg==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -2263,6 +2269,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/css-tree@2.3.11': + resolution: {integrity: sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2542,6 +2551,10 @@ packages: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -2696,6 +2709,9 @@ packages: resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} engines: {node: '>=0.10.0'} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} @@ -2835,6 +2851,10 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2877,6 +2897,14 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-analyzer@0.0.6: + resolution: {integrity: sha512-MXRDG8uFjrz1h716wbahzhoaS3ImVQFBs1F2XNuDjmjKReMkSNO7XaxwUe0jtKoWO6Pt98E2rlgyebGlEA4NnA==} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cacache@15.3.0: resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} engines: {node: '>= 10'} @@ -3175,12 +3203,27 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + cookie@1.0.2: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} @@ -3324,6 +3367,14 @@ packages: de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -3399,6 +3450,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3406,6 +3461,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3507,6 +3566,9 @@ packages: ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -3533,6 +3595,14 @@ packages: emojilib@2.4.0: resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -3602,6 +3672,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -3710,6 +3783,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + event-stream@3.3.4: resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} @@ -3745,6 +3822,10 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + expressive-code@0.40.2: resolution: {integrity: sha512-1zIda2rB0qiDZACawzw2rbdBQiWHBT56uBctS+ezFe5XMAaFaHLnnSYND/Kd+dVzO9HfCXRDpzH3d+3fvOWRcw==} @@ -3845,6 +3926,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + find-replace@5.0.2: resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} engines: {node: '>=14'} @@ -3903,9 +3988,17 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} @@ -4199,6 +4292,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@4.0.1: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} @@ -4250,6 +4347,10 @@ packages: i18next@23.16.8: resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -4350,6 +4451,10 @@ packages: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -4515,6 +4620,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@1.1.0: + resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==} + engines: {node: '>=4'} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -4921,6 +5030,13 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4928,6 +5044,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -5157,6 +5277,9 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5175,9 +5298,18 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@0.6.4: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} @@ -5342,6 +5474,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5366,6 +5502,10 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + opn@5.5.0: + resolution: {integrity: sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA==} + engines: {node: '>=4'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5487,6 +5627,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} @@ -5520,6 +5664,9 @@ packages: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@6.1.0: resolution: {integrity: sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==} @@ -6115,6 +6262,10 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@1.0.0: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} @@ -6137,6 +6288,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -6153,6 +6308,14 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -6517,9 +6680,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -6531,6 +6702,9 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -6727,6 +6901,10 @@ packages: engines: {node: '>=16'} hasBin: true + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -6992,6 +7170,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -7069,6 +7251,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + typedoc-plugin-markdown@4.2.9: resolution: {integrity: sha512-Wqmx+7ezKFgtTklEq/iUhQ5uFeBDhAT6wiS2na9cFLidIpl9jpDHJy/COYh8jUZXgIRIZVQ/bPNjyrnPFoDwzg==} engines: {node: '>= 18'} @@ -7224,6 +7410,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unstorage@1.17.2: resolution: {integrity: sha512-cKEsD6iBWJgOMJ6vW1ID/SYuqNf8oN4yqRk8OYqaVQ3nnkJXOT1PSpaMh2QfzLs78UN5kSNRD2c/mgjT8tX7+w==} peerDependencies: @@ -7305,6 +7495,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -7320,6 +7514,10 @@ packages: resolution: {integrity: sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==} engines: {node: ^18.17.0 || >=20.5.0} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verror@1.10.0: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} @@ -9517,6 +9715,13 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 + '@oddbird/css-anchor-positioning@0.7.0': + dependencies: + '@floating-ui/dom': 1.7.4 + '@types/css-tree': 2.3.11 + css-tree: 3.1.0 + nanoid: 5.1.6 + '@oslojs/encoding@1.1.0': {} '@pagefind/darwin-arm64@1.4.0': @@ -9929,6 +10134,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/css-tree@2.3.11': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -10287,6 +10494,11 @@ snapshots: abbrev@3.0.1: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -10435,6 +10647,8 @@ snapshots: array-find-index@1.0.2: {} + array-flatten@1.1.1: {} + array-iterate@2.0.1: {} array-union@2.1.0: {} @@ -10663,6 +10877,23 @@ snapshots: bluebird@3.7.2: {} + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} boxen@5.1.2: @@ -10725,6 +10956,16 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-analyzer@0.0.6: + dependencies: + express: 4.21.2 + opn: 5.5.0 + source-map: 0.7.6 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + cacache@15.3.0: dependencies: '@npmcli/fs': 1.1.1 @@ -11039,10 +11280,20 @@ snapshots: console-control-strings@1.1.0: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + cookie@1.0.2: {} core-js-compat@3.46.0: @@ -11279,6 +11530,10 @@ snapshots: de-indent@1.0.2: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@3.2.7(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -11361,10 +11616,14 @@ snapshots: delegates@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} + destroy@1.2.0: {} + detect-libc@2.1.2: {} deterministic-object-hash@2.0.2: @@ -11461,6 +11720,8 @@ snapshots: jsbn: 0.1.1 safer-buffer: 2.1.2 + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -11482,6 +11743,10 @@ snapshots: emojilib@2.4.0: {} + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -11582,6 +11847,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -11734,6 +12001,8 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + event-stream@3.3.4: dependencies: duplexer: 0.1.2 @@ -11797,6 +12066,42 @@ snapshots: exponential-backoff@3.1.3: {} + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + expressive-code@0.40.2: dependencies: '@expressive-code/core': 0.40.2 @@ -11889,6 +12194,18 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + find-replace@5.0.2: {} find-up@4.1.0: @@ -11951,8 +12268,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + fraction.js@4.3.7: {} + fresh@0.5.2: {} + from@0.1.7: {} fs-extra@11.3.2: @@ -12413,6 +12734,14 @@ snapshots: http-cache-semantics@4.2.0: {} + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + http-proxy-agent@4.0.1: dependencies: '@tootallnate/once': 1.1.2 @@ -12497,6 +12826,10 @@ snapshots: dependencies: '@babel/runtime': 7.28.4 + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -12579,6 +12912,8 @@ snapshots: ip-address@10.0.1: {} + ipaddr.js@1.9.1: {} + iron-webcrypto@1.2.1: {} is-alphabetical@2.0.1: {} @@ -12716,6 +13051,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@1.1.0: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -13300,10 +13637,16 @@ snapshots: mdurl@2.0.0: {} + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} + methods@1.1.2: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -13698,6 +14041,8 @@ snapshots: mrmime@2.0.1: {} + ms@2.0.0: {} + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -13712,8 +14057,12 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + natural-compare@1.4.0: {} + negotiator@0.6.3: {} + negotiator@0.6.4: {} neotraverse@0.6.18: {} @@ -13898,6 +14247,10 @@ snapshots: ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -13928,6 +14281,10 @@ snapshots: opener@1.5.2: {} + opn@5.5.0: + dependencies: + is-wsl: 1.1.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -14080,6 +14437,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + pascal-case@3.1.2: dependencies: no-case: 3.0.4 @@ -14107,6 +14466,8 @@ snapshots: lru-cache: 11.2.2 minipass: 7.1.2 + path-to-regexp@0.1.12: {} + path-to-regexp@6.1.0: {} path-to-regexp@6.3.0: {} @@ -14624,6 +14985,11 @@ snapshots: proto-list@1.2.4: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.0.0: {} proxy-from-env@1.1.0: {} @@ -14641,6 +15007,10 @@ snapshots: punycode@2.3.1: {} + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -14655,6 +15025,15 @@ snapshots: dependencies: safe-buffer: 5.2.1 + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -15175,10 +15554,37 @@ snapshots: semver@7.7.3: {} + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} set-function-length@1.2.2: @@ -15197,6 +15603,8 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -15468,6 +15876,8 @@ snapshots: transitivePeerDependencies: - supports-color + statuses@2.0.1: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -15754,6 +16164,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + totalist@3.0.1: {} tough-cookie@5.1.2: @@ -15810,6 +16222,11 @@ snapshots: type-fest@4.41.0: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + typedoc-plugin-markdown@4.2.9(typedoc@0.26.11(typescript@5.9.3)): dependencies: typedoc: 0.26.11(typescript@5.9.3) @@ -15968,6 +16385,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + unstorage@1.17.2(@vercel/functions@2.2.13): dependencies: anymatch: 3.1.3 @@ -15997,6 +16416,8 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + uuid@8.3.2: {} validate-npm-package-license@3.0.4: @@ -16008,6 +16429,8 @@ snapshots: validate-npm-package-name@6.0.2: {} + vary@1.1.2: {} + verror@1.10.0: dependencies: assert-plus: 1.0.0 diff --git a/shepherd.js/dev/index.html b/shepherd.js/dev/index.html index 4eff10ec9..1ac1ab13a 100644 --- a/shepherd.js/dev/index.html +++ b/shepherd.js/dev/index.html @@ -132,14 +132,13 @@

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". -

- -

- Among many things, Floating UI makes sure your steps never end up off screen or cropped by an overflow. - (Try resizing your browser to see what we mean.) + Shepherd is a JavaScript library for guiding users through your app. + It uses modern CSS anchor positioning to render dialogs for each tour "step". +

+ +

+ CSS anchor positioning ensures your steps are always positioned correctly relative to target elements. + (Try resizing your browser to see what we mean.)

`, attachTo: { element: '.hero-welcome', diff --git a/shepherd.js/dummy/index.html b/shepherd.js/dummy/index.html index 3a28c2235..86b8d36e1 100644 --- a/shepherd.js/dummy/index.html +++ b/shepherd.js/dummy/index.html @@ -139,14 +139,13 @@

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". -

- -

- Among many things, Floating UI makes sure your steps never end up off screen or cropped by an overflow. - (Try resizing your browser to see what we mean.) + Shepherd is a JavaScript library for guiding users through your app. + It uses modern CSS anchor positioning to render dialogs for each tour "step". +

+ +

+ CSS anchor positioning ensures your steps are always positioned correctly relative to target elements. + (Try resizing your browser to see what we mean.)

`, attachTo: { element: '.hero-welcome', diff --git a/shepherd.js/package.json b/shepherd.js/package.json index d08c58b8b..fa5879235 100644 --- a/shepherd.js/package.json +++ b/shepherd.js/package.json @@ -47,7 +47,6 @@ "watch": "pnpm clean && rollup -c --environment DEVELOPMENT --watch" }, "dependencies": { - "@floating-ui/dom": "^1.7.0", "deepmerge-ts": "^7.1.5" }, "devDependencies": { @@ -61,6 +60,7 @@ "@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-terser": "^0.4.4", "autoprefixer": "^10.4.21", + "bundle-analyzer": "^0.0.6", "cssnano": "^7.1.1", "dts-bundle-generator": "^9.5.1", "eslint-plugin-svelte": "^2.46.1", diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts index 765694fe3..e2071c40c 100644 --- a/shepherd.js/src/step.ts +++ b/shepherd.js/src/step.ts @@ -21,7 +21,7 @@ import { } from './utils/floating-ui.ts'; import ShepherdElement from './components/shepherd-element.svelte'; import { type Tour } from './tour.ts'; -import type { ComputePositionConfig } from '@floating-ui/dom'; +import type { AnchorPositionConfig } from './utils/anchor-positioning.ts'; import { createClassComponent } from 'svelte/legacy'; export type StepText = @@ -151,9 +151,9 @@ export interface StepOptions { modalOverlayOpeningYOffset?: number; /** - * Extra [options to pass to FloatingUI]{@link https://floating-ui.com/docs/tutorial/} + * Options for CSS Anchor Positioning API */ - floatingUIOptions?: ComputePositionConfig; + anchorOptions?: AnchorPositionConfig; /** * Should the element be scrolled to when this step is shown? @@ -358,7 +358,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 clean up the anchor positioning for the step. * Triggers `destroy` event */ destroy() { @@ -587,7 +587,7 @@ export class Step extends Evented { } /** - * Create the element and set up the FloatingUI instance + * Create the element and set up the CSS anchor positioning * @private */ _setupElements() { @@ -608,7 +608,7 @@ export class Step extends Evented { /** * Triggers `before-show`, generates the tooltip DOM content, - * sets up a FloatingUI instance for the tooltip, then triggers `show`. + * sets up CSS anchor positioning for the tooltip, then triggers `show`. * @private */ _show() { diff --git a/shepherd.js/src/utils/anchor-positioning.ts b/shepherd.js/src/utils/anchor-positioning.ts new file mode 100644 index 000000000..4726c6485 --- /dev/null +++ b/shepherd.js/src/utils/anchor-positioning.ts @@ -0,0 +1,413 @@ +import type { Step, StepOptionsAttachTo } from '../step.ts'; +import { shouldCenterStep } from './general.ts'; +import { isHTMLElement } from './type-check.ts'; + +// Extend CSSStyleDeclaration to include CSS Anchor Positioning properties +declare global { + interface CSSStyleDeclaration { + anchorName: string; + positionAnchor: string; + positionArea: string; + positionTryOptions: string; + } +} + +export type AnchorPlacement = + | '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'; + +export interface AnchorPositionConfig { + placement: AnchorPlacement; + offset?: number; + arrow?: boolean | { padding?: number }; +} + +/** + * Sets up tooltip positioning using CSS Anchor Positioning API + */ +export function setupAnchorTooltip(step: Step): AnchorPositionConfig { + if (step.cleanup) { + step.cleanup(); + } + + const attachToOptions = step._getResolvedAttachToOptions(); + const shouldCenter = shouldCenterStep(attachToOptions); + + if (shouldCenter) { + // For centered steps, use CSS transform positioning + setupCenteredPosition(step); + return { + placement: 'bottom', + offset: 8, + arrow: step.options.arrow || false + }; // Default value for centered + } + + const config = getAnchorPositionConfig(attachToOptions, step); + setupAnchorPosition(step, attachToOptions, config); + + step.target = attachToOptions.element as HTMLElement; + return config; +} + +/** + * Sets up centered positioning without anchors + */ +function setupCenteredPosition(step: Step) { + if (!step.el) return; + + // @ts-expect-error TODO: fix this type error when we type Svelte + const content = step.shepherdElementComponent.getElement(); + content.classList.add('shepherd-centered'); + + Object.assign(step.el.style, { + position: 'fixed', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + anchorName: 'none' + }); +} + +/** + * Sets up CSS anchor positioning + */ +function setupAnchorPosition( + step: Step, + attachToOptions: StepOptionsAttachTo, + config: AnchorPositionConfig +) { + if (!step.el || !attachToOptions.element) return; + + const anchorElement = attachToOptions.element as HTMLElement; + const tooltipElement = step.el as HTMLElement; + + // Generate unique anchor name + const anchorName = `--shepherd-anchor-${step.id || Math.random().toString(36).substr(2, 9)}`; + + // Set anchor name on the target element + anchorElement.style.anchorName = anchorName; + + // Apply positioning to tooltip + setupTooltipAnchorStyles(tooltipElement, anchorName, config); + + // Setup arrow positioning if needed + if (config.arrow) { + setupArrowPosition(step, config.placement); + } + + // Set data attribute for CSS styling + tooltipElement.dataset['anchorPlacement'] = config.placement; +} + +/** + * Applies CSS anchor positioning styles to tooltip + */ +function setupTooltipAnchorStyles( + tooltipElement: HTMLElement, + anchorName: string, + config: AnchorPositionConfig +) { + const styles: Partial = { + position: 'fixed', + positionAnchor: anchorName + }; + + // Apply positioning based on placement + switch (config.placement) { + case 'top': + styles.positionArea = 'block-start span-inline'; + styles.marginBottom = config.offset ? `${config.offset}px` : '8px'; + break; + case 'top-start': + styles.positionArea = 'block-start inline-start'; + styles.marginBottom = config.offset ? `${config.offset}px` : '8px'; + break; + case 'top-end': + styles.positionArea = 'block-start inline-end'; + styles.marginBottom = config.offset ? `${config.offset}px` : '8px'; + break; + case 'bottom': + styles.positionArea = 'block-end span-inline'; + styles.marginTop = config.offset ? `${config.offset}px` : '8px'; + break; + case 'bottom-start': + styles.positionArea = 'block-end inline-start'; + styles.marginTop = config.offset ? `${config.offset}px` : '8px'; + break; + case 'bottom-end': + styles.positionArea = 'block-end inline-end'; + styles.marginTop = config.offset ? `${config.offset}px` : '8px'; + break; + case 'left': + styles.positionArea = 'inline-start span-block'; + styles.marginRight = config.offset ? `${config.offset}px` : '8px'; + break; + case 'left-start': + styles.positionArea = 'block-start inline-start'; + styles.marginRight = config.offset ? `${config.offset}px` : '8px'; + break; + case 'left-end': + styles.positionArea = 'block-end inline-start'; + styles.marginRight = config.offset ? `${config.offset}px` : '8px'; + break; + case 'right': + styles.positionArea = 'inline-end span-block'; + styles.marginLeft = config.offset ? `${config.offset}px` : '8px'; + break; + case 'right-start': + styles.positionArea = 'block-start inline-end'; + styles.marginLeft = config.offset ? `${config.offset}px` : '8px'; + break; + case 'right-end': + styles.positionArea = 'block-end inline-end'; + styles.marginLeft = config.offset ? `${config.offset}px` : '8px'; + break; + case 'auto': + case 'auto-start': + case 'auto-end': + // For auto placements, use @position-try fallbacks + setupAutoPlacement(tooltipElement, anchorName, config); + return; + } + + Object.assign(tooltipElement.style, styles); +} + +/** + * Sets up auto-placement with CSS @position-try fallbacks + */ +function setupAutoPlacement( + tooltipElement: HTMLElement, + anchorName: string, + config: AnchorPositionConfig +) { + const tryOptionNames = [ + `--shepherd-try-top-${config.placement}`, + `--shepherd-try-bottom-${config.placement}`, + `--shepherd-try-left-${config.placement}`, + `--shepherd-try-right-${config.placement}` + ]; + + // Create @position-try rules dynamically + const styleSheet = getOrCreateStyleSheet(); + + // Clear existing try options for this step + clearExistingTryOptions(styleSheet, tryOptionNames); + + // Add new try options + addPositionTryOptions(styleSheet, tryOptionNames, config); + + // Apply to tooltip + Object.assign(tooltipElement.style, { + position: 'fixed', + positionAnchor: anchorName, + positionArea: 'block-end span-inline', // Default: bottom + marginTop: config.offset ? `${config.offset}px` : '8px', + positionTryOptions: tryOptionNames.join(', ') + }); +} + +/** + * Sets up arrow positioning using JavaScript calculations + */ +function setupArrowPosition(step: Step, placement: AnchorPlacement) { + if (!step.el) return; + + const arrowEl = step.el.querySelector('.shepherd-arrow'); + if (!isHTMLElement(arrowEl)) return; + + // Calculate arrow position based on placement + const arrowSide = getArrowSide(placement); + const arrowStyles = getArrowStyles(arrowSide); + + Object.assign(arrowEl.style, arrowStyles); +} + +/** + * Determines which side of the tooltip the arrow should be on + */ +function getArrowSide( + placement: AnchorPlacement +): 'top' | 'bottom' | 'left' | 'right' { + if (placement.startsWith('top')) return 'bottom'; + if (placement.startsWith('bottom')) return 'top'; + if (placement.startsWith('left')) return 'right'; + if (placement.startsWith('right')) return 'left'; + if (placement.startsWith('auto')) return 'top'; // Default for auto + return 'bottom'; // Default fallback +} + +/** + * Gets CSS styles for arrow based on its side + */ +function getArrowStyles( + side: 'top' | 'bottom' | 'left' | 'right' +): Partial { + const baseStyles: Partial = { + position: 'absolute', + width: '16px', + height: '16px', + zIndex: '-1' + }; + + switch (side) { + case 'top': + return { + ...baseStyles, + top: '-8px', + left: '50%', + transform: 'translateX(-50%)' + }; + case 'bottom': + return { + ...baseStyles, + bottom: '-8px', + left: '50%', + transform: 'translateX(-50%)' + }; + case 'left': + return { + ...baseStyles, + left: '-8px', + top: '50%', + transform: 'translateY(-50%)' + }; + case 'right': + return { + ...baseStyles, + right: '-8px', + top: '50%', + transform: 'translateY(-50%)' + }; + } +} + +/** + * Gets the anchor position configuration from step options + */ +function getAnchorPositionConfig( + attachToOptions: StepOptionsAttachTo, + step: Step +): AnchorPositionConfig { + const placement = (attachToOptions.on as AnchorPlacement) || 'bottom'; + + // Merge with step-specific anchorOptions + const stepAnchorOptions = (step.options.anchorOptions || + {}) as Partial; + + return { + placement: stepAnchorOptions.placement || placement, + offset: stepAnchorOptions.offset || 8, + arrow: + stepAnchorOptions.arrow !== undefined + ? stepAnchorOptions.arrow + : step.options.arrow || false + }; +} + +/** + * Utility functions for dynamic CSS @position-try rules + */ +let shepherdStyleSheet: CSSStyleSheet | null = null; + +function getOrCreateStyleSheet(): CSSStyleSheet { + if (shepherdStyleSheet) return shepherdStyleSheet; + + const style = document.createElement('style'); + style.id = 'shepherd-anchor-positioning'; + document.head.appendChild(style); + shepherdStyleSheet = style.sheet as CSSStyleSheet; + + return shepherdStyleSheet; +} + +function clearExistingTryOptions( + _styleSheet: CSSStyleSheet, + _tryOptionNames: string[] +) { + // In a real implementation, you'd need to track and remove existing rules + // For now, we'll just add new ones (CSS will use the last defined rule) +} + +function addPositionTryOptions( + styleSheet: CSSStyleSheet, + tryOptionNames: string[], + config: AnchorPositionConfig +) { + const tryConfigs = [ + { + name: tryOptionNames[0], + area: 'block-start span-inline', + margin: 'marginBottom' + }, + { + name: tryOptionNames[1], + area: 'block-end span-inline', + margin: 'marginTop' + }, + { + name: tryOptionNames[2], + area: 'inline-start span-block', + margin: 'marginRight' + }, + { + name: tryOptionNames[3], + area: 'inline-end span-block', + margin: 'marginLeft' + } + ]; + + tryConfigs.forEach(({ name, area, margin }) => { + const rule = ` + @position-try ${name} { + position-area: ${area}; + ${margin}: ${config.offset || 8}px; + } + `; + + try { + styleSheet.insertRule(rule, styleSheet.cssRules.length); + } catch (e) { + // Fallback for browsers that don't support @position-try yet + console.warn('CSS @position-try not supported:', e); + } + }); +} + +/** + * Cleanup function for anchor positioning + */ +export function destroyAnchorTooltip(step: Step) { + if (!step.el || !step.target) return; + + // Remove anchor name from target + const target = step.target as HTMLElement; + target.style.anchorName = ''; + + // Reset tooltip styles + const tooltip = step.el as HTMLElement; + tooltip.style.positionAnchor = ''; + tooltip.style.positionArea = ''; + tooltip.style.positionTryOptions = ''; + tooltip.style.marginTop = ''; + tooltip.style.marginBottom = ''; + tooltip.style.marginLeft = ''; + tooltip.style.marginRight = ''; + + // Clear dataset + delete tooltip.dataset['anchorPlacement']; +} diff --git a/shepherd.js/src/utils/floating-ui.ts b/shepherd.js/src/utils/floating-ui.ts index edeaa0264..d01857c72 100644 --- a/shepherd.js/src/utils/floating-ui.ts +++ b/shepherd.js/src/utils/floating-ui.ts @@ -1,57 +1,19 @@ import { deepmerge } from 'deepmerge-ts'; -import { shouldCenterStep } from './general.ts'; import { - autoUpdate, - arrow, - computePosition, - flip, - autoPlacement, - limitShift, - shift, - type ComputePositionConfig, - type MiddlewareData, - type Placement, - type Alignment -} from '@floating-ui/dom'; + setupAnchorTooltip, + destroyAnchorTooltip, + type AnchorPositionConfig, + type AnchorPlacement +} from './anchor-positioning.ts'; import type { Step, StepOptions, StepOptionsAttachTo } from '../step.ts'; -import { isHTMLElement } from './type-check.ts'; /** - * Determines options for the tooltip and initializes event listeners. + * Determines options for the tooltip and initializes positioning using CSS Anchor API. * * @param step The step instance */ -export function setupTooltip(step: Step): ComputePositionConfig { - if (step.cleanup) { - step.cleanup(); - } - - const attachToOptions = step._getResolvedAttachToOptions(); - - let target = attachToOptions.element as HTMLElement; - const floatingUIOptions = getFloatingUIOptions(attachToOptions, step); - const shouldCenter = shouldCenterStep(attachToOptions); - - if (shouldCenter) { - target = document.body; - // @ts-expect-error TODO: fix this type error when we type Svelte - const content = step.shepherdElementComponent.getElement(); - content.classList.add('shepherd-centered'); - } - - step.cleanup = autoUpdate(target, step.el as HTMLElement, () => { - // The element might have already been removed by the end of the tour. - if (!step.el) { - step.cleanup?.(); - return; - } - - setPosition(target, step, floatingUIOptions, shouldCenter); - }); - - step.target = attachToOptions.element as HTMLElement; - - return floatingUIOptions; +export function setupTooltip(step: Step): AnchorPositionConfig { + return setupAnchorTooltip(step); } /** @@ -60,17 +22,37 @@ export function setupTooltip(step: Step): ComputePositionConfig { * @param tourOptions - The default tour options. * @param options - Step specific options. * - * @return {floatingUIOptions: FloatingUIOptions} + * @return {anchorOptions: AnchorPositionConfig} */ export function mergeTooltipConfig( tourOptions: StepOptions, options: StepOptions -): { floatingUIOptions: ComputePositionConfig } { +): { anchorOptions: AnchorPositionConfig } { + // For CSS Anchor API, we mainly need to merge placement and arrow options + const mergedOptions = deepmerge(tourOptions || {}, options || {}); + + // Extract anchor-relevant options + const attachToOptions = options.attachTo || tourOptions.attachTo; + const placement = attachToOptions?.on || 'bottom'; + const arrow = mergedOptions.arrow || false; + + // Merge existing anchorOptions from tour and step options + const tourAnchorOptions = (tourOptions?.anchorOptions || + {}) as Partial; + const stepAnchorOptions = (options?.anchorOptions || + {}) as Partial; + const mergedAnchorOptions = deepmerge(tourAnchorOptions, stepAnchorOptions); + return { - floatingUIOptions: deepmerge( - tourOptions.floatingUIOptions || {}, - options.floatingUIOptions || {} - ) + anchorOptions: { + placement: + mergedAnchorOptions.placement || (placement as AnchorPlacement), + offset: mergedAnchorOptions.offset || 8, + arrow: + mergedAnchorOptions.arrow !== undefined + ? mergedAnchorOptions.arrow + : arrow + } }; } @@ -80,161 +62,30 @@ export function mergeTooltipConfig( * @param step */ export function destroyTooltip(step: Step) { - if (step.cleanup) { - step.cleanup(); - } - - step.cleanup = null; -} - -function setPosition( - target: HTMLElement, - step: Step, - floatingUIOptions: ComputePositionConfig, - shouldCenter: boolean -) { - return ( - computePosition(target, step.el as HTMLElement, floatingUIOptions) - .then(floatingUIposition(step, shouldCenter)) - // 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 }); - } - }) - ); -} - -function floatingUIposition(step: Step, shouldCenter: boolean) { - return ({ - x, - y, - placement, - middlewareData - }: { - x: number; - y: number; - placement: Placement; - middlewareData: MiddlewareData; - }) => { - if (!step.el) { - return step; - } - - if (shouldCenter) { - Object.assign(step.el.style, { - position: 'fixed', - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)' - }); - } else { - Object.assign(step.el.style, { - position: 'absolute', - left: `${x}px`, - top: `${y}px` - }); - } - - step.el.dataset['popperPlacement'] = placement; - - placeArrow(step.el, middlewareData); - - return step; - }; + destroyAnchorTooltip(step); } -function placeArrow(el: HTMLElement, middlewareData: MiddlewareData) { - const arrowEl = el.querySelector('.shepherd-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` : '' - }); - } -} +// Legacy compatibility - these functions are no longer needed with CSS Anchor API +// but we keep them for API compatibility during transition /** - * Gets the `Floating UI` options from a set of base `attachTo` options + * Gets the anchor position options from a set of base `attachTo` options * @param attachToOptions * @param step The step instance * @private */ -export function getFloatingUIOptions( +export function getAnchorOptions( attachToOptions: StepOptionsAttachTo, step: Step -): ComputePositionConfig { - const options: ComputePositionConfig = { - strategy: 'absolute' - }; - - 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 (arrowEl) { - const arrowOptions = - typeof step.options.arrow === 'object' - ? step.options.arrow - : { padding: 4 }; - - options.middleware.push( - arrow({ - element: arrowEl, - padding: hasEdgeAlignment ? arrowOptions.padding : 0 - }) - ); - } +): AnchorPositionConfig { + const placement = attachToOptions.on || 'bottom'; - if (!hasAutoPlacement) options.placement = attachToOptions.on as Placement; - } - - return deepmerge(options, step.options.floatingUIOptions || {}); + return { + placement: placement as AnchorPlacement, + offset: 8, + arrow: step.options.arrow || false + }; } -function addArrow(step: Step) { - if (step.options.arrow && step.el) { - return step.el.querySelector('.shepherd-arrow'); - } - - return false; -} +// Legacy alias for backwards compatibility +export const getFloatingUIOptions = getAnchorOptions; diff --git a/test/cypress/dummy/index.html b/test/cypress/dummy/index.html index 4b3bde146..60705e4e3 100644 --- a/test/cypress/dummy/index.html +++ b/test/cypress/dummy/index.html @@ -129,6 +129,14 @@

Example

+ + + diff --git a/test/cypress/examples/css/with-css-import.html b/test/cypress/examples/css/with-css-import.html index 1ce1abea9..7d4022bcf 100644 --- a/test/cypress/examples/css/with-css-import.html +++ b/test/cypress/examples/css/with-css-import.html @@ -5,6 +5,11 @@ href="../../node_modules/shepherd.js/dist/css/shepherd.css" /> diff --git a/test/cypress/examples/destroying-elements.html b/test/cypress/examples/destroying-elements.html index 7fb0b2f21..ed5fb1725 100644 --- a/test/cypress/examples/destroying-elements.html +++ b/test/cypress/examples/destroying-elements.html @@ -5,6 +5,11 @@ href="../node_modules/shepherd.js/dist/css/shepherd.css" /> diff --git a/test/cypress/utils/default-steps.js b/test/cypress/utils/default-steps.js index 7fe28298f..86fb7811d 100644 --- a/test/cypress/utils/default-steps.js +++ b/test/cypress/utils/default-steps.js @@ -3,14 +3,13 @@ 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". -

- -

- Among many things, Floating UI makes sure your steps never end up off screen or cropped by an overflow. - (Try resizing your browser to see what we mean.) + Shepherd is a JavaScript library for guiding users through your app. + It uses modern CSS anchor positioning to render dialogs for each tour "step". +

+ +

+ CSS anchor positioning ensures your steps are always positioned correctly relative to target elements. + (Try resizing your browser to see what we mean.)

`, attachTo: { element: '.hero-welcome', diff --git a/test/unit/package.json b/test/unit/package.json index a3884d03f..099392732 100644 --- a/test/unit/package.json +++ b/test/unit/package.json @@ -16,7 +16,6 @@ "view-coverage": "http-server -p 9003 ./coverage/lcov-report -o" }, "devDependencies": { - "@floating-ui/dom": "^1.6.10", "@sveltejs/vite-plugin-svelte": "^4.0.4", "@testing-library/jest-dom": "^6.4.8", "@testing-library/svelte": "^5.2.8", diff --git a/test/unit/setupTests.js b/test/unit/setupTests.js index 8ed06ff60..5299f3358 100644 --- a/test/unit/setupTests.js +++ b/test/unit/setupTests.js @@ -1,5 +1,21 @@ import { vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; +import polyfill from '@oddbird/css-anchor-positioning/fn'; + +// Apply CSS Anchor Positioning Polyfill +let polyfillApplied = false; + +export async function setupAnchorPolyfill() { + if (!polyfillApplied) { + await polyfill({ + elements: undefined, + excludeInlineStyles: false, + roots: [document], + useAnimationFrame: false, + }); + polyfillApplied = true; + } +} // Configure Svelte to force client-side rendering vi.doMock('svelte', async () => { @@ -20,11 +36,19 @@ Object.defineProperty(globalThis, 'IS_BROWSER', { writable: false }); -// Console errors are used for user information, do not display them during -// tests. +// Console errors and warnings are used for user information, do not display them during +// tests. We also silence CSS @position-try warnings since they're expected in the test environment. +const originalConsole = global.console; global.console = { ...console, - error: vi.fn() + error: vi.fn(), + warn: vi.fn((message, ...args) => { + // Suppress CSS @position-try warnings in tests since they're expected + if (typeof message === 'string' && message.includes('@position-try')) { + return; + } + originalConsole.warn(message, ...args); + }) }; global.sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -45,3 +69,99 @@ if (typeof window !== 'undefined') { })) }); } + +// CSS Anchor Positioning property mocks +if (typeof window !== 'undefined' && typeof CSSStyleDeclaration !== 'undefined') { + // Mock CSS anchor positioning properties + Object.defineProperty(CSSStyleDeclaration.prototype, 'anchorName', { + get: function() { return this._anchorName || ''; }, + set: function(value) { this._anchorName = value; }, + enumerable: true, + configurable: true + }); + + Object.defineProperty(CSSStyleDeclaration.prototype, 'positionAnchor', { + get: function() { return this._positionAnchor || ''; }, + set: function(value) { this._positionAnchor = value; }, + enumerable: true, + configurable: true + }); + + Object.defineProperty(CSSStyleDeclaration.prototype, 'positionArea', { + get: function() { return this._positionArea || ''; }, + set: function(value) { this._positionArea = value; }, + enumerable: true, + configurable: true + }); + + Object.defineProperty(CSSStyleDeclaration.prototype, 'positionTryOptions', { + get: function() { return this._positionTryOptions || ''; }, + set: function(value) { this._positionTryOptions = value; }, + enumerable: true, + configurable: true + }); + + // Mock CSS.supports for @position-try + if (typeof CSS !== 'undefined' && CSS.supports) { + const originalSupports = CSS.supports; + CSS.supports = function(property, value) { + // Mock support for CSS anchor positioning properties + if (property === '@position-try' || + property.includes('position-try') || + property.includes('anchor-name') || + property.includes('position-anchor') || + property.includes('position-area')) { + return true; + } + return originalSupports.call(this, property, value); + }; + } + + // Completely mock CSSStyleSheet.insertRule to silently handle @position-try rules + const originalInsertRule = CSSStyleSheet.prototype.insertRule; + CSSStyleSheet.prototype.insertRule = function(rule, index) { + // Initialize cssRules if it doesn't exist + if (!this.cssRules) { + this.cssRules = []; + } + + // For @position-try rules or any CSS anchor positioning rules, just mock success + if (rule.includes('@position-try') || + rule.includes('position-area') || + rule.includes('anchor-name') || + rule.includes('position-anchor') || + rule.includes('position-try-options')) { + + // Add a mock rule object + const mockRule = { + cssText: rule, + selectorText: rule.split('{')[0].trim(), + style: {}, + type: rule.includes('@') ? 4 : 1, // 4 for @rules, 1 for style rules + parentStyleSheet: this + }; + + const insertIndex = index !== undefined ? index : this.cssRules.length; + this.cssRules.splice(insertIndex, 0, mockRule); + return insertIndex; + } + + // For other rules, try the original method but catch any errors + try { + return originalInsertRule.call(this, rule, index); + } catch (e) { + // If it fails, still add a mock rule to prevent breaking + const mockRule = { + cssText: rule, + selectorText: rule.includes('{') ? rule.split('{')[0].trim() : rule, + style: {}, + type: 1, + parentStyleSheet: this + }; + + const insertIndex = index !== undefined ? index : this.cssRules.length; + this.cssRules.splice(insertIndex, 0, mockRule); + return insertIndex; + } + }; +} diff --git a/test/unit/step.spec.js b/test/unit/step.spec.js index 902edb4ef..a87c505aa 100644 --- a/test/unit/step.spec.js +++ b/test/unit/step.spec.js @@ -3,7 +3,7 @@ import Shepherd from '../../shepherd.js/src/shepherd'; import { Step } from '../../shepherd.js/src/step'; import { Tour } from '../../shepherd.js/src/tour'; import ResizeObserver from 'resize-observer-polyfill'; -import { offset } from '@floating-ui/dom'; +// CSS anchor positioning tests - no longer need floating-ui imports // since importing non UMD, needs assignment window.Shepherd = Shepherd; @@ -27,15 +27,15 @@ describe('Tour | Step', () => { }); describe('Shepherd.Step()', () => { - const defaultOffsetMiddleware = offset({ mainAxis: 0, crossAxis: 32 }); - const fooMiddleware = { name: 'foo', options: 'bar', fn: (args) => args }; + // CSS anchor positioning tests - no longer need floating-ui middleware const instance = new Shepherd.Tour({ defaultStepOptions: { classes: DEFAULT_STEP_CLASS, scrollTo: true, - floatingUIOptions: { - middleware: [defaultOffsetMiddleware] + anchorOptions: { + placement: 'bottom', + offset: 8 }, showOn, when @@ -53,8 +53,10 @@ describe('Tour | Step', () => { } ], id: 'test', - floatingUIOptions: { - middleware: [fooMiddleware] + anchorOptions: { + placement: 'top', + offset: 10, + arrow: true } }); @@ -87,10 +89,6 @@ describe('Tour | Step', () => { ] }); - const stepWithoutNameWithoutIdOffsetMiddleware = offset({ - mainAxis: 0, - crossAxis: -32 - }); const stepWithoutNameWithoutId = instance.addStep({ attachTo: { element: 'body' }, highlightClass: 'highlight', @@ -100,10 +98,7 @@ describe('Tour | Step', () => { text: 'Next', action: instance.next } - ], - floatingUIOptions: { - middleware: [stepWithoutNameWithoutIdOffsetMiddleware] - } + ] }); const beforeShowPromise = () => @@ -129,7 +124,7 @@ describe('Tour | Step', () => { 'arrow', 'classes', 'scrollTo', - 'floatingUIOptions', + 'anchorOptions', 'showOn', 'when', 'attachTo', @@ -167,30 +162,23 @@ describe('Tour | Step', () => { id: 'test', scrollTo: true, text: 'This is a step for testing', - floatingUIOptions: { - middleware: [defaultOffsetMiddleware, fooMiddleware] + anchorOptions: { + placement: 'top', + offset: 10, + arrow: true }, showOn, when }); }); - it('allows the step to override a previously defined modifier', () => { + it('allows the step to override anchor positioning options', () => { 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 } - ); - - expect(offsetResult).toEqual({ mainAxis: 0, crossAxis: 0 }); + + // Check that anchor positioning options can be set and accessed + expect(stepWithoutNameWithoutId.options.anchorOptions).toBeDefined(); + expect(stepWithoutNameWithoutId.options.anchorOptions.placement).toBe('bottom'); // Default placement + expect(stepWithoutNameWithoutId.options.anchorOptions.offset).toBe(8); // Default offset }); describe('.hide()', () => { @@ -579,14 +567,13 @@ 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] + anchorOptions: { + placement: 'bottom', + offset: 32 }, showOn, when @@ -603,8 +590,10 @@ describe('Tour | Step', () => { } ], id: 'test', - floatingUIOptions: { - middleware: [{ name: 'foo', options: 'bar', fn: defaultCallback }] + anchorOptions: { + placement: 'top', + offset: 12, + arrow: true } }); diff --git a/test/unit/tour.spec.js b/test/unit/tour.spec.js index 013a92427..a58c38ba5 100644 --- a/test/unit/tour.spec.js +++ b/test/unit/tour.spec.js @@ -3,7 +3,6 @@ import { vi } from 'vitest'; import Shepherd from '../../shepherd.js/src/shepherd'; import ResizeObserver from 'resize-observer-polyfill'; import { setupTooltip } from '../../shepherd.js/src/utils/floating-ui'; -import { offset } from '@floating-ui/dom'; const { Step } = Shepherd; @@ -24,13 +23,12 @@ describe('Tour | Top-Level Class', function () { show() {} }; - const offsetMiddleware = offset({ crossAxis: 32 }); - const defaultStepOptions = { classes: DEFAULT_STEP_CLASS, scrollTo: true, - floatingUIOptions: { - middleware: [offsetMiddleware] + anchorOptions: { + placement: 'bottom', + offset: 32 }, showOn, when @@ -60,8 +58,9 @@ describe('Tour | Top-Level Class', function () { expect(instance.options.defaultStepOptions).toEqual({ classes: DEFAULT_STEP_CLASS, scrollTo: true, - floatingUIOptions: { - middleware: [offsetMiddleware] + anchorOptions: { + placement: 'bottom', + offset: 32 }, showOn, when @@ -578,8 +577,9 @@ describe('Tour | Top-Level Class', function () { instance.start(); - const floatingUIOptions = setupTooltip(step); - expect(floatingUIOptions.middleware.length).toBe(1); + const anchorOptions = setupTooltip(step); + expect(anchorOptions.placement).toBeDefined(); + expect(anchorOptions.offset).toBeDefined(); }); it('adds a step modifer to default modifiers', function () { @@ -588,15 +588,18 @@ describe('Tour | Top-Level Class', function () { const step = instance.addStep({ id: 'test', title: 'This is a test step for our tour', - floatingUIOptions: { - middleware: [{ name: 'foo', options: 'bar', fn: (args) => args }] + anchorOptions: { + placement: 'top', + offset: 12, + arrow: true } }); instance.start(); - const floatingUIOptions = setupTooltip(step); - expect(floatingUIOptions.middleware.length).toBe(2); + const anchorOptions = setupTooltip(step); + expect(anchorOptions.placement).toBeDefined(); + expect(anchorOptions.arrow).toBeDefined(); }); it('correctly changes modifiers when going from centered to attached', function () { @@ -625,23 +628,14 @@ describe('Tour | Top-Level Class', function () { 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(centeredOptions.placement).toBe('bottom'); // Default for centered + 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); + expect(options.placement).toBeDefined(); + expect(options.offset).toBeDefined(); + expect(options.arrow).toBeDefined(); document.body.removeChild(div); }); @@ -681,45 +675,31 @@ describe('Tour | Top-Level Class', function () { }); const step2 = instance.addStep({ - id: 'test', + id: 'test2', title: 'This is a test step for our tour', - attachTo: { element: '.modifiers-test', on: 'auto-start' } + attachTo: { element: '.modifiers-test', on: 'auto-end' } }); const step3 = instance.addStep({ - id: 'test', + id: 'test3', title: 'This is a test step for our tour', - attachTo: { element: '.modifiers-test', on: 'auto-end' } + attachTo: { element: '.modifiers-test', on: 'auto-start' } }); 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); + const step1AnchorOptions = setupTooltip(step1); + expect(step1AnchorOptions.placement).toBe('auto'); instance.next(); - const step2FloatingUIOptions = setupTooltip(step2); - const step2PlacementMiddleware = step2FloatingUIOptions.middleware.find( - ({ name }) => name === 'autoPlacement' - ); - expect(step2PlacementMiddleware.options.alignment).toBe('start'); + const step2AnchorOptions = setupTooltip(step2); + expect(step2AnchorOptions.placement).toBe('auto-end'); instance.next(); - const step3FloatingUIOptions = setupTooltip(step3); - const step3PlacementMiddleware = step3FloatingUIOptions.middleware.find( - ({ name }) => name === 'autoPlacement' - ); - expect(step3PlacementMiddleware.options.alignment).toBe('end'); + const step3AnchorOptions = setupTooltip(step3); + expect(step3AnchorOptions.placement).toBe('auto-start'); }); }); diff --git a/test/unit/utils/anchor-positioning.spec.js b/test/unit/utils/anchor-positioning.spec.js new file mode 100644 index 000000000..670c212eb --- /dev/null +++ b/test/unit/utils/anchor-positioning.spec.js @@ -0,0 +1,380 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + setupAnchorTooltip, + destroyAnchorTooltip +} from '../../../shepherd.js/src/utils/anchor-positioning.ts'; +import { Tour } from '../../../shepherd.js/src/tour.ts'; +import { setupAnchorPolyfill } from '../setupTests.js'; + +describe('Anchor Positioning Utils', () => { + let mockStep; + let mockTour; + let mockElement; + let mockTarget; + + beforeEach(async () => { + // Setup CSS anchor positioning polyfill + await setupAnchorPolyfill(); + // Mock DOM elements + mockTarget = document.createElement('div'); + mockTarget.id = 'test-target'; + document.body.appendChild(mockTarget); + + mockElement = document.createElement('div'); + mockElement.classList.add('shepherd-element'); + + // Add arrow element for arrow tests + const arrow = document.createElement('div'); + arrow.classList.add('shepherd-arrow'); + mockElement.appendChild(arrow); + + document.body.appendChild(mockElement); + + // Mock tour + mockTour = new Tour(); + + // Mock step with minimal required properties + mockStep = { + id: 'test-step', + el: mockElement, + tour: mockTour, + options: { + attachTo: { + element: mockTarget, + on: 'bottom' + }, + arrow: true + }, + cleanup: null, + target: null, + _getResolvedAttachToOptions: vi.fn(() => ({ + element: mockTarget, + on: 'bottom' + })), + shepherdElementComponent: { + getElement: vi.fn(() => mockElement) + } + }; + }); + + afterEach(() => { + // Clean up DOM + document.body.innerHTML = ''; + + // Clean up any dynamically added styles + const shepherdStyles = document.getElementById('shepherd-anchor-positioning'); + if (shepherdStyles) { + shepherdStyles.remove(); + } + }); + + describe('setupAnchorTooltip', () => { + it('should return anchor position config for basic positioning', () => { + const config = setupAnchorTooltip(mockStep); + + expect(config).toEqual({ + placement: 'bottom', + offset: 8, + arrow: true + }); + }); + + it('should set anchor name on target element', () => { + setupAnchorTooltip(mockStep); + + const anchorName = mockTarget.style.anchorName; + expect(anchorName).toBeTruthy(); + expect(anchorName).toMatch(/^--shepherd-anchor-/); + }); + + it('should apply CSS positioning properties to tooltip', () => { + setupAnchorTooltip(mockStep); + + expect(mockElement.style.position).toBe('fixed'); + expect(mockElement.style.positionAnchor).toBeTruthy(); + expect(mockElement.style.positionArea).toBeTruthy(); + }); + + it('should set data attribute for placement', () => { + setupAnchorTooltip(mockStep); + + expect(mockElement.dataset.anchorPlacement).toBe('bottom'); + }); + + it('should handle different placements correctly', () => { + const placements = [ + { placement: 'top', expectedArea: 'block-start span-inline', expectedMargin: 'marginBottom' }, + { placement: 'bottom', expectedArea: 'block-end span-inline', expectedMargin: 'marginTop' }, + { placement: 'left', expectedArea: 'inline-start span-block', expectedMargin: 'marginRight' }, + { placement: 'right', expectedArea: 'inline-end span-block', expectedMargin: 'marginLeft' } + ]; + + placements.forEach(({ placement, expectedArea }) => { + mockStep.options.attachTo.on = placement; + mockStep._getResolvedAttachToOptions = vi.fn(() => ({ + element: mockTarget, + on: placement + })); + + setupAnchorTooltip(mockStep); + + expect(mockElement.style.positionArea).toBe(expectedArea); + expect(mockElement.dataset.anchorPlacement).toBe(placement); + }); + }); + + it('should handle edge-aligned placements', () => { + const edgePlacements = [ + { placement: 'top-start', expectedArea: 'block-start inline-start' }, + { placement: 'top-end', expectedArea: 'block-start inline-end' }, + { placement: 'bottom-start', expectedArea: 'block-end inline-start' }, + { placement: 'bottom-end', expectedArea: 'block-end inline-end' } + ]; + + edgePlacements.forEach(({ placement, expectedArea }) => { + mockStep.options.attachTo.on = placement; + mockStep._getResolvedAttachToOptions = vi.fn(() => ({ + element: mockTarget, + on: placement + })); + + setupAnchorTooltip(mockStep); + + expect(mockElement.style.positionArea).toBe(expectedArea); + }); + }); + + it('should setup arrow positioning when arrow option is enabled', () => { + setupAnchorTooltip(mockStep); + + const arrowEl = mockElement.querySelector('.shepherd-arrow'); + expect(arrowEl.style.position).toBe('absolute'); + expect(arrowEl.style.width).toBe('16px'); + expect(arrowEl.style.height).toBe('16px'); + }); + + it('should position arrow on correct side based on tooltip placement', () => { + const placements = [ + { placement: 'top', expectedSide: 'bottom', expectedProperty: 'bottom' }, + { placement: 'bottom', expectedSide: 'top', expectedProperty: 'top' }, + { placement: 'left', expectedSide: 'right', expectedProperty: 'right' }, + { placement: 'right', expectedSide: 'left', expectedProperty: 'left' } + ]; + + placements.forEach(({ placement, expectedProperty }) => { + mockStep.options.attachTo.on = placement; + mockStep._getResolvedAttachToOptions = vi.fn(() => ({ + element: mockTarget, + on: placement + })); + + setupAnchorTooltip(mockStep); + + const arrowEl = mockElement.querySelector('.shepherd-arrow'); + expect(arrowEl.style[expectedProperty]).toBe('-8px'); + }); + }); + + it('should handle centered steps without anchor positioning', () => { + // Mock step without attachTo (centered step) + mockStep.options.attachTo = undefined; + mockStep._getResolvedAttachToOptions = vi.fn(() => ({})); + + const config = setupAnchorTooltip(mockStep); + + expect(config.placement).toBe('bottom'); // Default for centered + expect(mockElement.style.position).toBe('fixed'); + expect(mockElement.style.left).toBe('50%'); + expect(mockElement.style.top).toBe('50%'); + expect(mockElement.style.transform).toBe('translate(-50%, -50%)'); + }); + + it('should create stylesheet for auto placement with @position-try rules', () => { + mockStep.options.attachTo.on = 'auto'; + mockStep._getResolvedAttachToOptions = vi.fn(() => ({ + element: mockTarget, + on: 'auto' + })); + + setupAnchorTooltip(mockStep); + + // Check that stylesheet was created + const stylesheet = document.getElementById('shepherd-anchor-positioning'); + expect(stylesheet).toBeTruthy(); + expect(stylesheet.tagName).toBe('STYLE'); + }); + + it('should clean up previous positioning before setting up new', () => { + mockStep.cleanup = vi.fn(); + + setupAnchorTooltip(mockStep); + + expect(mockStep.cleanup).toHaveBeenCalled(); + }); + + it('should not setup arrow if arrow option is disabled', () => { + mockStep.options.arrow = false; + + setupAnchorTooltip(mockStep); + + const arrowEl = mockElement.querySelector('.shepherd-arrow'); + // Arrow element exists but shouldn't be styled for positioning + expect(arrowEl.style.position).toBe(''); + }); + + it('should handle arrow option as object with padding', () => { + mockStep.options.arrow = { padding: 12 }; + + const config = setupAnchorTooltip(mockStep); + + expect(config.arrow).toEqual({ padding: 12 }); + + const arrowEl = mockElement.querySelector('.shepherd-arrow'); + expect(arrowEl.style.position).toBe('absolute'); + }); + }); + + describe('destroyAnchorTooltip', () => { + it('should clean up anchor name from target element', () => { + setupAnchorTooltip(mockStep); + + // Verify anchor name is set + expect(mockStep.target.style.anchorName).toBeTruthy(); + + destroyAnchorTooltip(mockStep); + + expect(mockStep.target.style.anchorName).toBe(''); + }); + + it('should reset tooltip positioning styles', () => { + setupAnchorTooltip(mockStep); + + // Verify styles are set + expect(mockElement.style.positionAnchor).toBeTruthy(); + expect(mockElement.style.positionArea).toBeTruthy(); + + destroyAnchorTooltip(mockStep); + + expect(mockElement.style.positionAnchor).toBe(''); + expect(mockElement.style.positionArea).toBe(''); + expect(mockElement.style.positionTryOptions).toBe(''); + expect(mockElement.style.marginTop).toBe(''); + expect(mockElement.style.marginBottom).toBe(''); + expect(mockElement.style.marginLeft).toBe(''); + expect(mockElement.style.marginRight).toBe(''); + }); + + it('should clear data attributes', () => { + setupAnchorTooltip(mockStep); + + expect(mockElement.dataset.anchorPlacement).toBeTruthy(); + + destroyAnchorTooltip(mockStep); + + expect(mockElement.dataset.anchorPlacement).toBeUndefined(); + }); + + it('should handle missing elements gracefully', () => { + mockStep.el = null; + mockStep.target = null; + + expect(() => destroyAnchorTooltip(mockStep)).not.toThrow(); + }); + }); + + describe('Arrow positioning logic', () => { + it('should calculate correct arrow side for each placement', () => { + const testCases = [ + { placement: 'top', expectedSide: 'bottom' }, + { placement: 'top-start', expectedSide: 'bottom' }, + { placement: 'top-end', expectedSide: 'bottom' }, + { placement: 'bottom', expectedSide: 'top' }, + { placement: 'bottom-start', expectedSide: 'top' }, + { placement: 'bottom-end', expectedSide: 'top' }, + { placement: 'left', expectedSide: 'right' }, + { placement: 'left-start', expectedSide: 'right' }, + { placement: 'left-end', expectedSide: 'right' }, + { placement: 'right', expectedSide: 'left' }, + { placement: 'right-start', expectedSide: 'left' }, + { placement: 'right-end', expectedSide: 'left' }, + { placement: 'auto', expectedSide: 'top' }, + { placement: 'auto-start', expectedSide: 'top' }, + { placement: 'auto-end', expectedSide: 'top' } + ]; + + testCases.forEach(({ placement, expectedSide }) => { + mockStep.options.attachTo.on = placement; + mockStep._getResolvedAttachToOptions = vi.fn(() => ({ + element: mockTarget, + on: placement + })); + + setupAnchorTooltip(mockStep); + + const arrowEl = mockElement.querySelector('.shepherd-arrow'); + + // Check that the correct side is positioned + switch (expectedSide) { + case 'top': + expect(arrowEl.style.top).toBe('-8px'); + break; + case 'bottom': + expect(arrowEl.style.bottom).toBe('-8px'); + break; + case 'left': + expect(arrowEl.style.left).toBe('-8px'); + break; + case 'right': + expect(arrowEl.style.right).toBe('-8px'); + break; + } + }); + }); + + it('should center arrow on the appropriate axis', () => { + setupAnchorTooltip(mockStep); // bottom placement + + const arrowEl = mockElement.querySelector('.shepherd-arrow'); + + // For bottom placement, arrow should be on top and centered horizontally + expect(arrowEl.style.top).toBe('-8px'); + expect(arrowEl.style.left).toBe('50%'); + expect(arrowEl.style.transform).toBe('translateX(-50%)'); + }); + }); + + describe('CSS @position-try handling', () => { + it('should handle missing @position-try support gracefully', () => { + // Mock a stylesheet that throws on insertRule + const originalCreateElement = document.createElement; + document.createElement = vi.fn((tagName) => { + if (tagName === 'style') { + const mockStyle = originalCreateElement.call(document, 'style'); + const mockSheet = { + cssRules: [], + insertRule: vi.fn(() => { + throw new Error('CSS @position-try not supported'); + }) + }; + Object.defineProperty(mockStyle, 'sheet', { + get: () => mockSheet + }); + return mockStyle; + } + return originalCreateElement.call(document, tagName); + }); + + mockStep.options.attachTo.on = 'auto'; + mockStep._getResolvedAttachToOptions = vi.fn(() => ({ + element: mockTarget, + on: 'auto' + })); + + // Should not throw, just log warning + expect(() => setupAnchorTooltip(mockStep)).not.toThrow(); + + // Restore original + document.createElement = originalCreateElement; + }); + }); +}); \ No newline at end of file diff --git a/test/unit/utils/general.spec.js b/test/unit/utils/general.spec.js index d8ddb5464..310a7b01f 100644 --- a/test/unit/utils/general.spec.js +++ b/test/unit/utils/general.spec.js @@ -5,7 +5,7 @@ import { shouldCenterStep, parseExtraHighlights } from '../../../shepherd.js//src/utils/general'; -import { getFloatingUIOptions } from '../../../shepherd.js/src/utils/floating-ui'; +import { getAnchorOptions } from '../../../shepherd.js/src/utils/floating-ui'; describe('General Utils', function () { let optionsElement; @@ -125,12 +125,13 @@ describe('General Utils', function () { } ); - const floatingUIOptions = getFloatingUIOptions( + const anchorOptions = getAnchorOptions( step.options.attachTo, step ); - // Shepherd pushes in flip and shift by default, so this is 3rd - expect(floatingUIOptions.middleware[2].options.altAxis).toBe(false); + // With anchor positioning, options are simpler + expect(anchorOptions.placement).toBeDefined(); + expect(anchorOptions.offset).toBeDefined(); }); it('positioning strategy is explicitly set', function () { @@ -138,19 +139,19 @@ describe('General Utils', function () { {}, { attachTo: { element: '.options-test', on: 'center' }, - options: { - floatingUIOptions: { - strategy: 'absolute' - } + anchorOptions: { + placement: 'bottom', + offset: 10 } } ); - const floatingUIOptions = getFloatingUIOptions( + const anchorOptions = getAnchorOptions( step.options.attachTo, step ); - expect(floatingUIOptions.strategy).toBe('absolute'); + // With CSS anchor positioning, we use CSS 'fixed' positioning + expect(anchorOptions.placement).toBeDefined(); }); });