Skip to content

Commit 4e5df13

Browse files
committed
fix(panel): keep focus where a press, autofocus or resize leaves it
A press on the resize handle never focused it: preventing the pointerdown default also cancelled the focus a press gives, so the arrow keys did nothing until Tab reached the handle. The drag already blocks text selection with user-select, so let the press focus it. A pane that autofocuses a field takes focus before the panel's mount effect runs, so the panel saved that field as its opener and focus fell to the body on close. Record where focus came from when it moved into the panel and use that as the opener in this case. Under StrictMode the simulated remount would hand the field's focus to the opener, so give it back when the effect sets up again. When the row widened and the overlay went away, focus stayed on the panel root. Give it back to what the overlay took it from, if it is still on the panel itself.
1 parent 4625da2 commit 4e5df13

4 files changed

Lines changed: 139 additions & 7 deletions

File tree

‎e2e/panel.spec.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ test("double-click and Enter reset the width", async ({ page }) => {
8181
expect(Math.round(await width(page, "panel"))).toBe(start);
8282
});
8383

84+
test("a click on the handle focuses it for the arrow keys", async ({
85+
page,
86+
}) => {
87+
await page.goto("/panel");
88+
const handle = page.getByRole("separator", { name: "Resize panel" });
89+
const output = page.getByRole("status", { name: "Width" });
90+
const start = Math.round(await width(page, "panel"));
91+
await page.getByRole("button", { name: "Show notes" }).focus();
92+
93+
await handle.click();
94+
await expect(handle).toBeFocused();
95+
await expect(output).toHaveText("default");
96+
await page.keyboard.press("ArrowLeft");
97+
await expect(output).toHaveText(String(start + 16));
98+
});
99+
84100
test("the main area keeps its minimum width", async ({ page }) => {
85101
await page.goto("/panel");
86102
const handle = page.getByRole("separator", { name: "Resize panel" });
@@ -215,4 +231,6 @@ test("narrowing the row while the panel is open turns it into an overlay", async
215231
await page.setViewportSize({ width: 1280, height: 720 });
216232
await expect(main).not.toHaveAttribute("inert");
217233
await expect(page.getByRole("separator")).toBeVisible();
234+
// Side by side again, focus goes back to what the overlay covered.
235+
await expect(page.getByRole("button", { name: "Show notes" })).toBeFocused();
218236
});

‎src/hooks/use-resizable.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,6 @@ export function useResizable(options: UseResizableOptions): UseResizableResult {
226226
const element = elementRef.current;
227227
const handle = event.currentTarget;
228228
if (element === null || event.button !== 0 || !event.isPrimary) return;
229-
event.preventDefault();
230229
stopDragRef.current?.();
231230
handle.setPointerCapture(event.pointerId);
232231

@@ -249,6 +248,8 @@ export function useResizable(options: UseResizableOptions): UseResizableResult {
249248
draggingRef.current = true;
250249
handle.dataset["dragging"] = "true";
251250
root.style.cursor = "col-resize";
251+
// This, not preventing the press's default, keeps the drag from
252+
// selecting text: the default is what focuses the handle.
252253
root.style.userSelect = "none";
253254

254255
const move = (moveEvent: PointerEvent) => {

‎src/ui/panel.test.tsx‎

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, useState } from "react";
1+
import { act, StrictMode, useState } from "react";
22
import { createRoot, type Root } from "react-dom/client";
33
import { describe, expect, test } from "bun:test";
44

@@ -152,6 +152,84 @@ describe("Panel", () => {
152152
mounted.unmount();
153153
});
154154

155+
test("closing returns focus to the opener when a pane autofocused a field", () => {
156+
function Host() {
157+
const [open, setOpen] = useState(false);
158+
return (
159+
<>
160+
<button type="button" onClick={() => setOpen(true)}>
161+
Open thread
162+
</button>
163+
<button type="button" onClick={() => setOpen(false)}>
164+
Hide thread
165+
</button>
166+
{open ? (
167+
<Panel aria-label="Thread">
168+
<PanelContent value="reply">
169+
<PanelPane value="reply">
170+
<input aria-label="Reply" autoFocus />
171+
</PanelPane>
172+
</PanelContent>
173+
</Panel>
174+
) : null}
175+
</>
176+
);
177+
}
178+
const mounted = mount(<Host />);
179+
const [opener, hide] = mounted.container.querySelectorAll("button");
180+
if (opener === undefined || hide === undefined)
181+
throw new Error("expected the host's buttons");
182+
opener.focus();
183+
act(() => opener.click());
184+
expect(document.activeElement?.getAttribute("aria-label")).toBe("Reply");
185+
// Closed by the host while focus is still in the panel, as a shortcut
186+
// would: a click on the button would move focus to it first.
187+
act(() => hide.dispatchEvent(new MouseEvent("click", { bubbles: true })));
188+
expect(mounted.container.querySelector("aside")).toBeNull();
189+
expect(document.activeElement).toBe(opener);
190+
mounted.unmount();
191+
});
192+
193+
test("under StrictMode an autofocused field keeps focus, and close returns it", () => {
194+
function Host() {
195+
const [open, setOpen] = useState(false);
196+
return (
197+
<>
198+
<button type="button" onClick={() => setOpen(true)}>
199+
Open thread
200+
</button>
201+
<button type="button" onClick={() => setOpen(false)}>
202+
Hide thread
203+
</button>
204+
{open ? (
205+
<Panel aria-label="Thread">
206+
<PanelContent value="reply">
207+
<PanelPane value="reply">
208+
<input aria-label="Reply" autoFocus />
209+
</PanelPane>
210+
</PanelContent>
211+
</Panel>
212+
) : null}
213+
</>
214+
);
215+
}
216+
const mounted = mount(
217+
<StrictMode>
218+
<Host />
219+
</StrictMode>,
220+
);
221+
const [opener, hide] = mounted.container.querySelectorAll("button");
222+
if (opener === undefined || hide === undefined)
223+
throw new Error("expected the host's buttons");
224+
opener.focus();
225+
act(() => opener.click());
226+
expect(document.activeElement?.getAttribute("aria-label")).toBe("Reply");
227+
act(() => hide.dispatchEvent(new MouseEvent("click", { bubbles: true })));
228+
expect(mounted.container.querySelector("aside")).toBeNull();
229+
expect(document.activeElement).toBe(opener);
230+
mounted.unmount();
231+
});
232+
155233
test("as an overlay the panel makes the covered main area inert until it closes", () => {
156234
function Host() {
157235
const [open, setOpen] = useState(false);

‎src/ui/panel.tsx‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export type PanelProps = React.ComponentProps<"aside"> & {
7676
* query), the panel covers the row instead: it takes focus, hides its
7777
* handle, and makes the rest of the row inert so focus and assistive
7878
* technology cannot reach what it covers. This follows the row's size while
79-
* the panel is open.
79+
* the panel is open; when the row widens again, focus still on the panel
80+
* itself goes back to what the overlay took it from.
8081
*
8182
* However it closes, focus that was inside the panel goes back to whatever
8283
* had it before the panel opened.
@@ -92,6 +93,7 @@ export function Panel({
9293
style,
9394
className,
9495
children,
96+
onFocus,
9597
...props
9698
}: PanelProps) {
9799
const generatedId = useId();
@@ -104,6 +106,14 @@ export function Panel({
104106
mainMinWidth,
105107
});
106108
const elementRef = useRef<HTMLElement | null>(null);
109+
// Where focus came from when it last moved into the panel. An `autoFocus`
110+
// field in a pane takes focus before the mount effect below runs, and
111+
// this is how that effect still finds the opener.
112+
const focusedFromRef = useRef<Element | null>(null);
113+
// What had focus inside the panel when the mount effect last cleaned up.
114+
// Only StrictMode's simulated remount runs the effect again, and it gives
115+
// that element its focus back.
116+
const focusedOnCleanupRef = useRef<HTMLElement | null>(null);
107117

108118
const setElement = useCallback(
109119
(element: HTMLElement | null) => {
@@ -118,24 +128,42 @@ export function Panel({
118128
useLayoutEffect(() => {
119129
const panel = elementRef.current;
120130
if (panel === null) return;
121-
const opener = document.activeElement;
131+
const opener = panel.contains(document.activeElement)
132+
? focusedFromRef.current
133+
: document.activeElement;
122134
let restoreSiblings: (() => void) | null = null;
135+
// What had focus when the panel became an overlay and took it.
136+
let covered: HTMLElement | null = null;
123137
// The `@container` query decides whether the panel covers the row, so
124138
// its computed position is the signal. As an overlay, the panel takes
125139
// focus and makes the covered main area inert; side by side again, the
126-
// main area comes back. Re-checked whenever the row resizes, as plain
127-
// DOM work with no React render.
140+
// main area comes back, and so does focus the overlay took if it is
141+
// still on the panel itself. Re-checked whenever the row resizes, as
142+
// plain DOM work with no React render.
128143
const syncOverlay = () => {
129144
const overlay = getComputedStyle(panel).position === "absolute";
130145
if (overlay && restoreSiblings === null) {
131-
if (!panel.contains(document.activeElement)) panel.focus();
146+
const active = document.activeElement;
147+
if (!panel.contains(active)) {
148+
covered =
149+
active instanceof HTMLElement && active !== document.body
150+
? active
151+
: null;
152+
panel.focus();
153+
}
132154
restoreSiblings = inertSiblings(panel);
133155
} else if (!overlay && restoreSiblings !== null) {
134156
restoreSiblings();
135157
restoreSiblings = null;
158+
if (document.activeElement === panel && covered?.isConnected)
159+
covered.focus();
160+
covered = null;
136161
}
137162
};
138163
syncOverlay();
164+
const refocus = focusedOnCleanupRef.current;
165+
focusedOnCleanupRef.current = null;
166+
if (refocus?.isConnected && panel.contains(refocus)) refocus.focus();
139167
const row = panel.parentElement;
140168
const observer =
141169
row === null || typeof ResizeObserver === "undefined"
@@ -149,6 +177,8 @@ export function Panel({
149177
const active = document.activeElement;
150178
const focusLeftWithPanel =
151179
active === null || active === document.body || panel.contains(active);
180+
focusedOnCleanupRef.current =
181+
active instanceof HTMLElement && panel.contains(active) ? active : null;
152182
// The opener is usually in the main area, so lift its inertness first.
153183
restoreSiblings?.();
154184
if (
@@ -175,6 +205,11 @@ export function Panel({
175205
tabIndex={-1}
176206
data-slot="panel"
177207
style={{ ...sizing, ...style }}
208+
onFocus={(event) => {
209+
onFocus?.(event);
210+
if (!event.currentTarget.contains(event.relatedTarget))
211+
focusedFromRef.current = event.relatedTarget;
212+
}}
178213
className={cn(
179214
"relative flex h-full min-h-0 shrink-0 flex-col border-s border-border bg-background text-foreground focus-visible:-outline-offset-2",
180215
"w-[var(--resizable-width,clamp(calc(var(--spacing)*120),46%,calc(var(--spacing)*190)))]",

0 commit comments

Comments
 (0)