mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-21 07:51:29 +02:00
fix visible focus ring
This commit is contained in:
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
49
client/ui/frontend/src/hooks/useFocusVisible.ts
Normal file
49
client/ui/frontend/src/hooks/useFocusVisible.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user