diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index 09b1e1038..dda93de0d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -774,6 +774,54 @@ describe("FlatSlider — Grade extensions", () => { expect(track.getAttribute("aria-valuenow")).toBe("80"); act(() => root.unmount()); }); + + it("resets the dragging state on lostpointercapture even without a prior pointerup/pointercancel", () => { + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }), + ); + }); + expect(track.getAttribute("aria-valuenow")).toBe("30"); + act(() => { + // Capture lost WITHOUT a pointerup/pointercancel first — e.g. another + // element steals it, or the browser reclaims it for a scroll gesture. + track.dispatchEvent(new Event("lostpointercapture", { bubbles: true })); + }); + act(() => { + root.render( + , + ); + }); + // If lostpointercapture hadn't cleared the dragging flag, this external + // value change would be silently ignored (mid-drag echo suppression) + // forever — the knob would be stuck at 30. + expect(track.getAttribute("aria-valuenow")).toBe("99"); + act(() => root.unmount()); + }); }); describe("FlatSelectRow", () => { @@ -813,6 +861,28 @@ describe("FlatSelectRow", () => { act(() => root.unmount()); }); + it("disables the reset button (and gives the select an accessible name) when the row itself is disabled", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const select = host.querySelector("select"); + expect(select?.getAttribute("aria-label")).toBe("Shadow"); + const reset = host.querySelector('[data-flat-select-reset="true"]'); + expect(reset?.disabled).toBe(true); + act(() => reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + it("fires onChange when the select value changes", () => { const onChange = vi.fn(); const { host, root } = renderInto( diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index df7120b2c..7e5afca27 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -404,6 +404,14 @@ export function FlatSlider({ e.currentTarget.releasePointerCapture(e.pointerId); } }} + onLostPointerCapture={() => { + // Capture can be lost without either pointerup or pointercancel + // firing first (e.g. another element steals it, or the browser + // reclaims it for a scroll/touch gesture) — without this, + // draggingRef stays stuck true and the knob permanently stops + // syncing to the committed value prop. + draggingRef.current = false; + }} onKeyDown={(e) => { if (disabled) return; const next = sliderKeyTarget(e.key, draft, min, max, step); @@ -507,6 +515,7 @@ export function FlatSelectRow({