Repository: shipshapecode/shepherd (language: javascript)

Issue:
Upgrade from popperjs to floating-ui
Hello,

The popperjs library kinda "rebranded" to https://github.com/floating-ui/floating-ui, are there any plan to make the switch for shepherd? 

In Drupal and we're going to [update our code to use floating-ui instead of popperjs](https://www.drupal.org/project/drupal/issues/3301545). And since we use shepherd and shepherd uses popperjs it'd be great to not have two different libraries for that.  

Are there any plans or interest in moving over to floating-ui? in a few weeks we should be able to help with making a PR to switch, before spending time on it I just wanted to make sure it's a welcome change :)


Discussion / hints:
@theodoreb we are always interested in staying current, so yes we would fully support this. If you could sync up with @EmNicholson93 he should be able to help with this effort.
For planning purpose on our side, do you think switching the helper library will translate to a major version bump for shepherd ? 
@theodoreb only if the public Shepherd API changes. I'm not afraid of a major version bump, but if we don't need it, we could just do a minor.
The public API seems to imply the popper API is part of shepherd since there is a `popperOptions: Extra options to pass to [Popper](https://popper.js.org/docs/v2/constructors/#options)`

Which would mean deprecating/removing that option => so major bump seems likely
I have time to work on this tomorrow FYI

Fix patch:
diff --git a/package.json b/package.json
index cac3e8fba..f0de01b46 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
     "watch": "yarn clean && rollup -c --environment DEVELOPMENT --watch"
   },
   "dependencies": {
-    "@popperjs/core": "^2.11.6",
+    "@floating-ui/dom": "^1.0.1",
     "deepmerge": "^4.2.2"
   },
   "devDependencies": {
@@ -88,6 +88,7 @@
     "release-it": "^15.5.0",
     "release-it-lerna-changelog": "^5.0.0",
     "replace": "^1.2.1",
+    "resize-observer-polyfill": "^1.5.1",
     "rimraf": "^3.0.2",
     "rollup": "^2.79.1",
     "rollup-plugin-analyzer": "^4.0.0",
diff --git a/src/js/step.js b/src/js/step.js
index 738104204..5bb878d4b 100644
--- a/src/js/step.js
+++ b/src/js/step.js
@@ -8,12 +8,12 @@ import {
   isUndefined
 } from './utils/type-check.js';
 import { bindAdvance } from './utils/bind.js';
+import { parseAttachTo, normalizePrefix, uuid } from './utils/general.js';
 import {
   setupTooltip,
-  parseAttachTo,
-  normalizePrefix,
-  uuid
-} from './utils/general.js';
+  destroyTooltip,
+  mergeTooltipConfig
+} from './utils/floating-ui.js';
 import ShepherdElement from './components/shepherd-element.svelte';
 
 /**
@@ -155,13 +155,10 @@ export class Step extends Evented {
    * Triggers `destroy` event
    */
   destroy() {
-    if (this.tooltip) {
-      this.tooltip.destroy();
-      this.tooltip = null;
-    }
+    destroyTooltip(this);
 
-    if (isHTMLElement(this.el) && this.el.parentNode) {
-      this.el.parentNode.removeChild(this.el);
+    if (isHTMLElement(this.el)) {
+      this.el.remove();
       this.el = null;
     }
 
@@ -232,12 +229,11 @@ export class Step extends Evented {
    */
   show() {
     if (isFunction(this.options.beforeShowPromise)) {
-      const beforeShowPromise = this.options.beforeShowPromise();
-      if (!isUndefined(beforeShowPromise)) {
-        return beforeShowPromise.then(() => this._show());
-      }
+      return Promise.resolve(this.options.beforeShowPromise()).then(() =>
+        this._show()
+      );
     }
-    this._show();
+    return Promise.resolve(this._show());
   }
 
   /**
@@ -353,7 +349,8 @@ export class Step extends Evented {
         arrow: true
       },
       tourOptions,
-      options
+      options,
+      mergeTooltipConfig(tourOptions, options)
     );
 
     const { when } = this.options;
@@ -384,6 +381,9 @@ export class Step extends Evented {
     if (this.options.advanceOn) {
       bindAdvance(this);
     }
+
+    // The tooltip implementation details are handled outside of the Step
+    // object.
     setupTooltip(this);
   }
 
diff --git a/src/js/utils/floating-ui.js b/src/js/utils/floating-ui.js
new file mode 100644
index 000000000..c4e8d0e5e
--- /dev/null
+++ b/src/js/utils/floating-ui.js
@@ -0,0 +1,203 @@
+import merge from 'deepmerge';
+import { shouldCenterStep } from './general';
+import {
+  computePosition,
+  autoUpdate,
+  shift,
+  arrow,
+  limitShift
+} from '@floating-ui/dom';
+
+/**
+ * Floating UI Options
+ *
+ * @typedef {object} FloatingUIOptions
+ */
+
+/**
+ * Determines options for the tooltip and initializes event listeners.
+ *
+ * @param {Step} step The step instance
+ *
+ * @return {FloatingUIOptions}
+ */
+export function setupTooltip(step) {
+  if (step.cleanup) {
+    step.cleanup();
+  }
+
+  const attachToOptions = step._getResolvedAttachToOptions();
+
+  let target = attachToOptions.element;
+  const floatingUIOptions = getFloatingUIOptions(attachToOptions, step);
+
+  if (shouldCenterStep(attachToOptions)) {
+    target = document.body;
+    const content = step.shepherdElementComponent.getElement();
+    content.classList.add('shepherd-centered');
+  }
+
+  step.cleanup = autoUpdate(target, step.el, () => {
+    // The element might have already been removed by the end of the tour.
+    if (!step.el) {
+      step.cleanup();
+      return;
+    }
+
+    setPosition(target, step, floatingUIOptions);
+  });
+
+  step.target = attachToOptions.element;
+
+  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, options) {
+  return {
+    floatingUIOptions: merge(
+      tourOptions.floatingUIOptions || {},
+      options.floatingUIOptions || {}
+    )
+  };
+}
+
+/**
+ * Cleanup function called when the step is closed/destroyed.
+ *
+ * @param {Step} step
+ */
+export function destroyTooltip(step) {
+  if (step.cleanup) {
+    step.cleanup();
+  }
+
+  step.cleanup = null;
+}
+
+/**
+ *
+ * @return {Promise<*>}
+ */
+function setPosition(target, step, floatingUIOptions) {
+  return (
+    computePosition(target, step.el, floatingUIOptions)
+      .then(floatingUIposition(step))
+      // Wait before forcing focus.
+      .then(
+        (step) =>
+          new Promise((resolve) => {
+            setTimeout(() => resolve(step), 300);
+          })
+      )
+      // Replaces focusAfterRender modifier.
+      .then((step) => {
+        if (step && step.el) {
+          step.el.focus({ preventScroll: true });
+        }
+      })
+  );
+}
+
+/**
+ *
+ * @param step
+ * @return {function({x: *, y: *, placement: *, middlewareData: *}): Promise<unknown>}
+ */
+function floatingUIposition(step) {
+  return ({ x, y, placement, middlewareData }) => {
+    if (!step.el) {
+      return step;
+    }
+
+    Object.assign(step.el.style, {
+      position: 'absolute',
+      left: `${x}px`,
+      top: `${y}px`
+    });
+
+    step.el.dataset.popperPlacement = placement;
+
+    placeArrow(step.el, placement, middlewareData);
+
+    return step;
+  };
+}
+
+/**
+ *
+ * @param el
+ * @param placement
+ * @param middlewareData
+ */
+function placeArrow(el, placement, middlewareData) {
+  const arrowEl = el.querySelector('.shepherd-arrow');
+  if (arrowEl) {
+    const { x: arrowX, y: arrowY } = middlewareData.arrow;
+
+    const staticSide = {
+      top: 'bottom',
+      right: 'left',
+      bottom: 'top',
+      left: 'right'
+    }[placement.split('-')[0]];
+
+    Object.assign(arrowEl.style, {
+      left: arrowX != null ? `${arrowX}px` : '',
+      top: arrowY != null ? `${arrowY}px` : '',
+      right: '',
+      bottom: '',
+      [staticSide]: '-35px'
+    });
+  }
+}
+
+/**
+ * Gets the `Floating UI` options from a set of base `attachTo` options
+ * @param attachToOptions
+ * @param {Step} step The step instance
+ * @return {Object}
+ * @private
+ */
+export function getFloatingUIOptions(attachToOptions, step) {
+  const options = {
+    strategy: 'absolute',
+    middleware: [
+      // Replicate PopperJS default behavior.
+      shift({
+        limiter: limitShift(),
+        crossAxis: true
+      })
+    ]
+  };
+
+  const arrowEl = addArrow(step);
+  if (arrowEl) {
+    options.middleware.push(arrow({ element: arrowEl }));
+  }
+
+  if (!shouldCenterStep(attachToOptions)) {
+    options.placement = attachToOptions.on;
+  }
+
+  return merge(step.options.floatingUIOptions || {}, options);
+}
+
+/**
+ * @param {Step} step
+ * @return {HTMLElement|false|null}
+ */
+function addArrow(step) {
+  if (step.options.arrow && step.el) {
+    return step.el.querySelector('.shepherd-arrow');
+  }
+
+  return false;
+}
diff --git a/src/js/utils/general.js b/src/js/utils/general.js
index f4b8e357a..1c7b09ca8 100644
--- a/src/js/utils/general.js
+++ b/src/js/utils/general.js
@@ -1,9 +1,4 @@
-import { createPopper } from '@popperjs/core';
 import { isFunction, isString } from './type-check';
-import {
-  makeCenteredPopper,
-  generateFocusAfterRenderModifier
-} from './popper-options';
 
 /**
  * Ensure class prefix ends in `-`
@@ -69,33 +64,6 @@ export function shouldCenterStep(resolvedAttachToOptions) {
   return !resolvedAttachToOptions.element || !resolvedAttachToOptions.on;
 }
 
-/**
- * Determines options for the tooltip and initializes
- * `step.tooltip` as a Popper instance.
- * @param {Step} step The step instance
- */
-export function setupTooltip(step) {
-  if (step.tooltip) {
-    step.tooltip.destroy();
-  }
-
-  const attachToOptions = step._getResolvedAttachToOptions();
-
-  let target = attachToOptions.element;
-  const popperOptions = getPopperOptions(attachToOptions, step);
-
-  if (shouldCenterStep(attachToOptions)) {
-    target = document.body;
-    const content = step.shepherdElementComponent.getElement();
-    content.classList.add('shepherd-centered');
-  }
-
-  step.tooltip = createPopper(target, step.el, popperOptions);
-  step.target = attachToOptions.element;
-
-  return popperOptions;
-}
-
 /**
  * Create a unique id for steps, tours, modals, etc
  * @return {string}
@@ -108,71 +76,3 @@ export function uuid() {
     return (c == 'x' ? r : (r & 0x3) | 0x8).toString(16);
   });
 }
-
-/**
- * Gets the `Popper` options from a set of base `attachTo` options
- * @param attachToOptions
- * @param {Step} step The step instance
- * @return {Object}
- * @private
- */
-export function getPopperOptions(attachToOptions, step) {
-  let popperOptions = {
-    modifiers: [
-      {
-        name: 'preventOverflow',
-        options: {
-          altAxis: true,
-          tether: false
-        }
-      },
-      generateFocusAfterRenderModifier(step)
-    ],
-    strategy: 'absolute'
-  };
-
-  if (shouldCenterStep(attachToOptions)) {
-    popperOptions = makeCenteredPopper(step);
-  } else {
-    popperOptions.placement = attachToOptions.on;
-  }
-
-  const defaultStepOptions =
-    step.tour && step.tour.options && step.tour.options.defaultStepOptions;
-
-  if (defaultStepOptions) {
-    popperOptions = _mergeModifiers(defaultStepOptions, popperOptions);
-  }
-
-  popperOptions = _mergeModifiers(step.options, popperOptions);
-
-  return popperOptions;
-}
-
-function _mergeModifiers(stepOptions, popperOptions) {
-  if (stepOptions.popperOptions) {
-    let mergedPopperOptions = Object.assign(
-      {},
-      popperOptions,
-      stepOptions.popperOptions
-    );
-
-    if (
-      stepOptions.popperOptions.modifiers &&
-      stepOptions.popperOptions.modifiers.length > 0
-    ) {
-      const names = stepOptions.popperOptions.modifiers.map((mod) => mod.name);
-      const filteredModifiers = popperOptions.modifiers.filter(
-        (mod) => !names.includes(mod.name)
-      );
-
-      mergedPopperOptions.modifiers = Array.from(
-        new Set([...filteredModifiers, ...stepOptions.popperOptions.modifiers])
-      );
-    }
-
-    return mergedPopperOptions;
-  }
-
-  return popperOptions;
-}
diff --git a/src/js/utils/popper-options.js b/src/js/utils/popper-options.js
deleted file mode 100644
index b4002f6c0..000000000
--- a/src/js/utils/popper-options.js
+++ /dev/null
@@ -1,92 +0,0 @@
-function _getCenteredStylePopperModifier() {
-  return [
-    {
-      name: 'applyStyles',
-      fn({ state }) {
-        Object.keys(state.elements).forEach((name) => {
-          if (name !== 'popper') {
-            return;
-          }
-          const style = {
-            position: 'fixed',
-            left: '50%',
-            top: '50%',
-            transform: 'translate(-50%, -50%)'
-          };
-
-          const attributes = state.attributes[name] || {};
-          const element = state.elements[name];
-
-          Object.assign(element.style, style);
-          Object.keys(attributes).forEach((name) => {
-            const value = attributes[name];
-            if (value === false) {
-              element.removeAttribute(name);
-            } else {
-              element.setAttribute(name, value === true ? '' : value);
-            }
-          });
-        });
-      }
-    },
-    {
-      name: 'computeStyles',
-      options: {
-        adaptive: false
-      }
-    }
-  ];
-}
-
-/**
- * Generates a modifier for popper that will help focus the element after it has
- * been rendered
- *
- * @param {Step} step The step instance
- * @return {Object} The focus after render modifier configuration object
- */
-export function generateFocusAfterRenderModifier(step) {
-  return {
-    name: 'focusAfterRender',
-    enabled: true,
-    phase: 'afterWrite',
-    fn() {
-      setTimeout(() => {
-        if (step.el) {
-          const focusOptions = {
-            preventScroll: true
-          };
-
-          step.el.focus(focusOptions);
-        }
-      }, 300);
-    }
-  };
-}
-
-/**
- * Generates the array of options for a tooltip that doesn't have a
- * target element in the DOM -- and thus is positioned in the center
- * of the view
- *
- * @param {Step} step The step instance
- * @return {Object} The final Popper options object
- */
-export function makeCenteredPopper(step) {
-  const centeredStylePopperModifier = _getCenteredStylePopperModifier();
-
-  let popperOptions = {
-    placement: 'top',
-    strategy: 'fixed',
-    modifiers: [generateFocusAfterRenderModifier(step)]
-  };
-
-  popperOptions = {
-    ...popperOptions,
-    modifiers: Array.from(
-      new Set([...popperOptions.modifiers, ...centeredStylePopperModifier])
-    )
-  };
-
-  return popperOptions;
-}
diff --git a/yarn.lock b/yarn.lock
index 9f8fd3e92..8510a1f66 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1087,6 +1087,18 @@
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
+"@floating-ui/core@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.1.tgz#00e64d74e911602c8533957af0cce5af6b2e93c8"
+  integrity sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA==
+
+"@floating-ui/dom@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.1.tgz#3321d4e799d6ac2503e729131d07ad0e714aabeb"
+  integrity sha512-wBDiLUKWU8QNPNOTAFHiIAkBv1KlHauG2AhqjSeh2H+wR8PX+AArXfz8NkRexH5PgMJMmSOS70YS89AbWYh5dA==
+  dependencies:
+    "@floating-ui/core" "^1.0.1"
+
 "@gar/promisify@^1.0.1":
   version "1.1.2"
   resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz"
@@ -1619,11 +1631,6 @@
     "@pnpm/network.ca-file" "^1.0.1"
     config-chain "^1.1.11"
 
-"@popperjs/core@^2.11.6":
-  version "2.11.6"
-  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45"
-  integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
-
 "@sideway/address@^4.1.0":
   version "4.1.2"
   resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.2.tgz"
@@ -8401,6 +8408,11 @@ requizzle@^0.2.3:
   dependencies:
     lodash "^4.17.14"
 
+resize-observer-polyfill@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
+  integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
+
 resolve-alpn@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"


Test patch:
diff --git a/test/cypress/integration/destroying-elements.cy.js b/test/cypress/integration/destroying-elements.cy.js
index f4da4c556..a7768981e 100644
--- a/test/cypress/integration/destroying-elements.cy.js
+++ b/test/cypress/integration/destroying-elements.cy.js
@@ -43,44 +43,50 @@ describe('destroying-elements', () => {
 
     cy.wait(250);
 
-    cy.get('[data-shepherd-step-id="first"]').then((stepElement) => {
-      const initialFirstStepTransform = stepElement[0].style.transform;
-
-      cy.document().then((document) => {
-        tour.next();
-
-        // Remove the first element
-        document.querySelector('.first').remove();
-
-        tour.back();
-
-        cy.wait(250);
-
-        cy.get('[data-shepherd-step-id="first"]').then((stepElement2) => {
-          const secondFirstStepTransform = stepElement2[0].style.transform;
-
-          expect(initialFirstStepTransform).to.not.equal(secondFirstStepTransform);
-          expect(secondFirstStepTransform).to.equal('translate(-50%, -50%)');
+    let initialPosition;
+    let missingStepElementPosition;
+    let finalPosition;
 
-          tour.next();
-
-          // Create the first element again
-          const first = document.createElement('div');
-          first.className = 'first';
-          first.textContent = 'First';
-          document.body.appendChild(first);
-
-          tour.back();
-
-          cy.wait(250);
-
-          cy.get('[data-shepherd-step-id="first"]').then((stepElement3) => {
-            const finalFirstStepTransform = stepElement3[0].style.transform;
-            expect(finalFirstStepTransform).to.equal(initialFirstStepTransform);
-            expect(finalFirstStepTransform).to.not.equal('translate(-50%, -50%)');
-          });
-        });
-      });
+    cy.get('[data-shepherd-step-id="first"]').then((stepElement) => {
+      initialPosition = stepElement.css(['position', 'top', 'left']);
+      tour.next();
+
+      return cy.get('.first');
+    })
+    .then((firstElement) => {
+      // Remove the first element
+      firstElement.remove();
+
+      tour.back();
+      cy.wait(250);
+
+      return cy.get('[data-shepherd-step-id="first"]');
+    })
+    .then((stepElement2) => {
+      missingStepElementPosition = stepElement2.css(['position', 'top', 'left']);
+      expect(missingStepElementPosition).to.not.deep.equal(initialPosition);
+
+      tour.next();
+
+      return cy.document();
+    })
+    .then((document) => {
+      // Create the first element again
+      const first = document.createElement('div');
+      first.className = 'first';
+      first.textContent = 'First';
+      document.body.appendChild(first);
+
+      tour.back();
+
+      cy.wait(250);
+
+      return cy.get('[data-shepherd-step-id="first"]');
+    })
+    .then((stepElement3) => {
+      finalPosition = stepElement3.css(['position', 'top', 'left']);
+      expect(finalPosition).to.deep.equal(initialPosition);
     });
+
   });
 });
diff --git a/test/unit/step.spec.js b/test/unit/step.spec.js
index ed3b0efdb..8f14f7bb6 100644
--- a/test/unit/step.spec.js
+++ b/test/unit/step.spec.js
@@ -2,9 +2,12 @@ import { spy } from 'sinon';
 import Shepherd from '../../src/js/shepherd';
 import { Step } from '../../src/js/step';
 import { Tour } from '../../src/js/tour';
+import ResizeObserver from 'resize-observer-polyfill';
+import { offset } from "@floating-ui/dom";
 
 // since importing non UMD, needs assignment
 window.Shepherd = Shepherd;
+window.ResizeObserver = ResizeObserver;
 
 const DEFAULT_STEP_CLASS = 'shepherd-step-tooltip';
 
@@ -24,12 +27,15 @@ 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,
-        popperOptions: {
-          modifiers: [{ name: 'offset', options: { offset: [0, 32] } }]
+        floatingUIOptions: {
+          middleware: [defaultOffsetMiddleware],
         },
         showOn,
         when
@@ -47,9 +53,9 @@ describe('Tour | Step', () => {
         }
       ],
       id: 'test',
-      popperOptions: {
-        modifiers: [{ name: 'foo', options: 'bar' }]
-      }
+      floatingUIOptions: {
+        middleware: [fooMiddleware],
+      },
     });
 
     const showTestStep = instance.addStep({
@@ -81,6 +87,7 @@ describe('Tour | Step', () => {
       ]
     });
 
+    const stepWithoutNameWithoutIdOffsetMiddleware = offset({mainAxis: 0, crossAxis: -32});
     const stepWithoutNameWithoutId = instance.addStep({
       attachTo: { element: 'body' },
       highlightClass: 'highlight',
@@ -91,21 +98,22 @@ describe('Tour | Step', () => {
           action: instance.next
         }
       ],
-      popperOptions: {
-        modifiers: [{ name: 'offset', options: { offset: [0, 0] } }]
+      floatingUIOptions: {
+        middleware: [stepWithoutNameWithoutIdOffsetMiddleware]
       }
     });
 
-    const beforeShowPromise = new Promise((resolve) => {
-      return setTimeout(() => resolve('beforeShowPromise worked!'), 1000);
+    const beforeShowPromise = () => new Promise((resolve) => {
+       setTimeout(() => {
+         console.log('beforeShowPromise worked!');
+         resolve('beforeShowPromise worked!')
+       }, 1000);
     });
 
     const beforeShowPromiseTestStep = instance.addStep({
       text: 'Before Show Promise Step',
       id: 'test3',
-      beforeShowPromise() {
-        return beforeShowPromise;
-      }
+      beforeShowPromise,
     });
 
     afterEach(() => {
@@ -117,7 +125,7 @@ describe('Tour | Step', () => {
         'arrow',
         'classes',
         'scrollTo',
-        'popperOptions',
+        'floatingUIOptions',
         'showOn',
         'when',
         'attachTo',
@@ -155,8 +163,8 @@ describe('Tour | Step', () => {
         id: 'test',
         scrollTo: true,
         text: 'This is a step for testing',
-        popperOptions: {
-          modifiers: [{ name: 'foo', options: 'bar' }]
+        floatingUIOptions: {
+          middleware: [defaultOffsetMiddleware, fooMiddleware],
         },
         showOn,
         when
@@ -165,18 +173,14 @@ describe('Tour | Step', () => {
 
     it('allows the step to override a previously defined modifier', () => {
       stepWithoutNameWithoutId.show();
-      const offsetValues = stepWithoutNameWithoutId.options.popperOptions.modifiers.reduce(
-        (prev, next) => {
-          if (next.name === 'offset') {
-            return `${next.options.offset}`;
-          }
+      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});
 
-          return '';
-        },
-        ''
-      );
-
-      expect(offsetValues).toBe('0,0');
+      expect(offsetResult).toEqual({mainAxis: 0, crossAxis: 0});
     });
 
     describe('.hide()', () => {
@@ -195,12 +199,11 @@ describe('Tour | Step', () => {
 
     describe('.show()', () => {
       it('beforeShowPromise called before `show`', () => {
-        beforeShowPromiseTestStep.show();
+        console.log = jest.fn();
+        const promise = beforeShowPromiseTestStep.show();
 
-        return beforeShowPromise.then((result) => {
-          expect(result, 'beforeShowPromise is called').toBe(
-            'beforeShowPromise worked!'
-          );
+        return promise.then(() => {
+          expect(console.log).toHaveBeenCalledWith('beforeShowPromise worked!');
         });
       });
 
@@ -431,10 +434,8 @@ describe('Tour | Step', () => {
     it('calls destroy on the tooltip if it already exists', () => {
       const step = new Step(tour, {});
       let destroyCalled = false;
-      step.tooltip = {
-        destroy() {
-          destroyCalled = true;
-        }
+      step.cleanup = () => {
+        destroyCalled = true;
       };
       step._setupElements();
       expect(
@@ -568,12 +569,14 @@ 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,
-        popperOptions: {
-          modifiers: [{ name: 'offset', options: { offset: [0, 32] } }]
+        floatingUIOptions: {
+          middleware: [offsetMiddleware],
         },
         showOn,
         when
@@ -590,9 +593,9 @@ describe('Tour | Step', () => {
         }
       ],
       id: 'test',
-      popperOptions: {
-        modifiers: [{ name: 'foo', options: 'bar' }]
-      }
+      floatingUIOptions: {
+        middleware: [{name: 'foo', options: 'bar', fn: defaultCallback}],
+      },
     });
 
     afterEach(() => {
diff --git a/test/unit/tour.spec.js b/test/unit/tour.spec.js
index 55aa4447b..f0aa1de25 100644
--- a/test/unit/tour.spec.js
+++ b/test/unit/tour.spec.js
@@ -1,12 +1,15 @@
 import _ from 'lodash';
 import { stub } from 'sinon';
 import Shepherd from '../../src/js/shepherd';
+import ResizeObserver from 'resize-observer-polyfill';
 import { Step } from '../../src/js/step';
-import { setupTooltip } from '../../src/js/utils/general.js';
+import { setupTooltip } from '../../src/js/utils/floating-ui';
 import { spy } from 'sinon';
+import { offset } from "@floating-ui/dom";
 
 // since importing non UMD, needs assignment
 window.Shepherd = Shepherd;
+window.ResizeObserver = ResizeObserver;
 
 const DEFAULT_STEP_CLASS = 'shepherd-step-tooltip';
 
@@ -22,11 +25,13 @@ describe('Tour | Top-Level Class', function() {
     }
   };
 
+  const offsetMiddleware = offset({crossAxis: 32});
+
   const defaultStepOptions = {
     classes: DEFAULT_STEP_CLASS,
     scrollTo: true,
-    popperOptions: {
-      modifiers: [{ name: 'offset', options: { offset: [0, 32] } }]
+    floatingUIOptions: {
+      middleware: [offsetMiddleware],
     },
     showOn,
     when
@@ -53,8 +58,8 @@ describe('Tour | Top-Level Class', function() {
       expect(instance.options.defaultStepOptions).toEqual({
         classes: DEFAULT_STEP_CLASS,
         scrollTo: true,
-        popperOptions: {
-          modifiers: [{ name: 'offset', options: { offset: [0, 32] } }]
+        floatingUIOptions: {
+          middleware: [offsetMiddleware],
         },
         showOn,
         when
@@ -98,12 +103,12 @@ describe('Tour | Top-Level Class', function() {
         title: 'This is a test step for our tour'
       });
 
-      
+
       instance.addStep({
         id: 'test2',
         title: 'Another Step'
       });
-      
+
       instance.addStep({
         classes: 'skipped',
         id: 'skipped-step',
@@ -424,7 +429,7 @@ describe('Tour | Top-Level Class', function() {
     });
   });
 
-  describe('popperOptions', () => {
+  describe('floatingUIOptions', () => {
     it('applies the default modifiers from defaultStepOptions', function() {
       instance = new Shepherd.Tour({ defaultStepOptions });
 
@@ -435,9 +440,8 @@ describe('Tour | Top-Level Class', function() {
 
       instance.start();
 
-      const popperOptions = setupTooltip(step);
-
-      expect(popperOptions.modifiers.length).toBe(4);
+      const floatingUIOptions = setupTooltip(step);
+      expect(floatingUIOptions.middleware.length).toBe(2);
     });
 
     it('adds a step modifer to default modifiers', function() {
@@ -446,16 +450,15 @@ describe('Tour | Top-Level Class', function() {
       const step = instance.addStep({
         id: 'test',
         title: 'This is a test step for our tour',
-        popperOptions: {
-          modifiers: [{ name: 'foo', options: 'bar' }]
-        }
+        floatingUIOptions: {
+          middleware: [{name: 'foo', options: 'bar', fn: (args) => args}],
+        },
       });
 
       instance.start();
 
-      const popperOptions = setupTooltip(step);
-
-      expect(popperOptions.modifiers.length).toBe(5);
+      const floatingUIOptions = setupTooltip(step);
+      expect(floatingUIOptions.middleware.length).toBe(3);
     });
 
     it('correctly changes modifiers when going from centered to attached', function() {
@@ -467,41 +470,39 @@ describe('Tour | Top-Level Class', function() {
       const centeredStep = instance.addStep({
         id: 'centered',
         title: 'This is a centered step for our tour',
-        popperOptions: {
-          modifiers: [{ name: 'foo', options: 'bar' }]
-        }
+        floatingUIOptions: {
+          middleware: [{name: 'foo', options: 'bar', fn: (args) => args}],
+        },
       });
 
       const attachedStep = instance.addStep({
         attachTo: { element: '.modifiers-test', on: 'top' },
         id: 'attached',
         title: 'This is an attached step for our tour',
-        popperOptions: {
-          modifiers: [{ name: 'foo', options: 'bar' }]
-        }
+        floatingUIOptions: {
+          middleware: [{name: 'foo', options: 'bar', fn: (args) => args}],
+        },
       });
 
       instance.start();
 
-      let popperOptions = setupTooltip(centeredStep);
-      let modifierNames = popperOptions.modifiers.map((modifier) => modifier.name);
-      expect(popperOptions.modifiers.length).toBe(5);
-      expect(modifierNames.includes('applyStyles')).toBe(true);
-      expect(modifierNames.includes('computeStyles')).toBe(true);
-      expect(modifierNames.includes('offset')).toBe(true);
-      expect(modifierNames.includes('foo')).toBe(true);
-      expect(modifierNames.includes('preventOverflow')).toBe(false);
+      const centeredOptions = setupTooltip(centeredStep);
+      const centeredMiddlewareNames = centeredOptions.middleware.map(({name}) => name);
+      expect(centeredOptions.middleware.length).toBe(3);
+      expect(centeredMiddlewareNames.includes('offset')).toBe(true);
+      expect(centeredMiddlewareNames.includes('foo')).toBe(true);
+      expect(centeredMiddlewareNames.includes('shift')).toBe(true);
+      expect(centeredMiddlewareNames.includes('arrow')).toBe(false);
 
       instance.next();
 
-      popperOptions = setupTooltip(attachedStep);
-      modifierNames = popperOptions.modifiers.map((modifier) => modifier.name);
-      expect(popperOptions.modifiers.length).toBe(4);
-      expect(modifierNames.includes('preventOverflow')).toBe(true);
-      expect(modifierNames.includes('offset')).toBe(true);
-      expect(modifierNames.includes('foo')).toBe(true);
-      expect(modifierNames.includes('applyStyles')).toBe(false);
-      expect(modifierNames.includes('computeStyles')).toBe(false);
+      const options = setupTooltip(attachedStep);
+      const middlewareNames = options.middleware.map(({name}) => name);
+      expect(options.middleware.length).toBe(4);
+      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);
     });
diff --git a/test/unit/utils/general.spec.js b/test/unit/utils/general.spec.js
index 2291de618..d4a672984 100644
--- a/test/unit/utils/general.spec.js
+++ b/test/unit/utils/general.spec.js
@@ -1,6 +1,12 @@
 import { spy } from 'sinon';
 import { Step } from '../../../src/js/step.js';
-import { getPopperOptions, parseAttachTo, shouldCenterStep } from '../../../src/js/utils/general.js';
+import {
+  parseAttachTo,
+  shouldCenterStep
+} from '../../../src/js/utils/general.js';
+import {
+  getFloatingUIOptions
+} from '../../../src/js/utils/floating-ui.js';
 
 describe('General Utils', function() {
   let optionsElement;
@@ -59,12 +65,12 @@ describe('General Utils', function() {
     });
   });
 
-  describe('getPopperOptions', function() {
-    it('modifiers can be overridden', function() {
+  describe('floatingUIOptions', function() {
+    it('middleware can be overridden', function() {
       const step = new Step({}, {
         attachTo: { element: '.options-test', on: 'right' },
-        popperOptions: {
-          modifiers: [
+        floatingUIOptions: {
+          middleware: [
             {
               name: 'preventOverflow',
               options: {
@@ -75,44 +81,24 @@ describe('General Utils', function() {
         }
       });
 
-      const popperOptions = getPopperOptions(step.options.attachTo, step);
-      expect(popperOptions.modifiers[1].options.altAxis).toBe(false);
+      const floatingUIOptions = getFloatingUIOptions(step.options.attachTo, step);
+      expect(floatingUIOptions.middleware[0].options.altAxis).toBe(false);
     });
 
     it('positioning strategy is explicitly set', function() {
       const step = new Step({}, {
         attachTo: { element: '.options-test', on: 'center' },
         options: {
-          popperOptions: {
+          floatingUIOptions: {
             strategy: 'absolute'
           }
         }
       });
 
-      const popperOptions = getPopperOptions(step.options.attachTo, step);
-      expect(popperOptions.strategy).toBe('absolute');
+      const floatingUIOptions = getFloatingUIOptions(step.options.attachTo, step);
+      expect(floatingUIOptions.strategy).toBe('absolute');
     });
 
-    it(`has a modifier to focus on the step's element after render`, function () {
-      const step = new Step({}, {
-        attachTo: { element: '.options-test', on: 'center' },
-      });
-
-      const popperOptions = getPopperOptions(step.options.attachTo, step);
-
-      const expectedModifier = {
-        name: 'focusAfterRender',
-        enabled: true,
-        phase: 'afterWrite',
-        fn: expect.any(Function)
-      };
-
-      const actualModifier = popperOptions.modifiers.find(
-        (modifier) => modifier.name === expectedModifier.name
-      );
-
-      expect(actualModifier).toMatchObject(expectedModifier);
-    });
   });
 
   describe('shouldCenterStep()', () => {