fix: show alert focus rings only for keyboard input

Radix focus transfers can preserve :focus-visible during mouse hover and
when returning focus to the alert trigger. Track actual input modality so
pointer interactions keep the thin hover border and keyboard navigation
retains its focus rings.

Cover desktop and narrow layouts, pointer selection, keyboard return focus,
and switching input modes with browser regressions.
This commit is contained in:
2026-11-10 10:33:43 +08:00
parent d7eb01652a
commit 865491cb23
2 changed files with 111 additions and 8 deletions
@@ -16,7 +16,7 @@ import {
UserRound, UserRound,
type LucideIcon type LucideIcon
} from "lucide-react"; } from "lucide-react";
import { useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ProjectSwitchDialog } from "./project-switch-dialog"; import { ProjectSwitchDialog } from "./project-switch-dialog";
import { import {
@@ -143,13 +143,28 @@ export function AlertMenu({
onAnalyzeAlertsWithAgent: () => void | Promise<void>; onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) { }) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const keyboardInteraction = useRef(false); const [keyboardInteraction, setKeyboardInteraction] = useState(false);
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current; const skipRemovalMotion = reduceMotion === true || keyboardInteraction;
const hasAlerts = alerts.length > 0; const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning"); const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral"; const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
const latestAlertTime = alerts[0]?.time; const latestAlertTime = alerts[0]?.time;
useEffect(() => {
// Radix can preserve :focus-visible through pointer-driven focus transfers.
// Track actual input, including Tab from controls outside this portaled menu.
const handleKeyboard = () => setKeyboardInteraction(true);
const handlePointer = () => setKeyboardInteraction(false);
document.addEventListener("keydown", handleKeyboard, true);
document.addEventListener("pointerdown", handlePointer, true);
document.addEventListener("pointermove", handlePointer, true);
return () => {
document.removeEventListener("keydown", handleKeyboard, true);
document.removeEventListener("pointerdown", handlePointer, true);
document.removeEventListener("pointermove", handlePointer, true);
};
}, []);
return ( return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}> <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -158,6 +173,7 @@ export function AlertMenu({
className={cn( className={cn(
"group relative px-2", "group relative px-2",
headerControlButtonClassName, headerControlButtonClassName,
!keyboardInteraction && "focus-visible:ring-0 focus-visible:ring-offset-0",
hasAlerts && !compact && hasAlerts && !compact &&
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"), (hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
hasAlerts && !compact && hasAlerts && !compact &&
@@ -197,10 +213,7 @@ export function AlertMenu({
)} )}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)} <DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
>
<AlertQueueSummary <AlertQueueSummary
count={alerts.length} count={alerts.length}
latestAlertTime={latestAlertTime} latestAlertTime={latestAlertTime}
@@ -235,7 +248,10 @@ export function AlertMenu({
> >
<DropdownMenuItem <DropdownMenuItem
asChild asChild
className="absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]" className={cn(
"absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]",
keyboardInteraction && "focus-visible:ring-2"
)}
onSelect={() => onSelectAlert(alert)} onSelect={() => onSelectAlert(alert)}
> >
<button <button
+87
View File
@@ -0,0 +1,87 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { investigationFixture, mockInvestigationPresentation } from "./support/mock-investigation-presentation";
import type { OperationalTaskNotification } from "../../src/features/workbench/api/operational-task-client";
async function openAlertQueue(page: Page) {
const task = investigationFixture(1, "通知焦点测试调查");
const notification: OperationalTaskNotification = {
notification_id: "96000000-0000-4000-8000-000000000021", task_id: task.task_id,
kind: "human_input_required", title: "待核验漏点与阀门", description: "请核对现场情况后继续调查。",
required_permission: null, lifecycle_status: "active", subject_type: "question", subject_id: null,
resolved_at: null, resolution_reason: null, status: "unread", version: 1,
created_at: task.created_at, read_at: null, read_by: null
};
await mockInvestigationPresentation(page, [task], [], [notification]);
await page.goto("/", { waitUntil: "domcontentloaded" });
const trigger = page.getByRole("button", { name: /查看待办与告警/ });
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 1 条待处理事项", { timeout: 15_000 });
await trigger.click();
await expect(page.getByRole("menu")).toBeVisible();
return {
trigger,
item: page.getByRole("menuitem", { name: `打开通知:${notification.title}` }),
row: page.locator(`[data-alert-id="operational-task-notification:${notification.notification_id}"]`)
};
}
async function ringShadow(element: Locator) {
return element.evaluate((node) => getComputedStyle(node).getPropertyValue("--tw-ring-shadow").trim());
}
for (const width of [1920, 375]) {
test(`鼠标悬停通知保持细边框且键盘导航仍有焦点环 ${width}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const { trigger, item, row } = await openAlertQueue(page);
await item.hover();
await expect(item).toBeFocused();
await expect(row).toHaveCSS("background-color", "rgb(218, 229, 253)");
await expect(row).toHaveCSS("border-width", "1px");
await expect(item).toHaveCSS("box-shadow", "none");
await expect(item).toHaveCSS("outline-style", "none");
await testInfo.attach("pointer-alert-focus", {
body: await page.getByRole("menu").screenshot({ path: testInfo.outputPath("pointer-alert-focus.png") }),
contentType: "image/png"
});
// Switching to keyboard on the same focused item must immediately show its indicator.
await page.keyboard.press("Home");
await expect(item).toBeFocused();
await expect.poll(() => ringShadow(item)).toContain("inset 0 0 0 calc(2px");
await testInfo.attach("keyboard-alert-focus", {
body: await page.getByRole("menu").screenshot({ path: testInfo.outputPath("keyboard-alert-focus.png") }),
contentType: "image/png"
});
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect.poll(() => ringShadow(trigger)).toContain("calc(2px + 2px)");
});
test(`鼠标选择通知后顶部按钮无外环且再次切换键盘可见焦点 ${width}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const { trigger, item } = await openAlertQueue(page);
await item.click();
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect.poll(() => ringShadow(trigger)).toContain("calc(0px + 0px)");
await expect(trigger).toHaveCSS("outline-style", "none");
await testInfo.attach("pointer-alert-trigger", {
body: await trigger.screenshot({ path: testInfo.outputPath("pointer-alert-trigger.png"), animations: "disabled" }),
contentType: "image/png"
});
// Tab originates on another control, outside the menu and trigger handlers.
await page.keyboard.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(trigger).toBeFocused();
await expect.poll(() => ringShadow(trigger)).toContain("calc(2px + 2px)");
await testInfo.attach("keyboard-alert-trigger", {
body: await trigger.screenshot({ path: testInfo.outputPath("keyboard-alert-trigger.png"), animations: "disabled" }),
contentType: "image/png"
});
// Pointer movement must also clear an existing keyboard indicator.
await trigger.hover();
await expect.poll(() => ringShadow(trigger)).toContain("calc(0px + 0px)");
});
}