diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 3f79b047f..76a5051e2 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -685,7 +685,6 @@ const createReactGrabPageObject = ( await waitForPromptMode(true); const promptInput = page.locator("textarea[data-react-grab-input]"); await promptInput.waitFor({ state: "visible", timeout: UI_STATE_TIMEOUT_MS }); - await promptInput.focus(); }; const isPromptModeActive = async (): Promise => { diff --git a/packages/react-grab/e2e/prompt-mode.spec.ts b/packages/react-grab/e2e/prompt-mode.spec.ts index 5f5a28b6a..7185c313a 100644 --- a/packages/react-grab/e2e/prompt-mode.spec.ts +++ b/packages/react-grab/e2e/prompt-mode.spec.ts @@ -56,6 +56,145 @@ test.describe("Prompt Mode", () => { expect(hasTextarea).toBe(true); }); + + test("comment composer should enter at its final size without scaling", async ({ + reactGrab, + }) => { + await reactGrab.registerCommentAction(); + await reactGrab.enterPromptMode("h1"); + + const enterMotion = await reactGrab.page.evaluate(() => { + const host = document.querySelector("[data-react-grab]"); + const surface = host?.shadowRoot?.querySelector( + ".animate-comment-composer-enter", + ); + const animation = surface?.getAnimations()[0]; + if (!surface || !animation || !(animation.effect instanceof KeyframeEffect)) return null; + + return { + animationName: getComputedStyle(surface).animationName, + keyframes: animation.effect.getKeyframes().map((keyframe) => ({ + opacity: keyframe.opacity, + transform: keyframe.transform, + clipPath: keyframe.clipPath, + width: keyframe.width, + height: keyframe.height, + })), + }; + }); + + expect(enterMotion).not.toBeNull(); + expect(enterMotion?.animationName).toBe("comment-composer-enter"); + expect(enterMotion?.keyframes.map((keyframe) => keyframe.opacity)).toEqual(["0", "1"]); + expect( + enterMotion?.keyframes.every( + (keyframe) => + keyframe.transform === undefined && + keyframe.clipPath === undefined && + keyframe.width === undefined && + keyframe.height === undefined, + ), + ).toBe(true); + }); + + test("comment composer should focus a compact input and grow it line by line", async ({ + reactGrab, + }) => { + await reactGrab.registerCommentAction(); + await reactGrab.enterPromptMode("h1"); + + const initialLayout = await reactGrab.page.evaluate(() => { + const host = document.querySelector("[data-react-grab]"); + const shadowRoot = host?.shadowRoot; + const textarea = shadowRoot?.querySelector( + "textarea[data-react-grab-input]", + ); + const submitButton = shadowRoot?.querySelector( + "[data-react-grab-submit]", + ); + if (!textarea || !submitButton) return null; + + const textareaBounds = textarea.getBoundingClientRect(); + const submitButtonBounds = submitButton.getBoundingClientRect(); + return { + textareaLineHeight: Number.parseFloat(getComputedStyle(textarea).lineHeight), + textareaMaxHeight: Number.parseFloat(getComputedStyle(textarea).maxHeight), + textareaTargetHeight: textarea.scrollHeight, + textareaWidth: textareaBounds.width, + textareaBottom: textareaBounds.bottom, + submitButtonTop: submitButtonBounds.top, + submitButtonHeight: submitButtonBounds.height, + transitionProperty: getComputedStyle(textarea).transitionProperty, + isFocused: shadowRoot?.activeElement === textarea, + }; + }); + + expect(initialLayout).not.toBeNull(); + expect(initialLayout?.textareaWidth).toBeGreaterThanOrEqual(240); + expect(initialLayout?.submitButtonTop).toBeGreaterThan(initialLayout?.textareaBottom ?? 0); + expect(initialLayout?.transitionProperty).toContain("height"); + expect(initialLayout?.isFocused).toBe(true); + + const getTextareaHeight = () => + reactGrab.page.evaluate(() => { + const host = document.querySelector("[data-react-grab]"); + const textarea = host?.shadowRoot?.querySelector( + "textarea[data-react-grab-input]", + ); + return textarea?.getBoundingClientRect().height ?? 0; + }); + + const initialTargetHeight = initialLayout?.textareaTargetHeight ?? 0; + const textareaLineHeight = initialLayout?.textareaLineHeight ?? 0; + expect(initialLayout?.submitButtonHeight).toBeLessThanOrEqual(textareaLineHeight * 1.2); + expect( + (initialLayout?.submitButtonTop ?? 0) - (initialLayout?.textareaBottom ?? 0), + ).toBeLessThan(textareaLineHeight / 4); + await expect.poll(getTextareaHeight).toBeCloseTo(initialTargetHeight); + expect(initialTargetHeight).toBeGreaterThanOrEqual(textareaLineHeight); + expect(initialTargetHeight).toBeLessThan(textareaLineHeight * 1.1); + + await reactGrab.typeInInput("First line"); + const firstLineTargetHeight = await reactGrab.page.evaluate(() => { + const host = document.querySelector("[data-react-grab]"); + const textarea = host?.shadowRoot?.querySelector( + "textarea[data-react-grab-input]", + ); + return textarea?.scrollHeight ?? 0; + }); + await expect.poll(getTextareaHeight).toBeCloseTo(firstLineTargetHeight); + expect(firstLineTargetHeight).toBe(initialTargetHeight); + + await reactGrab.typeInInput("\nSecond line"); + const secondLineTargetHeight = await reactGrab.page.evaluate(() => { + const host = document.querySelector("[data-react-grab]"); + const textarea = host?.shadowRoot?.querySelector( + "textarea[data-react-grab-input]", + ); + return textarea?.scrollHeight ?? 0; + }); + await expect.poll(getTextareaHeight).toBeCloseTo(secondLineTargetHeight); + + await reactGrab.typeInInput("\nThird line"); + const thirdLineTargetHeight = await reactGrab.page.evaluate(() => { + const host = document.querySelector("[data-react-grab]"); + const textarea = host?.shadowRoot?.querySelector( + "textarea[data-react-grab-input]", + ); + return textarea?.scrollHeight ?? 0; + }); + await expect.poll(getTextareaHeight).toBeCloseTo(thirdLineTargetHeight); + + expect(secondLineTargetHeight).toBeGreaterThanOrEqual( + firstLineTargetHeight + textareaLineHeight, + ); + expect(secondLineTargetHeight).toBeLessThan(firstLineTargetHeight + textareaLineHeight * 1.1); + expect(thirdLineTargetHeight).toBeGreaterThanOrEqual( + secondLineTargetHeight + textareaLineHeight, + ); + expect(thirdLineTargetHeight).toBeLessThan(secondLineTargetHeight + textareaLineHeight * 1.1); + expect(thirdLineTargetHeight).toBeLessThan(initialLayout?.textareaMaxHeight ?? 0); + }); }); test.describe("Prompt Mode Control", () => { diff --git a/packages/react-grab/src/components/selection-label/index.tsx b/packages/react-grab/src/components/selection-label/index.tsx index 9ade07862..dfa7a2f4f 100644 --- a/packages/react-grab/src/components/selection-label/index.tsx +++ b/packages/react-grab/src/components/selection-label/index.tsx @@ -10,6 +10,9 @@ import { } from "solid-js"; import type { ArrowPosition, SelectionLabelProps } from "../../types.js"; import { + COMMENT_COMPOSER_ENTER_DURATION_MS, + COMMENT_COMPOSER_SUBMIT_SIZE_PX, + COMMENT_COMPOSER_WIDTH_PX, FADE_DURATION_MS, PANEL_SHADOW, VIEWPORT_MARGIN_PX, @@ -17,7 +20,9 @@ import { ARROW_LABEL_MARGIN_PX, LABEL_GAP_PX, SELECTION_LABEL_OFFSCREEN_PX, + TEXTAREA_MIN_HEIGHT_PX, TEXTAREA_MAX_HEIGHT_PX, + TEXTAREA_RESIZE_DURATION_MS, Z_INDEX_OVERLAY, } from "../../constants.js"; import { autoResizeTextarea } from "../../utils/auto-resize-textarea.js"; @@ -392,9 +397,17 @@ export const SelectionLabel: Component = (props) => { (panelRef = element)} shape={isSinglePanelLine() ? "pill" : "panel"} - class={cn("flex items-center gap-[5px] w-fit h-fit p-0", isShaking() && "animate-shake")} + class={cn( + "flex items-center gap-[5px] w-fit h-fit p-0", + canInteract() && + props.isPromptMode && + !props.discardPrompt && + "animate-comment-composer-enter", + isShaking() && "animate-shake", + )} style={{ display: isCompletedStatus() && !props.error ? "none" : undefined, + "animation-duration": `${COMMENT_COMPOSER_ENTER_DURATION_MS}ms`, }} onAnimationEnd={() => setIsShaking(false)} > @@ -426,8 +439,11 @@ export const SelectionLabel: Component = (props) => { -
-
+
+
= (props) => { />
-
+