From 445f95d1411e3c0530a99cbed621b0776ad7b397 Mon Sep 17 00:00:00 2001 From: brxs Date: Sat, 18 Jul 2026 22:37:19 -0700 Subject: [PATCH] fix(ui): restore modal opener focus --- package-lock.json | 2 +- packages/ui/package.json | 2 +- packages/ui/src/components/Modal.tsx | 15 +++++++++- packages/ui/test/interactions.test.mjs | 40 +++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2a6002..f0f28be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3381,7 +3381,7 @@ }, "packages/ui": { "name": "@protocol-works/ui", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "@radix-ui/react-dialog": "^1.1.19", diff --git a/packages/ui/package.json b/packages/ui/package.json index 8c915fe..763c610 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@protocol-works/ui", - "version": "0.3.0", + "version": "0.3.1", "description": "Portable React primitives for [protocol]works products.", "type": "module", "sideEffects": [ diff --git a/packages/ui/src/components/Modal.tsx b/packages/ui/src/components/Modal.tsx index 951725e..745239c 100644 --- a/packages/ui/src/components/Modal.tsx +++ b/packages/ui/src/components/Modal.tsx @@ -1,5 +1,5 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; -import type { ReactNode, RefObject } from "react"; +import { useRef, type ReactNode, type RefObject } from "react"; import { IconButton } from "./IconButton.js"; import { Heading } from "./typography/Heading.js"; import { cn } from "../lib/cn.js"; @@ -48,6 +48,8 @@ export function Modal({ bodyClassName, footerClassName, }: ModalProps) { + const returnFocusRef = useRef(null); + return ( { + returnFocusRef.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; if (!initialFocusRef?.current) return; event.preventDefault(); initialFocusRef.current.focus(); }} + onCloseAutoFocus={(event) => { + const returnTarget = returnFocusRef.current; + returnFocusRef.current = null; + if (!returnTarget?.isConnected) return; + event.preventDefault(); + returnTarget.focus(); + }} className={cn( "fixed top-1/2 left-1/2 flex max-h-[85vh] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-tight border border-rule bg-background text-foreground shadow-overlay outline-none", sizeClass[size], diff --git a/packages/ui/test/interactions.test.mjs b/packages/ui/test/interactions.test.mjs index 75af509..de2e43d 100644 --- a/packages/ui/test/interactions.test.mjs +++ b/packages/ui/test/interactions.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test, { afterEach } from "node:test"; import { JSDOM } from "jsdom"; -import { createElement as h, createRef } from "react"; +import { createElement as h, createRef, useState } from "react"; const dom = new JSDOM("", { url: "http://localhost", @@ -215,3 +215,41 @@ test("Modal focuses the requested control and blocks dismissal while busy", asyn await user.keyboard("{Escape}"); assert.equal(closeCount, 0); }); + +test("controlled Modal restores focus to its external opener", async () => { + const user = userEvent.setup({ document }); + + function ModalHarness() { + const [open, setOpen] = useState(false); + return h( + "div", + null, + h( + "button", + { type: "button", onClick: () => setOpen(true) }, + "Open modal", + ), + h( + Modal, + { + open, + onClose: () => setOpen(false), + title: "Save holon", + }, + h("input", { "aria-label": "File name" }), + ), + ); + } + + render(h(ModalHarness)); + + const opener = screen.getByRole("button", { name: "Open modal" }); + await user.click(opener); + assert.ok(await screen.findByRole("dialog", { name: "Save holon" })); + + await user.keyboard("{Escape}"); + await waitFor(() => { + assert.equal(screen.queryByRole("dialog", { name: "Save holon" }), null); + }); + assert.equal(document.activeElement, opener); +});