Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion packages/react-grab/e2e/edit-panel-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const TAILWIND_LABEL_ATTR = "data-react-grab-tailwind-label";
export const IDLE_BUFFER_MS = 700;
export const DISCARD_PROMPT_IDLE_MS = 2000;
export const BUTTON_SELECTOR = "[data-testid='nested-button']";
export const CARD_SELECTOR = "[data-testid='nested-card']";
export const MAIN_TITLE_SELECTOR = "[data-testid='main-title']";

export const isEditPanelVisible = async (page: Page): Promise<boolean> =>
page.evaluate(
Expand Down
138 changes: 110 additions & 28 deletions packages/react-grab/e2e/edit-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { expect, test } from "./fixtures.js";
import {
ATTRIBUTE_NAME,
BUTTON_SELECTOR,
CARD_SELECTOR,
COPY_BUTTON_ATTR,
DISCARD_PROMPT_IDLE_MS,
EDIT_PANEL_ATTR,
Expand Down Expand Up @@ -33,6 +32,7 @@ import {
isEditPanelCompact,
isEditPanelVisible,
isHeaderCopyButtonVisible,
MAIN_TITLE_SELECTOR,
openDiscardPromptViaEscape,
openEditPanel,
readSessionStorageEntries,
Expand Down Expand Up @@ -226,9 +226,11 @@ test.describe("Style Panel", () => {
expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(beforeTweak);
});

test("click outside dismisses the panel", async ({ reactGrab }) => {
test("outside mousedown without a grabbable target dismisses the panel", async ({
reactGrab,
}) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
await reactGrab.page.mouse.click(5, 5);
await dispatchOutsideDismiss(reactGrab.page);
await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false);
});

Expand Down Expand Up @@ -1389,7 +1391,7 @@ test.describe("Style Panel", () => {
});
});

test.describe("Selection lock", () => {
test.describe("Element switching", () => {
test("panel stays open while pointer moves over other elements", async ({ reactGrab }) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
await reactGrab.page.mouse.move(10, 10);
Expand All @@ -1399,36 +1401,116 @@ test.describe("Style Panel", () => {
expect(await isEditPanelVisible(reactGrab.page)).toBe(true);
});

test("clicking another element does not change selection", async ({ reactGrab }) => {
test("clicking another element switches the style target and keeps applied styles", async ({
reactGrab,
}) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
const panelStateBeforeClick = await reactGrab.page.evaluate(
({ attrName }) => {
const host = document.querySelector(`[${attrName}]`);
const shadowRoot = host?.shadowRoot;
const panel = shadowRoot?.querySelector("[data-react-grab-edit-panel]");
return panel ? "panel-open" : "panel-gone";
},
{ attrName: ATTRIBUTE_NAME },
await reactGrab.page.keyboard.press("ArrowRight");
await reactGrab.page.waitForTimeout(80);
const buttonStyleAfterTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR);
expect(buttonStyleAfterTweak.length).toBeGreaterThan(0);

await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true });
await reactGrab.page.waitForTimeout(150);

expect(await isEditPanelVisible(reactGrab.page)).toBe(true);
expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(
buttonStyleAfterTweak,
);

await reactGrab.page.keyboard.press("ArrowRight");
await reactGrab.page.waitForTimeout(80);
const titleStyleAfterTweak = await getInlineStyleAttribute(
reactGrab.page,
MAIN_TITLE_SELECTOR,
);
expect(panelStateBeforeClick).toBe("panel-open");
expect(titleStyleAfterTweak.length).toBeGreaterThan(0);
});

await reactGrab.page.locator(CARD_SELECTOR).first().click({ force: true });
test("edits compound across switched elements on copy", async ({ reactGrab }) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
await reactGrab.page.keyboard.press("ArrowRight");
await reactGrab.page.waitForTimeout(80);

await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true });
await reactGrab.page.waitForTimeout(150);
await reactGrab.page.keyboard.press("ArrowRight");
await reactGrab.page.waitForTimeout(80);

const stateAfter = await reactGrab.page.evaluate(
({ attrName, buttonSelector }) => {
const host = document.querySelector(`[${attrName}]`);
const shadowRoot = host?.shadowRoot;
const panel = shadowRoot?.querySelector("[data-react-grab-edit-panel]");
const buttonElement = document.querySelector(buttonSelector);
return {
panelStillOpen: panel !== null,
buttonStillExists: buttonElement !== null,
};
},
{ attrName: ATTRIBUTE_NAME, buttonSelector: BUTTON_SELECTOR },
const buttonStyleBeforeCopy = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR);
const titleStyleBeforeCopy = await getInlineStyleAttribute(
reactGrab.page,
MAIN_TITLE_SELECTOR,
);

await reactGrab.page.keyboard.press("Enter");
await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false);
await expect
.poll(() => reactGrab.getClipboardContent())
.toContain("best expresses the underlying layout intent");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent.match(/```css/g)?.length).toBe(2);

expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(
buttonStyleBeforeCopy,
);
expect(await getInlineStyleAttribute(reactGrab.page, MAIN_TITLE_SELECTOR)).toBe(
titleStyleBeforeCopy,
);
});

test("discarding after switching restores every styled element", async ({ reactGrab }) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
const buttonStyleBeforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR);
await reactGrab.page.keyboard.press("ArrowRight");
await reactGrab.page.waitForTimeout(80);

await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true });
await reactGrab.page.waitForTimeout(150);
const titleStyleBeforeTweak = await getInlineStyleAttribute(
reactGrab.page,
MAIN_TITLE_SELECTOR,
);
await reactGrab.page.keyboard.press("ArrowRight");
await reactGrab.page.waitForTimeout(80);
expect(await getInlineStyleAttribute(reactGrab.page, MAIN_TITLE_SELECTOR)).not.toBe(
titleStyleBeforeTweak,
);

await openDiscardPromptViaEscape(reactGrab.page);
expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true);
await reactGrab.page.keyboard.press("Escape");
await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false);

expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(
buttonStyleBeforeTweak,
);
expect(await getInlineStyleAttribute(reactGrab.page, MAIN_TITLE_SELECTOR)).toBe(
titleStyleBeforeTweak,
);
});

test("session edits from a previous element keep the discard prompt armed", async ({
reactGrab,
}) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
const buttonStyleBeforeTweak = await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR);
await reactGrab.page.keyboard.press("ArrowRight");
await reactGrab.page.waitForTimeout(80);

await reactGrab.page.locator(MAIN_TITLE_SELECTOR).click({ force: true });
await reactGrab.page.waitForTimeout(150);

await reactGrab.page.keyboard.press("Escape");
await reactGrab.page.waitForTimeout(80);
expect(await isEditPanelVisible(reactGrab.page)).toBe(true);
expect(await isDiscardPromptVisible(reactGrab.page)).toBe(true);

await reactGrab.page.keyboard.press("Escape");
await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false);
expect(await getInlineStyleAttribute(reactGrab.page, BUTTON_SELECTOR)).toBe(
buttonStyleBeforeTweak,
);
expect(stateAfter.buttonStillExists).toBe(true);
});
});

Expand Down
30 changes: 15 additions & 15 deletions packages/react-grab/src/components/edit-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ import type {
EditableProperty,
EditPanelState,
OverlayDismissSource,
PendingEdits,
} from "../../types.js";
import { clampToRange } from "../../utils/clamp-to-range.js";
import { cn } from "../../utils/cn.js";
import { createAnchoredDropdown } from "../../utils/create-anchored-dropdown.js";
import { findTailwindClass } from "../../utils/find-tailwind-class.js";
import { formatEditableValue, roundEditableNumericValue } from "../../utils/format-css-value.js";
import { formatSessionEditsPrompt } from "../../utils/format-edit-prompt.js";
import { getShadowActiveElement } from "../../utils/get-shadow-active-element.js";
import { getTagDisplay } from "../../utils/get-tag-display.js";
import { isEventFromOverlay } from "../../utils/is-event-from-overlay.js";
Expand All @@ -54,7 +54,8 @@ interface EditPanelProps {
state: EditPanelState | null;
position: DropdownAnchor | null;
onDismiss: () => void;
onSubmit: (prompt: string) => void;
onSubmit: (pendingEdits: PendingEdits) => void;
onPendingEditsChange?: (pendingEdits: PendingEdits) => void;
onInteractingChange?: (interacting: boolean) => void;
}

Expand All @@ -68,6 +69,7 @@ export const EditPanel: Component<EditPanelProps> = (props) => (
position={() => props.position}
onDismiss={props.onDismiss}
onSubmit={props.onSubmit}
onPendingEditsChange={props.onPendingEditsChange}
onInteractingChange={props.onInteractingChange}
/>
)}
Expand All @@ -80,7 +82,8 @@ interface EditPanelBodyProps {
state: EditPanelState;
position: () => DropdownAnchor | null;
onDismiss: () => void;
onSubmit: (prompt: string) => void;
onSubmit: (pendingEdits: PendingEdits) => void;
onPendingEditsChange?: (pendingEdits: PendingEdits) => void;
onInteractingChange?: (interacting: boolean) => void;
}

Expand All @@ -107,6 +110,9 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
};
const [activeIndex, setActiveIndex] = createSignal(firstNumericActiveIndex());
const hasPendingStyles = createMemo(() => styleStore.hasPendingStyles());
const hasSubmittableEdits = createMemo(
() => hasPendingStyles() || Boolean(props.state.hasSessionEdits),
);
const [isCompact, setIsCompact] = createSignal(false);

let activeKeyTimerId: ReturnType<typeof setTimeout> | undefined;
Expand Down Expand Up @@ -246,6 +252,7 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
) => {
styleStore.applyStyle(property, nextValue);
preview.apply(property.cssProperties, formatEditableValue(property, nextValue));
props.onPendingEditsChange?.(styleStore.buildPendingEdits());
markAsInteracting();
if (!options.isFromKeyRepeat) discardConfirmation.hide();
if (options.flashDirection) flashActiveKey(options.flashDirection === 1 ? "right" : "left");
Expand Down Expand Up @@ -314,13 +321,7 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
);

const handleSubmit = () => {
const pendingEdits = styleStore.buildPendingEdits();
const entry = {
filePath: props.state.filePath ?? "",
lineNumber: props.state.lineNumber ?? 0,
edits: pendingEdits,
};
props.onSubmit(formatSessionEditsPrompt(pendingEdits.length > 0 ? [entry] : []));
props.onSubmit(styleStore.buildPendingEdits());
};

const discardConfirmation = createDiscardConfirmation();
Expand All @@ -346,7 +347,7 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
closePanel("discard");
return;
}
if (!hasPendingStyles()) {
if (!hasSubmittableEdits()) {
closePanel(preview.hasAppliedStyles() ? "discard" : "preserve");
return;
}
Expand Down Expand Up @@ -413,7 +414,7 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
// and the pending change is discarded instead of copied.
const isUnchangedColor =
property?.kind === "color" && !styleStore.hasChangedStyleFor(property.key);
if (isUnchangedColor && colorPickerTrigger && !hasPendingStyles()) {
if (isUnchangedColor && colorPickerTrigger && !hasSubmittableEdits()) {
colorPickerTrigger();
return;
}
Expand Down Expand Up @@ -498,7 +499,6 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
discardConfirmation.cleanup();
dropdown.clearAnimationHandles();
setIsTransientInteraction(false);
preview.forget();
});
});

Expand Down Expand Up @@ -555,7 +555,7 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
<div
class={cn(
"contain-layout shrink-0 flex items-center gap-1 pt-1.5 pb-1 h-fit px-2",
hasPendingStyles() ? "w-full self-stretch justify-between" : "w-fit",
hasSubmittableEdits() ? "w-full self-stretch justify-between" : "w-fit",
)}
onMouseEnter={() => setIsHeaderHovered(true)}
onMouseLeave={() => setIsHeaderHovered(false)}
Expand All @@ -567,7 +567,7 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
onClick={() => {}}
shrink
/>
<Show when={hasPendingStyles()}>
<Show when={hasSubmittableEdits()}>
<button
data-react-grab-ignore-events
data-react-grab-copy-button
Expand Down
1 change: 1 addition & 0 deletions packages/react-grab/src/components/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export const ReactGrabRenderer: Component<ReactGrabRendererProps> = (props) => {
position={props.editPanelPosition ?? null}
onDismiss={props.onEditPanelDismiss ?? (() => {})}
onSubmit={props.onEditPanelSubmit ?? (() => {})}
onPendingEditsChange={props.onEditPanelPendingEditsChange}
onInteractingChange={props.onEditPanelInteractingChange}
/>
</>
Expand Down
Loading
Loading