From b86f03ad8844a7a30b367d87a370a1af3780b793 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Sun, 11 Oct 2026 17:21:35 +0200 Subject: [PATCH] fix: track D9Menu's position only while it is open (BITS-208) The floating wrapper is now rendered only while the menu is open, so floating-ui's autoUpdate no longer keeps scroll/resize listeners, a ResizeObserver and an IntersectionObserver for every closed menu. MenuItems is static so its content stays during the leave transition, and useFloating positions with left/top so the slide-in transform on the wrapper isn't overridden. Co-Authored-By: Claude Opus 5.5 --- src/Menu/MenuContainer.vue | 31 ++++++++++------ tests/Menu.spec.ts | 73 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 tests/Menu.spec.ts diff --git a/src/Menu/MenuContainer.vue b/src/Menu/MenuContainer.vue index 529176d..2a568d6 100644 --- a/src/Menu/MenuContainer.vue +++ b/src/Menu/MenuContainer.vue @@ -1,5 +1,5 @@ @@ -76,6 +83,8 @@ const placement = props.position === "left" ? "bottom-end" : "bottom-start"; const { floatingStyles } = useFloating(reference, floating, { placement, + // left/top instead of a transform, so the transition's translate isn't overridden + transform: false, whileElementsMounted: autoUpdate, middleware: [offset(6), flip(), shift({ padding: 6 })], }); diff --git a/tests/Menu.spec.ts b/tests/Menu.spec.ts new file mode 100644 index 0000000..7ff4e4f --- /dev/null +++ b/tests/Menu.spec.ts @@ -0,0 +1,73 @@ +import { mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { h } from "vue"; +import { D9Menu, D9MenuLink } from "../src"; + +// jsdom has neither observer, so count the live ones floating-ui's autoUpdate creates. +let observers = 0; +class FakeObserver { + constructor() { + observers++; + } + observe() {} + unobserve() {} + disconnect() { + observers--; + } +} + +// In jsdom, window is the only scroll ancestor autoUpdate listens on. +let listeners = 0; +const countListeners = (method: "addEventListener" | "removeEventListener") => { + const original = window[method]; + vi.spyOn(window, method).mockImplementation((...args) => { + if (args[0] === "scroll" || args[0] === "resize") { + listeners += method === "addEventListener" ? 1 : -1; + } + return original.apply(window, args); + }); +}; + +beforeEach(() => { + observers = 0; + listeners = 0; + vi.stubGlobal("ResizeObserver", FakeObserver); + vi.stubGlobal("IntersectionObserver", FakeObserver); + // autoUpdate only watches layout shifts of a reference that has a size. + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 20, 20) + ); + countListeners("addEventListener"); + countListeners("removeEventListener"); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +// useFloating styles the floating wrapper inline. +const floating = () => document.querySelectorAll('[style*="position"]').length; + +describe("D9Menu", () => { + it("tracks its position only while open", async () => { + const menus = [1, 2, 3].map(() => + mount(D9Menu, { + props: { position: "right", usePortal: true }, + slots: { default: () => h(D9MenuLink, { label: "Account" }) }, + attachTo: document.body, + }) + ); + expect([floating(), observers, listeners]).toEqual([0, 0, 0]); + + await menus[0].find("button").trigger("click"); + expect(floating()).toBe(1); + expect(observers).toBeGreaterThan(0); + expect(listeners).toBeGreaterThan(0); + + await menus[0].find("button").trigger("click"); + expect([floating(), observers, listeners]).toEqual([0, 0, 0]); + + menus.forEach((menu) => menu.unmount()); + }); +});