Repository: GetJobber/atlantis
Pull request #2435: refactor(components): Replace popperjs with floating-ui in combobox
Author: login: MichaelParadis
Labels: name: approved
name: Team UX Foundations
Created: 2025-03-20T19:02:39Z | Merged: 2025-03-26T14:09:40Z

## Motivations

Since PopperJS is EOL we are aiming to replace it with Floating UI. This is the first PR to remove PopperJS from our repo.

Decisions:
 - I updated the keyboard dismiss logic to take advantage of Floating UI's [useDismiss](https://floating-ui.com/docs/useDismiss) functionality
 

## Changes

<!-- https://keepachangelog.com/en/1.0.0/ -->

### Added

- <!-- new features -->

### Changed

- Changed Combobox content to be rendered with Floating UI instead of PopperJS

### Deprecated

- <!-- soon-to-be removed features -->

### Removed

- <!-- now removed features -->

### Fixed

- <!-- for any bug fixes -->

### Security

- <!-- in case of vulnerabilities -->

## Testing


Open up combox box stories
 - Verify the following with both mouse navigation and keyboard navigation
   - On the non-keep open on click story verify that selecting the Combobox action closes the combobox
   - On the keep open on click story verify that selecting the action keeps the combobox open
   - Verify on single select, selecting an option closes the combobox
   - Verify on multi select, selecting an option does not close the combobox
   - Verify that custom activators open the combobox as expected

Changes can be
[tested via Pre-release](https://github.com/GetJobber/atlantis/actions/workflows/trigger-qa-build.yml)

---

[In Atlantis we use Github's built in pull request reviews](https://help.github.com/en/articles/about-pull-request-reviews).

![Random photo of Atlantis](https://loremflickr.com/672/400/atlantis)



Diff:
diff --git a/package-lock.json b/package-lock.json
index a09f968969..c50479288c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4197,17 +4197,40 @@
         "@floating-ui/utils": "^0.1.3"
       }
     },
+    "node_modules/@floating-ui/react": {
+      "version": "0.27.5",
+      "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.5.tgz",
+      "integrity": "sha512-BX3jKxo39Ba05pflcQmqPPwc0qdNsdNi/eweAFtoIdrJWNen2sVEWMEac3i6jU55Qfx+lOcdMNKYn2CtWmlnOQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/react-dom": "^2.1.2",
+        "@floating-ui/utils": "^0.2.9",
+        "tabbable": "^6.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=17.0.0",
+        "react-dom": ">=17.0.0"
+      }
+    },
     "node_modules/@floating-ui/react-dom": {
-      "version": "2.0.4",
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
+      "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
       "license": "MIT",
       "dependencies": {
-        "@floating-ui/dom": "^1.5.1"
+        "@floating-ui/dom": "^1.0.0"
       },
       "peerDependencies": {
         "react": ">=16.8.0",
         "react-dom": ">=16.8.0"
       }
     },
+    "node_modules/@floating-ui/react/node_modules/@floating-ui/utils": {
+      "version": "0.2.9",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
+      "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
+      "license": "MIT"
+    },
     "node_modules/@floating-ui/utils": {
       "version": "0.1.6",
       "license": "MIT"
@@ -41687,6 +41710,12 @@
         "uuid": "dist/bin/uuid"
       }
     },
+    "node_modules/tabbable": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+      "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
+      "license": "MIT"
+    },
     "node_modules/table": {
       "version": "6.8.1",
       "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
@@ -44667,6 +44696,7 @@
       "version": "6.22.0",
       "license": "MIT",
       "dependencies": {
+        "@floating-ui/react": "^0.27.5",
         "@jobber/formatters": "^0.4.0",
         "@popperjs/core": "^2.0.6",
         "@tanstack/react-table": "8.5.13",
diff --git a/packages/components/package.json b/packages/components/package.json
index 8a12a00220..8f9ff1674f 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -418,6 +418,7 @@
     "dist/*"
   ],
   "dependencies": {
+    "@floating-ui/react": "^0.27.5",
     "@jobber/formatters": "^0.4.0",
     "@popperjs/core": "^2.0.6",
     "@tanstack/react-table": "8.5.13",
@@ -489,4 +490,4 @@
     "> 1%",
     "IE 10"
   ]
-}
+}
\ No newline at end of file
diff --git a/packages/components/src/Combobox/components/ComboboxContent/ComboboxContent.module.css b/packages/components/src/Combobox/components/ComboboxContent/ComboboxContent.module.css
index 1b678c7d4d..df577a9c60 100644
--- a/packages/components/src/Combobox/components/ComboboxContent/ComboboxContent.module.css
+++ b/packages/components/src/Combobox/components/ComboboxContent/ComboboxContent.module.css
@@ -2,7 +2,6 @@
   z-index: var(--elevation-tooltip);
   width: calc(var(--space-extravagant) * 3.75);
   box-shadow: var(--shadow-base);
-  margin-top: var(--space-small);
   border: var(--border-base) solid var(--color-border);
   border-radius: var(--radius-base);
   overflow: auto;
diff --git a/packages/components/src/Combobox/components/ComboboxContent/ComboboxContent.tsx b/packages/components/src/Combobox/components/ComboboxContent/ComboboxContent.tsx
index 8efc12eefc..a47cbb8a41 100644
--- a/packages/components/src/Combobox/components/ComboboxContent/ComboboxContent.tsx
+++ b/packages/components/src/Combobox/components/ComboboxContent/ComboboxContent.tsx
@@ -14,7 +14,7 @@ export function ComboboxContent(props: ComboboxContentProps): JSX.Element {
   const optionsExist = props.options.length > 0;
   const { optionsListRef } = useComboboxContent(props.open, props.selected);
 
-  const { popperRef, popperStyles, attributes } = useComboboxAccessibility(
+  const { popperRef, popperStyles, floatingProps } = useComboboxAccessibility(
     props.handleSelection,
     props.options,
     optionsListRef,
@@ -30,8 +30,8 @@ export function ComboboxContent(props: ComboboxContentProps): JSX.Element {
       data-elevation={"elevated"}
       tabIndex={0}
       className={classnames(styles.content, { [styles.hidden]: !props.open })}
-      style={popperStyles.popper}
-      {...attributes.popper}
+      style={popperStyles}
+      {...floatingProps}
     >
       <ComboboxContentSearch
         open={props.open}
diff --git a/packages/components/src/Combobox/hooks/useComboboxAccessibility.ts b/packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
index 6ccaf15343..c69521d4af 100644
--- a/packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
+++ b/packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
@@ -1,11 +1,21 @@
 import { useContext, useEffect, useRef } from "react";
 import { useRefocusOnActivator } from "@jobber/hooks/useRefocusOnActivator";
+import {
+  UseInteractionsReturn,
+  autoPlacement,
+  autoUpdate,
+  offset,
+  shift,
+  useDismiss,
+  useFloating,
+  useInteractions,
+} from "@floating-ui/react";
 import { useFocusTrap } from "@jobber/hooks/useFocusTrap";
-import { usePopper } from "react-popper";
-import { useOnKeyDown } from "@jobber/hooks/useOnKeyDown";
 import { ComboboxOption } from "../Combobox.types";
 import { ComboboxContext } from "../ComboboxProvider";
 
+const COMBOBOX_OFFSET = 8;
+
 // eslint-disable-next-line max-statements
 export function useComboboxAccessibility(
   selectionCallback: (selection: ComboboxOption) => void,
@@ -15,8 +25,8 @@ export function useComboboxAccessibility(
   wrapperRef: React.RefObject<HTMLDivElement>,
 ): {
   popperRef: React.RefObject<HTMLDivElement>;
-  popperStyles: { [key: string]: React.CSSProperties };
-  attributes: { [key: string]: { [key: string]: string } | undefined };
+  popperStyles: React.CSSProperties;
+  floatingProps: ReturnType<UseInteractionsReturn["getFloatingProps"]>;
 } {
   const { handleClose } = useContext(ComboboxContext);
   const hasOptionsVisible = open && filteredOptions.length > 0;
@@ -25,29 +35,36 @@ export function useComboboxAccessibility(
   useRefocusOnActivator(open);
 
   const popperRef = useFocusTrap<HTMLDivElement>(open);
-  const {
-    styles: popperStyles,
-    attributes,
-    update,
-  } = usePopper(wrapperRef.current, popperRef.current, {
-    modifiers: [
-      {
-        name: "flip",
-        options: {
-          fallbackPlacements: ["top-start"],
-        },
-      },
+
+  const { floatingStyles, update, context } = useFloating({
+    elements: {
+      reference: wrapperRef.current,
+      floating: popperRef.current,
+    },
+    open,
+    onOpenChange: openState => {
+      if (!openState) handleClose();
+    },
+    middleware: [
+      offset(COMBOBOX_OFFSET),
+      autoPlacement({ allowedPlacements: ["bottom-start", "top-start"] }),
+      shift({ padding: COMBOBOX_OFFSET }),
     ],
     placement: "bottom-start",
+    whileElementsMounted: autoUpdate,
   });
 
+  const dismiss = useDismiss(context);
+
+  const { getFloatingProps } = useInteractions([dismiss]);
+
   useEffect(() => {
     focusedIndex.current = null;
 
     if (open) {
       update?.();
     }
-  }, [open, filteredOptions.length]);
+  }, [open, filteredOptions.length, update]);
 
   useEffect(() => {
     if (open) {
@@ -59,12 +76,6 @@ export function useComboboxAccessibility(
     };
   }, [open, optionsListRef, filteredOptions]);
 
-  useOnKeyDown(() => {
-    if (open) {
-      handleClose();
-    }
-  }, "Escape");
-
   function handleContentKeydown(event: KeyboardEvent) {
     if (!hasOptionsVisible) return;
 
@@ -114,7 +125,7 @@ export function useComboboxAccessibility(
 
   return {
     popperRef,
-    popperStyles,
-    attributes,
+    popperStyles: floatingStyles,
+    floatingProps: getFloatingProps(),
   };
 }


Reviews:
user: login: taylorvnoj
body: Great change to use this library instead - I am however seeing some styling/positioning  changes
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T18:30:56Z
user: login: taylorvnoj
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T18:33:55Z
user: login: taylorvnoj
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T18:39:17Z
user: login: MichaelParadis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T20:03:09Z
user: login: MichaelParadis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T20:03:16Z
user: login: taylorvnoj
body: LGTM! Thanks for making those changes!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T21:19:12Z

Review comments:
diff_hunk: @@ -25,29 +31,34 @@ export function useComboboxAccessibility(
   useRefocusOnActivator(open);
 
   const popperRef = useFocusTrap<HTMLDivElement>(open);
-  const {
-    styles: popperStyles,
-    attributes,
-    update,
-  } = usePopper(wrapperRef.current, popperRef.current, {
-    modifiers: [
-      {
-        name: "flip",
-        options: {
-          fallbackPlacements: ["top-start"],
-        },
-      },
+
+  const { floatingStyles, update, context } = useFloating({
+    elements: {
+      reference: wrapperRef.current,
+      floating: popperRef.current,
+    },
+    open,
+    onOpenChange: openState => {
+      if (!openState) handleClose();
+    },
+    middleware: [
+      autoPlacement({ allowedPlacements: ["bottom-start", "top-start"] }),
path: packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
user: login: taylorvnoj
body: It's great that we can now utilize `autoPlacement` - I'm just wondering if we need to target the styling a bit when the menu is opened above the activator - more like the gap we have when the menu is opened below the activator? 

![Screenshot 2025-03-25 at 2 20 38 PM](https://github.com/user-attachments/assets/1b353080-df08-4e47-92a2-5a5e89a6cbab)

created_at: 2025-03-25T18:27:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,8 +21,8 @@ export function useComboboxAccessibility(
   wrapperRef: React.RefObject<HTMLDivElement>,
 ): {
   popperRef: React.RefObject<HTMLDivElement>;
-  popperStyles: { [key: string]: React.CSSProperties };
-  attributes: { [key: string]: { [key: string]: string } | undefined };
+  popperStyles: React.CSSProperties;
+  floatingProps: ReturnType<UseInteractionsReturn["getFloatingProps"]>;
path: packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
user: login: taylorvnoj
body: We are also now loosing half of the Combobox when it's near the edge of the browser
![Screenshot 2025-03-25 at 2 24 36 PM](https://github.com/user-attachments/assets/fb43287a-6eda-4960-ba11-3f78e9130ab0)

Whereas before, we'd see the Combobox menu fully:
![Screenshot 2025-03-25 at 2 27 39 PM](https://github.com/user-attachments/assets/3c0cd94b-825e-4cee-b860-e7772b07449e)

created_at: 2025-03-25T18:27:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,8 +21,8 @@ export function useComboboxAccessibility(
   wrapperRef: React.RefObject<HTMLDivElement>,
 ): {
   popperRef: React.RefObject<HTMLDivElement>;
-  popperStyles: { [key: string]: React.CSSProperties };
-  attributes: { [key: string]: { [key: string]: string } | undefined };
+  popperStyles: React.CSSProperties;
+  floatingProps: ReturnType<UseInteractionsReturn["getFloatingProps"]>;
path: packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
user: login: taylorvnoj
body: We can possibly utilize shift? https://floating-ui.com/docs/shift#shift
created_at: 2025-03-25T18:33:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,8 +21,8 @@ export function useComboboxAccessibility(
   wrapperRef: React.RefObject<HTMLDivElement>,
 ): {
   popperRef: React.RefObject<HTMLDivElement>;
-  popperStyles: { [key: string]: React.CSSProperties };
-  attributes: { [key: string]: { [key: string]: string } | undefined };
+  popperStyles: React.CSSProperties;
+  floatingProps: ReturnType<UseInteractionsReturn["getFloatingProps"]>;
path: packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
user: login: taylorvnoj
body: Yup looks like we can call `shift()` in the `middleware` - you can also do `shift({ padding: 8 })` which might be nice to have a bit of a gap
created_at: 2025-03-25T18:39:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,8 +21,8 @@ export function useComboboxAccessibility(
   wrapperRef: React.RefObject<HTMLDivElement>,
 ): {
   popperRef: React.RefObject<HTMLDivElement>;
-  popperStyles: { [key: string]: React.CSSProperties };
-  attributes: { [key: string]: { [key: string]: string } | undefined };
+  popperStyles: React.CSSProperties;
+  floatingProps: ReturnType<UseInteractionsReturn["getFloatingProps"]>;
path: packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
user: login: MichaelParadis
body: @taylorvnoj resolved this in 0c08c33bf
created_at: 2025-03-25T20:03:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -25,29 +31,34 @@ export function useComboboxAccessibility(
   useRefocusOnActivator(open);
 
   const popperRef = useFocusTrap<HTMLDivElement>(open);
-  const {
-    styles: popperStyles,
-    attributes,
-    update,
-  } = usePopper(wrapperRef.current, popperRef.current, {
-    modifiers: [
-      {
-        name: "flip",
-        options: {
-          fallbackPlacements: ["top-start"],
-        },
-      },
+
+  const { floatingStyles, update, context } = useFloating({
+    elements: {
+      reference: wrapperRef.current,
+      floating: popperRef.current,
+    },
+    open,
+    onOpenChange: openState => {
+      if (!openState) handleClose();
+    },
+    middleware: [
+      autoPlacement({ allowedPlacements: ["bottom-start", "top-start"] }),
path: packages/components/src/Combobox/hooks/useComboboxAccessibility.ts
user: login: MichaelParadis
body: Resolved in 0c08c33bf
created_at: 2025-03-25T20:03:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-03-20T19:07:46Z
body: ## Deploying atlantis with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>0c08c33</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://a74ea6e5.atlantis.pages.dev'>https://a74ea6e5.atlantis.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://job-84045-job-119041-replace.atlantis.pages.dev'>https://job-84045-job-119041-replace.atlantis.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/afcb5b64e57606ea3b66c95558cfd059/pages/view/atlantis/a74ea6e5-f0d3-4176-8372-6b4f165f1e50)

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-24T13:42:51Z
body: Published Pre-release for 0c08c33bfedc9eee1a6ef2bbcd27629cbdf990da with versions:
```
  - @jobber/components@6.22.1-JOB-84045-0c08c33.4+0c08c33b
```

To install the new version(s) for Web run:
```
npm install @jobber/components@6.22.1-JOB-84045-0c08c33.4+0c08c33b
```
author_association: NONE