diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 92477801eb72..57c4924b2f1a 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -10,6 +10,7 @@ import { import { AlertDialog, AlertDialogClose, + AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, @@ -62,6 +63,7 @@ export function ConfirmDialogHost() { const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); const confirmVariant = state.status === "idle" ? "default" : state.variant; + const focusConfirm = state.status !== "idle" && state.initialFocus === "confirm"; const onCancel = () => respondToConfirmDialog(false); const onConfirm = () => respondToConfirmDialog(true); @@ -86,9 +88,13 @@ export function ConfirmDialogHost() { }>Cancel - + {focusConfirm && confirmVariant === "default" ? ( + Confirm + ) : ( + + )} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index b9c03f52bc3e..28b08e8c70dc 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -3547,6 +3547,7 @@ export default function LegacySidebar() { try { confirmed = await ensureLocalApi().dialogs.confirm( getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), + { initialFocus: "confirm" }, ); } catch (error) { setDesktopUpdateActionPending(false); diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index 8a86d2d37bb0..e287e6363f27 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -6,6 +6,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; type SessionResult = AsyncResult.AsyncResult; const testState = vi.hoisted(() => ({ @@ -62,6 +63,7 @@ import { respondToConfirmDialog, } from "~/confirmDialog"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { ConfirmDialogHost } from "./ConfirmDialogHost"; import { ServerUpdateAction, ServerUpdateProgress, @@ -304,11 +306,21 @@ describe("ServerUpdateAction", () => { expect(button()).toBeUndefined(); }); - it("updates remote desktop apps through the shared update flow after confirmation", async () => { - registerConfirmDialogHost(); + it.each(["{Enter}", " "])("confirms a remote desktop update with %j", async (key) => { testState.updateServer.mockResolvedValue(success("desktop-app", "0.0.34")); - await renderAction({ selfUpdate: "desktop-managed", desktopAppUpdate: true }); + await render( + <> + + + , + ); await click("Update"); expect(readConfirmDialogState()).toEqual( expect.objectContaining({ @@ -316,7 +328,9 @@ describe("ServerUpdateAction", () => { message: expect.stringContaining("Test server"), }), ); - await act(async () => respondToConfirmDialog(true)); + expect(testState.updateServer).not.toHaveBeenCalled(); + await expect.element(page.getByRole("button", { name: "Confirm", exact: true })).toHaveFocus(); + await act(async () => userEvent.keyboard(key)); expect(testState.updateServer).toHaveBeenCalledWith({ environmentId: "env-test", diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 3d809655e28b..b35d2b4e17df 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -146,6 +146,7 @@ export function ServerUpdatesAction({ const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`, + { initialFocus: "confirm" }, )) ?? true; if (!confirmed) return; } @@ -257,6 +258,7 @@ export function ServerUpdateAction({ const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, + { initialFocus: "confirm" }, )) ?? true; if (!confirmed) { return; diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx index 78282f659151..cff5a659c95a 100644 --- a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx @@ -12,6 +12,7 @@ import { Button } from "../ui/button"; import { Dialog, DialogDescription, + DialogAction, DialogFooter, DialogHeader, DialogPanel, @@ -112,9 +113,9 @@ export function RelayClientInstallDialog() { > Cancel - + ) : null} diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index 7c088cb94f5c..0e1504e5d4cc 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -12,6 +12,7 @@ import { Dialog, DialogClose, DialogDescription, + DialogAction, DialogFooter, DialogHeader, DialogPanel, @@ -201,7 +202,7 @@ function QuitStep({ - + I’ve quit it ); @@ -573,7 +574,7 @@ function BlockedStep({ - {onRetry ? : null} + {onRetry ? Try again : null} ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6824c2dd5016..bfc811ce0099 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -344,6 +344,7 @@ function AboutVersionSection() { getDesktopUpdateInstallConfirmationMessage( updateState ?? { availableVersion: null, downloadedVersion: null }, ), + { initialFocus: "confirm" }, ); } catch (error) { setIsUpdateActionPending(false); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index cfe0ba456829..a3c38cc6c347 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -292,6 +292,7 @@ function SidebarUpdateControl() { try { confirmed = await ensureLocalApi().dialogs.confirm( getDesktopUpdateInstallConfirmationMessage(state), + { initialFocus: "confirm" }, ); } catch (error) { setIsActionPending(false); diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index ee086887557f..387ac92ade95 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -1,8 +1,10 @@ "use client"; import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import { useRender } from "@base-ui/react/use-render"; import { cn } from "~/lib/utils"; +import { DialogAction, useDialogActionInitialFocus } from "~/components/ui/dialog"; import { DIALOG_BACKDROP_CLASS, DIALOG_MOBILE_SHEET_CLASS, @@ -46,22 +48,30 @@ function AlertDialogPopup({ bottomStickOnMobile?: boolean; portalContainer?: AlertDialogPrimitive.Portal.Props["container"]; }) { + const { popupRef, initialFocus } = useDialogActionInitialFocus(); + const popup = useRender({ + ref: popupRef, + render: ( + + ), + }); return ( - + {popup} ); @@ -140,5 +150,6 @@ export { AlertDialogTitle, AlertDialogDescription, AlertDialogClose, + DialogAction as AlertDialogAction, AlertDialogViewport, }; diff --git a/apps/web/src/components/ui/dialog.browser.test.tsx b/apps/web/src/components/ui/dialog.browser.test.tsx new file mode 100644 index 000000000000..777be8e5bf3a --- /dev/null +++ b/apps/web/src/components/ui/dialog.browser.test.tsx @@ -0,0 +1,149 @@ +import { act, createRef, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { requestConfirmDialog, resetConfirmDialogForTests } from "~/confirmDialog"; +import { ConfirmDialogHost } from "../ConfirmDialogHost"; +import { Button } from "./button"; +import { + Dialog, + DialogAction, + DialogClose, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "./dialog"; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + resetConfirmDialogForTests(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + resetConfirmDialogForTests(); + vi.unstubAllGlobals(); +}); + +async function render(element: ReactNode) { + await act(async () => root.render(element)); +} + +describe("dialog keyboard interaction", () => { + it.each([ + { label: "generic", options: undefined }, + { label: "destructive opt-in", options: { variant: "destructive", initialFocus: "confirm" } }, + ] as const)("cancels a $label confirmation on Enter", async ({ options }) => { + await render(); + let confirmation: Promise | undefined; + await act(async () => { + confirmation = requestConfirmDialog("Continue?", options); + }); + + await expect.element(page.getByRole("button", { name: "Cancel", exact: true })).toHaveFocus(); + await act(async () => userEvent.keyboard("{Enter}")); + await expect(confirmation).resolves.toBe(false); + }); + + it("activates a dialog action with Space after opening from the keyboard", async () => { + const retry = vi.fn(); + const popup = createRef(); + await render( + + Open retry + + Import failed + Cancel + Try again + + , + ); + await act(async () => page.getByRole("button", { name: "Open retry" }).element().focus()); + await act(async () => userEvent.keyboard("{Enter}")); + + await expect.element(page.getByRole("button", { name: "Try again" })).toHaveFocus(); + expect(popup.current).toBe(page.getByRole("dialog").element()); + expect(retry).not.toHaveBeenCalled(); + await act(async () => userEvent.keyboard(" ")); + expect(retry).toHaveBeenCalledOnce(); + }); + + it.each(["disabled action", "caller initialFocus"] as const)( + "falls back to Cancel for %s", + async (reason) => { + const popup = createRef(); + const cancel = createRef(); + const confirm = vi.fn(); + await render( + + + Install update? + }> + Cancel + + + Install + + + , + ); + + await expect.element(page.getByRole("button", { name: "Cancel", exact: true })).toHaveFocus(); + await act(async () => userEvent.keyboard("{Enter}")); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + expect(confirm).not.toHaveBeenCalled(); + }, + ); + + it("focuses the popup instead of its input or action when opened by touch", async () => { + const install = vi.fn(); + const popup = createRef(); + const cleanup = vi.fn(); + await render( + + Open installer + { + popup.current = element; + return cleanup; + }} + > + Install relay client + + Cancel + Install + + , + ); + + const trigger = page.getByRole("button", { name: "Open installer" }).element(); + await act(async () => { + trigger.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }), + ); + trigger.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerType: "touch" })); + trigger.dispatchEvent( + new PointerEvent("click", { bubbles: true, pointerType: "touch", detail: 1 }), + ); + }); + + await expect.element(page.getByRole("dialog")).toHaveFocus(); + expect(popup.current).toBe(page.getByRole("dialog").element()); + await act(async () => userEvent.keyboard("{Enter}")); + expect(install).not.toHaveBeenCalled(); + await act(async () => userEvent.keyboard("{Escape}")); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + expect(cleanup).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 11b27495657c..2809330cf86a 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -1,9 +1,11 @@ "use client"; import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { useRender } from "@base-ui/react/use-render"; import { XIcon } from "lucide-react"; +import { useRef } from "react"; import { cn } from "~/lib/utils"; -import { Button } from "~/components/ui/button"; +import { Button, type ButtonVariant } from "~/components/ui/button"; import { DIALOG_BACKDROP_CLASS, DIALOG_MEDIA_BACKDROP_CLASS, @@ -15,6 +17,20 @@ import { ScrollArea } from "~/components/ui/scroll-area"; const Dialog = DialogPrimitive.Root; +function useDialogActionInitialFocus() { + const popupRef = useRef(null); + const initialFocus = (openType: string) => { + // Match Base UI: touch opens focus the popup to avoid opening the virtual keyboard. + if (openType === "touch") return popupRef.current; + return ( + popupRef.current?.querySelector( + '[data-slot="dialog-action"]:not(:disabled):not([aria-disabled="true"])', + ) ?? true + ); + }; + return { popupRef, initialFocus }; +} + const DialogPortal = DialogPrimitive.Portal; function DialogTrigger(props: DialogPrimitive.Trigger.Props) { @@ -68,6 +84,35 @@ function DialogPopup({ bottomStickOnMobile?: boolean; variant?: "default" | "media"; }) { + const { popupRef, initialFocus } = useDialogActionInitialFocus(); + const popup = useRender({ + ref: popupRef, + render: ( + + {children} + {showCloseButton && ( + } + > + + + )} + + ), + }); return ( {/* Media opens from inside other overlays (a composer chip, a popover), so it sits above them. */} @@ -79,33 +124,20 @@ function DialogPopup({ "z-[60] grid-rows-1 place-items-center px-4 pb-6 pt-[calc(var(--native-titlebar-height,0px)+1.5rem)] [-webkit-app-region:no-drag]", )} > - - {children} - {showCloseButton && ( - } - > - - - )} - + {popup} ); } +type DialogActionProps = Omit, "variant"> & { + variant?: Exclude; +}; + +function DialogAction(props: DialogActionProps) { + return