fix visible focus ring

This commit is contained in:
Eduard Gert
2026-06-18 15:25:30 +02:00
parent de12fe7851
commit 461ec1c8f5
7 changed files with 76 additions and 7 deletions

View File

@@ -8,6 +8,7 @@ import { Preferences } from "@bindings/services";
import { type LanguageCode, type Language } from "@bindings/i18n/models.js";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
@@ -24,6 +25,7 @@ export function LanguagePicker() {
const [languages, setLanguages] = useState<Language[]>([]);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const isFocusVisible = useFocusVisible();
useEffect(() => {
let cancelled = false;
@@ -96,7 +98,8 @@ export function LanguagePicker() {
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:opacity-50",
)}
>

View File

@@ -2,6 +2,7 @@ import { type ComponentType, type ReactNode, forwardRef } from "react";
import * as Tabs from "@radix-ui/react-tabs";
import { type LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { useFocusVisible } from "@/hooks/useFocusVisible";
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
function VerticalTabsRoot({ className, ...props }, ref) {
@@ -40,6 +41,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
ref,
) {
const isFocusVisible = useFocusVisible();
return (
<Tabs.Trigger
ref={ref}
@@ -48,7 +50,8 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
className,
)}
{...props}

View File

@@ -1,5 +1,6 @@
import { type ButtonHTMLAttributes, type ComponentType, forwardRef } from "react";
import { type LucideProps } from "lucide-react";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
@@ -12,6 +13,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
{ icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props },
ref,
) {
const isFocusVisible = useFocusVisible();
return (
<button
ref={ref}
@@ -21,7 +23,8 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
className={cn(
"flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none",
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable transition-colors duration-150",
className,
)}

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
// Tracks the user's current input modality (keyboard vs pointer) at module
// scope, mirroring what @react-aria/interactions does. Radix programmatically
// focuses elements like Tabs triggers and Select triggers, which makes the
// browser's :focus-visible heuristic light up on mouse-driven interactions too.
// Gating focus styles on this hook lets us only paint a focus ring when the
// user is actually navigating with the keyboard.
// See react-aria's useFocusVisible for context.
type Modality = "keyboard" | "pointer";
let currentModality: Modality = "pointer";
const subscribers = new Set<(m: Modality) => void>();
const setModality = (m: Modality) => {
if (m === currentModality) return;
currentModality = m;
subscribers.forEach((cb) => cb(m));
};
const isKeyboardEvent = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return false;
return e.key === "Tab" || e.key === "Escape" || e.key.startsWith("Arrow");
};
if (typeof window !== "undefined") {
window.addEventListener(
"keydown",
(e) => {
if (isKeyboardEvent(e)) setModality("keyboard");
},
true,
);
window.addEventListener("pointerdown", () => setModality("pointer"), true);
}
export const useFocusVisible = (): boolean => {
const [visible, setVisible] = useState(currentModality === "keyboard");
useEffect(() => {
setVisible(currentModality === "keyboard");
const cb = (m: Modality) => setVisible(m === "keyboard");
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}, []);
return visible;
};

View File

@@ -16,6 +16,7 @@ import { CopyToClipboard } from "@/components/CopyToClipboard";
import { TruncatedText } from "@/components/TruncatedText";
import { shortenDns } from "@/lib/formatters";
import { contentTop } from "@/components/empty-state/EmptyState";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
import * as Popover from "@radix-ui/react-popover";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
@@ -279,6 +280,7 @@ export const MainConnectionStatusSwitch = () => {
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const isFocusVisible = useFocusVisible();
const hasV6 = !!ipv6;
if (!hasV6) {
@@ -318,7 +320,8 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
aria-expanded={open}
className={cn(
"group relative inline-flex cursor-default items-center rounded-sm outline-none",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"transition-colors",
)}
>
@@ -369,6 +372,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
const IpRow = ({ value }: { value: string }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isFocusVisible = useFocusVisible();
const handleClick = async () => {
if (!value) return;
try {
@@ -390,7 +394,8 @@ const IpRow = ({ value }: { value: string }) => {
"rounded-md px-2 py-1.5 text-left",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
>
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>

View File

@@ -8,6 +8,7 @@ import { cn } from "@/lib/cn";
import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
const NONE_VALUE = "__none__";
@@ -146,6 +147,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
{ title, description, disabled, active = false, className, ...props },
ref,
) {
const isFocusVisible = useFocusVisible();
return (
<button
ref={ref}
@@ -157,7 +159,8 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
"border border-nb-gray-920 bg-nb-gray-940",
"transition-colors duration-150",
"wails-no-draggable",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
disabled
? "cursor-not-allowed opacity-60"
: "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935",

View File

@@ -8,6 +8,7 @@ import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import type { Profile } from "@bindings/services/models.js";
import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/contexts/ProfileContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
@@ -195,6 +196,7 @@ type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
function ProfileTriggerButton({ name, className, disabled, ...props }, ref) {
const { t } = useTranslation();
const isFocusVisible = useFocusVisible();
const Icon = pickProfileIcon(name) ?? UserCircle;
return (
<button
@@ -209,7 +211,8 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
"text-nb-gray-200 hover:bg-nb-gray-900",
"data-[state=open]:bg-nb-gray-900",
"disabled:opacity-50 disabled:hover:bg-transparent",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable transition-colors duration-150",
className,
)}