From 0c575f83a561fab122e0cb37706bfad6b6385e04 Mon Sep 17 00:00:00 2001 From: Nicholas Sollazzo Date: Thu, 2 Jul 2026 19:22:23 +0200 Subject: [PATCH] test(ui): pin focus save/restore + teardown for useModalDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast-follow to the Dialog/Sheet modal-dialog dedup (#35): the shared useModalDialog() now owns the a11y-critical focus save/restore for both overlays, but nothing pinned it. Add two tests to the Dialog suite (both overlays delegate to the same helper, so one suite guards both): - restores focus to the opener when it closes - restores focus when unmounted while still open (the $effect cleanup / teardown branch — native `close` never fires here, so only the helper can restore focus; verified via mutation testing that this test fails when the restore branch is removed) The close-path test locks the end-to-end contract but also passes on native Chromium behavior; the teardown test is the one that isolates the helper-only logic. Test-only, gates green (lint, svelte-check 615/0, knip, vitest 302/302). Co-Authored-By: Paperclip --- src/lib/components/Dialog.svelte.test.ts | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/lib/components/Dialog.svelte.test.ts b/src/lib/components/Dialog.svelte.test.ts index 30942a1..6e8fa16 100644 --- a/src/lib/components/Dialog.svelte.test.ts +++ b/src/lib/components/Dialog.svelte.test.ts @@ -49,6 +49,55 @@ describe('Dialog', () => { expect(closed).toBe(1); }); + // Focus save/restore lives in the shared useModalDialog controller (Dialog and + // Sheet both delegate to it), so pinning it via one overlay guards both — the + // a11y branches the dedup exists to protect. + // + // The close path below locks the end-to-end contract, but note Chromium's native + // modal- already returns focus to the opener on close(), so it also passes + // if the helper's own restore is removed. The teardown test is the one that + // isolates helper-only logic: on unmount-while-open the native `close` never fires, + // so *only* the $effect cleanup can restore focus. + test('restores focus to the opener when it closes', async () => { + const opener = document.createElement('button'); + document.body.appendChild(opener); + opener.focus(); + expect(document.activeElement).toBe(opener); // captured as previouslyFocused on open + + render(ThemedHarness, { + theme: 'machine', + Comp: Dialog, + componentProps: { open: true, title: 'T', children: body } + }); + const el = document.querySelector('dialog.pn-dialog') as HTMLDialogElement; + expect(el.open).toBe(true); // showModal() moved focus into the dialog + + el.close(); // native close — same path Escape/backdrop take + await expect.poll(() => document.activeElement).toBe(opener); + + opener.remove(); + }); + + test('restores focus when unmounted while still open (teardown branch)', async () => { + const opener = document.createElement('button'); + document.body.appendChild(opener); + opener.focus(); + expect(document.activeElement).toBe(opener); + + const { unmount } = render(ThemedHarness, { + theme: 'machine', + Comp: Dialog, + componentProps: { open: true, title: 'T', children: body } + }); + const el = document.querySelector('dialog.pn-dialog') as HTMLDialogElement; + expect(el.open).toBe(true); // still open — native `close` never fires on teardown + + await unmount(); // $effect cleanup restores focus in lieu of handleClose + await expect.poll(() => document.activeElement).toBe(opener); + + opener.remove(); + }); + test('a titleless dialog is named by aria-label', async () => { const screen = render(ThemedHarness, { theme: 'machine',