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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/react-grab/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
Expand Down
139 changes: 139 additions & 0 deletions packages/react-grab/e2e/prompt-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(
".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<HTMLTextAreaElement>(
"textarea[data-react-grab-input]",
);
const submitButton = shadowRoot?.querySelector<HTMLButtonElement>(
"[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<HTMLTextAreaElement>(
"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<HTMLTextAreaElement>(
"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<HTMLTextAreaElement>(
"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<HTMLTextAreaElement>(
"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", () => {
Expand Down
59 changes: 44 additions & 15 deletions packages/react-grab/src/components/selection-label/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ 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,
ARROW_CENTER_PERCENT,
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";
Expand Down Expand Up @@ -392,9 +397,17 @@ export const SelectionLabel: Component<SelectionLabelProps> = (props) => {
<Surface
ref={(element) => (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)}
>
Expand Down Expand Up @@ -426,8 +439,11 @@ export const SelectionLabel: Component<SelectionLabelProps> = (props) => {
</Show>

<Show when={canInteract() && props.isPromptMode && !props.discardPrompt}>
<div class="contain-layout shrink-0 flex flex-col justify-center items-start w-fit h-fit min-w-[150px] max-w-[280px]">
<div class="contain-layout shrink-0 flex items-center gap-1 pt-1.5 pb-1 w-fit h-fit px-2 max-w-full">
<div
class="contain-layout shrink-0 flex flex-col justify-center items-start h-fit"
style={{ width: `${COMMENT_COMPOSER_WIDTH_PX}px` }}
>
<div class="contain-layout shrink-0 flex items-center gap-1 pt-1 pb-0.5 w-fit h-fit px-2 max-w-full">
<TagBadge
tagName={tagDisplayResult().tagName}
componentName={tagDisplayResult().componentName}
Expand All @@ -437,7 +453,7 @@ export const SelectionLabel: Component<SelectionLabelProps> = (props) => {
/>
</div>
<BottomSection>
<div class="shrink-0 flex justify-between items-end w-full min-h-4">
<div class="shrink-0 flex flex-col items-stretch gap-0.5 w-full">
<textarea
ref={(element) => {
inputRef = element;
Expand All @@ -455,12 +471,12 @@ export const SelectionLabel: Component<SelectionLabelProps> = (props) => {
data-react-grab-input
aria-label="Add context for selected element"
aria-keyshortcuts="Enter Escape"
class="text-[var(--rg-text-primary)] text-[13px] leading-4 font-medium bg-transparent border-none resize-none flex-1 p-0 m-0 wrap-break-word overflow-y-auto"
class="text-[var(--rg-text-primary)] text-[13px] leading-4 font-medium bg-transparent border-none resize-none w-full p-0 m-0 wrap-break-word overflow-y-auto"
style={{
"field-sizing": "content",
"min-height": "16px",
"min-height": `${TEXTAREA_MIN_HEIGHT_PX}px`,
"max-height": `${TEXTAREA_MAX_HEIGHT_PX}px`,
"scrollbar-width": "none",
transition: `height ${TEXTAREA_RESIZE_DURATION_MS}ms var(--ease-drawer)`,
}}
value={props.inputValue ?? ""}
onInput={handleInput}
Expand All @@ -470,15 +486,28 @@ export const SelectionLabel: Component<SelectionLabelProps> = (props) => {
readOnly={!props.onSubmit}
/>
<Show when={props.onSubmit}>
<button
data-react-grab-submit
type="button"
aria-label="Submit context"
class="contain-layout shrink-0 flex items-center justify-center size-4 rounded-full bg-[var(--rg-submit-bg)] cursor-pointer ml-1 interactive-scale a11y-hitbox"
onClick={() => props.onSubmit?.()}
<div
class="shrink-0 flex items-center justify-end w-full"
style={{ height: `${COMMENT_COMPOSER_SUBMIT_SIZE_PX}px` }}
>
<IconSubmit size={10} aria-hidden="true" class="text-[var(--rg-submit-fg)]" />
</button>
<button
data-react-grab-submit
type="button"
aria-label="Submit context"
class="contain-layout box-border shrink-0 flex items-center justify-center p-0 border-none rounded-full bg-[var(--rg-submit-bg)] cursor-pointer interactive-scale a11y-hitbox"
style={{
width: `${COMMENT_COMPOSER_SUBMIT_SIZE_PX}px`,
height: `${COMMENT_COMPOSER_SUBMIT_SIZE_PX}px`,
}}
onClick={() => props.onSubmit?.()}
>
<IconSubmit
size={10}
aria-hidden="true"
class="text-[var(--rg-submit-fg)]"
/>
</button>
</div>
</Show>
</div>
</BottomSection>
Expand Down
5 changes: 5 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,12 @@ export const DROPDOWN_EDGE_TRANSFORM_ORIGIN = {

export const NEXTJS_REVALIDATION_DELAY_MS = 1000;

export const COMMENT_COMPOSER_ENTER_DURATION_MS = 160;
export const COMMENT_COMPOSER_SUBMIT_SIZE_PX = 18;
export const COMMENT_COMPOSER_WIDTH_PX = 280;
export const TEXTAREA_MIN_HEIGHT_PX = 16;
export const TEXTAREA_MAX_HEIGHT_PX = 95;
export const TEXTAREA_RESIZE_DURATION_MS = 180;

export const IME_COMPOSING_KEY_CODE = 229;
export const SELECTION_LABEL_OFFSCREEN_PX = -9999;
Expand Down
16 changes: 16 additions & 0 deletions packages/react-grab/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,22 @@
will-change: transform, opacity;
}

@keyframes comment-composer-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

.animate-comment-composer-enter {
animation-name: comment-composer-enter;
animation-timing-function: ease-out;
animation-fill-mode: both;
will-change: opacity;
}

.animate-shake-vertical {
--rg-shake-x: 0;
--rg-shake-y: 1;
Expand Down
22 changes: 21 additions & 1 deletion packages/react-grab/src/utils/auto-resize-textarea.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
const pendingResizeFrameHandles = new WeakMap<HTMLTextAreaElement, number>();

export const autoResizeTextarea = (textarea: HTMLTextAreaElement, maxHeight: number) => {
const pendingResizeFrameHandle = pendingResizeFrameHandles.get(textarea);
if (pendingResizeFrameHandle !== undefined) {
cancelAnimationFrame(pendingResizeFrameHandle);
}

const currentHeight = textarea.offsetHeight;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
const targetHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${currentHeight}px`;

if (targetHeight === currentHeight) {
pendingResizeFrameHandles.delete(textarea);
return;
}

const resizeFrameHandle = requestAnimationFrame(() => {
textarea.style.height = `${targetHeight}px`;
pendingResizeFrameHandles.delete(textarea);
});
pendingResizeFrameHandles.set(textarea, resizeFrameHandle);
};
Loading