From 8939bfde4e693ccce40f30869a32dc067c17b7e7 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 29 Oct 2026 18:08:54 +0545 Subject: [PATCH 1/2] fix(panel): restore the committed width and keep focus on pane switch A drag that ends without a release (a new press on the handle, or the hook unmounting) now drops the preview and puts back the committed `--resizable-width`, so CSS and controlled state cannot diverge. The drop moves from `commit` into the drag's teardown, which every end path runs. Pointercancel and lost capture still commit the preview, as before. Hiding a keep-mounted pane that holds focus now moves focus to the panel before the browser applies `inert`, instead of letting it fall to the page. Addresses #142 findings 15 and 16. --- e2e/fixtures.tsx | 1 + e2e/panel.spec.ts | 16 ++++++++++++++++ src/hooks/use-resizable.test.ts | 29 ++++++++++++++++++++++++++++- src/hooks/use-resizable.ts | 7 ++++--- src/ui/panel.tsx | 13 ++++++++++++- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 8ff5f6a..41f5a3f 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -668,6 +668,7 @@ function PanelScenario() { + diff --git a/e2e/panel.spec.ts b/e2e/panel.spec.ts index d9ea9f5..dcae666 100644 --- a/e2e/panel.spec.ts +++ b/e2e/panel.spec.ts @@ -158,6 +158,22 @@ test("a hidden pane keeps its typed text and scroll position", async ({ ).toBeLessThanOrEqual(1); }); +test("switching away from the pane with focus keeps focus in the panel", async ({ + page, +}) => { + await page.goto("/panel"); + await page.getByRole("button", { name: "Open draft" }).click(); + await expect(page.getByTestId("draft-pane")).toHaveAttribute( + "data-state", + "active", + ); + await nextFrames(page); + + await expect( + page.getByRole("complementary", { name: "Side panel" }), + ).toBeFocused(); +}); + test("a narrow row gets an overlay that returns focus on close", async ({ page, }) => { diff --git a/src/hooks/use-resizable.test.ts b/src/hooks/use-resizable.test.ts index 9a7acd2..977d654 100644 --- a/src/hooks/use-resizable.test.ts +++ b/src/hooks/use-resizable.test.ts @@ -250,7 +250,7 @@ describe("useResizable", () => { mounted.unmount(); }); - test("a controlled parent that ignores a change keeps its width", () => { + test("a controlled parent that ignores a change keeps its width", async () => { const mounted = mount({ width: 700 }); expect(shownWidth(mounted)).toBe(700); mounted.press("ArrowLeft"); @@ -258,6 +258,14 @@ describe("useResizable", () => { expect(shownWidth(mounted)).toBe(700); mounted.rerender({ width: 716 }); expect(shownWidth(mounted)).toBe(716); + + mounted.pointer("pointerdown", 500); + mounted.pointer("pointermove", 400); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(shownWidth(mounted)).toBe(816); + mounted.pointer("pointerup", 400); + expect(mounted.changes).toEqual([716, 816]); + expect(shownWidth(mounted)).toBe(716); mounted.unmount(); }); @@ -344,6 +352,25 @@ describe("useResizable", () => { mounted.unmount(); }); + test("a drag cut short without a release puts the committed width back", async () => { + const mounted = mount({ defaultWidth: 600 }); + mounted.pointer("pointerdown", 500); + mounted.pointer("pointermove", 400); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(mounted.panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe( + "700px", + ); + // A new press abandons the drag in flight; it never moves, so it commits + // nothing either. + mounted.pointer("pointerdown", 400); + mounted.pointer("pointerup", 400); + expect(mounted.changes).toEqual([]); + expect(mounted.panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe( + "600px", + ); + mounted.unmount(); + }); + test("a press that never moves past the drag threshold commits nothing", () => { // The stored 900 renders clamped to 560: a commit would overwrite it. rowWidth = 1000; diff --git a/src/hooks/use-resizable.ts b/src/hooks/use-resizable.ts index e4be8ac..6980a68 100644 --- a/src/hooks/use-resizable.ts +++ b/src/hooks/use-resizable.ts @@ -213,9 +213,6 @@ export function useResizable(options: UseResizableOptions): UseResizableResult { ); resolved = clamped === measureDefault(element) ? null : clamped; } - // Drop any drag-time override first: if the parent keeps its width, - // nothing else would put the element back. - writeWidth(element, widthRef.current); if (resolved !== widthRef.current) setWidth(resolved); measure(); }, @@ -286,6 +283,10 @@ export function useResizable(options: UseResizableOptions): UseResizableResult { // React only rewrites the attribute when its value changes, so put // back what it last rendered. if (startValue !== null) handle.setAttribute("aria-valuenow", startValue); + // Drop the preview, whatever ends the drag: a release commits it next, + // and a parent that keeps its width would leave nothing else to put + // the element back. + writeWidth(element, widthRef.current); draggingRef.current = false; stopDragRef.current = null; }; diff --git a/src/ui/panel.tsx b/src/ui/panel.tsx index fe6c460..8a042d3 100644 --- a/src/ui/panel.tsx +++ b/src/ui/panel.tsx @@ -344,7 +344,8 @@ export type PanelContentProps = React.ComponentProps<"div"> & { /** * Keep every pane mounted, hiding all but the shown one, so a pane keeps * its scroll position and anything typed in it while another is in front. - * Without it only the shown pane is mounted. + * Focus in a pane as it is hidden moves to the panel. Without it only the + * shown pane is mounted. */ readonly keepMounted?: boolean; }; @@ -384,6 +385,16 @@ export function PanelContent({ ); }); + // A pane hidden with focus inside it would drop that focus to the page + // once the browser applies `inert`, which happens at the next frame, not + // yet here. The panel takes it instead. + useLayoutEffect(() => { + const pane = document.activeElement?.closest( + '[data-slot="panel-pane"][inert]', + ); + pane?.closest('[data-slot="panel"]')?.focus(); + }, [value]); + return (
Date: Fri, 30 Oct 2026 12:24:31 +0000 Subject: [PATCH 2/2] fix(panel): leave pane focus for its own change This pull request now only restores the committed width when a drag ends without a release. --- e2e/fixtures.tsx | 1 - e2e/panel.spec.ts | 16 ---------------- src/ui/panel.tsx | 13 +------------ 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 41f5a3f..8ff5f6a 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -668,7 +668,6 @@ function PanelScenario() { - diff --git a/e2e/panel.spec.ts b/e2e/panel.spec.ts index dcae666..d9ea9f5 100644 --- a/e2e/panel.spec.ts +++ b/e2e/panel.spec.ts @@ -158,22 +158,6 @@ test("a hidden pane keeps its typed text and scroll position", async ({ ).toBeLessThanOrEqual(1); }); -test("switching away from the pane with focus keeps focus in the panel", async ({ - page, -}) => { - await page.goto("/panel"); - await page.getByRole("button", { name: "Open draft" }).click(); - await expect(page.getByTestId("draft-pane")).toHaveAttribute( - "data-state", - "active", - ); - await nextFrames(page); - - await expect( - page.getByRole("complementary", { name: "Side panel" }), - ).toBeFocused(); -}); - test("a narrow row gets an overlay that returns focus on close", async ({ page, }) => { diff --git a/src/ui/panel.tsx b/src/ui/panel.tsx index 8a042d3..fe6c460 100644 --- a/src/ui/panel.tsx +++ b/src/ui/panel.tsx @@ -344,8 +344,7 @@ export type PanelContentProps = React.ComponentProps<"div"> & { /** * Keep every pane mounted, hiding all but the shown one, so a pane keeps * its scroll position and anything typed in it while another is in front. - * Focus in a pane as it is hidden moves to the panel. Without it only the - * shown pane is mounted. + * Without it only the shown pane is mounted. */ readonly keepMounted?: boolean; }; @@ -385,16 +384,6 @@ export function PanelContent({ ); }); - // A pane hidden with focus inside it would drop that focus to the page - // once the browser applies `inert`, which happens at the next frame, not - // yet here. The panel takes it instead. - useLayoutEffect(() => { - const pane = document.activeElement?.closest( - '[data-slot="panel-pane"][inert]', - ); - pane?.closest('[data-slot="panel"]')?.focus(); - }, [value]); - return (