Let the slider keep the drag the browser wanted to take
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m38s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m41s
pre-commit / pre-commit (push) Failing after 2m47s
Test Backend / test-backend (push) Successful in 2m19s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m4s
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m38s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m41s
pre-commit / pre-commit (push) Failing after 2m47s
Test Backend / test-backend (push) Successful in 2m19s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m4s
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012n6CehUsHYYUXJ48rxaD18
This commit is contained in:
@@ -203,6 +203,13 @@ export function useSliderDrag({
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
Reference in New Issue
Block a user