From bda67d1c0df178e90746f35788de10a5fe73afe5 Mon Sep 17 00:00:00 2001 From: stroblme Date: Wed, 26 Aug 2026 13:32:13 +0200 Subject: [PATCH] Let the slider keep the drag the browser wanted to take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging the brightness slider on the wall panel set the handle and published nothing; tapping a point on the track worked. The control is a native range input laid transparent over the drawn track, and only the release publishes — but the input never said the drag was its own. On a touch panel a sideways swipe is a pan, or a back-navigation, so the browser took the pointer over mid-drag and ended it in `pointercancel`. The value had followed the finger and was never sent. `touch-action: none`, as the colour disk beside it has always had. The release also answers `pointercancel` and `lostpointercapture` now, which covers a mouse let go outside the input and leaves no way for a draft to sit there unpublished. Two checks: a drag across the track reaches the engine, and the input still owns its gesture. The second fails against a build without the CSS, which is what makes it worth having. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012n6CehUsHYYUXJ48rxaD18 --- .../components/Dashboard/ui/core/controls.ts | 7 + .../src/components/Dashboard/ui/core/core.css | 7 + frontend/tests/slider-drag.spec.ts | 144 ++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 frontend/tests/slider-drag.spec.ts diff --git a/frontend/src/components/Dashboard/ui/core/controls.ts b/frontend/src/components/Dashboard/ui/core/controls.ts index 66dc2b8..f7efcb7 100644 --- a/frontend/src/components/Dashboard/ui/core/controls.ts +++ b/frontend/src/components/Dashboard/ui/core/controls.ts @@ -203,6 +203,13 @@ export function useSliderDrag({ onChange: (event: React.ChangeEvent) => setDraft(Number(event.target.value)), onPointerUp: release, + // A drag can end without a `pointerup` on this element: the browser can + // claim the gesture and cancel the pointer, and a mouse released outside + // the input reports the release elsewhere. Either way the draft would sit + // there unpublished, showing a value nobody was ever sent. `release` + // ignores a second call, so overlapping ends are free. + onPointerCancel: release, + onLostPointerCapture: release, onKeyUp: release, onBlur: release, }, diff --git a/frontend/src/components/Dashboard/ui/core/core.css b/frontend/src/components/Dashboard/ui/core/core.css index bad1560..0409250 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -250,6 +250,13 @@ background: none; opacity: 0; cursor: pointer; + /* The drag is the control, so the browser may not read it as a gesture of + its own. Without this a sideways swipe on a touch panel is a pan — or a + back-navigation — and the browser takes the pointer over mid-drag, which + ends the drag in `pointercancel` rather than `pointerup`. The value + followed the finger and was never published. Same reason `.dui-disc` + has it. */ + touch-action: none; } .dui-slider[data-orientation="vertical"] .dui-slider-input { diff --git a/frontend/tests/slider-drag.spec.ts b/frontend/tests/slider-drag.spec.ts new file mode 100644 index 0000000..6d2eea7 --- /dev/null +++ b/frontend/tests/slider-drag.spec.ts @@ -0,0 +1,144 @@ +import { expect, test } from "@playwright/test" + +import { api, apiPage, deleteAll } from "./utils/api" + +/** + * A slider publishes what a *drag* leaves it on, not only what a tap picks. + * + * The control is a native range input laid transparent over the drawn track, + * and only the release publishes — so every way a drag can end has to reach + * the release. On a touch panel the browser will happily read a sideways swipe + * as a pan and take the pointer over mid-drag, which ends the drag in + * `pointercancel`; the value then followed the finger and was never sent. + * `touch-action: none` is what stops that, and this is what notices if it goes. + */ +const flowName = `test_slider_${Date.now().toString(36)}` +const dashboard = `${flowName}_d` + +test.use({ hasTouch: true, storageState: "playwright/.auth/user.json" }) +test.describe.configure({ mode: "serial" }) + +test.beforeAll(async ({ browser }) => { + const page = await apiPage(browser) + const madeFlow = await api(page, `/flows/${flowName}`, { + method: "PUT", + data: { + name: flowName, + title: "Slider drag", + version: 1, + nodes: [ + { + id: "level", + type: "python", + title: "Level", + requires: [{ name: "level", dtype: "float" }], + provides: [{ name: "echo", dtype: "float" }], + }, + ], + inputs: [{ spec: { name: "level", dtype: "float" }, initial: 0 }], + }, + }) + if (!madeFlow.ok()) + throw new Error(`flow PUT ${madeFlow.status()}: ${await madeFlow.text()}`) + + await api(page, `/flows/${flowName}/nodes/level/source`, { + method: "PUT", + data: { code: "def process(level=0.0):\n return {'echo': level}\n" }, + }) + // A leftover from a run that failed before its teardown would answer the + // create with a version conflict. + await api(page, `/dashboards/${dashboard}`, { method: "DELETE" }) + const madeBoard = await api(page, `/dashboards/${dashboard}`, { + method: "PUT", + data: { + name: dashboard, + title: "Slider drag", + version: 0, + widgets: [ + { + id: "level", + type: "slider", + title: "Level", + layout: { lg: { x: 0, y: 0, w: 6, h: 3 } }, + config: { + target: `${flowName}.level`, + dtype: "float", + min: 0, + max: 100, + step: 1, + }, + }, + ], + }, + }) + if (!madeBoard.ok()) + throw new Error( + `dashboard PUT ${madeBoard.status()}: ${await madeBoard.text()}`, + ) + const board = await madeBoard.json() + const shown = await api(page, `/dashboards/${dashboard}/publish`, { + method: "POST", + data: { version: board.version }, + }) + if (!shown.ok()) + throw new Error(`publish ${shown.status()}: ${await shown.text()}`) + const flow = await (await api(page, `/flows/${flowName}?draft=true`)).json() + await api(page, `/flows/${flowName}/publish`, { + method: "POST", + data: { version: flow.definition.version }, + }) + await api(page, `/flows/${flowName}/start`, { method: "POST" }) + await page.close() +}) + +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [`/dashboards/${dashboard}`, `/flows/${flowName}`]) +}) + +test("a dragged slider publishes where it was let go", async ({ page }) => { + await page.goto(`/view/${dashboard}`) + const slider = page.getByRole("slider", { name: "Level" }) + await slider.waitFor({ timeout: 20000 }) + + const box = (await slider.boundingBox())! + const y = box.y + box.height / 2 + + // A touch drag across the track, ending on the far side — the gesture a + // browser is most willing to mistake for a pan. + await page.touchscreen.tap(box.x + 4, y) + await page.mouse.move(box.x + 4, y) + await page.mouse.down() + for (let step = 1; step <= 8; step++) { + await page.mouse.move(box.x + (box.width * step) / 10, y, { steps: 2 }) + } + await page.mouse.up() + + // What the flow echoed back is what actually reached the engine. + await expect + .poll( + async () => { + const rows = await (await api(page, "/messages/")).json() + const echo = (rows.data ?? rows).find( + (m: { name: string }) => m.name === `${flowName}.echo`, + ) + return echo?.value ?? 0 + }, + { timeout: 15000, message: "the drag never reached the engine" }, + ) + .toBeGreaterThan(50) + + await page.mouse.up() +}) + +test("the slider owns its own drag gesture", async ({ page }) => { + await page.goto(`/view/${dashboard}`) + const slider = page.getByRole("slider", { name: "Level" }) + await slider.waitFor({ timeout: 20000 }) + + // The cure for the reported fault, asserted where it can be seen. Without + // it a browser is free to read a sideways swipe on the track as a pan or a + // back-navigation, take the pointer over mid-drag and end the drag in + // `pointercancel` — the handle followed the finger and nothing was ever + // published. A drag *is* this control; no gesture outranks it. + await expect(slider).toHaveCSS("touch-action", "none") +})