diff --git a/packages/react-grab/e2e/copy-feedback.spec.ts b/packages/react-grab/e2e/copy-feedback.spec.ts index 41f2a70c3..2c0ad4261 100644 --- a/packages/react-grab/e2e/copy-feedback.spec.ts +++ b/packages/react-grab/e2e/copy-feedback.spec.ts @@ -79,25 +79,16 @@ test.describe("Copy Feedback Behavior", () => { await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); - const boundsBefore = await reactGrab.getSelectionBoxBounds(); - await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.down("c"); await reactGrab.clickElement("li:first-child"); - await reactGrab.page.waitForTimeout(100); await reactGrab.hoverElement("h1"); - await reactGrab.waitForSelectionBox(); - const boundsAfter = await reactGrab.getSelectionBoxBounds(); - const boundsSizeChanged = - boundsBefore && - boundsAfter && - (boundsBefore.width !== boundsAfter.width || - boundsBefore.height !== boundsAfter.height); + await reactGrab.page.waitForTimeout(FEEDBACK_DURATION_MS + 500); - expect(boundsBefore).not.toBeNull(); + expect(await reactGrab.isOverlayVisible()).toBe(true); + const boundsAfter = await reactGrab.getSelectionBoxBounds(); expect(boundsAfter).not.toBeNull(); - expect(boundsSizeChanged).toBe(true); await reactGrab.page.keyboard.up("c"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); diff --git a/packages/website/components/mobile-demo-animation.tsx b/packages/website/components/mobile-demo-animation.tsx index fa07ed9b7..be5cd38d2 100644 --- a/packages/website/components/mobile-demo-animation.tsx +++ b/packages/website/components/mobile-demo-animation.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef, type ReactElement } from "react"; import { cn } from "@/utils/cn"; -const ANIMATION_LOOP_INTERVAL_MS = 12000; +const ANIMATION_RESTART_DELAY_MS = 200; const SELECTION_PADDING_PX = 4; const DRAG_PADDING_PX = 6; const CURSOR_OFFSET_PX = 16; @@ -40,6 +40,7 @@ interface LabelState { y: number; componentName: string; tagName: string; + above?: boolean; } const HIDDEN_LABEL: LabelState = { @@ -48,11 +49,25 @@ const HIDDEN_LABEL: LabelState = { y: 0, componentName: "", tagName: "", + above: false, }; -type LabelMode = "idle" | "selecting" | "grabbing" | "copied" | "fading"; +type LabelMode = + | "idle" + | "selecting" + | "grabbing" + | "copied" + | "commenting" + | "submitted" + | "fading"; type CursorType = "default" | "crosshair" | "drag" | "grabbing"; +const ACTIVITY_DATA = [ + { label: "New signup", time: "2m ago", component: "SignupRow" }, + { label: "Order placed", time: "5m ago", component: "OrderRow" }, + { label: "Payment received", time: "12m ago", component: "PaymentRow" }, +]; + const createSelectionBox = (position: Position, padding: number): BoxState => ({ visible: true, x: position.x - padding, @@ -81,6 +96,24 @@ const CheckIcon = (): ReactElement => ( ); +const SubmitIcon = (): ReactElement => ( + + + +); + const LoaderIcon = (): ReactElement => ( { const [label, setLabel] = useState(HIDDEN_LABEL); const [labelMode, setLabelMode] = useState("idle"); const [cursorType, setCursorType] = useState("default"); + const [commentText, setCommentText] = useState(""); const containerRef = useRef(null); const metricCardRef = useRef(null); const metricValueRef = useRef(null); const exportButtonRef = useRef(null); + const activityRowRefs = useRef<(HTMLDivElement | null)[]>([]); + const fadingLabelTextRef = useRef<"Copied" | "Sent">("Sent"); const metricCardPosition = useRef({ x: 0, @@ -290,6 +326,7 @@ export const MobileDemoAnimation = (): ReactElement => { width: 0, height: 0, }); + const activityRowPositions = useRef<(Position | null)[]>([]); const measureElementPositions = (): void => { const container = containerRef.current; @@ -314,6 +351,17 @@ export const MobileDemoAnimation = (): ReactElement => { measureRelativePosition(metricCardRef.current, metricCardPosition); measureRelativePosition(metricValueRef.current, metricValuePosition); measureRelativePosition(exportButtonRef.current, exportButtonPosition); + + activityRowPositions.current = activityRowRefs.current.map((ref) => { + if (!ref) return null; + const rect = ref.getBoundingClientRect(); + return { + x: rect.left - containerRect.left, + y: rect.top - containerRect.top, + width: rect.width, + height: rect.height, + }; + }); }; useEffect(() => { @@ -327,7 +375,6 @@ export const MobileDemoAnimation = (): ReactElement => { useEffect(() => { let isCancelled = false; - let animationInterval: ReturnType; const resetAnimationState = (): void => { setCursorPos({ x: 150, y: 80 }); @@ -339,6 +386,7 @@ export const MobileDemoAnimation = (): ReactElement => { setSuccessFlash(HIDDEN_BOX); setLabel(HIDDEN_LABEL); setLabelMode("idle"); + setCommentText(""); }; const displaySelectionLabel = ( @@ -346,12 +394,16 @@ export const MobileDemoAnimation = (): ReactElement => { y: number, componentName: string, tagName: string, + above = false, ): void => { - setLabel({ visible: true, x, y, componentName, tagName }); + setLabel({ visible: true, x, y, componentName, tagName, above }); setLabelMode("selecting"); }; - const fadeOutSelectionLabel = async (): Promise => { + const fadeOutSelectionLabel = async ( + text: "Copied" | "Sent", + ): Promise => { + fadingLabelTextRef.current = text; setLabelMode("fading"); await wait(300); setLabel(HIDDEN_LABEL); @@ -371,10 +423,41 @@ export const MobileDemoAnimation = (): ReactElement => { if (isCancelled) return; setSuccessFlash(HIDDEN_BOX); - await fadeOutSelectionLabel(); + await fadeOutSelectionLabel("Copied"); setCursorType("crosshair"); }; + const simulateComment = async ( + position: Position, + comment: string, + ): Promise => { + await wait(300); + if (isCancelled) return; + + setLabelMode("commenting"); + setCommentText(""); + await wait(200); + if (isCancelled) return; + + for (let j = 0; j <= comment.length; j++) { + if (isCancelled) return; + setCommentText(comment.slice(0, j)); + await wait(50); + } + await wait(300); + if (isCancelled) return; + + setLabelMode("submitted"); + setSuccessFlash(createSelectionBox(position, SELECTION_PADDING_PX)); + await wait(500); + if (isCancelled) return; + + setSuccessFlash(HIDDEN_BOX); + setSelectionBox(HIDDEN_BOX); + await fadeOutSelectionLabel("Sent"); + setCommentText(""); + }; + const animateDragSelection = async ( startX: number, startY: number, @@ -412,10 +495,11 @@ export const MobileDemoAnimation = (): ReactElement => { await wait(300); if (isCancelled) return; + // 1. Export button - comment const buttonPos = exportButtonPosition.current; const buttonCenter = getElementCenter(buttonPos); setCursorPos(buttonCenter); - await wait(450); + await wait(400); if (isCancelled) return; setSelectionBox(createSelectionBox(buttonPos, SELECTION_PADDING_PX)); @@ -425,65 +509,31 @@ export const MobileDemoAnimation = (): ReactElement => { "ExportBtn", "button", ); - await wait(600); - if (isCancelled) return; - - await simulateClickAndCopy(buttonPos); - await wait(300); + await simulateComment(buttonPos, "add CSV option"); if (isCancelled) return; + // 2. MetricCard - comment const cardPos = metricCardPosition.current; - const dragStartX = cardPos.x - DRAG_PADDING_PX; - const dragStartY = cardPos.y - DRAG_PADDING_PX; - const dragEndX = cardPos.x + cardPos.width + DRAG_PADDING_PX; - const dragEndY = cardPos.y + cardPos.height + DRAG_PADDING_PX; - - setCursorPos({ x: dragStartX, y: dragStartY }); - await wait(500); - if (isCancelled) return; - - setIsDragging(true); - setCursorType("drag"); - setDragBox({ - visible: true, - x: dragStartX, - y: dragStartY, - width: 0, - height: 0, - }); - await animateDragSelection(dragStartX, dragStartY, dragEndX, dragEndY); - if (isCancelled) return; - await wait(200); + const cardCenter = getElementCenter(cardPos); + setCursorPos(cardCenter); + await wait(400); if (isCancelled) return; - setIsDragging(false); - setDragBox(HIDDEN_BOX); - setCursorType("grabbing"); - setSuccessFlash(createSelectionBox(cardPos, SELECTION_PADDING_PX)); + setSelectionBox(createSelectionBox(cardPos, SELECTION_PADDING_PX)); displaySelectionLabel( cardPos.x + cardPos.width / 2, cardPos.y - 10, "MetricCard", "div", ); - setLabelMode("grabbing"); - await wait(500); - if (isCancelled) return; - - setLabelMode("copied"); - await wait(500); - if (isCancelled) return; - - setSuccessFlash(HIDDEN_BOX); - await fadeOutSelectionLabel(); - setCursorType("crosshair"); - await wait(300); + await simulateComment(cardPos, "show graph"); if (isCancelled) return; + // 3. StatValue - comment const valuePos = metricValuePosition.current; const valueCenter = getElementCenter(valuePos); setCursorPos(valueCenter); - await wait(500); + await wait(400); if (isCancelled) return; setSelectionBox(createSelectionBox(valuePos, SELECTION_PADDING_PX)); @@ -493,41 +543,75 @@ export const MobileDemoAnimation = (): ReactElement => { "StatValue", "span", ); - await wait(600); + await simulateComment(valuePos, "format as USD"); if (isCancelled) return; - await simulateClickAndCopy(valuePos); - if (isCancelled) return; + // 4. SignupRow - comment + const signupRowPos = activityRowPositions.current[0]; + if (signupRowPos) { + const signupCenter = getElementCenter(signupRowPos); + setCursorPos(signupCenter); + await wait(400); + if (isCancelled) return; + + setSelectionBox(createSelectionBox(signupRowPos, SELECTION_PADDING_PX)); + displaySelectionLabel( + signupRowPos.x + 60, + signupRowPos.y + signupRowPos.height + 8, + "SignupRow", + "div", + ); + await simulateComment(signupRowPos, "add avatar"); + if (isCancelled) return; + } + + // 5. OrderRow - grab/copy (last one) + const orderRowPos = activityRowPositions.current[1]; + if (orderRowPos) { + const orderCenter = getElementCenter(orderRowPos); + setCursorPos(orderCenter); + await wait(400); + if (isCancelled) return; + + setSelectionBox(createSelectionBox(orderRowPos, SELECTION_PADDING_PX)); + displaySelectionLabel( + orderRowPos.x + 60, + orderRowPos.y + orderRowPos.height + 8, + "OrderRow", + "div", + ); + await wait(400); + if (isCancelled) return; + + await simulateClickAndCopy(orderRowPos); + if (isCancelled) return; + } setIsCursorVisible(false); setCursorType("default"); - await wait(600); + await wait(ANIMATION_RESTART_DELAY_MS); }; - const initializeAnimationLoop = (): void => { - isCancelled = false; - executeAnimationSequence(); - animationInterval = setInterval( - executeAnimationSequence, - ANIMATION_LOOP_INTERVAL_MS, - ); + const runAnimationLoop = async (): Promise => { + while (!isCancelled) { + await executeAnimationSequence(); + } }; const handleVisibilityChange = (): void => { if (document.visibilityState === "visible") { isCancelled = true; - clearInterval(animationInterval); resetAnimationState(); - setTimeout(initializeAnimationLoop, 100); + isCancelled = false; + runAnimationLoop(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); - initializeAnimationLoop(); + runAnimationLoop(); return () => { isCancelled = true; - clearInterval(animationInterval); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, []); @@ -560,7 +644,7 @@ export const MobileDemoAnimation = (): ReactElement => { `}
-
+
@@ -621,13 +705,12 @@ export const MobileDemoAnimation = (): ReactElement => {
- {[ - { label: "New signup", time: "2m ago" }, - { label: "Order placed", time: "5m ago" }, - { label: "Payment received", time: "12m ago" }, - ].map((activity) => ( + {ACTIVITY_DATA.map((activity, i) => (
{ + activityRowRefs.current[i] = el; + }} className="flex items-center justify-between px-3 py-2" >
@@ -717,40 +800,83 @@ export const MobileDemoAnimation = (): ReactElement => {
{labelMode === "selecting" && ( - <> +
{label.componentName} .{label.tagName} - +
)} {labelMode === "grabbing" && ( - <> +
Grabbing… - +
)} - {(labelMode === "copied" || labelMode === "fading") && ( - <> + {labelMode === "copied" && ( +
Copied - +
+ )} + {labelMode === "commenting" && ( +
+
+ + {label.componentName} + + + .{label.tagName} + +
+
+ + {commentText || "Add context"} + +
+ +
+
+
+ )} + {labelMode === "submitted" && ( +
+ + + Sent + +
+ )} + {labelMode === "fading" && ( +
+ + + {fadingLabelTextRef.current} + +
)}