From ce4ea447f3362e0700f5891b2e682facd37c73e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 12 Aug 2026 14:53:09 +0100 Subject: [PATCH] fix: refine task confirmation dialogs (#2553) --- apps/packages/ui/src/alert-dialog.tsx | 4 +- .../task/task-archive-confirm-dialog.tsx | 15 +++-- .../task/task-confirm-dialog-shared.ts | 4 ++ .../task/task-delete-confirm-dialog.tsx | 15 +++-- .../kanban/card-menu-delete-archive.spec.ts | 18 +++-- .../task/mobile-sidebar-task-actions.spec.ts | 67 +++++++++++++++++++ 6 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 apps/web/components/task/task-confirm-dialog-shared.ts diff --git a/apps/packages/ui/src/alert-dialog.tsx b/apps/packages/ui/src/alert-dialog.tsx index ac7cfb9118..b45db47942 100644 --- a/apps/packages/ui/src/alert-dialog.tsx +++ b/apps/packages/ui/src/alert-dialog.tsx @@ -56,7 +56,7 @@ function AlertDialogContent({ onKeyDown, ...props }: React.ComponentProps & { - size?: "default" | "sm"; + size?: "default" | "sm" | "lg"; /** Pressing Enter activates the semantic action button. Default: true. */ enterConfirms?: boolean; overlayClassName?: string; @@ -72,7 +72,7 @@ function AlertDialogContent({ data-slot="alert-dialog-content" data-size={size} className={cn( - "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-3 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none", + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-3 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-64 data-[size=default]:sm:max-w-sm data-[size=lg]:w-[calc(100vw-2rem)] data-[size=lg]:!max-w-[calc(100vw-2rem)] data-[size=lg]:sm:!max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none", className, )} onKeyDown={handleKeyDown} diff --git a/apps/web/components/task/task-archive-confirm-dialog.tsx b/apps/web/components/task/task-archive-confirm-dialog.tsx index a534e0fd98..5c121fdf03 100644 --- a/apps/web/components/task/task-archive-confirm-dialog.tsx +++ b/apps/web/components/task/task-archive-confirm-dialog.tsx @@ -18,6 +18,7 @@ import { useSubtaskCount } from "@/hooks/use-subtask-count"; import { useTaskInFlight } from "@/hooks/use-task-in-flight"; import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary"; import { StillWorkingWarning } from "./task-still-working-warning"; +import { TASK_CONFIRM_CLASS, stopDialogPropagation } from "./task-confirm-dialog-shared"; import { useTranslation } from "react-i18next"; type TaskArchiveConfirmDialogProps = { @@ -129,10 +130,10 @@ export function TaskArchiveConfirmDialog({ return ( - e.stopPropagation()}> + - {title} - + {title} +

{firstLine}

{cleanup.lines.map((line, i) => ( @@ -154,17 +155,19 @@ export function TaskArchiveConfirmDialog({ /> {t("task:alsoArchiveSubtasks", { count: subtaskCount })} - + {t("task:subtasksStayActiveUnlessYouTick")} )} - {t("common:cancel")} + + {t("common:cancel")} + { if (isArchiving) return; diff --git a/apps/web/components/task/task-confirm-dialog-shared.ts b/apps/web/components/task/task-confirm-dialog-shared.ts new file mode 100644 index 0000000000..6cfb0607c2 --- /dev/null +++ b/apps/web/components/task/task-confirm-dialog-shared.ts @@ -0,0 +1,4 @@ +import type { MouseEvent } from "react"; + +export const TASK_CONFIRM_CLASS = "font-sans !text-sm"; +export const stopDialogPropagation = (event: MouseEvent) => event.stopPropagation(); diff --git a/apps/web/components/task/task-delete-confirm-dialog.tsx b/apps/web/components/task/task-delete-confirm-dialog.tsx index e4676ff311..dd1220d6fc 100644 --- a/apps/web/components/task/task-delete-confirm-dialog.tsx +++ b/apps/web/components/task/task-delete-confirm-dialog.tsx @@ -17,6 +17,7 @@ import { useSubtaskCount } from "@/hooks/use-subtask-count"; import { useTaskInFlight } from "@/hooks/use-task-in-flight"; import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary"; import { StillWorkingWarning } from "./task-still-working-warning"; +import { TASK_CONFIRM_CLASS, stopDialogPropagation } from "./task-confirm-dialog-shared"; import { useTranslation } from "react-i18next"; type TaskDeleteConfirmDialogProps = { @@ -75,10 +76,10 @@ export function TaskDeleteConfirmDialog({ return ( - e.stopPropagation()}> + - {title} - + {title} +

{description}

{cleanup.lines.map((line, i) => ( @@ -102,17 +103,19 @@ export function TaskDeleteConfirmDialog({ /> {t("task:alsoDeleteSubtasks", { count: subtaskCount })} - + {t("task:subtasksBecomeRootTasksUnlessYou")} )} - {t("common:cancel")} + + {t("common:cancel")} + { if (isDeleting) return; diff --git a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts index 45d28fa834..12537ba6d1 100644 --- a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts +++ b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts @@ -26,8 +26,13 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", ( await deleteItem.click(); // Confirm dialog must appear with the task title - await expect(testPage.getByRole("alertdialog")).toBeVisible(); - await expect(testPage.getByRole("alertdialog")).toContainText("Card Menu Delete Task"); + const dialog = testPage.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Card Menu Delete Task"); + const dialogBox = await dialog.boundingBox(); + if (!dialogBox) throw new Error("delete confirmation dialog has no layout box"); + expect(dialogBox.width).toBeGreaterThanOrEqual(480); + await expect(dialog).toHaveClass(/font-sans/); // URL must not have changed (no navigation to /tasks/:id and no ?taskId=) expect(testPage.url()).toBe(startUrl); @@ -53,8 +58,13 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", ( await expect(archiveItem).toBeVisible(); await archiveItem.click(); - await expect(testPage.getByRole("alertdialog")).toBeVisible(); - await expect(testPage.getByRole("alertdialog")).toContainText("Card Menu Archive Task"); + const dialog = testPage.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Card Menu Archive Task"); + const dialogBox = await dialog.boundingBox(); + if (!dialogBox) throw new Error("archive confirmation dialog has no layout box"); + expect(dialogBox.width).toBeGreaterThanOrEqual(480); + await expect(dialog).toHaveClass(/font-sans/); expect(testPage.url()).toBe(startUrl); }); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts index eaf4a774c1..ef7b60c097 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts @@ -114,6 +114,73 @@ test.describe("Mobile sidebar task actions", () => { expect(cardBox.y).toBeGreaterThan(0); }); + test("keeps the archive confirmation readable on a phone", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.seedTask(seedData.workspaceId, "Mobile archive dialog target", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto(`/t/${task.task_id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await testPage.getByTestId("mobile-session-menu").tap(); + + const drawer = testPage.getByRole("dialog", { name: "Tasks" }); + const taskRow = drawer + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile archive dialog target" }); + await taskRow.getByRole("button", { name: "Task actions" }).tap(); + await testPage.getByRole("menuitem", { name: "Archive", exact: true }).tap(); + + const dialog = testPage.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveClass(/font-sans/); + await expect(dialog.locator('[data-slot="alert-dialog-description"]')).toHaveClass(/text-sm/); + await dialog.evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)), + ); + }); + + const [dialogBox, viewport] = await Promise.all([ + dialog.boundingBox(), + testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })), + ]); + if (!dialogBox) throw new Error("archive confirmation dialog has no layout box"); + expect(dialogBox.width).toBeGreaterThanOrEqual(viewport.width - 40); + expect(dialogBox.x).toBeGreaterThanOrEqual(8); + expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(viewport.width - 8); + expect(dialogBox.y).toBeGreaterThanOrEqual(8); + expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(viewport.height - 8); + + const typography = await dialog.evaluate((element) => { + const description = element.querySelector('[data-slot="alert-dialog-description"]'); + const cancel = element.querySelector('[data-slot="alert-dialog-cancel"]'); + const action = element.querySelector('[data-slot="alert-dialog-action"]'); + return { + dialogFontFamily: getComputedStyle(element).fontFamily, + descriptionFontFamily: description ? getComputedStyle(description).fontFamily : "", + descriptionFontSize: description ? getComputedStyle(description).fontSize : "", + cancelFontFamily: cancel ? getComputedStyle(cancel).fontFamily : "", + cancelFontSize: cancel ? getComputedStyle(cancel).fontSize : "", + actionFontFamily: action ? getComputedStyle(action).fontFamily : "", + actionFontSize: action ? getComputedStyle(action).fontSize : "", + }; + }); + expect(typography.dialogFontFamily).toContain("Figtree"); + expect(typography.descriptionFontFamily).toBe(typography.dialogFontFamily); + expect(typography.cancelFontFamily).toBe(typography.dialogFontFamily); + expect(typography.descriptionFontSize).toBe(typography.cancelFontSize); + expect(typography.actionFontFamily).toBe(typography.dialogFontFamily); + expect(typography.actionFontSize).toBe(typography.descriptionFontSize); + }); + test("keeps the tablet task switcher as a left-side sheet", async ({ testPage, apiClient,