diff --git a/src/features/workbench/components/workbench-top-bar-menus.tsx b/src/features/workbench/components/workbench-top-bar-menus.tsx index 6f98d53..2957616 100644 --- a/src/features/workbench/components/workbench-top-bar-menus.tsx +++ b/src/features/workbench/components/workbench-top-bar-menus.tsx @@ -18,7 +18,7 @@ import { type LucideIcon } from "lucide-react"; import { useRef, useState, type ReactNode } from "react"; -import { AnimatePresence } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ProjectSwitchDialog } from "./project-switch-dialog"; import { MAP_COMPACT_RADIUS_CLASS_NAME, @@ -138,6 +138,9 @@ export function AlertMenu({ onToggleConditionFeed: () => void; onAnalyzeAlertsWithAgent: () => void | Promise; }) { + const reduceMotion = useReducedMotion(); + const keyboardInteraction = useRef(false); + const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current; const hasAlerts = alerts.length > 0; const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning"); const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral"; @@ -190,7 +193,10 @@ export function AlertMenu({ )} - + { keyboardInteraction.current = true; }} + onPointerDownCapture={() => { keyboardInteraction.current = false; }} + > 待处理事项 -
- {hasAlerts ? alerts.map((alert) => { - const tone = alert.severity === "warning" ? "warning" : "danger"; - return ( -
-
+
+ + {hasAlerts ? alerts.map((alert) => { + const tone = alert.severity === "warning" ? "warning" : "danger"; + return ( + ({ + opacity: 0, x: skip ? 0 : 12, pointerEvents: "none", + transition: { duration: skip ? 0 : 0.18, ease: [0.4, 0, 1, 1] } + }) }} + transition={{ duration: skipRemovalMotion ? 0 : 0.18, ease: [0.4, 0, 1, 1] }} + className={cn( + tone === "warning" ? "status-tone-warning" : "status-tone-danger", + "relative my-1 min-h-[88px] border border-transparent py-3 pl-3 pr-[76px] transition-colors hover:border-[var(--status-border)] hover:bg-[var(--action-soft)] focus-within:border-[var(--status-border)] focus-within:bg-[var(--action-soft)]", + MAP_COMPACT_RADIUS_CLASS_NAME, + menuReadableRowClassName + )} + > {alert.time} -
- onSelectAlert(alert)} - > - 打开 - +

{alert.title}

+

{alert.description}

+
{onRemoveAlert ? ( { event.preventDefault(); onRemoveAlert(alert); @@ -234,23 +249,29 @@ export function AlertMenu({ ) : null} + onSelectAlert(alert)} + > +
+ + ); + }) : ( +
+
+ + + +

当前无待处理事项

+

新的待办和告警会进入这里。

-

{alert.title}

-

{alert.description}

- ); - }) : ( -
-
- - - -

当前无待处理事项

-

新的待办和告警会进入这里。

-
-
- )} + )} +
{ +for (const { width, reducedMotion } of [{ width: 1280, reducedMotion: false }, { width: 375, reducedMotion: false }, { width: 320, reducedMotion: false }, { width: 320, reducedMotion: true }]) { + test(`待办和告警可独立移除且刷新后保持 ${width}${reducedMotion ? " 减少动态效果" : ""}`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ reducedMotion: reducedMotion ? "reduce" : "no-preference" }); const task = investigationFixture(1, "通知移除测试调查"); const notification: OperationalTaskNotification = { notification_id: "96000000-0000-4000-8000-000000000011", task_id: task.task_id, @@ -32,25 +33,52 @@ for (const width of [1280, 375, 320]) { }); await page.goto("/", { waitUntil: "domcontentloaded" }); const trigger = page.getByRole("button", { name: /查看待办与告警/ }); - await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 2 条待处理事项"); + // Runtime alerts and task notifications finish loading independently on a cold page. + await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 2 条待处理事项", { timeout: 15_000 }); await trigger.click(); const menu = page.getByRole("menu"); await expect(menu).toBeVisible(); const removeTask = page.getByRole("menuitem", { name: `移除通知:${notification.title}` }); await expect(removeTask).toBeVisible(); const openTask = page.getByRole("menuitem", { name: `打开通知:${notification.title}` }); + await expect(openTask).toHaveText(""); const actions = await openTask.evaluate((element) => { const open = element.getBoundingClientRect(); - const remove = element.nextElementSibling!.getBoundingClientRect(); - return { topDifference: Math.abs(open.top - remove.top), gap: remove.left - open.right }; + const remove = element.previousElementSibling!.getBoundingClientRect(); + return { centerDifference: Math.abs((open.left + open.width / 2) - (remove.left + remove.width / 2)), gap: open.top - remove.bottom }; }); - expect(actions.topDifference).toBeLessThan(1); + expect(actions.centerDifference).toBeLessThan(1); expect(actions.gap).toBeGreaterThanOrEqual(0); const box = await menu.boundingBox(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x + box!.width).toBeLessThanOrEqual(width); await testInfo.attach(`alert-menu-${width}`, { body: await page.screenshot({ path: testInfo.outputPath("alert-menu.png"), style: "[data-sonner-toaster] { visibility: hidden !important; }" }), contentType: "image/png" }); - await removeTask.click(); + const row = page.locator(`[data-alert-id="operational-task-notification:${notification.notification_id}"]`); + if (!reducedMotion) { + const exitState = await removeTask.evaluate(async (element) => { + const row = element.closest("[data-alert-id]")!; + (element as HTMLElement).click(); + const list = row.parentElement!; + let opacity = 1; + for (let frame = 0; frame < 30 && opacity === 1; frame++) { + await new Promise(requestAnimationFrame); + opacity = Number(getComputedStyle(row).opacity); + } + const style = getComputedStyle(row); + return { retained: row.isConnected, opacity, pointerEvents: style.pointerEvents, + overflowX: getComputedStyle(list).overflowX, + pageOverflows: document.documentElement.scrollWidth > window.innerWidth }; + }); + expect(exitState.retained).toBe(true); + expect(exitState.opacity).toBeGreaterThan(0); + expect(exitState.opacity).toBeLessThan(1); + expect(exitState.pointerEvents).toBe("none"); + expect(exitState.overflowX).toBe("hidden"); + expect(exitState.pageOverflows).toBe(false); + } else { + await removeTask.click(); + } + await expect(row).toHaveCount(0); await expect(menu).toBeVisible(); await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 1 条待处理事项"); await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);