Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function dragSliderTrack(row: Element, clientX: number, trackWidth: number) {
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX }));
track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX }));
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {

it("commits a new volume value on slider track pointerdown", () => {
const onSetAttribute = vi.fn();
const element = makeVideoElement({ dataAttributes: { volume: "0.5" } });
const element = makeVideoElement({ dataAttributes: { volume: "0.2" } });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
Expand All @@ -177,8 +177,9 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
});
act(() => {
volumeTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
volumeTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5"
// starting volume 0.2 (draft=20); min=0, max=100, ratio=0.5 -> raw=50 -> commit(50) -> 50/100=0.5 -> "0.5"
expect(onSetAttribute).toHaveBeenCalledWith("volume", "0.5");
act(() => root.unmount());
});
Expand Down Expand Up @@ -207,6 +208,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
});
act(() => {
rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
rateTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
// min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3"
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3");
Expand Down Expand Up @@ -237,6 +239,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
});
act(() => {
mediaStartTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
mediaStartTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
// no source-duration set -> mediaStartMax=Math.max(30, Math.ceil(0+10))=30 -> max=3000
// ratio=1.0 -> raw=3000 -> commit(3000) -> (3000/100).toFixed(2) = "30.00"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,11 @@ describe("FlatSlider", () => {
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={50}
value={10}
min={0}
max={100}
tier="explicitCustom"
displayValue="50%"
displayValue="10%"
onCommit={onCommit}
/>,
);
Expand All @@ -249,6 +249,7 @@ describe("FlatSlider", () => {
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
expect(onCommit).toHaveBeenCalledWith(50);
act(() => root.unmount());
Expand Down Expand Up @@ -276,12 +277,13 @@ describe("FlatSlider", () => {
track.dispatchEvent(
new MouseEvent("pointerdown", { bubbles: true, clientX: 20, clientY: 18 }),
);
track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 20 }));
});
expect(onCommit).toHaveBeenCalledWith(10);
act(() => root.unmount());
});

it("commits continuously while dragging, not just on the initial pointerdown", () => {
it("tracks the knob instantly on every pointermove during a drag (draft state)", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
Expand All @@ -304,25 +306,78 @@ describe("FlatSlider", () => {
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(10);
// Instant, un-throttled knob feedback via aria-valuenow (draft state) —
// this must update on every pointermove regardless of the commit throttle.
expect(track.getAttribute("aria-valuenow")).toBe("10");
act(() => {
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(80);
expect(track.getAttribute("aria-valuenow")).toBe("80");
act(() => {
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(50);
expect(track.getAttribute("aria-valuenow")).toBe("50");
act(() => {
track.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
});
act(() => root.unmount());
});

it("throttles rapid drag commits to leading edge + final value on release, not every step", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={5}
min={0}
max={100}
tier="explicitCustom"
displayValue="5%"
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(() => {
// pointerdown fires the leading-edge commit immediately — a live
// preview needs to move the instant the drag starts, not wait 40ms.
track.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
);
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 160, pointerId: 1 }),
);
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 100, pointerId: 1 }),
);
});
// The leading-edge commit (10) fired; the rapid intermediate position (80)
// from the first pointermove never committed — it's within the 40ms
// throttle window, so only the trailing flush or the pointerup release
// gets to send the next value.
expect(onCommit).toHaveBeenCalledTimes(1);
expect(onCommit).toHaveBeenCalledWith(10);
act(() => {
// Real pointerup events always carry the pointer's true release position
// (matches the last pointermove) — the handler recomputes from this
// rather than trusting a possibly-stale `draft` closure.
track.dispatchEvent(
new PointerEvent("pointerup", { bubbles: true, clientX: 100, pointerId: 1 }),
);
});
// Release flushes immediately with the LAST position only.
expect(onCommit).toHaveBeenCalledTimes(2);
expect(onCommit).toHaveBeenNthCalledWith(2, 50);
act(() => root.unmount());
});

it("ignores pointermove once a drag has ended (pointer capture released)", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { RotateCcw } from "../../icons/SystemIcons";
import { CommitField } from "./propertyPanelPrimitives";
import {
Expand Down Expand Up @@ -241,13 +241,68 @@ export function FlatSlider({
onReset?: () => void;
onCommit: (nextValue: number) => void;
}) {
const clampedPct = Math.max(0, Math.min(100, ((value - min) / Math.max(max - min, 1e-6)) * 100));
// `draft` gives the knob instant, drag-local visual feedback. `onCommit` is
// throttled (not debounced) to at most once per 40ms: a real drag fires
// pointermove faster than that, and a pure debounce (reset the timer on
// every move) never commits until the pointer pauses or lifts — which kills
// live preview updates during a continuous drag. Throttling still fires on
// the leading edge and on a trailing timer, so the preview keeps updating
// while dragging, with an immediate flush on release for the final value.
const [draft, setDraft] = useState(value);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastCommitAtRef = useRef(0);
const pendingRef = useRef<number | null>(null);
// Tracks the last value actually sent to onCommit — separate from `value`
// (the committed prop) because in a single pointerdown+pointerup click the
// leading-edge commit fires before the parent has re-rendered with the new
// prop, so the release flush must dedupe against what we just sent, not
// against the stale prop, or the same value commits twice.
const lastCommittedRef = useRef(value);

const commitFromClientX = (clientX: number, rect: DOMRect) => {
useEffect(() => {
setDraft(value);
lastCommittedRef.current = value;
}, [value]);
useEffect(
() => () => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
},
[],
);

const clampedPct = Math.max(0, Math.min(100, ((draft - min) / Math.max(max - min, 1e-6)) * 100));

const stepFromClientX = (clientX: number, rect: DOMRect) => {
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(rect.width, 1)));
const raw = min + ratio * (max - min);
const stepped = Math.round(raw / step) * step;
onCommit(Math.max(min, Math.min(max, stepped)));
return Math.max(min, Math.min(max, stepped));
};
const commitDraft = (nextDraft: number) => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
pendingRef.current = null;
lastCommitAtRef.current = Date.now();
if (nextDraft !== lastCommittedRef.current) {
lastCommittedRef.current = nextDraft;
onCommit(nextDraft);
}
};
const scheduleCommit = (nextDraft: number) => {
const elapsed = Date.now() - lastCommitAtRef.current;
if (elapsed >= 40) {
commitDraft(nextDraft);
return;
}
pendingRef.current = nextDraft;
if (!commitTimerRef.current) {
commitTimerRef.current = setTimeout(() => {
commitTimerRef.current = null;
if (pendingRef.current !== null) commitDraft(pendingRef.current);
}, 40 - elapsed);
}
};

return (
Expand All @@ -257,22 +312,33 @@ export function FlatSlider({
data-flat-slider-track="true"
role="slider"
aria-label={label}
aria-valuenow={value}
aria-valuenow={draft}
aria-disabled={disabled}
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());
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
setDraft(stepped);
scheduleCommit(stepped);
}}
onPointerMove={(e) => {
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
commitFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
setDraft(stepped);
scheduleCommit(stepped);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
// Recompute from the event itself rather than reading the `draft`
// closure — if pointerdown+pointerup land in the same React batch
// (e.g. a very fast click), the onPointerUp handler can still be
// bound to the pre-drag render, making `draft` stale.
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
setDraft(stepped);
commitDraft(stepped);
}}
>
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ describe("FlatStyleSection — blur sliders", () => {
});
act(() => {
track.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
track.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// filterBlurValue=4 -> max=Math.max(40, 4)=40; clientX=50 of width 100 -> ratio 0.5 -> 20px.
expect(onSetStyle).toHaveBeenCalledWith("filter", "blur(20px)");
Expand All @@ -390,6 +391,7 @@ describe("FlatStyleSection — blur sliders", () => {
});
act(() => {
backdropTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
backdropTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// backdropBlurValue=6 -> max=Math.max(60, 6)=60; clientX=50 of width 100 -> ratio 0.5 -> 30px.
expect(onSetStyle).toHaveBeenCalledWith("backdrop-filter", "blur(30px)");
Expand Down Expand Up @@ -551,6 +553,7 @@ describe("FlatStyleSection — Overflow and Mask", () => {
});
act(() => {
maskInsetTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
maskInsetTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
// clipInsetValue=8 -> max=Math.max(120, 8)=120; clientX=50 of width 100 -> ratio 0.5 -> 60px.
// border-radius is unset here, so the clip-path's own `round 4px` is not reused — radiusValue
Expand Down Expand Up @@ -587,6 +590,7 @@ describe("FlatStyleSection — Opacity", () => {
});
act(() => {
opacityTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 50 }));
opacityTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 50 }));
});
expect(onSetStyle).toHaveBeenCalledWith("opacity", "0.5");
act(() => root.unmount());
Expand Down
Loading