diff --git a/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts new file mode 100644 index 000000000..2c6b6a4dd --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts @@ -0,0 +1,195 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, test, vi } from "vitest"; +import { swipeSidebar } from "./swipe-sidebar.ts"; + +let destroy: (() => void) | undefined; +afterEach(() => { + destroy?.(); + destroy = undefined; + document.body.replaceChildren(); + document.getSelection()?.removeAllRanges(); + vi.restoreAllMocks(); +}); + +describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => { + function fixture(html = "
Page content
") { + const node = document.createElement("div"); + node.innerHTML = html; + document.body.append(node); + let enabled = true; + let isOpen = initiallyOpen; + const open = vi.fn((next: boolean) => { + isOpen = next; + }); + const listeners = vi.spyOn(node, "addEventListener"); + destroy = swipeSidebar(node, { + enabled: () => enabled, + isOpen: () => isOpen, + setOpen: open, + }).destroy; + const target = node.firstElementChild!; + const send = ( + type: string, + x: number, + y: number, + time = 100, + count = 1, + id = 1, + ) => { + const touch = { + identifier: id, + clientX: initiallyOpen ? window.innerWidth - x : x, + clientY: y, + }; + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + touches: { value: type === "touchend" ? [] : Array(count).fill(touch) }, + changedTouches: { value: [touch] }, + timeStamp: { value: time }, + }); + target.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }; + return { + node, + target, + open, + send, + listeners, + flipState: () => { + isOpen = !isOpen; + }, + disable: () => { + enabled = false; + }, + }; + } + + test("intentional swipe changes state on release without claiming native touch events", () => { + const { node, open, send, listeners } = fixture(); + send("touchstart", 120, 200); + send("touchmove", 150, 203, 150); + send("touchmove", 230, 205, 200); + expect(open).not.toHaveBeenCalled(); + send("touchend", 230, 205, 250); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith(!initiallyOpen); + expect(node.style.touchAction).toBe(""); + for (const [name, , options] of listeners.mock.calls) { + if (String(name).startsWith("touch")) { + expect(options).toEqual({ passive: true }); + } + } + }); + + for ( + const [name, points] of [ + ["tap", [[120, 200], [122, 201]]], + ["short horizontal motion", [[120, 200], [170, 202]]], + ["vertical scroll", [[120, 200], [125, 250], [240, 252]]], + ["diagonal motion", [[120, 200], [140, 214], [230, 218]]], + ["opposite direction", [[240, 200], [210, 202], [120, 203]]], + ["vertical excursion before returning", [ + [120, 200], + [150, 202], + [230, 240], + [240, 201], + ]], + ["browser left edge", [[10, 200], [150, 201]]], + ] as const + ) { + test(`${name} does not open the sidebar`, () => { + const { open, send } = fixture(); + points.forEach(([x, y], i) => + send(i === 0 ? "touchstart" : "touchmove", x, y, 100 + i * 30) + ); + const [x, y] = points[points.length - 1]; + send("touchend", x, y, 300); + expect(open).not.toHaveBeenCalled(); + }); + } + + for ( + const html of [ + '', + "", + "", + "", + '