From 0ba54b4455cb560cf7a82baf71b31ed129cfc29b Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 24 Oct 2026 17:09:17 +0900 Subject: fix(tabs): finish accessible cascade confirmation and verify active closes --- src/features/tabs/tab-groups.test.ts | 9 +++ src/features/tabs/ui.test.ts | 71 ++++++++++++++++++++++- src/features/tabs/ui/CloseSubagentsConfirm.svelte | 44 +++++++++++--- src/features/tabs/ui/TabList.svelte | 12 ++++ 4 files changed, 126 insertions(+), 10 deletions(-) (limited to 'src/features') diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts index 67150ff..46b04e7 100644 --- a/src/features/tabs/tab-groups.test.ts +++ b/src/features/tabs/tab-groups.test.ts @@ -127,6 +127,15 @@ describe("cascadeCloseIds (the ids a cascading tab close removes)", () => { expect(cascadeCloseIds(tabs, "c2")).toEqual(["c2"]); }); + it("matches the rendered forest for cycles and manual regrouping", () => { + const cycle = [tab("p", "c1"), tab("c1", "p"), tab("g", "c1")]; + expect(cascadeCloseIds(cycle, "p")).toEqual(["p"]); + expect(cascadeCloseIds(cycle, "c1")).toEqual(["c1", "g"]); + const regrouped = [tab("p"), tab("c1", "solo"), tab("g", "c1"), tab("solo")]; + expect(cascadeCloseIds(regrouped, "p")).toEqual(["p"]); + expect(cascadeCloseIds(regrouped, "solo")).toEqual(["solo", "c1", "g"]); + }); + it("ignores tabs whose group parent is no longer open", () => { const stale = [tab("p"), tab("orphan", "gone")]; expect(cascadeCloseIds(stale, "p")).toEqual(["p"]); diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 88b89bf..8f775a1 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -744,10 +744,79 @@ describe("TabList", () => { expect(onClose.mock.calls.map((c) => c[0])).toEqual(["c1", "g"]); }); + it("restores keyboard focus to the opener after cancellation", async () => { + renderClosable(); + const user = userEvent.setup(); + const opener = within(card("p")).getByRole("button", { name: "Close tab" }); + await user.click(opener); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(opener).toHaveFocus(); + }); + + it("Cancel and backdrop dismiss without closing any tab", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + await clickCloseOn("p"); + await fireEvent.submit(dialog().querySelector("form") as HTMLFormElement); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("keeps the confirmed subtree snapshot when new tabs arrive", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ + ...props, + tabs: [ + ...grouped, + { + conversationId: "late", + model: "m", + title: "Late", + workspaceId: "w", + tabGroupParentId: "p", + }, + ], + }); + expect(dialog()).toHaveTextContent("3 subagent tabs"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((call) => call[0])).toEqual(["p", "c1", "c2", "g"]); + }); + + it("dismisses when the target disappears (remote close or workspace switch)", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ ...props, tabs: grouped.filter((tab) => tab.conversationId !== "p") }); + expect(screen.queryByRole("dialog")).toBeNull(); + await rerender(props); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + it("Escape dismisses the dialog without closing anything", async () => { const onClose = renderClosable(); await clickCloseOn("p"); - await fireEvent.keyDown(window, { key: "Escape" }); + await fireEvent(dialog(), new Event("cancel", { cancelable: true })); expect(screen.queryByRole("dialog")).toBeNull(); expect(onClose).not.toHaveBeenCalled(); }); diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte index cbbec79..7ffda6c 100644 --- a/src/features/tabs/ui/CloseSubagentsConfirm.svelte +++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte @@ -7,7 +7,8 @@ * the whole subtree — "Yes" cascades the close to every transitive * descendant (sub-sub-agents included, no further prompts); "No" closes * only the tab itself. Escape / backdrop dismisses without closing anything. - * daisyUI `modal` (controlled: `modal-open`). + * Native modal dialog with daisyUI chrome: top-layer rendering, focus + * containment, and inert background even inside the animated sidebar. */ let { tab, @@ -27,14 +28,31 @@ /** Escape / backdrop — close nothing. */ onDismiss: () => void; } = $props(); + function openModal(node: HTMLDialogElement) { + const opener = document.activeElement; + node.showModal(); + return { + destroy() { + node.close(); + if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); + }, + }; + } - e.key === "Escape" && onDismiss()} /> - -