import { describe, expect, it } from "vitest"; import { act, defineToolcraft, fireEvent, renderControlsPanelWithSchema, screen, vi, } from "../testing/controls-panel-test-utils"; function createNavigationSchema() { return defineToolcraft({ canvas: { enabled: false }, panels: { controls: { sections: [ { controls: { density: { defaultValue: 50, label: "Density", max: 100, min: 0, target: "field.density", type: "slider", }, }, title: "Field", }, { controls: { enabled: { defaultValue: true, label: "Enabled", target: "wind.enabled", type: "switch", }, }, title: "Wind", }, { actionGroup: "secondary", controls: { export: { actions: [ { command: "controls.apply", label: "Export", value: "export", variant: "default", }, ], target: "panel.actions", type: "panelActions", }, }, title: "Export", }, ], title: "Grass Studio", }, }, }); } function createRect({ height, left = 0, top, width = 300, }: { height: number; left?: number; top: number; width?: number; }): DOMRect { return { bottom: top + height, height, left, right: left + width, toJSON: () => ({}), top, width, x: left, y: top, }; } function prepareScrollablePanel(container: HTMLElement) { const root = container.querySelector( "[data-toolcraft-controls-panel-shell]", ); const viewport = container.querySelector( '[data-slot="toolcraft-panel-content"]', ); const panel = container.querySelector( '[data-panel-id="properties"]', ); const sectionAnchors = Array.from( container.querySelectorAll( "[data-toolcraft-controls-section-anchor]", ), ); expect(root).toBeTruthy(); expect(panel).toBeTruthy(); expect(viewport).toBeTruthy(); expect(sectionAnchors).toHaveLength(4); Object.defineProperties(viewport!, { clientHeight: { configurable: true, value: 300 }, scrollHeight: { configurable: true, value: 900 }, scrollTop: { configurable: true, value: 0, writable: true }, }); vi.spyOn(root!, "getBoundingClientRect").mockReturnValue( createRect({ height: 360, left: 400, top: 10 }), ); vi.spyOn(panel!, "getBoundingClientRect").mockReturnValue( createRect({ height: 360, left: 400, top: 10 }), ); vi.spyOn(viewport!, "getBoundingClientRect").mockReturnValue( createRect({ height: 300, left: 400, top: 60 }), ); const sectionTops = [-200, 40, 240, 380]; sectionAnchors.forEach((section, index) => { vi.spyOn(section, "getBoundingClientRect").mockImplementation(() => createRect({ height: 180, left: 400, top: sectionTops[index] ?? 0, }), ); }); const scrollTo = vi.fn(); Object.defineProperty(viewport!, "scrollTo", { configurable: true, value: scrollTo, }); return { panel: panel!, root: root!, scrollTo, sectionAnchors, viewport: viewport!, }; } 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(["Setup", "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: "Setup" }); 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(); } }); });