diff --git a/apps/openstory/stories/renderer.stories.tsx b/apps/openstory/stories/renderer.stories.tsx index a0037b6f0..79946a3f4 100644 --- a/apps/openstory/stories/renderer.stories.tsx +++ b/apps/openstory/stories/renderer.stories.tsx @@ -321,7 +321,9 @@ const Scene = (props: SceneProps) => { mouseX={mouseX()} isPromptMode={props.isPromptMode} inputValue={props.inputValue} - isPendingDismiss={props.isPendingDismiss} + discardPrompt={ + props.isPendingDismiss ? { onConfirm: noop, onCancel: noop } : undefined + } isFrozen={false} toolbarVisible={props.showToolbar} isActive={props.isActive} @@ -336,7 +338,6 @@ const Scene = (props: SceneProps) => { onInputSubmit={noop} onToggleExpand={noop} onConfirmDismiss={noop} - onCancelDismiss={noop} onToggleActive={noop} onToolbarStateChange={noop} onContextMenuDismiss={noop} diff --git a/apps/openstory/stories/selection-label.stories.tsx b/apps/openstory/stories/selection-label.stories.tsx index 7d7a8c7bd..fc787c812 100644 --- a/apps/openstory/stories/selection-label.stories.tsx +++ b/apps/openstory/stories/selection-label.stories.tsx @@ -17,7 +17,6 @@ const baseProps: SelectionLabelProps = { onSubmit: noop, onDismiss: noop, onConfirmDismiss: noop, - onCancelDismiss: noop, onAcknowledgeError: noop, onRetry: noop, onShowContextMenu: noop, @@ -122,7 +121,7 @@ export const PendingDismiss: Story = { tagName: "header", componentName: "Header", isPromptMode: true, - isPendingDismiss: true, + discardPrompt: { onConfirm: noop, onCancel: noop }, inputValue: "tweak the spacing", }, }; diff --git a/packages/react-grab/e2e/edit-panel-helpers.ts b/packages/react-grab/e2e/edit-panel-helpers.ts index 89088b953..ffc4d9d69 100644 --- a/packages/react-grab/e2e/edit-panel-helpers.ts +++ b/packages/react-grab/e2e/edit-panel-helpers.ts @@ -452,6 +452,24 @@ export const clickHeaderCopyButton = async (page: Page): Promise => { ); }; +export const clickDiscardButton = async ( + page: Page, + action: "cancel" | "confirm" | "copy", +): Promise => { + await page.evaluate( + ({ attrName, actionName }) => { + const host = document.querySelector(`[${attrName}]`); + const shadowRoot = host?.shadowRoot; + const button = shadowRoot?.querySelector( + `[data-react-grab-discard-button='${actionName}']`, + ); + if (!button) throw new Error("Discard button not found"); + button.click(); + }, + { attrName: ATTRIBUTE_NAME, actionName: action }, + ); +}; + export const dragActiveSlider = async (page: Page): Promise => { const sliderBounds = await page.evaluate( ({ attrName, propertyAttr }) => { diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index bea1245d5..6ce9d3fa9 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -9,6 +9,7 @@ import { IDLE_BUFFER_MS, SEARCH_INPUT_ATTR, clearEditStorage, + clickDiscardButton, clickHeaderCopyButton, dispatchOutsideDismiss, dragActiveSlider, @@ -25,7 +26,6 @@ import { getOverlayFocusVisualStates, getPropertyRowBounds, getSearchInputFocusVisualState, - getVisibleSliderVisualState, getVisiblePropertyKeys, hoverVisibleSlider, isDiscardPromptVisible, @@ -285,6 +285,110 @@ test.describe("Style Panel", () => { expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); }); + test("mouse movement after keyboard tweak opens discard prompt with Copy", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + const inlineStyleAfterTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); + expect(inlineStyleAfterTweak.length).toBeGreaterThan(0); + + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + expect(await isEditPanelVisible(reactGrab.page)).toBe(true); + expect(await isEditPanelCompact(reactGrab.page)).toBe(false); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + + await clickDiscardButton(reactGrab.page, "copy"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe( + inlineStyleAfterTweak, + ); + }); + + test("canceling mouse-move discard prompt consumes the pointer handoff", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + + await clickDiscardButton(reactGrab.page, "cancel"); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + + await reactGrab.page.mouse.move(20, 20); + await reactGrab.page.waitForTimeout(80); + expect(await isEditPanelVisible(reactGrab.page)).toBe(true); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + }); + + test("canceling outside-click discard prompt consumes the pointer handoff", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + + await dispatchOutsideDismiss(reactGrab.page); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + + await clickDiscardButton(reactGrab.page, "cancel"); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + + await reactGrab.page.mouse.move(20, 20); + await reactGrab.page.waitForTimeout(80); + expect(await isEditPanelVisible(reactGrab.page)).toBe(true); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + }); + + test("mouse movement while discard prompt is visible does not consume the handoff", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + + await openDiscardPromptViaEscape(reactGrab.page); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + + await clickDiscardButton(reactGrab.page, "cancel"); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + + await reactGrab.page.mouse.move(20, 20); + await reactGrab.page.waitForTimeout(80); + expect(await isEditPanelVisible(reactGrab.page)).toBe(true); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + }); + + test("keyboard navigation after pointer tweak does not arm mouse-move discard prompt", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await dragActiveSlider(reactGrab.page); + await reactGrab.page.waitForTimeout(80); + expect(await isHeaderCopyButtonVisible(reactGrab.page)).toBe(true); + + await reactGrab.page.keyboard.press("ArrowDown"); + await reactGrab.page.waitForTimeout(80); + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + + expect(await isEditPanelVisible(reactGrab.page)).toBe(true); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + }); + test("net-zero tweak dismiss restores preview inline styles", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); @@ -343,6 +447,91 @@ test.describe("Style Panel", () => { await reactGrab.page.keyboard.press("Escape"); await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); }); + + test("mouse movement without pending tweaks does not open the discard prompt", async ({ + reactGrab, + }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + + // Nothing was tweaked, so the handoff was never armed. + expect(await isEditPanelVisible(reactGrab.page)).toBe(true); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + }); + + test("mouse-move discard prompt Yes reverts the keyboard tweak", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); + + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + + await clickDiscardButton(reactGrab.page, "confirm"); + await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false); + expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak); + }); + + test("a fresh keyboard tweak re-arms the consumed pointer handoff", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + + await clickDiscardButton(reactGrab.page, "cancel"); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + + // The handoff is one-shot, but a new keyboard commit re-arms it. + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + await reactGrab.page.mouse.move(40, 40); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + }); + + test("typing a tailwind class arms the mouse-move discard handoff", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + const beforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR); + // px-4 (16px) differs from the button's px-2 (8px), so it's a real, + // submittable edit rather than a net-zero one. + await reactGrab.page.keyboard.type("px-4"); + await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); + expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).not.toBe(beforeTweak); + + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + }); + + test("touch pointer movement does not consume the keyboard handoff", async ({ reactGrab }) => { + await openEditPanel(reactGrab, BUTTON_SELECTOR); + await reactGrab.page.keyboard.press("ArrowRight"); + await reactGrab.page.waitForTimeout(80); + + // A touch pointermove is ignored by the mouse-only handoff, so it must + // neither open the prompt nor consume the arm. + await reactGrab.page.evaluate(() => { + window.dispatchEvent( + new PointerEvent("pointermove", { pointerType: "touch", bubbles: true }), + ); + }); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(false); + + // The still-armed handoff fires on the next real mouse move. + await reactGrab.page.mouse.move(10, 10); + await reactGrab.page.waitForTimeout(80); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); + }); }); test.describe("Property listing", () => { @@ -777,17 +966,16 @@ test.describe("Style Panel", () => { expect(await isEditPanelCompact(reactGrab.page)).toBe(true); }); - test("compact slider shows unit marks on hover", async ({ reactGrab }) => { + test("compact keyboard tweak opens discard prompt on hover", async ({ reactGrab }) => { await openEditPanel(reactGrab, BUTTON_SELECTOR); await reactGrab.page.keyboard.press("ArrowRight"); await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); expect(await isEditPanelCompact(reactGrab.page)).toBe(true); - const beforeHover = await getVisibleSliderVisualState(reactGrab.page); - expect(beforeHover.maxHashMarkOpacity).toBe(0); + await hoverVisibleSlider(reactGrab.page); await reactGrab.page.waitForTimeout(IDLE_BUFFER_MS); - const afterHover = await getVisibleSliderVisualState(reactGrab.page); - expect(afterHover.maxHashMarkOpacity).toBeGreaterThan(0); + expect(await isEditPanelCompact(reactGrab.page)).toBe(false); + expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true); }); test("typing in search re-expands the compact panel", async ({ reactGrab }) => { diff --git a/packages/react-grab/e2e/keyboard-navigation.spec.ts b/packages/react-grab/e2e/keyboard-navigation.spec.ts index 7b9f2cbc6..57ddede85 100644 --- a/packages/react-grab/e2e/keyboard-navigation.spec.ts +++ b/packages/react-grab/e2e/keyboard-navigation.spec.ts @@ -1,4 +1,54 @@ import { expect, test, type ReactGrabPageObject } from "./fixtures.js"; +import { isEditPanelVisible } from "./edit-panel-helpers.js"; + +const ATTRIBUTE_NAME = "data-react-grab"; + +const clickSelectionDiscardButton = async ( + reactGrab: ReactGrabPageObject, + action: "copy" | "yes", + options: { programmatic?: boolean } = {}, +): Promise => { + if (!options.programmatic) { + await reactGrab.page.locator(`[data-react-grab-discard-${action}]`).click(); + return; + } + await reactGrab.page.evaluate( + ({ attrName, actionName }) => { + const host = document.querySelector(`[${attrName}]`); + const shadowRoot = host?.shadowRoot; + const button = shadowRoot?.querySelector( + `[data-react-grab-discard-${actionName}]`, + ); + if (!button) throw new Error(`Discard ${actionName} button not found`); + button.click(); + }, + { attrName: ATTRIBUTE_NAME, actionName: action }, + ); +}; + +const getSelectionDiscardPromptState = async ( + reactGrab: ReactGrabPageObject, +): Promise<{ + promptText: string; + labelText: string; + hasCopy: boolean; + hasNo: boolean; + hasYes: boolean; +}> => { + return reactGrab.page.evaluate((attrName) => { + const host = document.querySelector(`[${attrName}]`); + const shadowRoot = host?.shadowRoot; + const label = shadowRoot?.querySelector("[data-react-grab-selection-label]"); + const prompt = shadowRoot?.querySelector("[data-react-grab-discard-prompt]"); + return { + promptText: prompt?.textContent?.replace(/\s+/g, " ").trim() ?? "", + labelText: label?.textContent?.replace(/\s+/g, " ").trim() ?? "", + hasCopy: Boolean(prompt?.querySelector("[data-react-grab-discard-copy]")), + hasNo: Boolean(prompt?.querySelector("[data-react-grab-discard-no]")), + hasYes: Boolean(prompt?.querySelector("[data-react-grab-discard-yes]")), + }; + }, ATTRIBUTE_NAME); +}; test.describe("Keyboard Navigation", () => { test("should navigate to next element with ArrowDown", async ({ reactGrab }) => { @@ -62,7 +112,9 @@ test.describe("Keyboard Navigation", () => { expect(isVisible).toBe(true); }); - test("should copy element after keyboard navigation with click", async ({ reactGrab }) => { + test("should copy element after keyboard navigation with mouse-move prompt", async ({ + reactGrab, + }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); await reactGrab.waitForSelectionBox(); @@ -73,15 +125,33 @@ test.describe("Keyboard Navigation", () => { const secondItem = reactGrab.page.locator("[data-testid='todo-list'] li:nth-child(2)"); const box = await secondItem.boundingBox(); if (box) { - await reactGrab.page.mouse.click(box.x + 10, box.y + 10); + await reactGrab.page.mouse.move(box.x + 10, box.y + 10); } - await reactGrab.page.waitForTimeout(500); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); + await clickSelectionDiscardButton(reactGrab, "copy"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toBeTruthy(); + await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).not.toBe(""); }); - test("should copy keyboard-selected element when clicking after mouse movement", async ({ + test("should copy keyboard-selected element when clicking over previous mouse target", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='todo-list'] h1"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.page.keyboard.press("ArrowLeft"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.page.mouse.down(); + await reactGrab.page.mouse.up(); + + await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).toContain("[ { await reactGrab.activate(); @@ -99,18 +169,34 @@ test.describe("Keyboard Navigation", () => { await reactGrab.page.mouse.move(10, 10); await reactGrab.page.waitForTimeout(50); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); - await reactGrab.page.mouse.click( - selectionBoundsAfterArrow!.x + selectionBoundsAfterArrow!.width / 2, - selectionBoundsAfterArrow!.y + selectionBoundsAfterArrow!.height / 2, - ); - await reactGrab.page.waitForTimeout(500); + await clickSelectionDiscardButton(reactGrab, "copy"); - const clipboardContent = await reactGrab.getClipboardContent(); - expect(clipboardContent).toBeTruthy(); + await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).not.toBe(""); }); - test("should freeze selection when navigating with arrow keys", async ({ reactGrab }) => { + test("should copy keyboard-selected element with Enter on focused Copy prompt button", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.page.keyboard.press("ArrowUp"); + await reactGrab.waitForSelectionBox(); + await reactGrab.page.mouse.move(10, 10); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); + + await reactGrab.page.locator("[data-react-grab-discard-copy]").focus(); + await reactGrab.page.keyboard.press("Enter"); + + await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).not.toBe(""); + }); + + test("should prompt before mouse movement discards arrow-key selection", async ({ + reactGrab, + }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); @@ -123,6 +209,110 @@ test.describe("Keyboard Navigation", () => { const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); + expect(await reactGrab.isPendingDismissVisible()).toBe(true); + const prompt = await getSelectionDiscardPromptState(reactGrab); + expect(prompt.promptText).toBe("Discard selection?CopyYes"); + expect(prompt.labelText).toBe("Discard selection?CopyYes"); + expect(prompt.hasCopy).toBe(true); + expect(prompt.hasNo).toBe(false); + expect(prompt.hasYes).toBe(true); + }); + + test("should discard arrow-key selection when confirming mouse handoff", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("li:first-child"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.page.keyboard.press("ArrowDown"); + await reactGrab.waitForSelectionBox(); + const selectionBoundsAfterArrow = await reactGrab.getSelectionBoxBounds(); + expect(selectionBoundsAfterArrow).not.toBeNull(); + + await reactGrab.page.mouse.move(0, 0); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); + await reactGrab.page.mouse.move(400, 400); + await reactGrab.page.waitForTimeout(100); + expect(await reactGrab.isPendingDismissVisible()).toBe(true); + expect((await reactGrab.getState()).targetElement).toBeFalsy(); + + await clickSelectionDiscardButton(reactGrab, "yes"); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(false); + }); + + test("should confirm discard selection with Enter", async ({ reactGrab }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("li:first-child"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.page.keyboard.press("ArrowDown"); + await reactGrab.waitForSelectionBox(); + await reactGrab.page.mouse.move(0, 0); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); + + await reactGrab.page.keyboard.press("Enter"); + + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(false); + }); + + test("Enter on the focused Copy button copies without opening the Style panel", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.page.keyboard.press("ArrowUp"); + await reactGrab.waitForSelectionBox(); + await reactGrab.page.mouse.move(10, 10); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); + + await reactGrab.page.locator("[data-react-grab-discard-copy]").focus(); + await reactGrab.page.keyboard.press("Enter"); + + // Enter on Copy must copy, not fall through to the Enter-to-expand + // shortcut that would open the Style panel. + await expect.poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }).not.toBe(""); + expect(await isEditPanelVisible(reactGrab.page)).toBe(false); + }); + + test("Enter on the focused Yes button discards without copying", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.page.keyboard.press("ArrowUp"); + await reactGrab.waitForSelectionBox(); + await reactGrab.page.mouse.move(10, 10); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); + + await reactGrab.page.locator("[data-react-grab-discard-yes]").focus(); + await reactGrab.page.keyboard.press("Enter"); + + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(false); + expect(await reactGrab.getClipboardContent()).toBe(""); + }); + + test("arrow keys are ignored while the discard-selection prompt is open", async ({ + reactGrab, + }) => { + await reactGrab.activate(); + await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); + await reactGrab.waitForSelectionBox(); + + await reactGrab.page.keyboard.press("ArrowDown"); + await reactGrab.waitForSelectionBox(); + await reactGrab.page.mouse.move(0, 0); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); + + // Navigation is suppressed while the prompt is up: a navigating key would + // re-select and clear the prompt, so it must stay open. + await reactGrab.page.keyboard.press("ArrowDown"); + await reactGrab.page.waitForTimeout(100); + expect(await reactGrab.isPendingDismissVisible()).toBe(true); }); }); @@ -148,6 +338,9 @@ test.describe("Navigation History and Wrapping", () => { await reactGrab.waitForSelectionBox(); } + await reactGrab.page.locator(scenario.targetSelector).hover({ force: true }); + await expect.poll(() => reactGrab.isPendingDismissVisible()).toBe(true); + await clickSelectionDiscardButton(reactGrab, "yes", { programmatic: true }); await reactGrab.page.locator(scenario.targetSelector).click({ force: true }); await expect diff --git a/packages/react-grab/src/components/edit-panel/active-property-control.tsx b/packages/react-grab/src/components/edit-panel/active-property-control.tsx index 5c261beb4..0d7a0a2e6 100644 --- a/packages/react-grab/src/components/edit-panel/active-property-control.tsx +++ b/packages/react-grab/src/components/edit-panel/active-property-control.tsx @@ -9,7 +9,7 @@ interface ActivePropertyControlProps { property: EditableProperty; activeKey: "left" | "right" | null; onStep: (direction: 1 | -1) => void; - onCommit: (value: number | string) => void; + onCommit: (value: number | string, source: "keyboard" | "pointer") => void; onEditComplete: () => void; onInvalidCommit: () => void; onInteract: () => void; diff --git a/packages/react-grab/src/components/edit-panel/color-picker.tsx b/packages/react-grab/src/components/edit-panel/color-picker.tsx index ca34d0776..6fd3db810 100644 --- a/packages/react-grab/src/components/edit-panel/color-picker.tsx +++ b/packages/react-grab/src/components/edit-panel/color-picker.tsx @@ -12,7 +12,7 @@ const stripHexAlpha = (hex: string): string => (hex.length === 9 ? hex.slice(0, interface ColorPickerProps { label?: string; value: string; - onCommit: (value: string) => void; + onCommit: (value: string, source: "keyboard" | "pointer") => void; onEditComplete?: () => void; onInvalidCommit?: () => void; onRegisterTrigger?: (trigger: (() => void) | null, owner?: () => void) => void; @@ -52,7 +52,7 @@ export const ColorPicker: Component = (props) => { if (!normalizedHexColor) { props.onInvalidCommit?.(); } else if (normalizedHexColor.toLowerCase() !== props.value.toLowerCase()) { - props.onCommit(normalizedHexColor); + props.onCommit(normalizedHexColor, "keyboard"); } props.onEditComplete?.(); }; @@ -170,7 +170,7 @@ export const ColorPicker: Component = (props) => { const nextColorValue = pickedRgb + preservedAlpha; props.onInteract?.(); if (nextColorValue && nextColorValue.toLowerCase() !== props.value.toLowerCase()) { - props.onCommit(nextColorValue); + props.onCommit(nextColorValue, "pointer"); } }} /> diff --git a/packages/react-grab/src/components/edit-panel/copy-button.tsx b/packages/react-grab/src/components/edit-panel/copy-button.tsx new file mode 100644 index 000000000..9fc819fc2 --- /dev/null +++ b/packages/react-grab/src/components/edit-panel/copy-button.tsx @@ -0,0 +1,25 @@ +import type { Component } from "solid-js"; + +interface EditPanelCopyButtonProps { + discardAction?: "copy"; + onCopy: () => void; +} + +export const EditPanelCopyButton: Component = (props) => ( + +); diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx index 4e8e2fd54..96f2012fc 100644 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ b/packages/react-grab/src/components/edit-panel/index.tsx @@ -35,12 +35,14 @@ import { findTailwindClass } from "../../utils/find-tailwind-class.js"; import { formatEditableValue, roundEditableNumericValue } from "../../utils/format-css-value.js"; import { getShadowActiveElement } from "../../utils/get-shadow-active-element.js"; import { getTagDisplay } from "../../utils/get-tag-display.js"; +import { createPointerMovePromptHandoff } from "../../utils/create-pointer-move-prompt-handoff.js"; import { isEventFromOverlay } from "../../utils/is-event-from-overlay.js"; import { registerOverlayDismiss } from "../../utils/register-overlay-dismiss.js"; import { suppressMenuEvent } from "../../utils/suppress-menu-event.js"; import { TagBadge } from "../selection-label/tag-badge.js"; import { ActivePropertyControl } from "./active-property-control.js"; import { HIDDEN_FOCUS_PRESERVING_STYLE } from "./constants.js"; +import { EditPanelCopyButton } from "./copy-button.js"; import { createDiscardConfirmation } from "./discard-confirmation.js"; import { PropertyList } from "./property-list.js"; import { arePropertyValuesEqual } from "./property-values-equal.js"; @@ -122,6 +124,7 @@ const EditPanelBody: Component = (props) => { const [isTransientInteraction, setIsTransientInteraction] = createSignal(false); const isInteracting = createMemo(() => isTransientInteraction() || hasPendingStyles()); const [isHeaderHovered, setIsHeaderHovered] = createSignal(false); + const pointerMovePromptHandoff = createPointerMovePromptHandoff(); const tagDisplay = createMemo(() => getTagDisplay({ @@ -243,6 +246,7 @@ const EditPanelBody: Component = (props) => { shouldFocus?: boolean; shouldCompact?: boolean; isFromKeyRepeat?: boolean; + source?: "keyboard" | "pointer"; } const commit = ( @@ -258,6 +262,7 @@ const EditPanelBody: Component = (props) => { if (options.flashDirection) flashActiveKey(options.flashDirection === 1 ? "right" : "left"); if (options.shouldFocus) ensureSearchFocused(); if (options.shouldCompact) setIsCompact(true); + if (options.source === "keyboard") pointerMovePromptHandoff.arm(); }; const isShiftHeld = createShiftTracker(); @@ -266,6 +271,7 @@ const EditPanelBody: Component = (props) => { direction: 1 | -1, shift: boolean, fromRepeat: boolean, + source: "keyboard" | "pointer", ): EditableProperty | null => { const property = activeProperty(); if (!property) return null; @@ -278,26 +284,34 @@ const EditPanelBody: Component = (props) => { flashDirection: direction, shouldFocus: true, isFromKeyRepeat: fromRepeat, + source, }); return property; }; const stepFromKeyboard = (direction: 1 | -1, shift: boolean, fromRepeat: boolean) => { - if (stepActiveProperty(direction, shift, fromRepeat)) setIsCompact(true); + if (!stepActiveProperty(direction, shift, fromRepeat, "keyboard")) return; + setIsCompact(true); + }; + + const stepFromPointer = (direction: 1 | -1) => { + stepActiveProperty(direction, false, false, "pointer"); }; const stepController = createStepController({ step: stepFromKeyboard, isShiftHeld }); - const commitActive = (rawValue: number | string) => { + const commitActive = (rawValue: number | string, source: "keyboard" | "pointer") => { const property = activeProperty(); if (!property) return; if (property.kind === "numeric" && typeof rawValue === "number") { const clamped = roundEditableNumericValue(clampToRange(rawValue, property.min, property.max)); - if (clamped !== property.value) commit(property, clamped); + if (clamped !== property.value) commit(property, clamped, { source }); return; } if (typeof rawValue !== "string") return; - if (!arePropertyValuesEqual(property, rawValue, property.value)) commit(property, rawValue); + if (!arePropertyValuesEqual(property, rawValue, property.value)) { + commit(property, rawValue, { source }); + } }; const activeTailwindLabel = createMemo(() => { @@ -312,7 +326,9 @@ const EditPanelBody: Component = (props) => { searchQuery, isCompact, activeProperty, - commit, + commit: (property, value, options) => { + commit(property, value, { ...options, source: "keyboard" }); + }, setIsCompact, }); @@ -321,6 +337,8 @@ const EditPanelBody: Component = (props) => { ); const handleSubmit = () => { + discardConfirmation.hide(); + pointerMovePromptHandoff.clear(); props.onSubmit(styleStore.buildPendingEdits()); }; @@ -343,6 +361,7 @@ const EditPanelBody: Component = (props) => { const attemptDismiss = (source: OverlayDismissSource) => { stepController.cancelRepeat(); + if (source === "pointer") pointerMovePromptHandoff.clear(); if (discardConfirmation.isPending()) { closePanel("discard"); return; @@ -368,6 +387,11 @@ const EditPanelBody: Component = (props) => { expandPanel(); }; + const cancelDiscardPrompt = () => { + pointerMovePromptHandoff.clear(); + discardConfirmation.hide(); + }; + let colorPickerTriggers: Array<() => void> = []; const registerColorPickerTrigger = (trigger: (() => void) | null, owner?: () => void) => { if (trigger === null) { @@ -486,13 +510,22 @@ const EditPanelBody: Component = (props) => { const handleWindowKeyUp = (event: KeyboardEvent) => { stepController.releaseKey(event.key); }; + const handleWindowPointerMove = (event: PointerEvent) => { + if (event.pointerType !== "mouse") return; + if (discardConfirmation.isPending()) return; + if (!pointerMovePromptHandoff.consume()) return; + if (!hasSubmittableEdits()) return; + attemptDismiss("pointer"); + }; window.addEventListener("keydown", handleWindowKeyDown, { capture: true }); window.addEventListener("keyup", handleWindowKeyUp, { capture: true }); + window.addEventListener("pointermove", handleWindowPointerMove, { capture: true }); onCleanup(() => { unregisterDismiss(); window.removeEventListener("keydown", handleWindowKeyDown, { capture: true }); window.removeEventListener("keyup", handleWindowKeyUp, { capture: true }); + window.removeEventListener("pointermove", handleWindowPointerMove, { capture: true }); clearTimeout(activeKeyTimerId); clearTimeout(interactingIdleTimerId); clearTimeout(inlineNumericReplaceTimerId); @@ -568,21 +601,7 @@ const EditPanelBody: Component = (props) => { shrink /> - + @@ -609,13 +628,14 @@ const EditPanelBody: Component = (props) => { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - discardConfirmation.hide(); + cancelDiscardPrompt(); }} > No + + + + + + +