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
15 changes: 13 additions & 2 deletions packages/react-grab/e2e/edit-panel-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,9 @@ export const hoverVisibleSlider = async (page: Page): Promise<void> => {
);
};

export const getActiveTailwindLabelOrder = async (
const getActiveTailwindLabelInfo = async (
page: Page,
): Promise<{ tailwindLeft: number | null; valueLeft: number | null }> =>
): Promise<{ text: string | null; tailwindLeft: number | null; valueLeft: number | null }> =>
page.evaluate(
({ attrName, tailwindLabelAttr }) => {
const host = document.querySelector(`[${attrName}]`);
Expand All @@ -197,13 +197,24 @@ export const getActiveTailwindLabelOrder = async (
?.closest("[data-react-grab-value]")
?.querySelector<HTMLElement>("[data-react-grab-value-text]") ?? null;
return {
text: tailwindLabel?.textContent ?? null,
tailwindLeft: tailwindLabel?.getBoundingClientRect().left ?? null,
valueLeft: valueText?.getBoundingClientRect().left ?? null,
};
},
{ attrName: ATTRIBUTE_NAME, tailwindLabelAttr: TAILWIND_LABEL_ATTR },
);

export const getActiveTailwindLabelOrder = async (
page: Page,
): Promise<{ tailwindLeft: number | null; valueLeft: number | null }> => {
const { tailwindLeft, valueLeft } = await getActiveTailwindLabelInfo(page);
return { tailwindLeft, valueLeft };
};

export const getActiveTailwindLabelText = async (page: Page): Promise<string | null> =>
(await getActiveTailwindLabelInfo(page)).text;

export interface SearchInputFocusVisualState {
isFocusVisible: boolean;
outlineStyle: string;
Expand Down
134 changes: 134 additions & 0 deletions packages/react-grab/e2e/edit-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getActivePropertyValue,
getActiveSliderVisualState,
getActiveTailwindLabelOrder,
getActiveTailwindLabelText,
getEditPanelCompactAttr,
getInlineStyleAttribute,
getInlineStyleProperty,
Expand All @@ -39,6 +40,11 @@ import {
typeInSearchInput,
} from "./edit-panel-helpers.js";

// Leaf element (text-only table cell with uniform `p-2`) whose center
// point hits the element itself — nested containers like the card
// select whichever child sits at their center.
const UNIFORM_PADDING_SELECTOR = "[data-testid='th-1']";

test.describe("Style Panel", () => {
test.beforeEach(async ({ reactGrab }) => {
await clearEditStorage(reactGrab.page);
Expand Down Expand Up @@ -631,6 +637,58 @@ test.describe("Style Panel", () => {
}
});

test("Tailwind label names the aggregate class on a uniform padding row", async ({
reactGrab,
}) => {
await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR);
await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("padding");
await reactGrab.page.keyboard.down("Shift");
try {
await expect.poll(() => getActiveTailwindLabelText(reactGrab.page)).toBe("p-2");
} finally {
await reactGrab.page.keyboard.up("Shift");
}
});

test("stepping an out-of-range value never jumps against the arrow direction", async ({
reactGrab,
}) => {
// 200px border-radius is above the 96px row max but applies no
// layout (unlike a 128px font, whose reflow makes the hover-based
// panel open flaky) so the regression is isolated cleanly.
await reactGrab.page.evaluate((buttonSelector) => {
const button = document.querySelector(buttonSelector);
if (button instanceof HTMLElement) button.style.borderRadius = "200px";
}, BUTTON_SELECTOR);
await openEditPanel(reactGrab, BUTTON_SELECTOR);
await setSearchInputValue(reactGrab.page, "rounded");
await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("border-radius");

// Stepping up from an above-max value is a no-op; settle then
// assert it held (can't poll for the absence of a change).
await reactGrab.page.keyboard.press("ArrowRight");
await reactGrab.page.waitForTimeout(80);
expect(await getActivePropertyValue(reactGrab.page)).toBe("200px");

// ArrowLeft → 199 proves the step came off the real 200, not a
// clamp to the 96px max (which would land on 95).
await reactGrab.page.keyboard.press("ArrowLeft");
await expect.poll(() => getActivePropertyValue(reactGrab.page)).toBe("199px");
});

test("rounded-full's infinite radius displays as a finite clamped value", async ({
reactGrab,
}) => {
await reactGrab.page.evaluate((buttonSelector) => {
const button = document.querySelector(buttonSelector);
if (button instanceof HTMLElement) button.style.borderRadius = "calc(infinity * 1px)";
}, BUTTON_SELECTOR);
await openEditPanel(reactGrab, BUTTON_SELECTOR);
await setSearchInputValue(reactGrab.page, "rounded");
await expect.poll(() => getActivePropertyKey(reactGrab.page)).toBe("border-radius");
expect(await getActivePropertyValue(reactGrab.page)).toBe("96px");
});

test("ArrowUp / ArrowDown navigate the list, not the value", async ({ reactGrab }) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
const initialActivePropertyKey = await getActivePropertyKey(reactGrab.page);
Expand Down Expand Up @@ -971,6 +1029,27 @@ test.describe("Style Panel", () => {
expect(marginTopAfterTyping).toContain("20");
});

test("typing -m-4 applies a negative margin", async ({ reactGrab }) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
await setSearchInputValue(reactGrab.page, "-m-4");

await expect
.poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top"))
.toBe("-16px");
expect(await getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-left")).toBe(
"-16px",
);
});

test("typing -mt-[8px] applies a negative arbitrary margin", async ({ reactGrab }) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
await setSearchInputValue(reactGrab.page, "-mt-[8px]");

await expect
.poll(() => getInlineStyleProperty(reactGrab.page, BUTTON_SELECTOR, "margin-top"))
.toBe("-8px");
});

test("typing font-mono applies font family + compact", async ({ reactGrab }) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
await reactGrab.page.keyboard.type("font-mono");
Expand Down Expand Up @@ -1148,6 +1227,61 @@ test.describe("Style Panel", () => {
expect(afterCommit).toBe(inlineStyleAfterTweak);
});

test("copied prompt matches the preview when aggregate and longhand tweaks overlap", async ({
reactGrab,
}) => {
await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR);
await setSearchInputValue(reactGrab.page, "pt-8");
await expect
.poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top"))
.toBe("32px");
await setSearchInputValue(reactGrab.page, "p-6");
await expect
.poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top"))
.toBe("24px");
await setSearchInputValue(reactGrab.page, "pt-1");
await expect
.poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top"))
.toBe("4px");
expect(
await getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-right"),
).toBe("24px");

await reactGrab.page.keyboard.press("Enter");
await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false);
await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 4px;");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toContain("padding-right: 24px;");
expect(clipboardContent).not.toContain("padding-top: 24px;");
});

test("copied prompt emits a longhand stepped back to its original under a changed aggregate", async ({
reactGrab,
}) => {
await openEditPanel(reactGrab, UNIFORM_PADDING_SELECTOR);
await setSearchInputValue(reactGrab.page, "pt-8");
await expect
.poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top"))
.toBe("32px");
await setSearchInputValue(reactGrab.page, "p-6");
await expect
.poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top"))
.toBe("24px");
// Back to the original 8px: the padding-top override must still
// be emitted or the prompt claims the p-6 fan-out covers the top.
await setSearchInputValue(reactGrab.page, "pt-2");
await expect
.poll(() => getInlineStyleProperty(reactGrab.page, UNIFORM_PADDING_SELECTOR, "padding-top"))
.toBe("8px");

await reactGrab.page.keyboard.press("Enter");
await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(false);
await expect.poll(() => reactGrab.getClipboardContent()).toContain("padding-top: 8px;");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toContain("padding-right: 24px;");
expect(clipboardContent).not.toContain("padding-top: 24px;");
});

test("header Copy button appears after a pending tweak and submits", async ({ reactGrab }) => {
await openEditPanel(reactGrab, BUTTON_SELECTOR);
expect(await isHeaderCopyButtonVisible(reactGrab.page)).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const ActivePropertyControl: Component<ActivePropertyControlProps> = (pro
value={enumProp().value}
options={enumProp().options}
activeKey={props.activeKey}
onCommit={props.onCommit}
onStep={props.onStep}
/>
)}
</Match>
Expand Down
16 changes: 5 additions & 11 deletions packages/react-grab/src/components/edit-panel/cycle-control.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Show, type Component } from "solid-js";
import type { EnumEditableOption } from "../../types.js";
import { pickNextOption } from "../../utils/pick-next-option.js";
import { EDIT_LABEL_CLASS } from "./constants.js";
import { StepArrow } from "./step-arrow.js";

Expand All @@ -9,7 +8,7 @@ interface CycleControlProps {
value: string;
options: ReadonlyArray<EnumEditableOption>;
activeKey: "left" | "right" | null;
onCommit: (value: string) => void;
onStep: (direction: 1 | -1) => void;
}

export const CycleControl: Component<CycleControlProps> = (props) => {
Expand All @@ -18,11 +17,6 @@ export const CycleControl: Component<CycleControlProps> = (props) => {
return match?.label ?? props.value;
};

const advance = (direction: 1 | -1) => {
const nextOption = pickNextOption(props.options, props.value, direction);
if (nextOption) props.onCommit(nextOption.value);
};

return (
<div class="flex items-center gap-2 w-full px-2 h-[20px]">
<Show when={props.label}>
Expand All @@ -34,7 +28,7 @@ export const CycleControl: Component<CycleControlProps> = (props) => {
<StepArrow
direction="left"
active={props.activeKey === "left"}
onPointerDown={() => advance(-1)}
onPointerDown={() => props.onStep(-1)}
/>
<span
data-react-grab-ignore-events
Expand All @@ -48,20 +42,20 @@ export const CycleControl: Component<CycleControlProps> = (props) => {
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
advance(1);
props.onStep(1);
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
advance(-1);
props.onStep(-1);
}}
>
{currentLabel()}
</span>
<StepArrow
direction="right"
active={props.activeKey === "right"}
onPointerDown={() => advance(1)}
onPointerDown={() => props.onStep(1)}
/>
</div>
</div>
Expand Down
3 changes: 1 addition & 2 deletions packages/react-grab/src/components/edit-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,7 @@ const EditPanelBody: Component<EditPanelBodyProps> = (props) => {
if (!isShiftHeld()) return null;
const property = activeProperty();
if (!property || property.kind !== "numeric") return null;
const cssKey = property.cssProperties[0];
return cssKey ? findTailwindClass(cssKey, property.value) : null;
return findTailwindClass(property.key, property.value);
});

const autoApply = createTailwindAutoApply({
Expand Down
10 changes: 9 additions & 1 deletion packages/react-grab/src/components/edit-panel/step-property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ export const stepProperty = (
}
if (property.kind !== "numeric") return null;
const multiplier = shift ? EDIT_SHIFT_STEP_MULTIPLIER : 1;
// Widen the range to include out-of-range originals (text-9xl is
// 128px against a 96px font-size cap) so the first step nudges from
// the real value instead of teleporting to the clamp bound —
// possibly against the pressed direction.
const candidate = roundEditableNumericValue(
clampToRange(property.value + direction * multiplier, property.min, property.max),
clampToRange(
property.value + direction * multiplier,
Math.min(property.min, property.value),
Math.max(property.max, property.value),
),
);
return candidate === property.value ? null : candidate;
};
Loading
Loading