Prevent cancelled drags from committing moves
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
This commit is contained in:
parent
e6e08b458c
commit
6bcde57e88
6 changed files with 148 additions and 12 deletions
48
e2e/fixtures/form-reorder-probe.js
Normal file
48
e2e/fixtures/form-reorder-probe.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Rôle : expose les hooks réels de formulaire et déplacement sous StrictMode pour les tests navigateur.
|
||||
import { createElement as h, StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useDraftForm } from "../../website/src/hooks/useDraftForm.js";
|
||||
import { usePointerReorder } from "../../website/src/hooks/usePointerReorder.js";
|
||||
|
||||
export function mountFormReorderProbe(element, kind) {
|
||||
const events = [];
|
||||
const root = createRoot(element);
|
||||
function Form() {
|
||||
const form = useDraftForm({ title: "Initial", detail: "Kept" });
|
||||
return h("form", { onSubmit: (event) => {
|
||||
form.handleSubmit(event, (values, { reset }) => {
|
||||
events.push({ ...values });
|
||||
if (!values.title.trim()) return "Required";
|
||||
reset();
|
||||
});
|
||||
events.push({ prevented: event.defaultPrevented });
|
||||
} },
|
||||
h("input", form.getFieldProps("title", { "aria-label": "Title", onChange: () => events.push("changed") })),
|
||||
h("input", form.getFieldProps("detail", { "aria-label": "Detail" })),
|
||||
h("p", { role: "alert" }, form.error),
|
||||
h("button", { type: "submit" }, "Submit"),
|
||||
h("button", { type: "button", onClick: () => form.updateValues({ title: "Patched" }) }, "Patch"),
|
||||
h("button", { type: "button", onClick: () => form.reset({ title: "Replacement", detail: "New" }) }, "Replace"));
|
||||
}
|
||||
function Reorder({ revision }) {
|
||||
const reorder = usePointerReorder({
|
||||
targetSelector: "[data-probe-item]",
|
||||
getTargetId: (element) => element.dataset.probeItem,
|
||||
canDropOn: (element) => element.dataset.probeItem !== "blocked",
|
||||
onMove: (source, target, placement) => events.push({ source, target, placement, revision })
|
||||
});
|
||||
return h("section", null,
|
||||
h("output", { "aria-label": "Dragging", style: { display: "block", height: 24 } }, reorder.draggingId),
|
||||
...["a", "b", "blocked"].map((id) => h("div", {
|
||||
key: id, "data-probe-item": id,
|
||||
style: { height: 100, width: 300, border: "1px solid", margin: 10 }
|
||||
}, h("button", { type: "button", onPointerDown: (event) => reorder.startDrag(event, id) }, id))));
|
||||
}
|
||||
const Component = kind === "form" ? Form : Reorder;
|
||||
function update(revision = 0) {
|
||||
flushSync(() => root.render(h(StrictMode, null, h(Component, { revision }))));
|
||||
}
|
||||
update();
|
||||
return { events, update, unmount: () => root.unmount() };
|
||||
}
|
||||
92
e2e/form-reorder.spec.js
Normal file
92
e2e/form-reorder.spec.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Rôle : vérifie les brouillons de formulaire et le cycle de déplacement par pointeur sans assertions de code source.
|
||||
/* global window, document */
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/form-reorder-probe.js", import.meta.url))}`;
|
||||
async function setup(page, kind) {
|
||||
await page.goto("/");
|
||||
await page.evaluate(async ({ url, kind }) => {
|
||||
const { mountFormReorderProbe } = await import(url);
|
||||
document.getElementById("app").style.display = "none";
|
||||
const element = document.createElement("div");
|
||||
document.body.prepend(element);
|
||||
window.interactionProbe = mountFormReorderProbe(element, kind);
|
||||
}, { url: probeUrl, kind });
|
||||
}
|
||||
|
||||
test("draft forms retain edits, report validation, prevent navigation and reset after success", async ({ page }) => {
|
||||
await setup(page, "form");
|
||||
const title = page.getByRole("textbox", { name: "Title", exact: true });
|
||||
const detail = page.getByRole("textbox", { name: "Detail", exact: true });
|
||||
await title.fill("");
|
||||
await page.getByRole("button", { name: "Submit", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toHaveText("Required");
|
||||
await title.fill("Draft");
|
||||
await expect(page.getByRole("alert")).toBeEmpty();
|
||||
await page.evaluate(() => window.interactionProbe.update(1));
|
||||
await expect(title).toHaveValue("Draft");
|
||||
await expect(detail).toHaveValue("Kept");
|
||||
await page.getByRole("button", { name: "Patch", exact: true }).click();
|
||||
await expect(title).toHaveValue("Patched");
|
||||
await expect(detail).toHaveValue("Kept");
|
||||
await page.getByRole("button", { name: "Replace", exact: true }).click();
|
||||
await expect(title).toHaveValue("Replacement");
|
||||
await expect(detail).toHaveValue("New");
|
||||
await title.press("Enter");
|
||||
await expect(title).toHaveValue("Initial");
|
||||
await expect(detail).toHaveValue("Kept");
|
||||
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([
|
||||
"changed", { title: "", detail: "Kept" }, { prevented: true },
|
||||
"changed", { title: "Replacement", detail: "New" }, { prevented: true }
|
||||
]);
|
||||
});
|
||||
|
||||
async function start(page) {
|
||||
await page.getByRole("button", { name: "a", exact: true }).hover();
|
||||
await page.mouse.down();
|
||||
await expect(page.getByLabel("Dragging")).toHaveText("a");
|
||||
}
|
||||
|
||||
test("pointer drops resolve before and after using the latest callback and reject invalid targets", async ({ page }) => {
|
||||
await setup(page, "reorder");
|
||||
await page.getByRole("button", { name: "a", exact: true }).hover();
|
||||
await page.mouse.down({ button: "right" });
|
||||
await expect(page.getByLabel("Dragging")).toBeEmpty();
|
||||
await page.mouse.up({ button: "right" });
|
||||
const target = await page.locator('[data-probe-item="b"]').boundingBox();
|
||||
for (const [offset, placement] of [[10, "before"], [90, "after"]]) {
|
||||
await start(page);
|
||||
await page.evaluate(() => window.interactionProbe.update(1));
|
||||
await page.mouse.move(target.x + 200, target.y + offset);
|
||||
await page.mouse.up();
|
||||
await expect(page.getByLabel("Dragging")).toBeEmpty();
|
||||
expect(await page.evaluate(() => window.interactionProbe.events.at(-1))).toEqual({ source: "a", target: "b", placement, revision: 1 });
|
||||
}
|
||||
await start(page);
|
||||
await page.locator('[data-probe-item="blocked"]').hover();
|
||||
await page.mouse.up();
|
||||
await start(page);
|
||||
await page.mouse.up();
|
||||
expect(await page.evaluate(() => window.interactionProbe.events.length)).toBe(2);
|
||||
});
|
||||
|
||||
test("pointer cancellation never commits a drop and unmount removes active listeners", async ({ page }) => {
|
||||
await setup(page, "reorder");
|
||||
const target = await page.locator('[data-probe-item="b"]').boundingBox();
|
||||
await start(page);
|
||||
await page.mouse.move(target.x + 200, target.y + 50);
|
||||
await page.getByRole("button", { name: "a", exact: true }).dispatchEvent("pointercancel", { clientX: target.x + 200, clientY: target.y + 50, pointerId: 1 });
|
||||
await expect(page.getByLabel("Dragging")).toBeEmpty();
|
||||
await page.mouse.up();
|
||||
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([]);
|
||||
await start(page);
|
||||
await page.evaluate(() => {
|
||||
const remainingTarget = document.querySelector('[data-probe-item="b"]').cloneNode(true);
|
||||
window.interactionProbe.unmount();
|
||||
document.body.prepend(remainingTarget);
|
||||
});
|
||||
await page.locator('[data-probe-item="b"]').hover();
|
||||
await page.mouse.up();
|
||||
expect(await page.evaluate(() => window.interactionProbe.events)).toEqual([]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue