Skip to content

Commit 8939bfd

Browse files
committed
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.
1 parent 9f97ae9 commit 8939bfd

5 files changed

Lines changed: 61 additions & 5 deletions

File tree

‎e2e/fixtures.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,7 @@ function PanelScenario() {
668668
<PanelContent value={shown} keepMounted>
669669
<PanelPane value="notes" data-testid="notes-pane">
670670
<input aria-label="Notes field" />
671+
<Button onClick={() => setShown("draft")}>Open draft</Button>
671672
<Filler label="Notes" />
672673
</PanelPane>
673674
<PanelPane value="draft" data-testid="draft-pane">

‎e2e/panel.spec.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,22 @@ test("a hidden pane keeps its typed text and scroll position", async ({
158158
).toBeLessThanOrEqual(1);
159159
});
160160

161+
test("switching away from the pane with focus keeps focus in the panel", async ({
162+
page,
163+
}) => {
164+
await page.goto("/panel");
165+
await page.getByRole("button", { name: "Open draft" }).click();
166+
await expect(page.getByTestId("draft-pane")).toHaveAttribute(
167+
"data-state",
168+
"active",
169+
);
170+
await nextFrames(page);
171+
172+
await expect(
173+
page.getByRole("complementary", { name: "Side panel" }),
174+
).toBeFocused();
175+
});
176+
161177
test("a narrow row gets an overlay that returns focus on close", async ({
162178
page,
163179
}) => {

‎src/hooks/use-resizable.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,14 +250,22 @@ describe("useResizable", () => {
250250
mounted.unmount();
251251
});
252252

253-
test("a controlled parent that ignores a change keeps its width", () => {
253+
test("a controlled parent that ignores a change keeps its width", async () => {
254254
const mounted = mount({ width: 700 });
255255
expect(shownWidth(mounted)).toBe(700);
256256
mounted.press("ArrowLeft");
257257
expect(mounted.changes).toEqual([716]);
258258
expect(shownWidth(mounted)).toBe(700);
259259
mounted.rerender({ width: 716 });
260260
expect(shownWidth(mounted)).toBe(716);
261+
262+
mounted.pointer("pointerdown", 500);
263+
mounted.pointer("pointermove", 400);
264+
await new Promise((resolve) => requestAnimationFrame(resolve));
265+
expect(shownWidth(mounted)).toBe(816);
266+
mounted.pointer("pointerup", 400);
267+
expect(mounted.changes).toEqual([716, 816]);
268+
expect(shownWidth(mounted)).toBe(716);
261269
mounted.unmount();
262270
});
263271

@@ -344,6 +352,25 @@ describe("useResizable", () => {
344352
mounted.unmount();
345353
});
346354

355+
test("a drag cut short without a release puts the committed width back", async () => {
356+
const mounted = mount({ defaultWidth: 600 });
357+
mounted.pointer("pointerdown", 500);
358+
mounted.pointer("pointermove", 400);
359+
await new Promise((resolve) => requestAnimationFrame(resolve));
360+
expect(mounted.panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe(
361+
"700px",
362+
);
363+
// A new press abandons the drag in flight; it never moves, so it commits
364+
// nothing either.
365+
mounted.pointer("pointerdown", 400);
366+
mounted.pointer("pointerup", 400);
367+
expect(mounted.changes).toEqual([]);
368+
expect(mounted.panel.style.getPropertyValue(RESIZABLE_WIDTH_VAR)).toBe(
369+
"600px",
370+
);
371+
mounted.unmount();
372+
});
373+
347374
test("a press that never moves past the drag threshold commits nothing", () => {
348375
// The stored 900 renders clamped to 560: a commit would overwrite it.
349376
rowWidth = 1000;

‎src/hooks/use-resizable.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,6 @@ export function useResizable(options: UseResizableOptions): UseResizableResult {
213213
);
214214
resolved = clamped === measureDefault(element) ? null : clamped;
215215
}
216-
// Drop any drag-time override first: if the parent keeps its width,
217-
// nothing else would put the element back.
218-
writeWidth(element, widthRef.current);
219216
if (resolved !== widthRef.current) setWidth(resolved);
220217
measure();
221218
},
@@ -286,6 +283,10 @@ export function useResizable(options: UseResizableOptions): UseResizableResult {
286283
// React only rewrites the attribute when its value changes, so put
287284
// back what it last rendered.
288285
if (startValue !== null) handle.setAttribute("aria-valuenow", startValue);
286+
// Drop the preview, whatever ends the drag: a release commits it next,
287+
// and a parent that keeps its width would leave nothing else to put
288+
// the element back.
289+
writeWidth(element, widthRef.current);
289290
draggingRef.current = false;
290291
stopDragRef.current = null;
291292
};

‎src/ui/panel.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,8 @@ export type PanelContentProps = React.ComponentProps<"div"> & {
344344
/**
345345
* Keep every pane mounted, hiding all but the shown one, so a pane keeps
346346
* its scroll position and anything typed in it while another is in front.
347-
* Without it only the shown pane is mounted.
347+
* Focus in a pane as it is hidden moves to the panel. Without it only the
348+
* shown pane is mounted.
348349
*/
349350
readonly keepMounted?: boolean;
350351
};
@@ -384,6 +385,16 @@ export function PanelContent({
384385
);
385386
});
386387

388+
// A pane hidden with focus inside it would drop that focus to the page
389+
// once the browser applies `inert`, which happens at the next frame, not
390+
// yet here. The panel takes it instead.
391+
useLayoutEffect(() => {
392+
const pane = document.activeElement?.closest(
393+
'[data-slot="panel-pane"][inert]',
394+
);
395+
pane?.closest<HTMLElement>('[data-slot="panel"]')?.focus();
396+
}, [value]);
397+
387398
return (
388399
<PanelPanesContext.Provider value={{ value, keepMounted, register }}>
389400
<div

0 commit comments

Comments
 (0)