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,
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<void>;
}) {
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 (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
@@ -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({
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
<AlertQueueSummary
count={alerts.length}
latestAlertTime={latestAlertTime}
@@ -235,7 +248,10 @@ export function AlertMenu({
>
<DropdownMenuItem
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)}
>
<button