Skip to content

Commit

Permalink
fix: defer floating-ui updating until component is connected and open (
Browse files Browse the repository at this point in the history
…#9443)

**Related Issue:** #9397

## Summary

This updates the `FloatingUIComponent` implementation to defer calling
`autoUpdate` until the component is open and connected (following
`floating-ui` [usage
notes](https://floating-ui.com/docs/autoupdate#usage)).
  • Loading branch information
jcfranco authored and benelan committed May 29, 2024
1 parent 4c55427 commit 4b30d71
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 101 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ export class Combobox
//
// --------------------------------------------------------------------------

connectedCallback(): void {
async connectedCallback(): Promise<void> {
connectInteractive(this);
connectLocalized(this);
connectMessages(this);
Expand All @@ -472,7 +472,9 @@ export class Combobox
onToggleOpenCloseComponent(this);
}

await componentOnReady(this.el);
connectFloatingUI(this, this.referenceEl, this.floatingEl);
afterConnectDefaultValueSet(this, this.getValue());
}

async componentWillLoad(): Promise<void> {
Expand All @@ -482,8 +484,6 @@ export class Combobox
}

componentDidLoad(): void {
afterConnectDefaultValueSet(this, this.getValue());
connectFloatingUI(this, this.referenceEl, this.floatingEl);
setComponentLoaded(this);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { createObserver } from "../../utils/observers";
import { onToggleOpenCloseComponent, OpenCloseComponent } from "../../utils/openCloseComponent";
import { RequestedItem } from "../dropdown-group/interfaces";
import { Scale } from "../interfaces";
import { componentOnReady } from "../../utils/component";
import { ItemKeyboardEvent } from "./interfaces";
import { SLOTS } from "./resources";

Expand Down Expand Up @@ -197,7 +198,7 @@ export class Dropdown
//
//--------------------------------------------------------------------------

connectedCallback(): void {
async connectedCallback(): Promise<void> {
this.mutationObserver?.observe(this.el, { childList: true, subtree: true });
this.setFilteredPlacements();
if (this.open) {
Expand All @@ -206,6 +207,8 @@ export class Dropdown
}
connectInteractive(this);
this.updateItems();

await componentOnReady(this.el);
connectFloatingUI(this, this.referenceEl, this.floatingEl);
}

Expand All @@ -215,7 +218,6 @@ export class Dropdown

componentDidLoad(): void {
setComponentLoaded(this);
connectFloatingUI(this, this.referenceEl, this.floatingEl);
}

componentDidRender(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import {
FocusTrapComponent,
} from "../../utils/focusTrapComponent";
import { guid } from "../../utils/guid";
import { getIconScale } from "../../utils/component";
import { componentOnReady, getIconScale } from "../../utils/component";
import { Status } from "../interfaces";
import { Validation } from "../functional/Validation";
import { normalizeToCurrentCentury, isTwoDigitYear } from "./utils";
Expand Down Expand Up @@ -461,7 +461,7 @@ export class InputDatePicker
//
// --------------------------------------------------------------------------

connectedCallback(): void {
async connectedCallback(): Promise<void> {
connectInteractive(this);
connectLocalized(this);

Expand Down Expand Up @@ -508,7 +508,9 @@ export class InputDatePicker
onToggleOpenCloseComponent(this);
}

await componentOnReady(this.el);
connectFloatingUI(this, this.referenceEl, this.floatingEl);
this.localizeInputValues();
}

async componentWillLoad(): Promise<void> {
Expand All @@ -520,8 +522,6 @@ export class InputDatePicker

componentDidLoad(): void {
setComponentLoaded(this);
this.localizeInputValues();
connectFloatingUI(this, this.referenceEl, this.floatingEl);
}

disconnectedCallback(): void {
Expand Down
17 changes: 6 additions & 11 deletions packages/calcite-components/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import {
} from "../../utils/loadable";
import { createObserver } from "../../utils/observers";
import { FloatingArrow } from "../functional/FloatingArrow";
import { getIconScale } from "../../utils/component";
import { componentOnReady, getIconScale } from "../../utils/component";
import PopoverManager from "./PopoverManager";
import { PopoverMessages } from "./assets/popover/t9n";
import { ARIA_CONTROLS, ARIA_EXPANDED, CSS, defaultPopoverPlacement } from "./resources";
Expand Down Expand Up @@ -278,8 +278,6 @@ export class Popover

transitionEl: HTMLDivElement;

hasLoaded = false;

focusTrap: FocusTrap;

// --------------------------------------------------------------------------
Expand All @@ -288,16 +286,18 @@ export class Popover
//
// --------------------------------------------------------------------------

connectedCallback(): void {
async connectedCallback(): Promise<void> {
this.setFilteredPlacements();
connectLocalized(this);
connectMessages(this);
this.setUpReferenceElement(this.hasLoaded);

await componentOnReady(this.el);
this.setUpReferenceElement();
connectFocusTrap(this);

if (this.open) {
onToggleOpenCloseComponent(this);
}
connectFloatingUI(this, this.effectiveReferenceElement, this.el);
}

async componentWillLoad(): Promise<void> {
Expand All @@ -307,11 +307,6 @@ export class Popover

componentDidLoad(): void {
setComponentLoaded(this);
if (this.referenceElement && !this.effectiveReferenceElement) {
this.setUpReferenceElement();
}
connectFloatingUI(this, this.effectiveReferenceElement, this.el);
this.hasLoaded = true;
}

disconnectedCallback(): void {
Expand Down
18 changes: 5 additions & 13 deletions packages/calcite-components/src/components/tooltip/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { guid } from "../../utils/guid";
import { onToggleOpenCloseComponent, OpenCloseComponent } from "../../utils/openCloseComponent";
import { FloatingArrow } from "../functional/FloatingArrow";
import { componentOnReady } from "../../utils/component";
import { ARIA_DESCRIBED_BY, CSS } from "./resources";
import TooltipManager from "./TooltipManager";
import { getEffectiveReferenceElement } from "./utils";
Expand Down Expand Up @@ -146,8 +147,6 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent {

guid = `calcite-tooltip-${guid()}`;

hasLoaded = false;

openTransitionProp = "opacity";

transitionEl: HTMLDivElement;
Expand All @@ -158,12 +157,13 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent {
//
// --------------------------------------------------------------------------

connectedCallback(): void {
this.setUpReferenceElement(this.hasLoaded);
async connectedCallback(): Promise<void> {
await componentOnReady(this.el);
this.setUpReferenceElement(true);

if (this.open) {
onToggleOpenCloseComponent(this);
}
connectFloatingUI(this, this.effectiveReferenceElement, this.el);
}

async componentWillLoad(): Promise<void> {
Expand All @@ -172,14 +172,6 @@ export class Tooltip implements FloatingUIComponent, OpenCloseComponent {
}
}

componentDidLoad(): void {
if (this.referenceElement && !this.effectiveReferenceElement) {
this.setUpReferenceElement();
}
connectFloatingUI(this, this.effectiveReferenceElement, this.el);
this.hasLoaded = true;
}

disconnectedCallback(): void {
this.removeReferences();
disconnectFloatingUI(this, this.effectiveReferenceElement, this.el);
Expand Down
127 changes: 78 additions & 49 deletions packages/calcite-components/src/utils/floating-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as floatingUI from "./floating-ui";
import { FloatingUIComponent } from "./floating-ui";

const {
cleanupMap,
autoUpdatingComponentMap,
connectFloatingUI,
defaultOffsetDistance,
disconnectFloatingUI,
Expand Down Expand Up @@ -36,28 +36,40 @@ it("should set calcite placement to FloatingUI placement", () => {
expect(getEffectivePlacement(el, "trailing-end")).toBe("left-end");
});

function createFakeFloatingUiComponent(referenceEl: HTMLElement, floatingEl: HTMLElement): FloatingUIComponent {
const fake: FloatingUIComponent = {
open: false,
reposition: async () => {
await reposition(fake, {
floatingEl,
referenceEl,
overlayPositioning: fake.overlayPositioning,
placement: "top",
flipPlacements: [],
type: "menu",
});
},
overlayPositioning: "absolute",
placement: "auto",
};

return fake;
}

describe("repositioning", () => {
let fakeFloatingUiComponent: FloatingUIComponent;
let floatingEl: HTMLDivElement;
let referenceEl: HTMLButtonElement;
let positionOptions: Parameters<typeof positionFloatingUI>[1];

function createFakeFloatingUiComponent(): FloatingUIComponent {
return {
open: false,
reposition: async () => {
/* noop */
},
overlayPositioning: "absolute",
placement: "auto",
};
}

beforeEach(() => {
fakeFloatingUiComponent = createFakeFloatingUiComponent();

floatingEl = document.createElement("div");
referenceEl = document.createElement("button");
floatingEl = document.createElement("div");

document.body.append(floatingEl);
document.body.append(referenceEl);

fakeFloatingUiComponent = createFakeFloatingUiComponent(referenceEl, floatingEl);

positionOptions = {
floatingEl,
Expand All @@ -66,6 +78,8 @@ describe("repositioning", () => {
placement: fakeFloatingUiComponent.placement,
type: "popover",
};

connectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);
});

function assertPreOpenPositioning(floatingEl: HTMLElement): void {
Expand Down Expand Up @@ -112,55 +126,70 @@ describe("repositioning", () => {
assertOpenPositioning(floatingEl);
});

describe("connect/disconnect helpers", () => {
it("has connectedCallback and disconnectedCallback helpers", () => {
expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(false);
expect(floatingEl.style.position).toBe("");
expect(floatingEl.style.visibility).toBe("");
expect(floatingEl.style.pointerEvents).toBe("");
it("debounces positioning per instance", async () => {
const positionSpy = jest.spyOn(floatingUI, "positionFloatingUI");
fakeFloatingUiComponent.open = true;

connectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);
const anotherFakeFloatingUiComponent = createFakeFloatingUiComponent(referenceEl, floatingEl);
anotherFakeFloatingUiComponent.open = true;

expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(true);
expect(floatingEl.style.position).toBe("absolute");
expect(floatingEl.style.visibility).toBe("hidden");
expect(floatingEl.style.pointerEvents).toBe("none");
floatingUI.reposition(fakeFloatingUiComponent, positionOptions, true);
expect(positionSpy).toHaveBeenCalledTimes(1);

disconnectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);
floatingUI.reposition(anotherFakeFloatingUiComponent, positionOptions, true);
expect(positionSpy).toHaveBeenCalledTimes(2);

expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(false);
expect(floatingEl.style.position).toBe("absolute");
await new Promise<void>((resolve) => setTimeout(resolve, repositionDebounceTimeout));
expect(positionSpy).toHaveBeenCalledTimes(2);
});
});

fakeFloatingUiComponent.overlayPositioning = "fixed";
connectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);
describe("connect/disconnect helpers", () => {
let fakeFloatingUiComponent: FloatingUIComponent;
let floatingEl: HTMLDivElement;
let referenceEl: HTMLButtonElement;

expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(true);
expect(floatingEl.style.position).toBe("fixed");
expect(floatingEl.style.visibility).toBe("hidden");
expect(floatingEl.style.pointerEvents).toBe("none");
beforeEach(() => {
referenceEl = document.createElement("button");
floatingEl = document.createElement("div");

disconnectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);
document.body.append(floatingEl);
document.body.append(referenceEl);

expect(cleanupMap.has(fakeFloatingUiComponent)).toBe(false);
expect(floatingEl.style.position).toBe("fixed");
});
fakeFloatingUiComponent = createFakeFloatingUiComponent(referenceEl, floatingEl);
});

it("debounces positioning per instance", async () => {
const positionSpy = jest.spyOn(floatingUI, "positionFloatingUI");
it("has connectedCallback and disconnectedCallback helpers", async () => {
fakeFloatingUiComponent.open = true;
expect(autoUpdatingComponentMap.has(fakeFloatingUiComponent)).toBe(false);
expect(floatingEl.style.position).toBe("");
expect(floatingEl.style.visibility).toBe("");
expect(floatingEl.style.pointerEvents).toBe("");

const anotherFakeFloatingUiComponent = createFakeFloatingUiComponent();
anotherFakeFloatingUiComponent.open = true;
await connectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);

floatingUI.reposition(fakeFloatingUiComponent, positionOptions, true);
expect(positionSpy).toHaveBeenCalledTimes(1);
expect(autoUpdatingComponentMap.has(fakeFloatingUiComponent)).toBe(true);
expect(floatingEl.style.position).toBe("absolute");
expect(floatingEl.style.visibility).toBe("hidden");
expect(floatingEl.style.pointerEvents).toBe("none");

floatingUI.reposition(anotherFakeFloatingUiComponent, positionOptions, true);
expect(positionSpy).toHaveBeenCalledTimes(2);
disconnectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);

await new Promise<void>((resolve) => setTimeout(resolve, repositionDebounceTimeout));
expect(positionSpy).toHaveBeenCalledTimes(2);
expect(autoUpdatingComponentMap.has(fakeFloatingUiComponent)).toBe(false);
expect(floatingEl.style.position).toBe("absolute");

fakeFloatingUiComponent.overlayPositioning = "fixed";
await connectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);

expect(autoUpdatingComponentMap.has(fakeFloatingUiComponent)).toBe(true);
expect(floatingEl.style.position).toBe("fixed");
expect(floatingEl.style.visibility).toBe("hidden");
expect(floatingEl.style.pointerEvents).toBe("none");

disconnectFloatingUI(fakeFloatingUiComponent, referenceEl, floatingEl);

expect(autoUpdatingComponentMap.has(fakeFloatingUiComponent)).toBe(false);
expect(floatingEl.style.position).toBe("fixed");
});
});

Expand Down
Loading

0 comments on commit 4b30d71

Please sign in to comment.