await reactGrab.typeInInput("comment");
await reactGrab.submitInput();
await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).toBeTruthy();
- // HACK: Wait for copy feedback transition and comments item addition
- await reactGrab.page.waitForTimeout(300);
+ await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true);
};
test.describe("Toolbar Copy All Button", () => {
@@ -24,18 +23,18 @@ test.describe("Toolbar Copy All Button", () => {
await copyElement(reactGrab, "li:first-child");
await reactGrab.clickCommentsButton();
- await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(true);
+ await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(true);
});
test("should hide when comments dropdown is closed", async ({ reactGrab }) => {
await copyElement(reactGrab, "li:first-child");
await reactGrab.clickCommentsButton();
- await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(true);
+ await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(true);
await reactGrab.clickCommentsButton();
- await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 2000 }).toBe(false);
+ await expect.poll(() => reactGrab.isToolbarCopyAllVisible(), { timeout: 5000 }).toBe(false);
});
});
@@ -60,7 +59,7 @@ test.describe("Toolbar Copy All Button", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
});
});
@@ -74,7 +73,7 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
});
@@ -84,7 +83,7 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickCommentsCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
});
});
@@ -98,13 +97,13 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
await reactGrab.confirmClearCommentsPrompt();
await reactGrab.page.waitForTimeout(200);
- await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false);
+ await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false);
});
test("should clear comments when confirmed via Enter key", async ({ reactGrab }) => {
@@ -114,17 +113,17 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
await reactGrab.pressEnter();
await reactGrab.page.waitForTimeout(200);
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(false);
- await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false);
+ await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false);
});
test("should dismiss the prompt after confirming", async ({ reactGrab }) => {
@@ -134,13 +133,13 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
await reactGrab.confirmClearCommentsPrompt();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(false);
});
@@ -153,12 +152,12 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
await reactGrab.confirmClearCommentsPrompt();
- await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false);
+ await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false);
await copyElement(reactGrab, "li:last-child");
@@ -166,10 +165,10 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(false);
- await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(false);
+ await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(false);
});
});
@@ -181,17 +180,17 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
await reactGrab.cancelClearCommentsPrompt();
await reactGrab.page.waitForTimeout(200);
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(false);
- await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(true);
+ await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true);
});
test("should dismiss prompt when cancelled via Escape key", async ({ reactGrab }) => {
@@ -201,17 +200,17 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
await reactGrab.pressEscape();
await reactGrab.page.waitForTimeout(200);
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(false);
- await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 2000 }).toBe(true);
+ await expect.poll(() => reactGrab.isCommentsButtonVisible(), { timeout: 5000 }).toBe(true);
});
});
@@ -223,7 +222,7 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
await reactGrab.activate();
@@ -232,7 +231,7 @@ test.describe("Clear History Prompt", () => {
await reactGrab.rightClickElement("li:first-child");
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(false);
});
@@ -243,14 +242,14 @@ test.describe("Clear History Prompt", () => {
await reactGrab.clickToolbarCopyAll();
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(true);
await reactGrab.clickToolbarEnabled();
await reactGrab.page.waitForTimeout(200);
await expect
- .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 2000 })
+ .poll(() => reactGrab.isClearCommentsPromptVisible(), { timeout: 5000 })
.toBe(false);
});
});
diff --git a/packages/react-grab/e2e/context-menu.spec.ts b/packages/react-grab/e2e/context-menu.spec.ts
index 603595d1f..284b93fd4 100644
--- a/packages/react-grab/e2e/context-menu.spec.ts
+++ b/packages/react-grab/e2e/context-menu.spec.ts
@@ -46,7 +46,7 @@ test.describe("Context Menu", () => {
const element = reactGrab.page.locator("li").first();
await element.click({ button: "right", force: true });
- await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 2000 }).toBe(true);
+ await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(true);
await reactGrab.page.keyboard.up("c");
await reactGrab.page.keyboard.up(reactGrab.modifierKey);
@@ -118,10 +118,8 @@ test.describe("Context Menu", () => {
expect(isVisibleBefore).toBe(true);
await reactGrab.page.keyboard.press("Escape");
- await reactGrab.page.waitForTimeout(200);
- const isVisibleAfter = await reactGrab.isContextMenuVisible();
- expect(isVisibleAfter).toBe(false);
+ await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(false);
});
test("should dismiss context menu when clicking outside", async ({ reactGrab }) => {
@@ -134,10 +132,8 @@ test.describe("Context Menu", () => {
expect(isVisibleBefore).toBe(true);
await reactGrab.page.mouse.click(10, 10);
- await reactGrab.page.waitForTimeout(200);
- const isVisibleAfter = await reactGrab.isContextMenuVisible();
- expect(isVisibleAfter).toBe(false);
+ await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(false);
});
test("should dismiss context menu after Copy action", async ({ reactGrab }) => {
diff --git a/packages/react-grab/e2e/element-detection.spec.ts b/packages/react-grab/e2e/element-detection.spec.ts
new file mode 100644
index 000000000..db657cf3b
--- /dev/null
+++ b/packages/react-grab/e2e/element-detection.spec.ts
@@ -0,0 +1,939 @@
+import { test, expect } from "./fixtures.js";
+
+const getTargetTestId = async (page: import("@playwright/test").Page): Promise
=> {
+ return page.evaluate(() => {
+ const api = (
+ window as {
+ __REACT_GRAB__?: {
+ getState: () => { targetElement: Element | null };
+ };
+ }
+ ).__REACT_GRAB__;
+ return api?.getState()?.targetElement?.getAttribute("data-testid") ?? null;
+ });
+};
+
+const getTargetTagName = async (page: import("@playwright/test").Page): Promise => {
+ return page.evaluate(() => {
+ const api = (
+ window as {
+ __REACT_GRAB__?: {
+ getState: () => { targetElement: Element | null };
+ };
+ }
+ ).__REACT_GRAB__;
+ return api?.getState()?.targetElement?.tagName?.toLowerCase() ?? null;
+ });
+};
+
+test.describe("Element Detection", () => {
+ test.describe("Overflow Clipping", () => {
+ test("should select visible child inside overflow:hidden container", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='overflow-clipping-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='overflow-visible-child']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("overflow-visible-child");
+ });
+
+ test("should select clipped element when hovering its visible region", async ({
+ reactGrab,
+ }) => {
+ const container = reactGrab.page.locator("[data-testid='overflow-hidden-container']");
+ await container.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const containerBox = await container.boundingBox();
+ if (!containerBox) throw new Error("Could not get container bounds");
+
+ await reactGrab.page.mouse.move(containerBox.x + 10, containerBox.y + 10);
+ await reactGrab.waitForSelectionBox();
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+ });
+
+ test("should not select clipped element at position outside container", async ({
+ reactGrab,
+ }) => {
+ const container = reactGrab.page.locator("[data-testid='overflow-hidden-container']");
+ await container.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const containerBox = await container.boundingBox();
+ if (!containerBox) throw new Error("Could not get container bounds");
+
+ await reactGrab.page.mouse.move(containerBox.x + containerBox.width + 20, containerBox.y + 5);
+ await reactGrab.page.waitForTimeout(200);
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).not.toBe("overflow-clipped-wide");
+ });
+
+ test("should handle overflow:auto scrollable containers", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='overflow-clipping-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='overflow-auto-inner']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("overflow-auto-inner");
+ });
+
+ test("should handle nested overflow:hidden containers", async ({ reactGrab }) => {
+ const innerContainer = reactGrab.page.locator("[data-testid='overflow-nested-inner']");
+ await innerContainer.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const innerBox = await innerContainer.boundingBox();
+ if (!innerBox) throw new Error("Could not get inner container bounds");
+
+ await reactGrab.page.mouse.move(innerBox.x + 5, innerBox.y + 5);
+ await reactGrab.waitForSelectionBox();
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+ });
+
+ test("should select elements inside scroll-container", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='scrollable-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='scroll-item-1']");
+ await reactGrab.waitForSelectionBox();
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+
+ await reactGrab.clickElement("[data-testid='scroll-item-1']");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("Scrollable Item 1");
+ });
+
+ test("should not select scroll items that are scrolled out of view", async ({ reactGrab }) => {
+ const container = reactGrab.page.locator("[data-testid='scroll-container']");
+ await container.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const containerBox = await container.boundingBox();
+ if (!containerBox) throw new Error("Could not get scroll container bounds");
+
+ await reactGrab.page.mouse.move(containerBox.x + containerBox.width / 2, containerBox.y + 5);
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).not.toBe("scroll-item-50");
+ });
+ });
+
+ test.describe("CSS Containment", () => {
+ test("should select child inside contain:paint container at visible region", async ({
+ reactGrab,
+ }) => {
+ const container = reactGrab.page.locator("[data-testid='contain-paint-container']");
+ await container.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const containerBox = await container.boundingBox();
+ if (!containerBox) throw new Error("Could not get container bounds");
+
+ await reactGrab.page.mouse.move(containerBox.x + 10, containerBox.y + 10);
+ await reactGrab.waitForSelectionBox();
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+ });
+
+ test("should not select paint-contained element outside container bounds", async ({
+ reactGrab,
+ }) => {
+ const container = reactGrab.page.locator("[data-testid='contain-paint-container']");
+ await container.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const containerBox = await container.boundingBox();
+ if (!containerBox) throw new Error("Could not get container bounds");
+
+ await reactGrab.page.mouse.move(
+ containerBox.x + containerBox.width + 30,
+ containerBox.y + 10,
+ );
+ await reactGrab.page.waitForTimeout(200);
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).not.toBe("contain-paint-clipped");
+ });
+
+ test("should select child inside contain:strict container", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='contain-paint-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='contain-strict-child']");
+ await reactGrab.waitForSelectionBox();
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+ });
+
+ test("should select child inside contain:content container", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='contain-paint-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='contain-content-child']");
+ await reactGrab.waitForSelectionBox();
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+ });
+ });
+
+ test.describe("Stacking Order", () => {
+ test("should select topmost z-indexed element at overlap point", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='stacking-order-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const topElement = reactGrab.page.locator("[data-testid='stacking-top']");
+ const topBox = await topElement.boundingBox();
+ if (!topBox) throw new Error("Could not get top element bounds");
+
+ await reactGrab.page.mouse.move(topBox.x + 10, topBox.y + 10);
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("stacking-top");
+ });
+
+ test("should select middle element where top element does not overlap", async ({
+ reactGrab,
+ }) => {
+ const section = reactGrab.page.locator("[data-testid='stacking-order-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const middleElement = reactGrab.page.locator("[data-testid='stacking-middle']");
+ const topElement = reactGrab.page.locator("[data-testid='stacking-top']");
+ const middleBox = await middleElement.boundingBox();
+ const topBox = await topElement.boundingBox();
+ if (!middleBox || !topBox) throw new Error("Could not get element bounds");
+
+ await reactGrab.page.mouse.move(middleBox.x + 5, middleBox.y + 5);
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("stacking-middle");
+ });
+
+ test("should select bottom element in non-overlapping region", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='stacking-order-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const bottomElement = reactGrab.page.locator("[data-testid='stacking-bottom']");
+ const bottomBox = await bottomElement.boundingBox();
+ if (!bottomBox) throw new Error("Could not get bottom element bounds");
+
+ await reactGrab.page.mouse.move(bottomBox.x + 5, bottomBox.y + 5);
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("stacking-bottom");
+ });
+
+ test("should prefer smaller front element over larger background", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='stacking-order-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const frontElement = reactGrab.page.locator("[data-testid='stacking-small-front']");
+ const frontBox = await frontElement.boundingBox();
+ if (!frontBox) throw new Error("Could not get front element bounds");
+
+ await reactGrab.page.mouse.move(
+ frontBox.x + frontBox.width / 2,
+ frontBox.y + frontBox.height / 2,
+ );
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("stacking-small-front");
+ });
+
+ test("should copy correct content from stacked elements", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='stacking-order-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='stacking-top']");
+ await reactGrab.waitForSelectionBox();
+ await reactGrab.clickElement("[data-testid='stacking-top']");
+
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("Top");
+ });
+ });
+
+ test.describe("Inline Elements", () => {
+ test("should select inline span element", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='inline-elements-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='inline-span']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("inline-span");
+ });
+
+ test("should select inline link element", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='inline-elements-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='inline-link']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("inline-link");
+ });
+
+ test("should select inline em element", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='inline-elements-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='inline-em']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("inline-em");
+ });
+
+ test("should select inline strong element", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='inline-elements-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='inline-strong']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("inline-strong");
+ });
+
+ test("should select inline code element", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='inline-elements-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='inline-code']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("inline-code");
+ });
+
+ test("should copy inline span content correctly", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='inline-elements-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='inline-span']");
+ await reactGrab.waitForSelectionBox();
+ await reactGrab.clickElement("[data-testid='inline-span']");
+
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("inline span");
+ });
+
+ test("should show selection box with correct bounds for inline elements", async ({
+ reactGrab,
+ }) => {
+ const section = reactGrab.page.locator("[data-testid='inline-elements-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='inline-span']");
+ await reactGrab.waitForSelectionBox();
+
+ const bounds = await reactGrab.getSelectionBoxBounds();
+ expect(bounds).not.toBeNull();
+ expect(bounds!.width).toBeGreaterThan(0);
+ expect(bounds!.height).toBeGreaterThan(0);
+ });
+ });
+
+ test.describe("Decorative Overlays", () => {
+ test("should select content element, not the empty overlay above it", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='decorative-overlay-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='decorative-content']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("decorative-content");
+ });
+
+ test("should select text content under positioned empty div", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='decorative-overlay-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='decorative-text-content']");
+ await reactGrab.waitForSelectionBox();
+
+ await reactGrab.clickElement("[data-testid='decorative-text-content']");
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("Text content under");
+ });
+
+ test("should copy content from element under decorative overlay", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='decorative-overlay-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='decorative-content']");
+ await reactGrab.waitForSelectionBox();
+ await reactGrab.clickElement("[data-testid='decorative-content']");
+
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("should be selectable");
+ });
+ });
+
+ test.describe("Transform and Opacity Stacking", () => {
+ test("should select element with CSS transform", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='transform-stacking-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='transform-element']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("transform-element");
+ });
+
+ test("should select element with sub-1 opacity", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='transform-stacking-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='opacity-element']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("opacity-element");
+ });
+
+ test("should prefer front transformed element at overlap", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='transform-stacking-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const frontElement = reactGrab.page.locator("[data-testid='transform-front']");
+ const frontBox = await frontElement.boundingBox();
+ if (!frontBox) throw new Error("Could not get front element bounds");
+
+ await reactGrab.page.mouse.move(
+ frontBox.x + frontBox.width / 2,
+ frontBox.y + frontBox.height / 2,
+ );
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("transform-front");
+ });
+ });
+
+ test.describe("Fixed Position Elements", () => {
+ test("should select fixed-position corner element", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='edge-top-left']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("edge-top-left");
+ });
+
+ test("should select different fixed-position corners", async ({ reactGrab }) => {
+ await reactGrab.activate();
+
+ await reactGrab.hoverElement("[data-testid='edge-top-right']");
+ await reactGrab.waitForSelectionBox();
+ const topRightId = await getTargetTestId(reactGrab.page);
+ expect(topRightId).toBe("edge-top-right");
+
+ await reactGrab.hoverElement("[data-testid='edge-bottom-left']");
+ await reactGrab.waitForSelectionBox();
+ const bottomLeftId = await getTargetTestId(reactGrab.page);
+ expect(bottomLeftId).toBe("edge-bottom-left");
+ });
+
+ test("should copy fixed-position element content", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='edge-top-left']");
+ await reactGrab.waitForSelectionBox();
+ await reactGrab.clickElement("[data-testid='edge-top-left']");
+
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("Top Left");
+ });
+ });
+
+ test.describe("Various Element Types", () => {
+ test("should select table cells", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='various-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='td-1-1']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("td-1-1");
+ });
+
+ test("should select image element", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='various-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='img-element']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("img-element");
+ });
+
+ test("should select article element", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='various-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='article-element']");
+ await reactGrab.waitForSelectionBox();
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+ });
+
+ test("should select button element", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='various-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='plain-button']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("plain-button");
+ });
+
+ test("should select gradient div", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='various-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='gradient-div']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("gradient-div");
+ });
+ });
+
+ test.describe("Element Removal After Index Build", () => {
+ test("should handle element removal after activation", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='dynamic-element-2']");
+ await reactGrab.waitForSelectionBox();
+
+ const testIdBefore = await getTargetTestId(reactGrab.page);
+ expect(testIdBefore).toBe("dynamic-element-2");
+
+ await reactGrab.removeElement("[data-testid='dynamic-element-2']");
+ await reactGrab.page.waitForTimeout(200);
+
+ await reactGrab.hoverElement("[data-testid='dynamic-element-1']");
+ await reactGrab.waitForSelectionBox();
+
+ const testIdAfter = await getTargetTestId(reactGrab.page);
+ expect(testIdAfter).toBe("dynamic-element-1");
+ });
+
+ test("should handle dynamically added elements", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='dynamic-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ await reactGrab.page.evaluate(() => {
+ const section = document.querySelector("[data-testid='dynamic-section']");
+ if (!section) return;
+ const newDiv = document.createElement("div");
+ newDiv.setAttribute("data-testid", "runtime-added-element");
+ newDiv.className = "p-4 bg-emerald-200 rounded mt-2";
+ newDiv.textContent = "Dynamically added at runtime";
+ section.appendChild(newDiv);
+ });
+ await reactGrab.page.waitForTimeout(200);
+
+ await reactGrab.hoverElement("[data-testid='runtime-added-element']");
+ await reactGrab.waitForSelectionBox();
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+ });
+ });
+
+ test.describe("Zero-Dimension and Invisible Elements", () => {
+ test("should not detect zero-size elements", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='zero-dimension-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const zeroEl = reactGrab.page.locator("[data-testid='zero-size-element']");
+ const box = await zeroEl.boundingBox();
+
+ if (box && box.width > 0 && box.height > 0) {
+ await reactGrab.page.mouse.move(box.x, box.y);
+ } else {
+ await reactGrab.page.mouse.move(100, 100);
+ }
+ await reactGrab.page.waitForTimeout(200);
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).not.toBe("zero-size-element");
+ });
+
+ test("should skip invisible elements and select visible ones", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='zero-dimension-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='zero-dimension-section'] h2");
+ await reactGrab.waitForSelectionBox();
+
+ const tagName = await getTargetTagName(reactGrab.page);
+ expect(tagName).toBe("h2");
+ });
+ });
+
+ test.describe("Modal and Overlay Interaction", () => {
+ test("should select modal content when modal is open", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='modal-dialog-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.page.click("[data-testid='modal-trigger']");
+ await reactGrab.page.waitForSelector("[data-testid='modal-content']");
+
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='modal-inner-button']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("modal-inner-button");
+ });
+
+ test("should copy modal content correctly", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='modal-dialog-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.page.click("[data-testid='modal-trigger']");
+ await reactGrab.page.waitForSelector("[data-testid='modal-content']");
+
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='modal-inner-button']");
+ await reactGrab.waitForSelectionBox();
+ await reactGrab.clickElement("[data-testid='modal-inner-button']");
+
+ await expect.poll(() => reactGrab.getClipboardContent()).toContain("Button Inside Modal");
+ });
+ });
+
+ test.describe("Deeply Nested Elements", () => {
+ test("should select deepest nested text element", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='deeply-nested-text']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("deeply-nested-text");
+ });
+
+ test("should select nested button inside cards", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='nested-button']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("nested-button");
+ });
+
+ test("should navigate through nested cards with arrows", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='nested-button']");
+ await reactGrab.waitForSelectionBox();
+
+ await reactGrab.pressArrowDown();
+ await reactGrab.page.waitForTimeout(200);
+
+ const isVisible = await reactGrab.isSelectionBoxVisible();
+ expect(isVisible).toBe(true);
+ });
+ });
+
+ test.describe("Reactivation Rebuilds Index", () => {
+ test("should rebuild element index on each activation cycle", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='main-title']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId1 = await getTargetTestId(reactGrab.page);
+ expect(testId1).toBe("main-title");
+
+ await reactGrab.deactivate();
+ await reactGrab.page.waitForTimeout(100);
+
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='main-title']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId2 = await getTargetTestId(reactGrab.page);
+ expect(testId2).toBe("main-title");
+ });
+
+ test("should detect elements added between activation cycles", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.deactivate();
+
+ await reactGrab.page.evaluate(() => {
+ const section = document.querySelector("[data-testid='dynamic-section']");
+ if (!section) return;
+ const newDiv = document.createElement("div");
+ newDiv.setAttribute("data-testid", "between-cycles-element");
+ newDiv.className = "p-4 bg-fuchsia-200 rounded mt-2";
+ newDiv.textContent = "Added between cycles";
+ section.appendChild(newDiv);
+ });
+
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='between-cycles-element']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("between-cycles-element");
+ });
+ });
+
+ test.describe("Animated Elements", () => {
+ test("should select pulsing element after freeze", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='animated-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='animated-pulse']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("animated-pulse");
+ });
+
+ test("should select spinning element after freeze", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='animated-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='animated-spin']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("animated-spin");
+ });
+
+ test("should select bouncing element after freeze", async ({ reactGrab }) => {
+ const section = reactGrab.page.locator("[data-testid='animated-section']");
+ await section.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='animated-bounce']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("animated-bounce");
+ });
+ });
+
+ test.describe("Injected Edge Cases", () => {
+ test("should skip absolutely positioned empty decorative div over content", async ({
+ reactGrab,
+ }) => {
+ await reactGrab.page.evaluate(() => {
+ const container = document.createElement("div");
+ container.id = "injected-decorative-test";
+ container.style.cssText = "position: relative; margin: 16px;";
+ container.innerHTML = `
+
+ Real content under decorative overlay
+
+
+ `;
+ document.body.appendChild(container);
+ });
+
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='injected-real-content']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("injected-real-content");
+
+ await reactGrab.page.evaluate(() => {
+ document.getElementById("injected-decorative-test")?.remove();
+ });
+ });
+
+ test("should handle deeply nested overflow clipping chain", async ({ reactGrab }) => {
+ await reactGrab.page.evaluate(() => {
+ const container = document.createElement("div");
+ container.id = "injected-nested-clip-test";
+ container.innerHTML = `
+
+
+
+
+ Visible
+
+
+ This extends far beyond all containers
+
+
+
+
+ `;
+ document.body.appendChild(container);
+ });
+
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='injected-deep-visible']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("injected-deep-visible");
+
+ await reactGrab.page.evaluate(() => {
+ document.getElementById("injected-nested-clip-test")?.remove();
+ });
+ });
+
+ test("should handle contain:paint with overflow interaction", async ({ reactGrab }) => {
+ await reactGrab.page.evaluate(() => {
+ const container = document.createElement("div");
+ container.id = "injected-contain-overflow-test";
+ container.innerHTML = `
+
+
+ Visible
+
+
+ Clipped by contain
+
+
+ `;
+ document.body.appendChild(container);
+ });
+
+ await reactGrab.activate();
+ await reactGrab.hoverElement("[data-testid='injected-contain-visible']");
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("injected-contain-visible");
+
+ await reactGrab.page.evaluate(() => {
+ document.getElementById("injected-contain-overflow-test")?.remove();
+ });
+ });
+
+ test("should handle multiple stacking contexts with transforms", async ({ reactGrab }) => {
+ await reactGrab.page.evaluate(() => {
+ const container = document.createElement("div");
+ container.id = "injected-transform-stack-test";
+ container.style.cssText = "position: relative; height: 100px; margin: 16px;";
+ container.innerHTML = `
+
+ Background
+
+
+ Foreground (transform)
+
+ `;
+ document.body.appendChild(container);
+ });
+
+ const frontElement = reactGrab.page.locator("[data-testid='injected-transform-front']");
+ await frontElement.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const frontBox = await frontElement.boundingBox();
+ if (!frontBox) throw new Error("Could not get front element bounds");
+
+ await reactGrab.page.mouse.move(
+ frontBox.x + frontBox.width / 2,
+ frontBox.y + frontBox.height / 2,
+ );
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("injected-transform-front");
+
+ await reactGrab.page.evaluate(() => {
+ document.getElementById("injected-transform-stack-test")?.remove();
+ });
+ });
+
+ test("should handle flex item stacking with z-index", async ({ reactGrab }) => {
+ await reactGrab.page.evaluate(() => {
+ const container = document.createElement("div");
+ container.id = "injected-flex-stack-test";
+ container.style.cssText = "display: flex; position: relative; height: 80px; margin: 16px;";
+ container.innerHTML = `
+
+ Flex behind
+
+
+ Flex front
+
+ `;
+ document.body.appendChild(container);
+ });
+
+ const frontElement = reactGrab.page.locator("[data-testid='injected-flex-front']");
+ await frontElement.scrollIntoViewIfNeeded();
+ await reactGrab.activate();
+
+ const frontBox = await frontElement.boundingBox();
+ if (!frontBox) throw new Error("Could not get front element bounds");
+
+ await reactGrab.page.mouse.move(
+ frontBox.x + frontBox.width / 2,
+ frontBox.y + frontBox.height / 2,
+ );
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("injected-flex-front");
+
+ await reactGrab.page.evaluate(() => {
+ document.getElementById("injected-flex-stack-test")?.remove();
+ });
+ });
+
+ test("should handle page with only fixed elements and no regular elements", async ({
+ reactGrab,
+ }) => {
+ await reactGrab.page.evaluate(() => {
+ document.body.innerHTML = "";
+ const fixedElement = document.createElement("div");
+ fixedElement.id = "injected-fixed-only-test";
+ fixedElement.setAttribute("data-testid", "fixed-only-element");
+ fixedElement.style.cssText =
+ "position: fixed; top: 10px; left: 10px; width: 200px; height: 60px; background: #93c5fd; padding: 8px; z-index: 10;";
+ fixedElement.textContent = "Only fixed element";
+ document.body.appendChild(fixedElement);
+ });
+
+ await reactGrab.activate();
+
+ const fixedElement = reactGrab.page.locator("[data-testid='fixed-only-element']");
+ const fixedBox = await fixedElement.boundingBox();
+ if (!fixedBox) throw new Error("Could not get fixed element bounds");
+
+ await reactGrab.page.mouse.move(
+ fixedBox.x + fixedBox.width / 2,
+ fixedBox.y + fixedBox.height / 2,
+ );
+ await reactGrab.waitForSelectionBox();
+
+ const testId = await getTargetTestId(reactGrab.page);
+ expect(testId).toBe("fixed-only-element");
+ });
+ });
+});
diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts
index c11fa5b3f..596bc9228 100644
--- a/packages/react-grab/e2e/fixtures.ts
+++ b/packages/react-grab/e2e/fixtures.ts
@@ -265,7 +265,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
const hoverElement = async (selector: string) => {
const element = page.locator(selector).first();
await element.hover({ force: true });
- await page.waitForTimeout(250);
+ await page.waitForTimeout(350);
};
const clickElement = async (selector: string) => {
@@ -422,23 +422,23 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
return expectedVisible ? menuItem !== null : menuItem === null;
},
{ attrName: ATTRIBUTE_NAME, expectedVisible: visible },
- { timeout: 2000 },
+ { timeout: 5000 },
);
};
const rightClickElement = async (selector: string) => {
+ const wasActive = await isOverlayVisible();
const element = page.locator(selector).first();
await element.click({ button: "right", force: true });
- const isActive = await isOverlayVisible();
- if (isActive) {
+ if (wasActive) {
await waitForContextMenu(true);
}
};
const rightClickAtPosition = async (x: number, y: number) => {
+ const wasActive = await isOverlayVisible();
await page.mouse.click(x, y, { button: "right" });
- const isActive = await isOverlayVisible();
- if (isActive) {
+ if (wasActive) {
await waitForContextMenu(true);
}
};
@@ -576,7 +576,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
return api?.getState()?.isPromptMode === expected;
},
active,
- { timeout: 2000 },
+ { timeout: 5000 },
);
};
@@ -600,7 +600,7 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => {
return state?.isSelectionBoxVisible || state?.targetElement !== null;
},
undefined,
- { timeout: 2000 },
+ { timeout: 5000 },
)
.then(() => true)
.catch(() => false);
diff --git a/packages/react-grab/e2e/freeze-updates.spec.ts b/packages/react-grab/e2e/freeze-updates.spec.ts
index 99a6205dc..3d2fcfa07 100644
--- a/packages/react-grab/e2e/freeze-updates.spec.ts
+++ b/packages/react-grab/e2e/freeze-updates.spec.ts
@@ -235,7 +235,7 @@ test.describe("Freeze Updates", () => {
await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']");
await reactGrab.deactivate();
// HACK: allow freeze cleanup to fully propagate before next iteration
- await reactGrab.page.waitForTimeout(300);
+ await reactGrab.page.waitForTimeout(500);
}
const getElementCount = async () => {
diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json
index 4ee57e2b1..6dfe305c7 100644
--- a/packages/react-grab/package.json
+++ b/packages/react-grab/package.json
@@ -102,7 +102,6 @@
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
- "solid-js": "^1.9.10",
"@playwright/test": "^1.40.0",
"@tailwindcss/cli": "^4.1.17",
"@types/babel__core": "^7.20.5",
@@ -111,6 +110,7 @@
"babel-preset-solid": "^1.9.10",
"concurrently": "^9.1.2",
"expect-sdk": "0.0.0-canary-20260405095424",
+ "solid-js": "^1.9.10",
"tailwindcss": "^4.1.0",
"tsx": "^4.21.0"
},
diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx
index 5036c2fc6..7c007c577 100644
--- a/packages/react-grab/src/components/toolbar/index.tsx
+++ b/packages/react-grab/src/components/toolbar/index.tsx
@@ -248,14 +248,14 @@ export const Toolbar: Component = (props) => {
setTooltipVisible: (visible: boolean) => void,
options?: FreezeHandlersOptions,
) => ({
- onMouseEnter: () => {
+ onMouseEnter: (event: MouseEvent) => {
if (drag.isDragging()) return;
safePolygonTracker.stop();
setTooltipVisible(true);
if (options?.shouldFreezeInteractions !== false && !unfreezeUpdatesCallback) {
unfreezeUpdatesCallback = freezeUpdates();
freezeGlobalAnimations();
- freezePseudoStates();
+ freezePseudoStates(event.clientX, event.clientY);
}
options?.onHoverChange?.(true);
},
diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts
index 9e4d92561..82f545005 100644
--- a/packages/react-grab/src/constants.ts
+++ b/packages/react-grab/src/constants.ts
@@ -130,6 +130,8 @@ export const ELEMENT_POSITION_THROTTLE_MS = 16;
export const POINTER_EVENTS_RESUME_DEBOUNCE_MS = 100;
export const VISIBILITY_CACHE_TTL_MS = 50;
+export const ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX = 10_000;
+
export const ZOOM_DETECTION_THRESHOLD = 0.01;
export const MOUNT_ROOT_RECHECK_DELAY_MS = 1000;
diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx
index 504c30579..3a5057d8e 100644
--- a/packages/react-grab/src/core/index.tsx
+++ b/packages/react-grab/src/core/index.tsx
@@ -118,7 +118,6 @@ import { createArrowNavigator } from "./arrow-navigation.js";
import { getRequiredModifiers, setupKeyboardEventClaimer } from "./keyboard-handlers.js";
import { createAutoScroller, getAutoScrollDirection } from "./auto-scroll.js";
import { logIntro } from "./log-intro.js";
-import { onIdle } from "../utils/on-idle.js";
import { getScriptOptions } from "../utils/get-script-options.js";
import { isEnterCode } from "../utils/is-enter-code.js";
import { isMac } from "../utils/is-mac.js";
@@ -136,6 +135,10 @@ import {
} from "../utils/freeze-animations.js";
import { freezePseudoStates, unfreezePseudoStates } from "../utils/freeze-pseudo-states.js";
import { freezeUpdates } from "../utils/freeze-updates.js";
+import {
+ buildElementAtPointIndex,
+ destroyElementAtPointIndex,
+} from "../utils/element-at-point-index.js";
import {
loadComments,
addCommentItem,
@@ -231,7 +234,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
const isDragging = createMemo(
() =>
store.current.state === "active" &&
- (store.current.phase === "dragging-select" || store.current.phase === "dragging-reposition"),
+ (store.current.phase === "dragging-select" ||
+ store.current.phase === "dragging-reposition"),
);
const isDragRepositioning = createMemo(
() => store.current.state === "active" && store.current.phase === "dragging-reposition",
@@ -257,13 +261,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
createEffect(
on(isActivated, (activated, previousActivated) => {
if (activated && !previousActivated) {
- freezePseudoStates();
+ freezePseudoStates(store.pointer.x, store.pointer.y);
freezeGlobalAnimations();
+ buildElementAtPointIndex();
document.body.style.touchAction = "none";
// iOS Safari auto-zooms on focused inputs with font-size < 16px,
// which would disrupt the overlay positioning.
unlockViewportZoom = lockViewportZoom();
} else if (!activated && previousActivated) {
+ destroyElementAtPointIndex();
unfreezePseudoStates();
unfreezeGlobalAnimations();
document.body.style.touchAction = "";
@@ -1624,7 +1630,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
) {
elementDetectionState.lastDetectionTimestamp = now;
elementDetectionState.pendingDetectionScheduledAt = now;
- onIdle(() => {
+ // setTimeout defers the elementFromPoint hit-test into its own macrotask
+ // so the style recalculation it triggers doesn't eat the current frame's
+ // budget. Alternatives considered:
+ // - queueMicrotask: runs before paint, blocks the frame (ivi/Preact use
+ // microtasks for lightweight JS work, but elementFromPoint forces the
+ // browser's layout engine which is categorically heavier)
+ // - requestAnimationFrame: runs inside the render pipeline, same problem
+ // - scheduler.postTask("background"): starved for 350ms–5s on pages with
+ // continuous React renders or CSS animations
+ // setTimeout(0) fires in ~1ms as a separate task with no nesting-based
+ // clamping (each call originates from a distinct pointermove handler).
+ setTimeout(() => {
const candidate = getElementAtPosition(
elementDetectionState.latestPointerX,
elementDetectionState.latestPointerY,
@@ -2867,6 +2884,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
onCleanup(() => {
eventListenerManager.abort();
+ destroyElementAtPointIndex();
if (dragPreviewDebounceTimerId !== null) {
window.clearTimeout(dragPreviewDebounceTimerId);
}
diff --git a/packages/react-grab/src/core/store.ts b/packages/react-grab/src/core/store.ts
index 825c24f15..7121799ae 100644
--- a/packages/react-grab/src/core/store.ts
+++ b/packages/react-grab/src/core/store.ts
@@ -12,12 +12,7 @@ interface FrozenDragRect {
height: number;
}
-type GrabPhase =
- | "hovering"
- | "frozen"
- | "dragging-select"
- | "dragging-reposition"
- | "justDragged";
+type GrabPhase = "hovering" | "frozen" | "dragging-select" | "dragging-reposition" | "justDragged";
type GrabState =
| { state: "idle" }
diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts
index 6a0320a6f..e9867922f 100644
--- a/packages/react-grab/src/primitives.ts
+++ b/packages/react-grab/src/primitives.ts
@@ -3,9 +3,8 @@ import {
freezeGlobalAnimations,
unfreezeGlobalAnimations,
} from "./utils/freeze-animations.js";
-import { freezePseudoStates } from "./utils/freeze-pseudo-states.js";
+import { freezePseudoStates, unfreezePseudoStates } from "./utils/freeze-pseudo-states.js";
import { freezeUpdates } from "./utils/freeze-updates.js";
-import { unfreezePseudoStates } from "./utils/freeze-pseudo-states.js";
import {
getComponentDisplayName,
getHTMLPreview,
diff --git a/packages/react-grab/src/utils/compare-stacking-order.ts b/packages/react-grab/src/utils/compare-stacking-order.ts
new file mode 100644
index 000000000..347bd5119
--- /dev/null
+++ b/packages/react-grab/src/utils/compare-stacking-order.ts
@@ -0,0 +1,97 @@
+const STACKING_PROPS =
+ /\b(?:position|z-index|opacity|transform|mix-blend-mode|filter|isolation|clip-path|backdrop-filter|perspective|contain)\b/;
+
+const getParentElement = (node: Element): Element | null => {
+ const parentNode = node.parentNode;
+ const shadowHost = parentNode && "host" in parentNode ? (parentNode as ShadowRoot).host : null;
+ return shadowHost ?? node.parentElement;
+};
+
+const getAncestorChain = (node: Element): Element[] => {
+ const ancestors: Element[] = [];
+ let current: Element | null = node;
+ while (current) {
+ ancestors.push(current);
+ current = getParentElement(current);
+ }
+ return ancestors;
+};
+
+const isFlexOrGridItem = (node: Element): boolean => {
+ const parentElement = getParentElement(node);
+ if (!parentElement) return false;
+ const display = getComputedStyle(parentElement).display;
+ return (
+ display === "flex" ||
+ display === "inline-flex" ||
+ display === "grid" ||
+ display === "inline-grid"
+ );
+};
+
+const hasPaintContainment = (containValue: string): boolean => {
+ if (containValue === "none" || containValue === "size" || containValue === "layout") return false;
+ return containValue === "paint" || containValue === "strict" || containValue === "content"
+ || containValue.includes("paint");
+};
+
+const isStackingContext = (node: Element, style: CSSStyleDeclaration): boolean => {
+ if (style.position === "fixed" || style.position === "sticky") return true;
+ if (style.zIndex !== "auto" && (style.position !== "static" || isFlexOrGridItem(node)))
+ return true;
+ if (+style.opacity < 1) return true;
+ if (style.transform !== "none") return true;
+ if ("mixBlendMode" in style && style.mixBlendMode !== "normal") return true;
+ if (style.filter !== "none") return true;
+ if ("isolation" in style && style.getPropertyValue("isolation") === "isolate") return true;
+ if (hasPaintContainment(style.contain)) return true;
+ if ("backdropFilter" in style && style.getPropertyValue("backdrop-filter") !== "none") return true;
+ if (style.perspective !== "none") return true;
+ if ("clipPath" in style && style.getPropertyValue("clip-path") !== "none") return true;
+ if (STACKING_PROPS.test(style.willChange)) return true;
+ return false;
+};
+
+const getNearestStackingContextZIndex = (ancestors: Element[]): number => {
+ let ancestorIndex = ancestors.length;
+ while (ancestorIndex--) {
+ const style = getComputedStyle(ancestors[ancestorIndex]);
+ if (isStackingContext(ancestors[ancestorIndex], style)) {
+ return Number(style.zIndex) || 0;
+ }
+ }
+ return 0;
+};
+
+export const compareStackingOrder = (elementA: Element, elementB: Element): number => {
+ if (elementA === elementB) return 0;
+
+ const ancestorsA = getAncestorChain(elementA);
+ const ancestorsB = getAncestorChain(elementB);
+
+ let commonAncestor: Element | undefined;
+
+ while (ancestorsA.at(-1) === ancestorsB.at(-1)) {
+ commonAncestor = ancestorsA.pop()!;
+ ancestorsB.pop();
+ }
+
+ const zIndexA = getNearestStackingContextZIndex(ancestorsA);
+ const zIndexB = getNearestStackingContextZIndex(ancestorsB);
+
+ if (zIndexA === zIndexB) {
+ if (!commonAncestor) return 0;
+ const children = commonAncestor.childNodes;
+ const furthestAncestorA = ancestorsA.at(-1);
+ const furthestAncestorB = ancestorsB.at(-1);
+
+ let childIndex = children.length;
+ while (childIndex--) {
+ const child = children[childIndex];
+ if (child === furthestAncestorA) return 1;
+ if (child === furthestAncestorB) return -1;
+ }
+ }
+
+ return Math.sign(zIndexA - zIndexB);
+};
diff --git a/packages/react-grab/src/utils/element-at-point-index.ts b/packages/react-grab/src/utils/element-at-point-index.ts
new file mode 100644
index 000000000..e37bd2dc2
--- /dev/null
+++ b/packages/react-grab/src/utils/element-at-point-index.ts
@@ -0,0 +1,252 @@
+import { HilbertRTree } from "./hilbert-r-tree.js";
+import { ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX } from "../constants.js";
+import { isValidGrabbableElement } from "./is-valid-grabbable-element.js";
+import { isDecorativeOverlay } from "./is-decorative-overlay.js";
+import { compareStackingOrder } from "./compare-stacking-order.js";
+
+interface PageRect {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+interface CachedFixedElement {
+ element: Element;
+ viewportRect: PageRect;
+ zIndex: number;
+}
+
+interface ElementAtPointIndexState {
+ tree: HilbertRTree | null;
+ elements: Element[];
+ fixedElements: CachedFixedElement[];
+}
+
+const SKIP_TAGS = new Set([
+ "SCRIPT",
+ "STYLE",
+ "HEAD",
+ "META",
+ "LINK",
+ "NOSCRIPT",
+ "BR",
+ "TEMPLATE",
+ "SLOT",
+]);
+
+let currentIndex: ElementAtPointIndexState | null = null;
+let pendingObserver: IntersectionObserver | null = null;
+
+export const buildElementAtPointIndex = (): void => {
+ destroyElementAtPointIndex();
+
+ let didObserveAnyElement = false;
+
+ const accumulatedElements: Element[] = [];
+ const accumulatedRects: PageRect[] = [];
+ const accumulatedFixedElements: CachedFixedElement[] = [];
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const scrollX = window.scrollX;
+ const scrollY = window.scrollY;
+
+ for (const entry of entries) {
+ observer.unobserve(entry.target);
+
+ const targetElement = entry.target as HTMLElement;
+ const boundingRect = entry.boundingClientRect;
+ if (boundingRect.width === 0 || boundingRect.height === 0) continue;
+ if (!isValidGrabbableElement(targetElement)) continue;
+ const computedStyle = getComputedStyle(targetElement);
+ if (computedStyle.position === "fixed") {
+ accumulatedFixedElements.push({
+ element: targetElement,
+ viewportRect: {
+ left: boundingRect.left,
+ top: boundingRect.top,
+ right: boundingRect.right,
+ bottom: boundingRect.bottom,
+ },
+ zIndex: parseInt(computedStyle.zIndex, 10) || 0,
+ });
+ continue;
+ }
+ if (isDecorativeOverlay(targetElement, computedStyle.position)) continue;
+
+ accumulatedElements.push(targetElement);
+
+ accumulatedRects.push({
+ left: boundingRect.left + scrollX,
+ top: boundingRect.top + scrollY,
+ right: boundingRect.right + scrollX,
+ bottom: boundingRect.bottom + scrollY,
+ });
+ }
+
+ if (accumulatedElements.length === 0 && accumulatedFixedElements.length === 0) return;
+
+ let tree: HilbertRTree | null = null;
+ if (accumulatedElements.length > 0) {
+ tree = new HilbertRTree(accumulatedElements.length);
+ for (const rect of accumulatedRects) {
+ tree.add(rect.left, rect.top, rect.right, rect.bottom);
+ }
+ tree.finish();
+ }
+
+ accumulatedFixedElements.sort((entryA, entryB) => entryA.zIndex - entryB.zIndex);
+
+ currentIndex = {
+ tree,
+ elements: [...accumulatedElements],
+ fixedElements: [...accumulatedFixedElements],
+ };
+ },
+ { rootMargin: `${ELEMENT_AT_POINT_INDEX_ROOT_MARGIN_PX}px` },
+ );
+
+ pendingObserver = observer;
+
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node) =>
+ SKIP_TAGS.has((node as Element).tagName)
+ ? NodeFilter.FILTER_REJECT
+ : NodeFilter.FILTER_ACCEPT,
+ });
+
+ while (walker.nextNode()) {
+ const element = walker.currentNode as HTMLElement;
+ if (element.offsetWidth === 0 && element.offsetHeight === 0) continue;
+ observer.observe(element);
+ didObserveAnyElement = true;
+ }
+
+ if (!didObserveAnyElement) {
+ observer.disconnect();
+ pendingObserver = null;
+ }
+};
+
+const CLIPPING_OVERFLOW_VALUES = new Set(["hidden", "scroll", "auto", "clip"]);
+const PAINT_CONTAIN_VALUES = new Set(["paint", "strict", "content"]);
+
+const CLIPS_X = 1;
+const CLIPS_Y = 2;
+
+let clipStateCache = new WeakMap();
+
+const hasPaintContainment = (containValue: string): boolean => {
+ for (const keyword of containValue.split(" ")) {
+ if (PAINT_CONTAIN_VALUES.has(keyword)) return true;
+ }
+ return false;
+};
+
+const getClipState = (ancestor: Element): number => {
+ const cached = clipStateCache.get(ancestor);
+ if (cached !== undefined) return cached;
+
+ const style = getComputedStyle(ancestor);
+ const isPaintContained = hasPaintContainment(style.contain);
+ let state = 0;
+ if (CLIPPING_OVERFLOW_VALUES.has(style.overflowX) || isPaintContained) state |= CLIPS_X;
+ if (CLIPPING_OVERFLOW_VALUES.has(style.overflowY) || isPaintContained) state |= CLIPS_Y;
+ clipStateCache.set(ancestor, state);
+ return state;
+};
+
+const isVisibleAtPoint = (element: Element, clientX: number, clientY: number): boolean => {
+ let ancestor = element.parentElement;
+ while (ancestor && ancestor !== document.documentElement) {
+ const clipState = getClipState(ancestor);
+
+ if (clipState !== 0) {
+ const ancestorRect = ancestor.getBoundingClientRect();
+ if (
+ (clipState & CLIPS_X) !== 0 &&
+ (clientX < ancestorRect.left || clientX > ancestorRect.right)
+ )
+ return false;
+ if (
+ (clipState & CLIPS_Y) !== 0 &&
+ (clientY < ancestorRect.top || clientY > ancestorRect.bottom)
+ )
+ return false;
+ }
+
+ ancestor = ancestor.parentElement;
+ }
+ return true;
+};
+
+const findTopmostFixedElement = (
+ fixedElements: CachedFixedElement[],
+ clientX: number,
+ clientY: number,
+): Element | null => {
+ let topmost: CachedFixedElement | null = null;
+
+ for (const entry of fixedElements) {
+ if (!entry.element.isConnected) continue;
+ const rect = entry.viewportRect;
+ if (
+ clientX >= rect.left &&
+ clientX <= rect.right &&
+ clientY >= rect.top &&
+ clientY <= rect.bottom
+ ) {
+ topmost = entry;
+ }
+ }
+
+ return topmost?.element ?? null;
+};
+
+export const queryElementAtPointIndex = (clientX: number, clientY: number): Element | null => {
+ if (!currentIndex) return null;
+
+ const pageX = clientX + window.scrollX;
+ const pageY = clientY + window.scrollY;
+
+ const fixedHit = findTopmostFixedElement(currentIndex.fixedElements, clientX, clientY);
+
+ const hitIndices = currentIndex.tree?.search(pageX, pageY, pageX, pageY) ?? [];
+
+ const visibleCandidates: Element[] = [];
+
+ for (const hitIndex of hitIndices) {
+ const candidate = currentIndex.elements[hitIndex];
+ if (!candidate.isConnected) continue;
+ if (!isVisibleAtPoint(candidate, clientX, clientY)) continue;
+ visibleCandidates.push(candidate);
+ }
+
+ if (fixedHit) {
+ if (visibleCandidates.length === 0) return fixedHit;
+ visibleCandidates.push(fixedHit);
+ }
+
+ if (visibleCandidates.length === 0) return null;
+ if (visibleCandidates.length === 1) return visibleCandidates[0];
+
+ visibleCandidates.sort((elementA, elementB) => compareStackingOrder(elementB, elementA));
+
+ return visibleCandidates[0];
+};
+
+export const isElementAtPointIndexReady = (): boolean => currentIndex !== null;
+
+export const destroyElementAtPointIndex = (): void => {
+ if (pendingObserver) {
+ pendingObserver.disconnect();
+ pendingObserver = null;
+ }
+ if (currentIndex) {
+ currentIndex.elements.length = 0;
+ currentIndex.fixedElements.length = 0;
+ currentIndex = null;
+ }
+ clipStateCache = new WeakMap();
+};
diff --git a/packages/react-grab/src/utils/freeze-animations.ts b/packages/react-grab/src/utils/freeze-animations.ts
index 74e9ba2cc..d141dc60b 100644
--- a/packages/react-grab/src/utils/freeze-animations.ts
+++ b/packages/react-grab/src/utils/freeze-animations.ts
@@ -201,20 +201,17 @@ export const unfreezeGlobalAnimations = (): void => {
// so calling finish() on animations the freeze never paused would break
// react-grab's own toolbar and label animations.
// @see https://github.com/aidenybai/react-grab/issues/163
- const animations: Animation[] = [];
+ const animationsToFinish: Animation[] = [];
for (const animation of document.getAnimations()) {
if (animation.effect instanceof KeyframeEffect) {
const target = animation.effect.target;
- if (target instanceof Element) {
- const rootNode = target.getRootNode();
- if (rootNode instanceof ShadowRoot) {
- continue;
- }
+ if (target instanceof Element && target.getRootNode() instanceof ShadowRoot) {
+ continue;
}
}
- animations.push(animation);
+ animationsToFinish.push(animation);
}
- finishAnimations(animations);
+ finishAnimations(animationsToFinish);
globalAnimationStyleElement.remove();
globalAnimationStyleElement = null;
diff --git a/packages/react-grab/src/utils/freeze-pseudo-states.ts b/packages/react-grab/src/utils/freeze-pseudo-states.ts
index 214b3ece2..9a5be3aee 100644
--- a/packages/react-grab/src/utils/freeze-pseudo-states.ts
+++ b/packages/react-grab/src/utils/freeze-pseudo-states.ts
@@ -89,32 +89,50 @@ const collectOriginalPropertyValues = (
return originalPropertyValues;
};
-const collectPseudoStates = (
- selector: string,
+const freezeElement = (
+ element: HTMLElement,
properties: readonly string[],
alreadyFrozen?: Map>,
-): FrozenPseudoState[] => {
- const elementsToFreeze: FrozenPseudoState[] = [];
-
- for (const element of document.querySelectorAll(selector)) {
- if (!(element instanceof HTMLElement)) continue;
- if (alreadyFrozen?.has(element)) continue;
+): FrozenPseudoState | null => {
+ if (alreadyFrozen?.has(element)) return null;
- const computed = getComputedStyle(element);
- let frozenStyles = element.style.cssText;
- const originalPropertyValues = collectOriginalPropertyValues(element, properties);
+ const computed = getComputedStyle(element);
+ let frozenStyles = element.style.cssText;
+ const originalPropertyValues = collectOriginalPropertyValues(element, properties);
- for (const prop of properties) {
- const computedValue = computed.getPropertyValue(prop);
- if (computedValue) {
- frozenStyles += `${prop}: ${computedValue} !important; `;
- }
+ for (const prop of properties) {
+ const computedValue = computed.getPropertyValue(prop);
+ if (computedValue) {
+ frozenStyles += `${prop}: ${computedValue} !important; `;
}
+ }
+
+ return { element, frozenStyles, originalPropertyValues };
+};
- elementsToFreeze.push({ element, frozenStyles, originalPropertyValues });
+const collectHoveredElements = (cursorX: number, cursorY: number): HTMLElement[] => {
+ const hoveredElements: HTMLElement[] = [];
+ let current = document.elementFromPoint(cursorX, cursorY);
+ while (current && current !== document.documentElement) {
+ if (current instanceof HTMLElement) {
+ hoveredElements.push(current);
+ }
+ current = current.parentElement;
}
+ return hoveredElements;
+};
- return elementsToFreeze;
+const collectFocusedElements = (): HTMLElement[] => {
+ const focusedElements: HTMLElement[] = [];
+ let current: Element | null = document.activeElement;
+ while (current && current !== document.body) {
+ if (current instanceof HTMLElement) {
+ focusedElements.push(current);
+ }
+ const shadowRoot = current.shadowRoot;
+ current = shadowRoot?.activeElement ?? null;
+ }
+ return focusedElements;
};
const applyFrozenStates = (
@@ -152,7 +170,7 @@ export const resumePointerEventsFreeze = (): void => {
if (pointerEventsStyle) pointerEventsStyle.disabled = false;
};
-export const freezePseudoStates = (): void => {
+export const freezePseudoStates = (cursorX?: number, cursorY?: number): void => {
if (pointerEventsStyle) return;
for (const eventType of MOUSE_EVENTS_TO_BLOCK) {
@@ -163,12 +181,29 @@ export const freezePseudoStates = (): void => {
document.addEventListener(eventType, preventFocusChange, true);
}
- const hoverStates = collectPseudoStates(":hover", HOVER_STYLE_PROPERTIES);
- const focusStates = collectPseudoStates(
- ":focus, :focus-visible",
- FOCUS_STYLE_PROPERTIES,
- frozenFocusElements,
- );
+ const hoverStates: FrozenPseudoState[] = [];
+ const isCursorInViewport =
+ cursorX !== undefined &&
+ cursorY !== undefined &&
+ cursorX >= 0 &&
+ cursorY >= 0 &&
+ cursorX < window.innerWidth &&
+ cursorY < window.innerHeight;
+ const hoveredElements = isCursorInViewport
+ ? collectHoveredElements(cursorX, cursorY)
+ : Array.from(document.querySelectorAll(":hover")).filter(
+ (element): element is HTMLElement => element instanceof HTMLElement,
+ );
+ for (const element of hoveredElements) {
+ const state = freezeElement(element, HOVER_STYLE_PROPERTIES);
+ if (state) hoverStates.push(state);
+ }
+
+ const focusStates: FrozenPseudoState[] = [];
+ for (const element of collectFocusedElements()) {
+ const state = freezeElement(element, FOCUS_STYLE_PROPERTIES, frozenFocusElements);
+ if (state) focusStates.push(state);
+ }
applyFrozenStates(hoverStates, frozenHoverElements);
applyFrozenStates(focusStates, frozenFocusElements);
diff --git a/packages/react-grab/src/utils/get-element-at-position.ts b/packages/react-grab/src/utils/get-element-at-position.ts
index 0fcb34bf8..9701e1b71 100644
--- a/packages/react-grab/src/utils/get-element-at-position.ts
+++ b/packages/react-grab/src/utils/get-element-at-position.ts
@@ -5,6 +5,7 @@ import {
POINTER_EVENTS_RESUME_DEBOUNCE_MS,
} from "../constants.js";
import { suspendPointerEventsFreeze, resumePointerEventsFreeze } from "./freeze-pseudo-states.js";
+import { isElementAtPointIndexReady, queryElementAtPointIndex } from "./element-at-point-index.js";
interface PositionCache {
clientX: number;
@@ -42,6 +43,14 @@ const isWithinThreshold = (x1: number, y1: number, x2: number, y2: number): bool
);
};
+const getElementArea = (element: Element): number => {
+ const blockWidth = element.clientWidth;
+ const blockHeight = element.clientHeight;
+ if (blockWidth > 0 && blockHeight > 0) return blockWidth * blockHeight;
+ const rect = element.getBoundingClientRect();
+ return rect.width * rect.height;
+};
+
export const getElementsAtPoint = (clientX: number, clientY: number): Element[] => {
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return [];
cancelScheduledResume();
@@ -60,40 +69,39 @@ export const getElementAtPosition = (clientX: number, clientY: number): Element
const isWithinThrottle = now - cache.timestamp < ELEMENT_POSITION_THROTTLE_MS;
if (isPositionClose || isWithinThrottle) {
- return cache.element;
+ if (cache.element === null || cache.element.isConnected) {
+ return cache.element;
+ }
+ cache = null;
+ }
+ }
+
+ if (isElementAtPointIndexReady()) {
+ const spatialResult = queryElementAtPointIndex(clientX, clientY);
+ if (
+ spatialResult &&
+ isValidGrabbableElement(spatialResult) &&
+ getElementArea(spatialResult) > 0
+ ) {
+ cache = { clientX, clientY, element: spatialResult, timestamp: now };
+ return spatialResult;
}
}
- // PERF: suspendPointerEventsFreeze toggles the html { pointer-events: none }
- // stylesheet, which dirties the entire style tree. elementFromPoint then forces
- // a Recalculate Style. The 100ms debounced resume (scheduleResume) ensures the
- // toggle is a no-op on rapid subsequent calls. The expensive recalc on those
- // calls comes from host-page CSS animations dirtying styles between frames,
- // which is unavoidable without removing pointer-events: none entirely.
- // Alternatives explored and rejected:
- // - IntersectionObserver pre-population: adds 1-frame latency to every poll
- // - event.target fast path: always html/document due to pointer-events: none
- // - bounds-check cache: ignores z-index/stacking, causes hover detection misses
- // - transparent overlay instead of pointer-events: none: leaks CSS-only :hover
- // dropdowns/tooltips during the hit-test toggle
cancelScheduledResume();
suspendPointerEventsFreeze();
+ const elementsAtPoint = document.elementsFromPoint(clientX, clientY);
let result: Element | null = null;
+ let smallestArea = Infinity;
- // elementFromPoint returns the topmost element, but if it's not grabbable
- // (e.g. a transparent overlay) we fall back to elementsFromPoint which
- // returns the full z-ordered stack at that coordinate.
- const topElement = document.elementFromPoint(clientX, clientY);
- if (topElement && isValidGrabbableElement(topElement)) {
- result = topElement;
- } else {
- const elementsAtPoint = document.elementsFromPoint(clientX, clientY);
- for (const candidateElement of elementsAtPoint) {
- if (candidateElement !== topElement && isValidGrabbableElement(candidateElement)) {
- result = candidateElement;
- break;
- }
+ for (const candidateElement of elementsAtPoint) {
+ if (!isValidGrabbableElement(candidateElement)) continue;
+ const area = getElementArea(candidateElement);
+ if (area === 0) continue;
+ if (area < smallestArea) {
+ smallestArea = area;
+ result = candidateElement;
}
}
diff --git a/packages/react-grab/src/utils/hilbert-r-tree.ts b/packages/react-grab/src/utils/hilbert-r-tree.ts
new file mode 100644
index 000000000..37a0e20bf
--- /dev/null
+++ b/packages/react-grab/src/utils/hilbert-r-tree.ts
@@ -0,0 +1,299 @@
+const NODE_SIZE = 16;
+
+const upperBound = (value: number, levelBounds: number[]): number => {
+ let low = 0;
+ let high = levelBounds.length - 1;
+ while (low < high) {
+ const mid = (low + high) >> 1;
+ if (levelBounds[mid] > value) {
+ high = mid;
+ } else {
+ low = mid + 1;
+ }
+ }
+ return levelBounds[low];
+};
+
+const swapItems = (
+ values: Uint32Array,
+ boxes: Float64Array,
+ indices: Uint16Array | Uint32Array,
+ indexA: number,
+ indexB: number,
+): void => {
+ const tempValue = values[indexA];
+ values[indexA] = values[indexB];
+ values[indexB] = tempValue;
+
+ const boxOffsetA = 4 * indexA;
+ const boxOffsetB = 4 * indexB;
+
+ const minXA = boxes[boxOffsetA];
+ const minYA = boxes[boxOffsetA + 1];
+ const maxXA = boxes[boxOffsetA + 2];
+ const maxYA = boxes[boxOffsetA + 3];
+ boxes[boxOffsetA] = boxes[boxOffsetB];
+ boxes[boxOffsetA + 1] = boxes[boxOffsetB + 1];
+ boxes[boxOffsetA + 2] = boxes[boxOffsetB + 2];
+ boxes[boxOffsetA + 3] = boxes[boxOffsetB + 3];
+ boxes[boxOffsetB] = minXA;
+ boxes[boxOffsetB + 1] = minYA;
+ boxes[boxOffsetB + 2] = maxXA;
+ boxes[boxOffsetB + 3] = maxYA;
+
+ const tempIndex = indices[indexA];
+ indices[indexA] = indices[indexB];
+ indices[indexB] = tempIndex;
+};
+
+const sortByHilbert = (
+ values: Uint32Array,
+ boxes: Float64Array,
+ indices: Uint16Array | Uint32Array,
+ left: number,
+ right: number,
+): void => {
+ const stack = [left, right];
+
+ while (stack.length) {
+ const stackRight = stack.pop()!;
+ const stackLeft = stack.pop()!;
+
+ if (
+ stackRight - stackLeft <= NODE_SIZE &&
+ Math.floor(stackLeft / NODE_SIZE) >= Math.floor(stackRight / NODE_SIZE)
+ )
+ continue;
+
+ const valueLeft = values[stackLeft];
+ const valueMid = values[(stackLeft + stackRight) >> 1];
+ const valueRight = values[stackRight];
+ const pivot =
+ valueLeft > valueMid !== valueLeft > valueRight
+ ? valueLeft
+ : valueMid < valueLeft !== valueMid < valueRight
+ ? valueMid
+ : valueRight;
+
+ let scanLeft = stackLeft - 1;
+ let scanRight = stackRight + 1;
+
+ while (true) {
+ do scanLeft++;
+ while (values[scanLeft] < pivot);
+ do scanRight--;
+ while (values[scanRight] > pivot);
+ if (scanLeft >= scanRight) break;
+ swapItems(values, boxes, indices, scanLeft, scanRight);
+ }
+
+ stack.push(stackLeft, scanRight, scanRight + 1, stackRight);
+ }
+};
+
+// Fast Hilbert curve algorithm by http://threadlocalmutex.com/
+// Ported from C++ https://github.com/rawrunprotected/hilbert_curves (public domain)
+const hilbert = (coordX: number, coordY: number): number => {
+ let xorXY = coordX ^ coordY;
+ let invertedXor = 0xffff ^ xorXY;
+ let invertedOr = 0xffff ^ (coordX | coordY);
+ let maskedAnd = coordX & (coordY ^ 0xffff);
+
+ let levelA = xorXY | (invertedXor >> 1);
+ let levelB = (xorXY >> 1) ^ xorXY;
+ let levelC = (invertedOr >> 1) ^ (invertedXor & (maskedAnd >> 1)) ^ invertedOr;
+ let levelD = (xorXY & (invertedOr >> 1)) ^ (maskedAnd >> 1) ^ maskedAnd;
+
+ xorXY = levelA;
+ invertedXor = levelB;
+ invertedOr = levelC;
+ maskedAnd = levelD;
+ levelA = (xorXY & (xorXY >> 2)) ^ (invertedXor & (invertedXor >> 2));
+ levelB = (xorXY & (invertedXor >> 2)) ^ (invertedXor & ((xorXY ^ invertedXor) >> 2));
+ levelC ^= (xorXY & (invertedOr >> 2)) ^ (invertedXor & (maskedAnd >> 2));
+ levelD ^= (invertedXor & (invertedOr >> 2)) ^ ((xorXY ^ invertedXor) & (maskedAnd >> 2));
+
+ xorXY = levelA;
+ invertedXor = levelB;
+ invertedOr = levelC;
+ maskedAnd = levelD;
+ levelA = (xorXY & (xorXY >> 4)) ^ (invertedXor & (invertedXor >> 4));
+ levelB = (xorXY & (invertedXor >> 4)) ^ (invertedXor & ((xorXY ^ invertedXor) >> 4));
+ levelC ^= (xorXY & (invertedOr >> 4)) ^ (invertedXor & (maskedAnd >> 4));
+ levelD ^= (invertedXor & (invertedOr >> 4)) ^ ((xorXY ^ invertedXor) & (maskedAnd >> 4));
+
+ xorXY = levelA;
+ invertedXor = levelB;
+ levelC ^= (xorXY & (invertedOr >> 8)) ^ (invertedXor & (maskedAnd >> 8));
+ levelD ^= (invertedXor & (invertedOr >> 8)) ^ ((xorXY ^ invertedXor) & (maskedAnd >> 8));
+
+ xorXY = levelC ^ (levelC >> 1);
+ invertedXor = levelD ^ (levelD >> 1);
+
+ let interleaved0 = coordX ^ coordY;
+ let interleaved1 = invertedXor | (0xffff ^ (interleaved0 | xorXY));
+
+ interleaved0 = (interleaved0 | (interleaved0 << 8)) & 0x00ff00ff;
+ interleaved0 = (interleaved0 | (interleaved0 << 4)) & 0x0f0f0f0f;
+ interleaved0 = (interleaved0 | (interleaved0 << 2)) & 0x33333333;
+ interleaved0 = (interleaved0 | (interleaved0 << 1)) & 0x55555555;
+
+ interleaved1 = (interleaved1 | (interleaved1 << 8)) & 0x00ff00ff;
+ interleaved1 = (interleaved1 | (interleaved1 << 4)) & 0x0f0f0f0f;
+ interleaved1 = (interleaved1 | (interleaved1 << 2)) & 0x33333333;
+ interleaved1 = (interleaved1 | (interleaved1 << 1)) & 0x55555555;
+
+ return ((interleaved1 << 1) | interleaved0) >>> 0;
+};
+
+export class HilbertRTree {
+ private readonly numItems: number;
+ private readonly boxes: Float64Array;
+ private readonly indices: Uint16Array | Uint32Array;
+ private readonly levelBounds: number[];
+ private position: number;
+
+ minX = Infinity;
+ minY = Infinity;
+ maxX = -Infinity;
+ maxY = -Infinity;
+
+ constructor(numItems: number) {
+ if (numItems <= 0) throw new Error(`Unexpected numItems value: ${numItems}.`);
+
+ this.numItems = numItems;
+
+ let nodeCount = numItems;
+ this.levelBounds = [nodeCount * 4];
+ let remaining = numItems;
+ do {
+ remaining = Math.ceil(remaining / NODE_SIZE);
+ nodeCount += remaining;
+ this.levelBounds.push(nodeCount * 4);
+ } while (remaining !== 1);
+
+ const IndexArrayType = nodeCount < 16384 ? Uint16Array : Uint32Array;
+ this.boxes = new Float64Array(nodeCount * 4);
+ this.indices = new IndexArrayType(nodeCount);
+ this.position = 0;
+ }
+
+ add(minX: number, minY: number, maxX: number = minX, maxY: number = minY): number {
+ const itemIndex = this.position >> 2;
+ this.indices[itemIndex] = itemIndex;
+ this.boxes[this.position++] = minX;
+ this.boxes[this.position++] = minY;
+ this.boxes[this.position++] = maxX;
+ this.boxes[this.position++] = maxY;
+
+ if (minX < this.minX) this.minX = minX;
+ if (minY < this.minY) this.minY = minY;
+ if (maxX > this.maxX) this.maxX = maxX;
+ if (maxY > this.maxY) this.maxY = maxY;
+
+ return itemIndex;
+ }
+
+ finish(): void {
+ if (this.position >> 2 !== this.numItems) {
+ throw new Error(`Added ${this.position >> 2} items when expected ${this.numItems}.`);
+ }
+
+ const boxes = this.boxes;
+
+ if (this.numItems <= NODE_SIZE) {
+ boxes[this.position++] = this.minX;
+ boxes[this.position++] = this.minY;
+ boxes[this.position++] = this.maxX;
+ boxes[this.position++] = this.maxY;
+ return;
+ }
+
+ const width = this.maxX - this.minX || 1;
+ const height = this.maxY - this.minY || 1;
+ const hilbertValues = new Uint32Array(this.numItems);
+ const hilbertMax = (1 << 16) - 1;
+
+ for (let itemIndex = 0, boxPosition = 0; itemIndex < this.numItems; itemIndex++) {
+ const rectMinX = boxes[boxPosition++];
+ const rectMinY = boxes[boxPosition++];
+ const rectMaxX = boxes[boxPosition++];
+ const rectMaxY = boxes[boxPosition++];
+ const normalizedX = Math.floor(
+ (hilbertMax * ((rectMinX + rectMaxX) / 2 - this.minX)) / width,
+ );
+ const normalizedY = Math.floor(
+ (hilbertMax * ((rectMinY + rectMaxY) / 2 - this.minY)) / height,
+ );
+ hilbertValues[itemIndex] = hilbert(normalizedX, normalizedY);
+ }
+
+ sortByHilbert(hilbertValues, boxes, this.indices, 0, this.numItems - 1);
+
+ for (
+ let levelIndex = 0, boxPosition = 0;
+ levelIndex < this.levelBounds.length - 1;
+ levelIndex++
+ ) {
+ const levelEnd = this.levelBounds[levelIndex];
+
+ while (boxPosition < levelEnd) {
+ const nodeStartIndex = boxPosition;
+
+ let nodeMinX = boxes[boxPosition++];
+ let nodeMinY = boxes[boxPosition++];
+ let nodeMaxX = boxes[boxPosition++];
+ let nodeMaxY = boxes[boxPosition++];
+ for (let childIndex = 1; childIndex < NODE_SIZE && boxPosition < levelEnd; childIndex++) {
+ nodeMinX = Math.min(nodeMinX, boxes[boxPosition++]);
+ nodeMinY = Math.min(nodeMinY, boxes[boxPosition++]);
+ nodeMaxX = Math.max(nodeMaxX, boxes[boxPosition++]);
+ nodeMaxY = Math.max(nodeMaxY, boxes[boxPosition++]);
+ }
+
+ this.indices[this.position >> 2] = nodeStartIndex;
+ boxes[this.position++] = nodeMinX;
+ boxes[this.position++] = nodeMinY;
+ boxes[this.position++] = nodeMaxX;
+ boxes[this.position++] = nodeMaxY;
+ }
+ }
+ }
+
+ search(minX: number, minY: number, maxX: number, maxY: number): number[] {
+ if (this.position !== this.boxes.length) {
+ throw new Error("Data not yet indexed - call finish().");
+ }
+
+ let nodeIndex: number | undefined = this.boxes.length - 4;
+ const searchQueue: number[] = [];
+ const results: number[] = [];
+
+ while (nodeIndex !== undefined) {
+ const nodeEnd = Math.min(nodeIndex + NODE_SIZE * 4, upperBound(nodeIndex, this.levelBounds));
+
+ for (let boxPosition = nodeIndex; boxPosition < nodeEnd; boxPosition += 4) {
+ const candidateMinX = this.boxes[boxPosition];
+ if (maxX < candidateMinX) continue;
+ const candidateMinY = this.boxes[boxPosition + 1];
+ if (maxY < candidateMinY) continue;
+ const candidateMaxX = this.boxes[boxPosition + 2];
+ if (minX > candidateMaxX) continue;
+ const candidateMaxY = this.boxes[boxPosition + 3];
+ if (minY > candidateMaxY) continue;
+
+ const candidateIndex = this.indices[boxPosition >> 2] | 0;
+
+ if (nodeIndex >= this.numItems * 4) {
+ searchQueue.push(candidateIndex);
+ } else {
+ results.push(candidateIndex);
+ }
+ }
+
+ nodeIndex = searchQueue.pop();
+ }
+
+ return results;
+ }
+}
diff --git a/packages/react-grab/src/utils/is-decorative-overlay.ts b/packages/react-grab/src/utils/is-decorative-overlay.ts
new file mode 100644
index 000000000..a6b08ca0d
--- /dev/null
+++ b/packages/react-grab/src/utils/is-decorative-overlay.ts
@@ -0,0 +1,18 @@
+const REPLACED_TAGS = new Set([
+ "IMG",
+ "VIDEO",
+ "CANVAS",
+ "SVG",
+ "IFRAME",
+ "INPUT",
+ "TEXTAREA",
+ "SELECT",
+ "OBJECT",
+ "EMBED",
+]);
+
+export const isDecorativeOverlay = (element: Element, computedPosition: string): boolean =>
+ (computedPosition === "absolute" || computedPosition === "sticky") &&
+ !REPLACED_TAGS.has(element.tagName) &&
+ element.childElementCount === 0 &&
+ (element.textContent?.trim().length ?? 0) === 0;
diff --git a/packages/react-grab/src/utils/is-valid-grabbable-element.ts b/packages/react-grab/src/utils/is-valid-grabbable-element.ts
index f2876803f..c9459a50b 100644
--- a/packages/react-grab/src/utils/is-valid-grabbable-element.ts
+++ b/packages/react-grab/src/utils/is-valid-grabbable-element.ts
@@ -34,28 +34,16 @@ const isDevToolsOverlay = (computedStyle: CSSStyleDeclaration): boolean => {
);
};
-const isFullViewportOverlay = (element: Element, computedStyle: CSSStyleDeclaration): boolean => {
- const position = computedStyle.position;
- if (position !== "fixed" && position !== "absolute") {
- return false;
- }
-
- const rect = element.getBoundingClientRect();
- const coversViewport =
- rect.width / window.innerWidth >= VIEWPORT_COVERAGE_THRESHOLD &&
- rect.height / window.innerHeight >= VIEWPORT_COVERAGE_THRESHOLD;
-
- if (!coversViewport) {
- return false;
- }
-
+const hasTransparentBackground = (computedStyle: CSSStyleDeclaration): boolean => {
const backgroundColor = computedStyle.backgroundColor;
- const hasInvisibleBackground =
- backgroundColor === "transparent" ||
- backgroundColor === "rgba(0, 0, 0, 0)" ||
- parseFloat(computedStyle.opacity) < 0.1;
+ return backgroundColor === "transparent" || backgroundColor === "rgba(0, 0, 0, 0)";
+};
+
+const isFullViewportOverlay = (computedStyle: CSSStyleDeclaration): boolean => {
+ const position = computedStyle.position;
+ if (position !== "fixed" && position !== "absolute") return false;
- if (hasInvisibleBackground) {
+ if (hasTransparentBackground(computedStyle) || parseFloat(computedStyle.opacity) < 0.1) {
return true;
}
@@ -110,7 +98,7 @@ export const isValidGrabbableElement = (element: Element): boolean => {
if (isDevToolsOverlay(computedStyle)) {
return false;
}
- if (isFullViewportOverlay(element, computedStyle)) {
+ if (isFullViewportOverlay(computedStyle)) {
return false;
}
}
diff --git a/packages/react-grab/src/utils/on-idle.ts b/packages/react-grab/src/utils/on-idle.ts
deleted file mode 100644
index 7437b19d9..000000000
--- a/packages/react-grab/src/utils/on-idle.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-interface BackgroundTaskScheduler {
- postTask: (callback: () => void, options: { priority: "background" }) => unknown;
-}
-
-declare global {
- interface Window {
- scheduler?: BackgroundTaskScheduler;
- }
-}
-
-const isBackgroundTaskScheduler = (value: unknown): value is BackgroundTaskScheduler => {
- if (typeof value !== "object" || value === null) return false;
- if (!("postTask" in value)) return false;
- return typeof value.postTask === "function";
-};
-
-// Defers work via the best available idle-scheduling primitive: the Scheduler
-// API with background priority (Chrome 94+), then requestIdleCallback, then a
-// setTimeout(0) fallback. This is used for expensive hit-testing work that
-// should never block user-visible rendering.
-export const onIdle = (callback: () => void): void => {
- if (typeof window !== "undefined") {
- const schedulerCandidate = window.scheduler;
- if (isBackgroundTaskScheduler(schedulerCandidate)) {
- schedulerCandidate.postTask(callback, {
- priority: "background",
- });
- return;
- }
- if ("requestIdleCallback" in window) {
- requestIdleCallback(callback);
- return;
- }
- }
- setTimeout(callback, 0);
-};