406 lines · 13.4 KB · TypeScript
RawDownload
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { observeSelectionActions, resolveSelectionActionPosition } from "./selectionActions"; function event(type: string, values: Record<string, unknown> = {}) {  return Object.assign(new Event(type, { cancelable: true }), values);} function createSelectionSurface({ interactiveActions = false } = {}) {  const frames = new Map<number, FrameRequestCallback>();  let frameId = 0;  const view = Object.assign(new EventTarget(), {    setTimeout: (callback: () => void, delay: number) => setTimeout(callback, delay),    clearTimeout: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),    requestAnimationFrame: (callback: FrameRequestCallback) => {      frames.set(++frameId, callback);      return frameId;    },    cancelAnimationFrame: (id: number) => frames.delete(id),  });  const document = Object.assign(new EventTarget(), {    defaultView: view,    activeElement: null as EventTarget | null,  });  const element = Object.assign(new EventTarget(), {    ownerDocument: document,    contains: (target: unknown): boolean => target === element,  });  const onSelection = vi.fn();  const onDismiss = vi.fn();  const field = new EventTarget();  const actionElement = Object.assign(new EventTarget(), {    contains: (target: unknown) => target === actionElement || target === field,  });  const actions = observeSelectionActions({    element: element as unknown as HTMLElement,    ...(interactiveActions      ? { getActionElement: () => actionElement as unknown as HTMLElement }      : {}),    onSelection,    onDismiss,  });  document.addEventListener("selectionchange", actions.selectionChanged);  const change = () => document.dispatchEvent(event("selectionchange"));  const down = ({    inside = true,    button = 0,    consumed = false,    isPrimary = true,    target = inside ? element : document,  }: {    inside?: boolean;    button?: number;    consumed?: boolean;    isPrimary?: boolean;    target?: EventTarget;  } = {}) => {    const press = event("pointerdown", { button, isPrimary });    Object.defineProperty(press, "target", { value: target });    document.dispatchEvent(press);    if (target === element && !consumed) element.dispatchEvent(press);  };  const focus = (target: EventTarget) => {    document.activeElement = target;    const focusEvent = event("focusin");    Object.defineProperty(focusEvent, "target", { value: target });    document.dispatchEvent(focusEvent);  };  const key = (key: string, target: EventTarget = document) => {    const keyEvent = event("keydown", { key });    Object.defineProperty(keyEvent, "target", { value: target });    document.dispatchEvent(keyEvent);  };  const pointerUp = (button = 0) => {    view.dispatchEvent(event("pointerup", { isPrimary: true, button }));  };  const up = ({ x = 300, y = 200, detail = 1, button = 0 } = {}) => {    pointerUp(button);    view.dispatchEvent(event("mouseup", { clientX: x, clientY: y, detail, button }));  };  const flushFrame = () => {    const callbacks = [...frames.values()];    frames.clear();    for (const callback of callbacks) callback(0);  };  const flush = (milliseconds = 0) => {    vi.advanceTimersByTime(milliseconds);    flushFrame();  };  return {    actions,    view,    document,    element,    actionElement,    field,    onSelection,    onDismiss,    down,    focus,    key,    up,    pointerUp,    change,    flush,    flushFrame,  };} beforeEach(() => vi.useFakeTimers());afterEach(() => vi.useRealTimers()); describe("selection action gestures", () => {  it("stays hidden through selection changes while dragging and uses the release point", () => {    const surface = createSelectionSurface();    surface.down();    surface.change();    surface.flush(1000);    surface.change();    surface.flush(1000);    expect(surface.onSelection).not.toHaveBeenCalled();    surface.up({ x: 420, y: 310 });    surface.change();    surface.flush();    expect(surface.onSelection.mock.calls).toEqual([[{ x: 420, y: 310 }]]);  });   it("preserves the release anchor if the final selectionchange arrives after the menu opens", () => {    const surface = createSelectionSurface();    surface.down();    surface.up({ x: 180, y: 120 });    surface.flush();    surface.change();    surface.flush();    expect(surface.onSelection).toHaveBeenLastCalledWith({ x: 180, y: 120 });  });   it("lets a third click supersede the double-click popup before it opens", () => {    const surface = createSelectionSurface();    surface.down();    surface.up({ detail: 2 });    surface.change();    surface.flush(400);    expect(surface.onSelection).not.toHaveBeenCalled();    surface.down();    surface.change();    surface.flush(500);    expect(surface.onSelection).not.toHaveBeenCalled();    surface.up({ detail: 3, x: 310, y: 210 });    surface.change();    surface.flush(499);    expect(surface.onSelection).not.toHaveBeenCalled();    surface.flush(1);    expect(surface.onSelection.mock.calls).toEqual([[{ x: 310, y: 210 }]]);  });   it("accepts release outside the surface but never a gesture that started outside", () => {    const surface = createSelectionSurface();    surface.down({ inside: false });    surface.change();    surface.up();    surface.change();    surface.flush();    expect(surface.onSelection).not.toHaveBeenCalled();    surface.down();    surface.up({ x: 1800, y: -10 });    surface.flush();    expect(surface.onSelection).toHaveBeenCalledWith({ x: 1800, y: -10 });  });   it("does not treat a consumed link or terminal mouse-reporting press as selection", () => {    const surface = createSelectionSurface();    surface.down({ consumed: true });    surface.up();    surface.change();    surface.flush();    expect(surface.onSelection).not.toHaveBeenCalled();  });   it("ignores nonprimary pointers and non-left mouseup without ending a left drag", () => {    const surface = createSelectionSurface();    surface.down({ isPrimary: false });    surface.up();    surface.flush();    expect(surface.onSelection).not.toHaveBeenCalled();    surface.down();    surface.up({ button: 1 });    surface.change();    surface.flush();    expect(surface.onSelection).not.toHaveBeenCalled();    surface.up();    surface.flush();    expect(surface.onSelection).toHaveBeenCalledOnce();  });   it("uses the text fallback after keyboard selection rather than a stale pointer", () => {    const surface = createSelectionSurface();    surface.down();    surface.up();    surface.flush();    surface.document.dispatchEvent(event("keydown", { key: "ArrowRight", shiftKey: true }));    surface.change();    surface.flush();    expect(surface.onSelection).toHaveBeenLastCalledWith(null);  });   it("allows keyboard selection after a toolbar press suppresses compatibility mouseup", () => {    const surface = createSelectionSurface();    surface.down();    surface.up();    surface.flush();    surface.down({ inside: false });    surface.pointerUp();    surface.document.dispatchEvent(event("keydown", { key: "ArrowRight", shiftKey: true }));    surface.change();    surface.flush();    expect(surface.onSelection).toHaveBeenLastCalledWith(null);  });   it("waits for mouseup's click count even if selectionchange lands after pointerup", () => {    const surface = createSelectionSurface();    surface.down();    surface.pointerUp();    surface.change();    surface.flush();    expect(surface.onSelection).not.toHaveBeenCalled();    surface.up({ detail: 2 });    surface.flush(499);    expect(surface.onSelection).not.toHaveBeenCalled();    surface.flush(1);    expect(surface.onSelection).toHaveBeenCalledOnce();  });   it.each(["Escape", "contextmenu", "pointercancel", "blur", "resize", "scroll"])(    "%s dismisses pending actions and ignores a late selectionchange",    (reason) => {      const surface = createSelectionSurface();      surface.down();      surface.up({ detail: 2 });      if (reason === "Escape") surface.document.dispatchEvent(event("keydown", { key: reason }));      else if (reason === "contextmenu" || reason === "scroll")        surface.element.dispatchEvent(event(reason));      else surface.view.dispatchEvent(event(reason));      surface.change();      surface.flush(1000);      expect(surface.onSelection).not.toHaveBeenCalled();      surface.down();      surface.up();      surface.flush();      expect(surface.onSelection).toHaveBeenCalledOnce();    },  );   it("keeps an autoscrolling drag eligible to open on release", () => {    const surface = createSelectionSurface();    surface.down();    surface.element.dispatchEvent(event("scroll"));    surface.change();    surface.flush(1000);    expect(surface.onSelection).not.toHaveBeenCalled();    surface.up();    surface.flush();    expect(surface.onSelection).toHaveBeenCalledOnce();  });   it("cancels a queued animation frame when a new press supersedes the release", () => {    const surface = createSelectionSurface();    surface.down();    surface.up();    vi.advanceTimersByTime(0);    expect(surface.actions.pending).toBe(true);    surface.down();    surface.flushFrame();    expect(surface.onSelection).not.toHaveBeenCalled();    surface.up();    surface.flush();    expect(surface.onSelection).toHaveBeenCalledOnce();  });   it("teardown cancels deferred work and detaches gesture listeners", () => {    const surface = createSelectionSurface();    surface.down();    surface.up();    vi.advanceTimersByTime(0);    surface.actions.dispose();    surface.down();    surface.up();    surface.change();    surface.flush(1000);    expect(surface.onSelection).not.toHaveBeenCalled();  });}); describe("interactive selection actions", () => {  it("keeps the captured selection while clicking, typing, and selecting in a comment field", () => {    const surface = createSelectionSurface({ interactiveActions: true });    surface.down();    surface.up();    surface.flush();    surface.onDismiss.mockClear();    surface.down({ target: surface.field });    surface.focus(surface.field);    surface.change();    surface.up();    surface.key("a", surface.field);    surface.change();    surface.key("ArrowLeft", surface.field);    surface.change();    surface.flush(1000);    expect(surface.onDismiss).not.toHaveBeenCalled();    expect(surface.onSelection).toHaveBeenCalledOnce();  });   it("cancels pending source reads when keyboard focus enters the actions", () => {    const surface = createSelectionSurface({ interactiveActions: true });    surface.key("Tab");    surface.change();    vi.advanceTimersByTime(0);    expect(surface.actions.pending).toBe(true);    surface.focus(surface.field);    surface.change();    surface.flushFrame();    expect(surface.onSelection).not.toHaveBeenCalled();    surface.focus(surface.actionElement);    expect(surface.onDismiss).not.toHaveBeenCalled();  });   it.each(["outside press", "outside focus", "Escape", "scroll"])(    "%s dismisses an interactive popover without recapturing its text",    (reason) => {      const surface = createSelectionSurface({ interactiveActions: true });      surface.focus(surface.field);      if (reason === "outside press") surface.down({ inside: false });      else if (reason === "outside focus") surface.focus(surface.element);      else if (reason === "Escape") surface.key("Escape", surface.field);      else surface.element.dispatchEvent(event("scroll"));      surface.change();      surface.flush(1000);      expect(surface.onDismiss).toHaveBeenCalledWith("cancel");      expect(surface.onSelection).not.toHaveBeenCalled();    },  );   it("allows a new source gesture to replace a comment selection", () => {    const surface = createSelectionSurface({ interactiveActions: true });    surface.focus(surface.field);    surface.down();    surface.focus(surface.element);    surface.change();    surface.flush();    expect(surface.onSelection).not.toHaveBeenCalled();    expect(surface.onDismiss).toHaveBeenCalledWith("interaction");    surface.up({ x: 520, y: 320 });    surface.flush();    expect(surface.onSelection).toHaveBeenCalledWith({ x: 520, y: 320 });  });}); describe("selection action positioning", () => {  const bounds = { left: 100, top: 50, width: 500, height: 220 };  const viewport = { width: 1024, height: 768 };  const selectionRect = { right: 260, bottom: 140 };   it("prefers the cursor release over the selection's bounding box", () => {    expect(      resolveSelectionActionPosition({        bounds,        selectionRect,        pointer: { x: 520, y: 200 },        viewport,      }),    ).toEqual({ x: 520, y: 200 });  });   it("anchors keyboard selections just below the text end", () => {    expect(      resolveSelectionActionPosition({ bounds, selectionRect, pointer: null, viewport }),    ).toEqual({ x: 260, y: 144 });  });   it.each([    [      { x: 720, y: 340 },      { x: 600, y: 270 },    ],    [      { x: 40, y: 20 },      { x: 100, y: 50 },    ],  ])("keeps an outside release %j within the surface", (pointer, expected) => {    expect(resolveSelectionActionPosition({ bounds, selectionRect, pointer, viewport })).toEqual(      expected,    );  });   it("keeps the anchor inside the browser even if the surface extends beyond it", () => {    expect(      resolveSelectionActionPosition({        bounds: { left: -100, top: -100, width: 2000, height: 2000 },        selectionRect,        pointer: { x: 1800, y: -50 },        viewport,      }),    ).toEqual({ x: 1016, y: 8 });  });});