Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/web/src/components/ConfirmDialogHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import {
AlertDialog,
AlertDialogClose,
AlertDialogAction,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
Expand Down Expand Up @@ -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);

Expand All @@ -86,9 +88,13 @@ export function ConfirmDialogHost() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
<Button variant={confirmVariant} onClick={onConfirm}>
Confirm
</Button>
{focusConfirm && confirmVariant === "default" ? (
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
) : (
<Button variant={confirmVariant} onClick={onConfirm}>
Confirm
</Button>
)}
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3547,6 +3547,7 @@ export default function LegacySidebar() {
try {
confirmed = await ensureLocalApi().dialogs.confirm(
getDesktopUpdateInstallConfirmationMessage(desktopUpdateState),
{ initialFocus: "confirm" },
);
} catch (error) {
setDesktopUpdateActionPending(false);
Expand Down
22 changes: 18 additions & 4 deletions apps/web/src/components/ServerUpdateAction.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthSessionState, Error>;
const testState = vi.hoisted(() => ({
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
respondToConfirmDialog,
} from "~/confirmDialog";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { ConfirmDialogHost } from "./ConfirmDialogHost";
import {
ServerUpdateAction,
ServerUpdateProgress,
Expand Down Expand Up @@ -304,19 +306,31 @@ 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(
<>
<ConfirmDialogHost />
<ServerUpdateAction
environmentId={"env-test" as EnvironmentId}
serverLabel="Test server"
selfUpdate="desktop-managed"
desktopAppUpdate
targetVersion="0.0.31"
/>
</>,
);
await click("Update");
expect(readConfirmDialogState()).toEqual(
expect.objectContaining({
status: "confirming",
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",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ServerUpdateAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/cloud/RelayClientInstallDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Button } from "../ui/button";
import {
Dialog,
DialogDescription,
DialogAction,
DialogFooter,
DialogHeader,
DialogPanel,
Expand Down Expand Up @@ -112,9 +113,9 @@ export function RelayClientInstallDialog() {
>
Cancel
</Button>
<Button onClick={() => respondToRelayClientInstallConfirmation(true)}>
<DialogAction onClick={() => respondToRelayClientInstallConfirmation(true)}>
Download and install
</Button>
</DialogAction>
</DialogFooter>
) : null}
</DialogPopup>
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/settings/BrowserImportWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Dialog,
DialogClose,
DialogDescription,
DialogAction,
DialogFooter,
DialogHeader,
DialogPanel,
Expand Down Expand Up @@ -201,7 +202,7 @@ function QuitStep({
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onRechecked}>I&rsquo;ve quit it</Button>
<DialogAction onClick={onRechecked}>I&rsquo;ve quit it</DialogAction>
</DialogFooter>
</>
);
Expand Down Expand Up @@ -573,7 +574,7 @@ function BlockedStep({
<Button variant="outline" onClick={onClose}>
Close
</Button>
{onRetry ? <Button onClick={onRetry}>Try again</Button> : null}
{onRetry ? <DialogAction onClick={onRetry}>Try again</DialogAction> : null}
</DialogFooter>
</>
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ function AboutVersionSection() {
getDesktopUpdateInstallConfirmationMessage(
updateState ?? { availableVersion: null, downloadedVersion: null },
),
{ initialFocus: "confirm" },
);
} catch (error) {
setIsUpdateActionPending(false);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ function SidebarUpdateControl() {
try {
confirmed = await ensureLocalApi().dialogs.confirm(
getDesktopUpdateInstallConfirmationMessage(state),
{ initialFocus: "confirm" },
);
} catch (error) {
setIsActionPending(false);
Expand Down
31 changes: 21 additions & 10 deletions apps/web/src/components/ui/alert-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -46,22 +48,30 @@ function AlertDialogPopup({
bottomStickOnMobile?: boolean;
portalContainer?: AlertDialogPrimitive.Portal.Props["container"];
}) {
const { popupRef, initialFocus } = useDialogActionInitialFocus();
const popup = useRender({
ref: popupRef,
render: (
<AlertDialogPrimitive.Popup
className={cn(
DIALOG_POPUP_CLASS,
"row-start-2 max-h-full max-w-lg text-popover-foreground",
bottomStickOnMobile && DIALOG_MOBILE_SHEET_CLASS,
className,
)}
data-slot="alert-dialog-popup"
initialFocus={initialFocus}
{...props}
/>
),
});
return (
<AlertDialogPortal container={portalContainer}>
<AlertDialogBackdrop />
<AlertDialogViewport
className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")}
>
<AlertDialogPrimitive.Popup
className={cn(
DIALOG_POPUP_CLASS,
"row-start-2 max-h-full max-w-lg text-popover-foreground",
bottomStickOnMobile && DIALOG_MOBILE_SHEET_CLASS,
className,
)}
data-slot="alert-dialog-popup"
{...props}
/>
{popup}
</AlertDialogViewport>
</AlertDialogPortal>
);
Expand Down Expand Up @@ -140,5 +150,6 @@ export {
AlertDialogTitle,
AlertDialogDescription,
AlertDialogClose,
DialogAction as AlertDialogAction,
AlertDialogViewport,
};
149 changes: 149 additions & 0 deletions apps/web/src/components/ui/dialog.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ConfirmDialogHost />);
let confirmation: Promise<boolean> | 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<HTMLDivElement>();
await render(
<Dialog>
<DialogTrigger>Open retry</DialogTrigger>
<DialogPopup ref={popup}>
<DialogTitle>Import failed</DialogTitle>
<DialogClose>Cancel</DialogClose>
<DialogAction onClick={retry}>Try again</DialogAction>
</DialogPopup>
</Dialog>,
);
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<HTMLDivElement>();
const cancel = createRef<HTMLButtonElement>();
const confirm = vi.fn();
await render(
<Dialog defaultOpen>
<DialogPopup
ref={popup}
{...(reason === "caller initialFocus" ? { initialFocus: cancel } : {})}
>
<DialogTitle>Install update?</DialogTitle>
<DialogClose ref={cancel} render={<Button variant="outline" />}>
Cancel
</DialogClose>
<DialogAction disabled={reason === "disabled action"} onClick={confirm}>
Install
</DialogAction>
</DialogPopup>
</Dialog>,
);

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<HTMLDivElement>();
const cleanup = vi.fn();
await render(
<Dialog>
<DialogTrigger>Open installer</DialogTrigger>
<DialogPopup
ref={(element) => {
popup.current = element;
return cleanup;
}}
>
<DialogTitle>Install relay client</DialogTitle>
<input aria-label="Install directory" />
<DialogClose>Cancel</DialogClose>
<DialogAction onClick={install}>Install</DialogAction>
</DialogPopup>
</Dialog>,
);

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();
});
});
Loading
Loading