fix(studio): support drag on FlatSlider, not just click-to-set

Only onPointerDown was wired, so dragging the knob/track only ever
committed the initial click position — nothing tracked the pointer
after that. Uses the Pointer Capture API (setPointerCapture on
pointerdown, onPointerMove while captured, release on pointerup) so
the value follows the cursor continuously during a drag, matching how
the legacy native <input type="range"> control behaves for free.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-14 15:51:57 -07:00
co-authored by Claude Sonnet 5
parent ce07dfcd4d
commit c699778689
2 changed files with 86 additions and 0 deletions
@@ -280,6 +280,82 @@ describe("FlatSlider", () => {
expect(onCommit).toHaveBeenCalledWith(10);
act(() => root.unmount());
});
it("commits continuously while dragging, not just on the initial pointerdown", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={50}
min={0}
max={100}
tier="explicitCustom"
displayValue="50%"
onCommit={onCommit}
/>,
);
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a track element");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
});
act(() => {
track.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(10);
act(() => {
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(80);
act(() => {
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(50);
act(() => {
track.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
});
act(() => root.unmount());
});
it("ignores pointermove once a drag has ended (pointer capture released)", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={50}
min={0}
max={100}
tier="explicitCustom"
displayValue="50%"
onCommit={onCommit}
/>,
);
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a track element");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
});
act(() => {
track.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
);
track.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
});
onCommit.mockClear();
act(() => {
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
);
});
expect(onCommit).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
describe("FlatSlider — Grade extensions", () => {
@@ -262,8 +262,18 @@ export function FlatSlider({
className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
onPointerDown={(e) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
}}
onPointerMove={(e) => {
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
>
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
{centerTick && (