Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-grab/e2e/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const SHADOW_FRAME_FOCUS_OUTLINE_COLOR = "rgb(168, 85, 247)";
export const FRAMEWORK_COPY_RETRY_TIMEOUT_MS = 20_000;
export const UI_STATE_TIMEOUT_MS = 10_000;
export const THREE_CANVAS_VERTICAL_CENTER_RATIO = 0.5;
export const THREE_DRAG_SELECTION_OUTSET_PX = 5;
export const THREE_JS_FRAME_COUNT_WINDOW_PROPERTY = "__REACT_GRAB_THREE_JS_FRAME_COUNT__";
export const THREE_FRAME_COUNT_WINDOW_PROPERTY = "__REACT_GRAB_THREE_FRAME_COUNT__";
export const THREE_ELAPSED_TIME_WINDOW_PROPERTY = "__REACT_GRAB_THREE_ELAPSED_TIME__";
Expand Down
21 changes: 11 additions & 10 deletions packages/react-grab/e2e/context-menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,9 @@ test.describe("Context Menu", () => {
test.describe("Keyboard Navigation Integration", () => {
test("should show context menu after keyboard navigation", async ({ reactGrab }) => {
await reactGrab.activate();
await reactGrab.hoverUntilSelected("li:first-child");
await reactGrab.hoverUntilSelected("li:first-child span");

await reactGrab.pressArrowDown();
await reactGrab.pressArrowUp();
await reactGrab.waitForSelectionBox();

await reactGrab.rightClickElement("li:nth-child(2)");
Expand All @@ -270,13 +270,14 @@ test.describe("Context Menu", () => {
reactGrab,
}) => {
await reactGrab.activate();
await reactGrab.hoverUntilSelected("li:first-child");
await reactGrab.hoverUntilSelected("li:first-child span");

await reactGrab.pressArrowDown();
await reactGrab.page.waitForTimeout(100);
await reactGrab.pressArrowUp();
await reactGrab.waitForSelectionBox();
await reactGrab.page.waitForTimeout(100);

await reactGrab.rightClickElement("li:nth-child(2)");
await reactGrab.page.keyboard.press("Shift+F10");
await expect.poll(() => reactGrab.isContextMenuVisible()).toBe(true);
await reactGrab.clickContextMenuItem("Copy");

await reactGrab.page.waitForTimeout(500);
Expand Down Expand Up @@ -706,7 +707,7 @@ test.describe("Context Menu", () => {
enabled: (context: { element: Element }) => {
(window as { __enabledTagName?: string }).__enabledTagName =
context.element.tagName;
return context.element.tagName.toLowerCase() === "li";
return context.element.tagName.toLowerCase() === "button";
},
onAction: () => {},
},
Expand All @@ -715,13 +716,13 @@ test.describe("Context Menu", () => {
});

await reactGrab.activate();
await reactGrab.hoverUntilSelected("li:first-child");
await reactGrab.rightClickElement("li:first-child");
await reactGrab.hoverUntilSelected("[data-testid='plain-button']");
await reactGrab.rightClickElement("[data-testid='plain-button']");

const enabledTagName = await reactGrab.page.evaluate(
() => (window as { __enabledTagName?: string }).__enabledTagName,
);
expect(enabledTagName).toBe("LI");
expect(enabledTagName).toBe("BUTTON");

const menuInfo = await reactGrab.getContextMenuInfo();
const lowerMenuItems = menuInfo.menuItems.map((item: string) => item.toLowerCase());
Expand Down
52 changes: 33 additions & 19 deletions packages/react-grab/e2e/disabled-elements.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ test.describe("Disabled Element Selection", () => {
await reactGrab.waitForSelectionBox();

await reactGrab.page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("pointer-events-none");
});

test("should select nested disabled element inside enabled parent", async ({ reactGrab }) => {
Expand Down Expand Up @@ -198,10 +198,10 @@ test.describe("Pointer Events None - Arrow Navigation", () => {
test("should support ArrowUp from pointer-events none element", async ({ reactGrab }) => {
await reactGrab.page.evaluate((containerId) => {
const container = document.getElementById(containerId);
const parent = document.createElement("div");
const parent = document.createElement("article");
parent.setAttribute("data-testid", "arrow-up-parent");
parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;";
const child = document.createElement("div");
const child = document.createElement("span");
child.setAttribute("data-testid", "arrow-up-child");
child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;";
child.textContent = "Pointer Events None Child";
Expand All @@ -215,20 +215,22 @@ test.describe("Pointer Events None - Arrow Navigation", () => {
if (!bounds) throw new Error("Could not get element bounds");
await reactGrab.page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect.poll(reactGrab.getTargetTestId).toBe("arrow-up-child");

await reactGrab.pressArrowUp();
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toBe("article");
});

test("should support ArrowDown back to pointer-events none element", async ({ reactGrab }) => {
await reactGrab.page.evaluate((containerId) => {
const container = document.getElementById(containerId);
const parent = document.createElement("div");
const parent = document.createElement("article");
parent.setAttribute("data-testid", "arrow-down-parent");
parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;";
const child = document.createElement("div");
const child = document.createElement("span");
child.setAttribute("data-testid", "arrow-down-child");
child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;";
child.textContent = "Pointer Events None Child";
Expand All @@ -245,19 +247,22 @@ test.describe("Pointer Events None - Arrow Navigation", () => {

await reactGrab.pressArrowUp();
await reactGrab.waitForSelectionBox();
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toBe("article");

await reactGrab.pressArrowDown();
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("span");
});

test("should support round-trip navigation", async ({ reactGrab }) => {
await reactGrab.page.evaluate((containerId) => {
const container = document.getElementById(containerId);
const parent = document.createElement("div");
const parent = document.createElement("article");
parent.setAttribute("data-testid", "round-trip-parent");
parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;";
const child = document.createElement("div");
const child = document.createElement("span");
child.setAttribute("data-testid", "round-trip-child");
child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;";
child.textContent = "Pointer Events None Child";
Expand All @@ -271,30 +276,35 @@ test.describe("Pointer Events None - Arrow Navigation", () => {
if (!bounds) throw new Error("Could not get element bounds");
await reactGrab.page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await reactGrab.waitForSelectionBox();
await expect.poll(reactGrab.getTargetTestId).toBe("round-trip-child");

await reactGrab.pressArrowUp();
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toBe("article");

await reactGrab.pressArrowDown();
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("span");

await reactGrab.pressArrowUp();
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toBe("article");
});

test("should navigate through nested pointer-events none elements", async ({ reactGrab }) => {
await reactGrab.page.evaluate((containerId) => {
const container = document.getElementById(containerId);
const grandparent = document.createElement("div");
const grandparent = document.createElement("section");
grandparent.setAttribute("data-testid", "nested-grandparent");
grandparent.style.cssText = "padding: 60px; background: #c0c0c0; margin-top: 10px;";
const parent = document.createElement("div");
const parent = document.createElement("article");
parent.setAttribute("data-testid", "nested-parent");
parent.style.cssText = "pointer-events: none; padding: 40px; background: #d0d0d0;";
const child = document.createElement("div");
const child = document.createElement("span");
child.setAttribute("data-testid", "nested-child");
child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;";
child.textContent = "Deeply Nested Pointer Events None";
Expand All @@ -309,14 +319,18 @@ test.describe("Pointer Events None - Arrow Navigation", () => {
if (!bounds) throw new Error("Could not get element bounds");
await reactGrab.page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect.poll(reactGrab.getTargetTestId).toBe("nested-child");

await reactGrab.pressArrowUp();
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toBe("article");

await reactGrab.pressArrowUp();
await reactGrab.waitForSelectionBox();
expect(await reactGrab.isSelectionBoxVisible()).toBe(true);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toBe("section");
});
});
145 changes: 145 additions & 0 deletions packages/react-grab/e2e/drag-selection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,78 @@
import { test, expect } from "./fixtures.js";
import type { ReactGrabPageObject } from "./fixtures.js";

const TODO_LIST_ITEM_SELECTOR = "[data-testid='todo-list'] li";

declare global {
interface Window {
__DID_WIDE_TEXT_DRAG_END__?: boolean;
__WIDE_TEXT_DRAG_TARGET_IDS__?: string[];
}
}

const configureWideTextTarget = async (
reactGrab: ReactGrabPageObject,
shouldAddEmptySpaceSentinel = false,
): Promise<void> => {
await reactGrab.page.evaluate((shouldAddSentinel) => {
for (const pageElement of document.body.querySelectorAll("*")) {
if (pageElement instanceof HTMLElement && !pageElement.closest("[data-react-grab]")) {
pageElement.style.visibility = "hidden";
}
}
const cardElement = document.createElement("div");
cardElement.id = "wide-text-drag-card";
cardElement.style.cssText =
"position:fixed;left:20px;top:80px;width:500px;height:80px;background:#222;visibility:visible;z-index:10000";
const paragraphElement = document.createElement("p");
paragraphElement.id = "wide-text-drag-target";
paragraphElement.textContent = "Short label";
Object.assign(paragraphElement.style, {
font: "20px sans-serif",
height: "40px",
left: "20px",
lineHeight: "40px",
margin: "0",
position: "fixed",
top: "80px",
visibility: "visible",
width: "500px",
});
cardElement.append(paragraphElement);
document.body.append(cardElement);
if (shouldAddSentinel) {
const sentinelElement = document.createElement("button");
sentinelElement.id = "wide-text-drag-sentinel";
sentinelElement.textContent = "Sentinel";
sentinelElement.style.cssText =
"position:fixed;left:370px;top:90px;width:40px;height:20px;z-index:10001";
document.body.append(sentinelElement);
}

window.__DID_WIDE_TEXT_DRAG_END__ = false;
window.__WIDE_TEXT_DRAG_TARGET_IDS__ = [];
const api = window.__REACT_GRAB__;
api?.unregisterPlugin("wide-text-drag-tracking");
api?.registerPlugin({
name: "wide-text-drag-tracking",
hooks: {
onDragEnd: (selectedElements: Element[]) => {
window.__DID_WIDE_TEXT_DRAG_END__ = true;
window.__WIDE_TEXT_DRAG_TARGET_IDS__ = selectedElements.map(
(selectedElement) => selectedElement.id,
);
},
},
});
}, shouldAddEmptySpaceSentinel);
};

const getWideTextDragTargetIds = async (reactGrab: ReactGrabPageObject): Promise<string[]> =>
reactGrab.page.evaluate(() => window.__WIDE_TEXT_DRAG_TARGET_IDS__ ?? []);

const didWideTextDragEnd = async (reactGrab: ReactGrabPageObject): Promise<boolean> =>
reactGrab.page.evaluate(() => window.__DID_WIDE_TEXT_DRAG_END__ ?? false);

test.describe("Drag Selection", () => {
test("should keep drag active when releasing Space in hold mode with Space activation key", async ({
reactGrab,
Expand Down Expand Up @@ -128,6 +199,80 @@ test.describe("Drag Selection", () => {
expect(clipboardContent.length).toBeGreaterThan(0);
});

test("should ignore empty space inside a wide text layout box", async ({ reactGrab }) => {
await configureWideTextTarget(reactGrab, true);
await reactGrab.activate();

const paragraphBounds = await reactGrab.page.locator("#wide-text-drag-target").boundingBox();
if (!paragraphBounds) throw new Error("Could not get wide text bounds");

await reactGrab.page.mouse.move(paragraphBounds.x + 300, paragraphBounds.y + 5);
await reactGrab.page.mouse.down();
await reactGrab.page.mouse.move(paragraphBounds.x + 450, paragraphBounds.y + 35, {
steps: 5,
});
await reactGrab.page.mouse.up();

expect(await didWideTextDragEnd(reactGrab)).toBe(true);
const selectedTargetIds = await getWideTextDragTargetIds(reactGrab);
expect(selectedTargetIds).toContain("wide-text-drag-sentinel");
expect(selectedTargetIds).not.toContain("wide-text-drag-target");
});

test("should drag-select the painted part of a wide text element", async ({ reactGrab }) => {
await configureWideTextTarget(reactGrab);
await reactGrab.activate();

const textBounds = await reactGrab.page
.locator("#wide-text-drag-target")
.evaluate((element) => {
const textNode = element.firstChild;
if (!textNode) return null;
const range = document.createRange();
range.selectNodeContents(textNode);
const bounds = range.getBoundingClientRect();
return { bottom: bounds.bottom, left: bounds.left, right: bounds.right, top: bounds.top };
});
if (!textBounds) throw new Error("Could not get painted text bounds");

await reactGrab.page.mouse.move(textBounds.left - 10, textBounds.top - 5);
await reactGrab.page.mouse.down();
await reactGrab.page.mouse.move(textBounds.right + 10, textBounds.bottom + 5, { steps: 5 });
await reactGrab.page.mouse.up();

expect(await getWideTextDragTargetIds(reactGrab)).toContain("wide-text-drag-target");
});

test("should hover the card through empty width beside its text", async ({ reactGrab }) => {
await configureWideTextTarget(reactGrab);
await reactGrab.activate();

const paragraphBounds = await reactGrab.page.locator("#wide-text-drag-target").boundingBox();
if (!paragraphBounds) throw new Error("Could not get wide text bounds");

await reactGrab.page.mouse.move(paragraphBounds.x + 20, paragraphBounds.y + 20);
await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("p");

await reactGrab.page.mouse.move(paragraphBounds.x + 300, paragraphBounds.y + 20);
await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("div");
});

test("should keep the full box targetable when a wide text element has paint", async ({
reactGrab,
}) => {
await configureWideTextTarget(reactGrab);
await reactGrab.page.locator("#wide-text-drag-target").evaluate((paragraphElement) => {
paragraphElement.style.background = "rgb(34, 34, 34)";
});
await reactGrab.activate();

const paragraphBounds = await reactGrab.page.locator("#wide-text-drag-target").boundingBox();
if (!paragraphBounds) throw new Error("Could not get wide text bounds");

await reactGrab.page.mouse.move(paragraphBounds.x + 300, paragraphBounds.y + 20);
await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("p");
});

test("should copy all selected elements to clipboard", async ({ reactGrab }) => {
await reactGrab.activate();

Expand Down
Loading
Loading