From 865491cb23e50d1a314fe0a591a3719d244760e8 Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 10 Nov 2026 10:33:43 +0800 Subject: [PATCH] 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. --- .../components/workbench-top-bar-menus.tsx | 32 +++++-- tests/browser/alert-focus.e2e.ts | 87 +++++++++++++++++++ 2 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 tests/browser/alert-focus.e2e.ts diff --git a/src/features/workbench/components/workbench-top-bar-menus.tsx b/src/features/workbench/components/workbench-top-bar-menus.tsx index 85b0b81..128991e 100644 --- a/src/features/workbench/components/workbench-top-bar-menus.tsx +++ b/src/features/workbench/components/workbench-top-bar-menus.tsx @@ -16,7 +16,7 @@ import { UserRound, type LucideIcon } 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 { ProjectSwitchDialog } from "./project-switch-dialog"; import { @@ -143,13 +143,28 @@ export function AlertMenu({ onAnalyzeAlertsWithAgent: () => void | Promise; }) { const reduceMotion = useReducedMotion(); - const keyboardInteraction = useRef(false); - const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current; + const [keyboardInteraction, setKeyboardInteraction] = useState(false); + const skipRemovalMotion = reduceMotion === true || keyboardInteraction; const hasAlerts = alerts.length > 0; const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning"); const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral"; 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 ( @@ -158,6 +173,7 @@ export function AlertMenu({ className={cn( "group relative px-2", headerControlButtonClassName, + !keyboardInteraction && "focus-visible:ring-0 focus-visible:ring-offset-0", hasAlerts && !compact && (hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"), hasAlerts && !compact && @@ -197,10 +213,7 @@ export function AlertMenu({ )} - { keyboardInteraction.current = true; }} - onPointerDownCapture={() => { keyboardInteraction.current = false; }} - > + onSelectAlert(alert)} >