import { describe, expect, it } from "vitest"; import { act, fireEvent, renderControlsPanelWithSchema, screen, vi, } from "../testing/controls-panel-test-utils"; import { createNavigationSchema, createRect, prepareScrollablePanel, } from "../testing/controls-panel-section-navigation-test-utils"; describe("ControlsPanel section navigation", () => { it("requires a 300ms dwell within the 12px left padding and cancels a fast crossing", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema( createNavigationSchema(), ); const { panel } = prepareScrollablePanel(container); act(() => vi.advanceTimersByTime(0)); fireEvent.pointerMove(panel, { clientX: 413, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(299)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); fireEvent.pointerLeave(panel, { clientX: 399, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); } finally { vi.useRealTimers(); } }); it("preserves panel-to-popup transfer and requires a fresh dwell after exit", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema( createNavigationSchema(), ); const { panel } = prepareScrollablePanel(container); act(() => vi.advanceTimersByTime(0)); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); const navigation = screen.getByRole("navigation", { name: "Panel sections", }); const surface = navigation.closest( "[data-toolcraft-controls-section-navigation-surface]", ); expect(surface).toBeTruthy(); expect(surface?.dataset.state).toBe("open"); expect(surface?.className).toContain("duration-[120ms]"); expect(surface?.className).toContain("data-[state=open]:animate-in"); expect(surface?.className).toContain("data-[state=closed]:animate-out"); fireEvent.pointerLeave(panel, { clientX: 399, clientY: 120 }); act(() => vi.advanceTimersByTime(49)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); fireEvent.pointerEnter(surface!); act(() => vi.advanceTimersByTime(1)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); fireEvent.pointerLeave(surface!); act(() => vi.advanceTimersByTime(49)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); act(() => vi.advanceTimersByTime(1)); expect(surface?.dataset.state).toBe("closed"); expect(surface?.className).toContain("pointer-events-none"); act(() => vi.advanceTimersByTime(119)); expect( container.querySelector( "[data-toolcraft-controls-section-navigation-surface]", ), ).toBeTruthy(); act(() => vi.advanceTimersByTime(1)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(299)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); act(() => vi.advanceTimersByTime(1)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); } finally { vi.useRealTimers(); } }); it("keeps the popup open during a slow gap crossing and movement inside it", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema( createNavigationSchema(), ); const { panel } = prepareScrollablePanel(container); act(() => vi.advanceTimersByTime(0)); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); const navigation = screen.getByRole("navigation", { name: "Panel sections", }); const surface = navigation.closest( "[data-toolcraft-controls-section-navigation-surface]", ); expect(surface).toBeTruthy(); vi.spyOn(surface!, "getBoundingClientRect").mockReturnValue( createRect({ height: 220, left: 220, top: 90, width: 168 }), ); fireEvent.pointerLeave(panel, { clientX: 399, clientY: 180 }); act(() => vi.advanceTimersByTime(40)); fireEvent.pointerMove(document, { clientX: 394, clientY: 180 }); act(() => vi.advanceTimersByTime(100)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); fireEvent.pointerMove(document, { clientX: 300, clientY: 180 }); act(() => vi.advanceTimersByTime(200)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); fireEvent.pointerMove(document, { clientX: 100, clientY: 400 }); act(() => vi.advanceTimersByTime(49)); expect( screen.getByRole("navigation", { name: "Panel sections" }), ).toBeTruthy(); act(() => vi.advanceTimersByTime(1)); expect(surface?.dataset.state).toBe("closed"); act(() => vi.advanceTimersByTime(120)); expect( screen.queryByRole("navigation", { name: "Panel sections" }), ).toBeNull(); } finally { vi.useRealTimers(); } }); it("keeps the shared layout and jumps to the current section immediately", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema( createNavigationSchema(), ); const { panel, scrollTo, viewport } = prepareScrollablePanel(container); viewport.scrollTop = 120; fireEvent.scroll(viewport); act(() => vi.advanceTimersByTime(0)); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); const navigation = screen.getByRole("navigation", { name: "Panel sections", }); const surface = navigation.closest( "[data-toolcraft-controls-section-navigation-surface]", ); const positioner = surface?.parentElement; const list = navigation.querySelector( "[data-toolcraft-controls-section-navigation-list]", ); const scrollFade = navigation.closest( '[data-slot="scroll-fade"]', ); const scrollViewport = scrollFade?.querySelector( "[data-scroll-fade-viewport]", ); const fieldButton = screen.getByRole("button", { name: "Field" }); const windButton = screen.getByRole("button", { name: "Wind" }); const navigationLabels = Array.from( navigation.querySelectorAll("button"), (button) => button.textContent, ); expect(navigationLabels).toEqual(["Settings", "Field", "Wind"]); expect(positioner?.style.right).toBe("calc(100% + 8px)"); expect(positioner?.style.top).toBe("200px"); expect(surface?.style.maxHeight).toBe("300px"); expect(surface?.className).toContain("py-2"); expect(surface?.className).not.toContain("px-3"); expect(scrollFade).toBeTruthy(); expect(scrollViewport?.style.maxHeight).toBe("284px"); expect(scrollViewport?.className).toContain("overflow-y-auto"); expect(navigation.className).not.toContain("overflow-y-auto"); expect(list?.className).toContain("gap-2"); expect(list?.className).toContain("px-3"); expect(list?.className).not.toContain("px-2"); expect(fieldButton.className).toContain("text-[13px]"); expect(fieldButton.className).toContain("px-0"); expect(fieldButton.className).not.toContain("pl-3"); expect(fieldButton.className).toContain( "data-[current=true]:text-[color:var(--foreground)]", ); expect(fieldButton.className).not.toContain("before:"); expect(windButton.className).not.toContain("before:"); expect(fieldButton.className).toContain("focus-visible:ring-2"); expect(windButton.className).toContain("var(--foreground)_50%"); expect(windButton.className).toContain( "hover:text-[color:color-mix(in_oklab,var(--foreground)_80%,transparent)]", ); expect(fieldButton.getAttribute("aria-current")).toBe("location"); expect(fieldButton.getAttribute("data-current")).toBe("true"); fireEvent.click(windButton); expect(scrollTo).toHaveBeenCalledWith({ behavior: "auto", top: 300 }); expect(windButton.getAttribute("aria-current")).toBe("location"); } finally { vi.useRealTimers(); } }); it("switches sections with wrapping Arrow Up and Arrow Down navigation", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema( createNavigationSchema(), ); const { panel, scrollTo } = prepareScrollablePanel(container); act(() => vi.advanceTimersByTime(0)); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); const setupButton = screen.getByRole("button", { name: "Settings" }); const fieldButton = screen.getByRole("button", { name: "Field" }); const windButton = screen.getByRole("button", { name: "Wind" }); fieldButton.focus(); fireEvent.keyDown(fieldButton, { key: "ArrowDown" }); expect(document.activeElement).toBe(windButton); expect(windButton.getAttribute("aria-current")).toBe("location"); expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 180, }); fireEvent.keyDown(windButton, { key: "ArrowDown" }); expect(document.activeElement).toBe(setupButton); expect(setupButton.getAttribute("aria-current")).toBe("location"); fireEvent.keyDown(setupButton, { key: "ArrowUp" }); expect(document.activeElement).toBe(windButton); expect(windButton.getAttribute("aria-current")).toBe("location"); } finally { vi.useRealTimers(); } }); it("shows bottom and top fades from the navigation scroll position", () => { vi.useFakeTimers(); try { const { container } = renderControlsPanelWithSchema( createNavigationSchema(), ); const { panel } = prepareScrollablePanel(container); act(() => vi.advanceTimersByTime(0)); fireEvent.pointerMove(panel, { clientX: 408, clientY: 120 }); act(() => vi.advanceTimersByTime(300)); const navigation = screen.getByRole("navigation", { name: "Panel sections", }); const scrollViewport = navigation.closest( "[data-scroll-fade-viewport]", ); expect(scrollViewport).toBeTruthy(); Object.defineProperties(scrollViewport!, { clientHeight: { configurable: true, value: 40 }, scrollHeight: { configurable: true, value: 120 }, scrollTop: { configurable: true, value: 0, writable: true }, }); fireEvent.scroll(scrollViewport!); expect( scrollViewport?.style.getPropertyValue("--scroll-fade-leading-fade"), ).toBe("0px"); expect( scrollViewport?.style.getPropertyValue("--scroll-fade-trailing-fade"), ).toBe("20px"); scrollViewport!.scrollTop = 40; fireEvent.scroll(scrollViewport!); expect( scrollViewport?.style.getPropertyValue("--scroll-fade-leading-fade"), ).toBe("20px"); expect( scrollViewport?.style.getPropertyValue("--scroll-fade-trailing-fade"), ).toBe("20px"); scrollViewport!.scrollTop = 80; fireEvent.scroll(scrollViewport!); expect( scrollViewport?.style.getPropertyValue("--scroll-fade-leading-fade"), ).toBe("20px"); expect( scrollViewport?.style.getPropertyValue("--scroll-fade-trailing-fade"), ).toBe("0px"); } finally { vi.useRealTimers(); } }); });