From 127830af47a9d8b023533281ed818b0ab4fcdd9d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 20 Sep 2026 21:07:26 -0400 Subject: [PATCH] Bugfix: Separator keeps invisible focus after a drag --- .../tests/tests/pointer-interactions.spec.tsx | 43 ++++++++++++++++++- lib/components/group/ResizePreview.test.tsx | 1 + .../event-handlers/onDocumentPointerDown.ts | 1 + lib/global/mutable-state/interactions.test.ts | 1 + lib/global/mutable-state/interactions.ts | 21 ++++++++- lib/global/mutable-state/types.ts | 1 + .../utils/updateActiveHitRegion.test.ts | 2 + lib/global/utils/updateActiveHitRegion.ts | 7 ++- 8 files changed, 74 insertions(+), 3 deletions(-) diff --git a/integrations/tests/tests/pointer-interactions.spec.tsx b/integrations/tests/tests/pointer-interactions.spec.tsx index 73bc55e4a..5c904de50 100644 --- a/integrations/tests/tests/pointer-interactions.spec.tsx +++ b/integrations/tests/tests/pointer-interactions.spec.tsx @@ -405,6 +405,46 @@ test.describe("pointer interactions", () => { }); test.describe("focus", () => { + for (const returnToStart of [false, true]) { + test(`should blur after dragging${returnToStart ? " back to the starting point" : ""}`, async ({ + page: mainPage + }) => { + const page = await goToUrl( + mainPage, + + + + + + ); + const separator = page.getByRole("separator"); + const { x, y } = getCenterCoordinates((await separator.boundingBox())!); + + await page.mouse.move(x, y); + await page.mouse.down(); + await expect(separator).toBeFocused(); + await page.mouse.move(x + 25, y); + if (returnToStart) { + await page.mouse.move(x, y); + } + await page.mouse.up(); + await expect(separator).not.toBeFocused(); + const valueAfterDrag = await separator.getAttribute("aria-valuenow"); + await page.keyboard.press("ArrowRight"); + await expect(separator).toHaveAttribute( + "aria-valuenow", + valueAfterDrag! + ); + + // A subsequent click should still retain focus and support keyboard resizing. + await separator.click(); + await expect(separator).toBeFocused(); + const value = await separator.getAttribute("aria-valuenow"); + await page.keyboard.press("ArrowRight"); + await expect(separator).not.toHaveAttribute("aria-valuenow", value!); + }); + } + test("should update focus to the nearest separator", async ({ page: mainPage }) => { @@ -563,7 +603,8 @@ test.describe("pointer interactions", () => { await expect(separator).toHaveAttribute("data-separator", "active"); await page.mouse.up(); - await expect(separator).toHaveAttribute("data-separator", "focus"); + await expect(separator).not.toBeFocused(); + await expect(separator).not.toHaveAttribute("data-separator", "active"); await page.mouse.move(0, 0); await page.mouse.down(); diff --git a/lib/components/group/ResizePreview.test.tsx b/lib/components/group/ResizePreview.test.tsx index cd5daa3b6..3928ec2fa 100644 --- a/lib/components/group/ResizePreview.test.tsx +++ b/lib/components/group/ResizePreview.test.tsx @@ -205,6 +205,7 @@ describe("separator previews", () => { let interaction: InteractionActive = { state: "active", cursorFlags: 0, + didPointerMove: false, hitRegions: [], initialLayoutMap: new Map(), previewLayoutMap: new Map(), diff --git a/lib/global/event-handlers/onDocumentPointerDown.ts b/lib/global/event-handlers/onDocumentPointerDown.ts index b25344baa..c68f73e98 100644 --- a/lib/global/event-handlers/onDocumentPointerDown.ts +++ b/lib/global/event-handlers/onDocumentPointerDown.ts @@ -53,6 +53,7 @@ export function onDocumentPointerDown(event: PointerEvent) { updateInteractionState({ cursorFlags: 0, + didPointerMove: false, hitRegions, initialLayoutMap, pointerDownAtPoint: { x: event.clientX, y: event.clientY }, diff --git a/lib/global/mutable-state/interactions.test.ts b/lib/global/mutable-state/interactions.test.ts index b39191d9a..27dff8d64 100644 --- a/lib/global/mutable-state/interactions.test.ts +++ b/lib/global/mutable-state/interactions.test.ts @@ -35,6 +35,7 @@ test("removing a group preserves the other group in a shared drag", () => { updateInteractionState({ cursorFlags: 0, + didPointerMove: false, hitRegions, initialLayoutMap, pointerDownAtPoint: { x: 100, y: 100 }, diff --git a/lib/global/mutable-state/interactions.ts b/lib/global/mutable-state/interactions.ts index 66c013252..cb3826166 100644 --- a/lib/global/mutable-state/interactions.ts +++ b/lib/global/mutable-state/interactions.ts @@ -34,13 +34,15 @@ export function subscribeToInteractionState( export function updateCursorFlags( cursorFlags: number, previews: ResizePreview[] = [], - previewLayoutMap?: InteractionActive["previewLayoutMap"] + previewLayoutMap?: InteractionActive["previewLayoutMap"], + didPointerMove = false ) { const prev = state; const next = { ...state }; next.cursorFlags = cursorFlags; if (next.state === "active") { + next.didPointerMove ||= didPointerMove; next.previews = previews; if (previewLayoutMap) { next.previewLayoutMap = previewLayoutMap; @@ -60,6 +62,23 @@ export function updateInteractionState(next: InteractionState) { state = next; + // Keep click-to-focus behavior, but release keyboard focus after a drag. + // This also handles interactions completed by the missed-pointerup fallback. + if ( + prev.state === "active" && + next.state !== "active" && + prev.didPointerMove + ) { + prev.hitRegions.forEach(({ separator }) => { + if ( + separator && + separator.element.ownerDocument.activeElement === separator.element + ) { + separator.element.blur(); + } + }); + } + eventEmitter.emit("change", { prev, next diff --git a/lib/global/mutable-state/types.ts b/lib/global/mutable-state/types.ts index d695f7f15..545097822 100644 --- a/lib/global/mutable-state/types.ts +++ b/lib/global/mutable-state/types.ts @@ -27,6 +27,7 @@ export type ResizePreview = { export type InteractionActive = { cursorFlags: number; + didPointerMove: boolean; hitRegions: HitRegion[]; initialLayoutMap: Map; pointerDownAtPoint: Point; diff --git a/lib/global/utils/updateActiveHitRegion.test.ts b/lib/global/utils/updateActiveHitRegion.test.ts index 1ea6aafa7..f99f6405d 100644 --- a/lib/global/utils/updateActiveHitRegion.test.ts +++ b/lib/global/utils/updateActiveHitRegion.test.ts @@ -48,6 +48,7 @@ describe("updateActiveHitRegions preview bounds", () => { updateInteractionState({ cursorFlags: 0, + didPointerMove: false, hitRegions, initialLayoutMap, pointerDownAtPoint, @@ -121,6 +122,7 @@ describe("updateActiveHitRegions preview bounds", () => { updateInteractionState({ state: "active", cursorFlags: 0, + didPointerMove: false, hitRegions, initialLayoutMap, pointerDownAtPoint, diff --git a/lib/global/utils/updateActiveHitRegion.ts b/lib/global/utils/updateActiveHitRegion.ts index 69df15d87..8dd01ac4a 100644 --- a/lib/global/utils/updateActiveHitRegion.ts +++ b/lib/global/utils/updateActiveHitRegion.ts @@ -182,6 +182,11 @@ export function updateActiveHitRegions({ cursorFlags |= nextCursorFlags & CURSOR_FLAGS_VERTICAL; } - updateCursorFlags(cursorFlags, previews, previewLayoutMap); + const didPointerMove = + interaction.state === "active" && + (event.clientX !== interaction.pointerDownAtPoint.x || + event.clientY !== interaction.pointerDownAtPoint.y); + + updateCursorFlags(cursorFlags, previews, previewLayoutMap, didPointerMove); updateCursorStyle(document); }